diff --git a/deno.lock b/deno.lock index 1f5d00e..bed5597 100644 --- a/deno.lock +++ b/deno.lock @@ -89,6 +89,11 @@ "https://deno.land/std@0.201.0/testing/asserts.ts": "b4e4b1359393aeff09e853e27901a982c685cb630df30426ed75496961931946", "https://deno.land/std@0.201.0/testing/snapshot.ts": "fd91f03c258c316bc9faf815846d85e80e0c82622b28ee44b57ec66dd91d3408", "https://deno.land/std@0.203.0/fs/exists.ts": "cb59a853d84871d87acab0e7936a4dac11282957f8e195102c5a7acb42546bb8", + "https://deno.land/std@0.204.0/assert/assert_is_error.ts": "c21113094a51a296ffaf036767d616a78a2ae5f9f7bbd464cd0197476498b94b", + "https://deno.land/std@0.204.0/assert/assert_rejects.ts": "45c59724de2701e3b1f67c391d6c71c392363635aad3f68a1b3408f9efca0057", + "https://deno.land/std@0.204.0/assert/assert_throws.ts": "63784e951475cb7bdfd59878cd25a0931e18f6dc32a6077c454b2cd94f4f4bcd", + "https://deno.land/std@0.204.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", + "https://deno.land/std@0.204.0/fmt/colors.ts": "c51c4642678eb690dcf5ffee5918b675bf01a33fba82acf303701ae1a4f8c8d9", "https://deno.land/x/progress@v1.3.9/deps.ts": "83050e627263931d853ba28b7c15c80bf4be912bea7e0d3d13da2bc0aaf7889d", "https://deno.land/x/progress@v1.3.9/mod.ts": "ca14ba3c56fc5991c4beee622faeb882e59db8f28d4f5e529ac17b7b07d55741", "https://deno.land/x/progress@v1.3.9/multi.ts": "1de7edf67047ba0050edf63229970f2cbf9cd069342fcd29444a3800d4cfdcbc", diff --git a/docs/pages/advanced/connect.md b/docs/pages/advanced/connect.md new file mode 100644 index 0000000..a3b65bd --- /dev/null +++ b/docs/pages/advanced/connect.md @@ -0,0 +1,45 @@ +--- +title: Connect to existing browser +description: How to connect an existing browser process with astral +index: 3 +--- + +If you already have a browser process running somewhere else or you're using a +service that provides remote browsers for automation (such as +[browserless.io](https://www.browserless.io/)), it is possible to directly +connect to its endpoint rather than spawning a new process. + +## Code + +```ts +// Import Astral +import { launch } from "https://deno.land/x/astral/mod.ts"; + +// Connect to remote endpoint +const browser = await launch({ + wsEndpoint: "wss://remote-browser-endpoint.example.com", +}); + +// Do stuff +const page = await browser.newPage("http://example.com"); +console.log(await page.evaluate(() => document.title)); + +// Close connection +await browser.close(); +``` + +## Reusing a browser spawned by astral + +A browser instance expose its WebSocket endpoint through `browser.wsEndpoint()`. + +```ts +// Spawn a browser process +const browser = await launch(); + +// Connect to first browser instead +const anotherBrowser = await launch({ wsEndpoint: browser.wsEndpoint() }); +``` + +This is especially useful in unit testing as you can setup a shared browser +instance before all your tests, while also properly closing resources to avoid +operations leaks. diff --git a/src/browser.ts b/src/browser.ts index 4f007f0..d79813a 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -70,22 +70,35 @@ export interface BrowserOptions { export class Browser { #options: BrowserOptions; #celestial: Celestial; - #process: Deno.ChildProcess; + #process: Deno.ChildProcess | null; readonly pages: Page[] = []; - constructor(ws: WebSocket, process: Deno.ChildProcess, opts: BrowserOptions) { + constructor( + ws: WebSocket, + process: Deno.ChildProcess | null, + opts: BrowserOptions, + ) { this.#celestial = new Celestial(ws); this.#process = process; this.#options = opts; } + /** Returns true if browser is connected remotely instead of using a subprocess */ + get isRemoteConnection() { + return !this.#process; + } + /** * Closes the browser and all of its pages (if any were opened). The Browser object itself is considered to be disposed and cannot be used anymore. */ async close() { await this.#celestial.close(); - this.#process.kill(); - await this.#process.status; + this.#process?.kill(); + await this.#process?.status; + // If we use a remote connection, then close all pages websockets + if (this.isRemoteConnection) { + await Promise.allSettled(this.pages.map((page) => page.close())); + } } /** @@ -96,7 +109,8 @@ export class Browser { url: "", }); const browserWsUrl = new URL(this.#celestial.ws.url); - const wsUrl = `${browserWsUrl.origin}/devtools/page/${targetId}`; + const wsUrl = + `${browserWsUrl.origin}/devtools/page/${targetId}${browserWsUrl.search}`; const websocket = new WebSocket(wsUrl); await websocketReady(websocket); @@ -143,6 +157,13 @@ export class Browser { wsEndpoint() { return this.#celestial.ws.url; } + + /** + * Returns true if the browser and its websocket have benn closed + */ + get closed() { + return this.#celestial.ws.readyState === WebSocket.CLOSED; + } } export interface LaunchOptions { @@ -150,6 +171,7 @@ export interface LaunchOptions { path?: string; product?: "chrome" | "firefox"; args?: string[]; + wsEndpoint?: string; } /** @@ -159,17 +181,25 @@ export async function launch(opts?: LaunchOptions) { const headless = opts?.headless ?? true; const product = opts?.product ?? "chrome"; const args = opts?.args ?? []; + const wsEndpoint = opts?.wsEndpoint; let path = opts?.path; - if (!path) { - path = await getBinary(product); - } - const options: BrowserOptions = { headless, product, }; + // Connect to endpoint directly if one was specified + if (wsEndpoint) { + const ws = new WebSocket(wsEndpoint); + await websocketReady(ws); + return new Browser(ws, null, options); + } + + if (!path) { + path = await getBinary(product); + } + // Launch child process const launch = new Deno.Command(path, { args: [ diff --git a/src/page.ts b/src/page.ts index 2298102..1fb6c12 100644 --- a/src/page.ts +++ b/src/page.ts @@ -200,11 +200,19 @@ export class Page extends EventTarget { * Close this page in the browser */ async close() { - const wsUrl = new URL(this.#celestial.ws.url); - const req = await fetch(`http://${wsUrl.host}/json/close/${this.#id}`); - const res = await req.text(); + let success: boolean; + let res = ""; + if (this.#browser.isRemoteConnection) { + await this.#celestial.close(); + success = this.#browser.pages.includes(this); + } else { + const wsUrl = new URL(this.#celestial.ws.url); + const req = await fetch(`http://${wsUrl.host}/json/close/${this.#id}`); + res = await req.text(); + success = res === "Target is closing"; + } - if (res === "Target is closing") { + if (success) { const index = this.#browser.pages.indexOf(this); if (index > -1) { this.#browser.pages.splice(index, 1); diff --git a/tests/existing_ws_endpoint_test.ts b/tests/existing_ws_endpoint_test.ts new file mode 100644 index 0000000..6a75d7a --- /dev/null +++ b/tests/existing_ws_endpoint_test.ts @@ -0,0 +1,35 @@ +import { launch } from "../mod.ts"; +import { assertThrows } from "https://deno.land/std@0.204.0/assert/assert_throws.ts"; +import { assert } from "https://deno.land/std@0.201.0/assert/assert.ts"; + +Deno.test("Test existing ws endpoint", async () => { + // Spawn one browser instance and spawn another one connecting to the first one + const a = await launch(); + const b = await launch({ wsEndpoint: a.wsEndpoint() }); + + // Test that second instance works without any process attached + const page = await b.newPage("http://example.com"); + await page.waitForSelector("h1"); + await page.close(); + assert(!b.pages.includes(page)); + + // Close first instance and ensure that b instance is inactive too + await a.close(); + assert(a.closed); + assert(b.closed); +}); + +Deno.test("Ensure pages are properly closed when closing existing endpoint", async () => { + // Spawn one browser instance and spawn another one connecting to the first one + const a = await launch(); + const b = await launch({ wsEndpoint: a.wsEndpoint() }); + + // Ensure closing existing endpoint properly clean resources + await b.newPage("http://example.com"); + await b.newPage("http://example.com"); + await b.close(); + assertThrows(() => b.pages[0].close(), "Page has already been closed"); + assertThrows(() => b.pages[1].close(), "Page has already been closed"); + assert(b.closed); + await a.close(); +});