Skip to content

Commit

Permalink
Merge pull request #31 from lowlighter/feat-support-existing-ws-endpoint
Browse files Browse the repository at this point in the history
Feat support existing ws endpoint
  • Loading branch information
lino-levan authored Oct 18, 2023
2 parents 9b03090 + 764161f commit 484c45a
Show file tree
Hide file tree
Showing 5 changed files with 136 additions and 13 deletions.
5 changes: 5 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions docs/pages/advanced/connect.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 39 additions & 9 deletions src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,22 +78,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()));
}
}

/**
Expand All @@ -104,7 +117,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);

Expand Down Expand Up @@ -151,13 +165,21 @@ 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 {
headless?: boolean;
path?: string;
product?: "chrome" | "firefox";
args?: string[];
wsEndpoint?: string;
}

/**
Expand All @@ -167,17 +189,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: [
Expand Down
16 changes: 12 additions & 4 deletions src/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
35 changes: 35 additions & 0 deletions tests/existing_ws_endpoint_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { launch } from "../mod.ts";
import { assertThrows } from "https://deno.land/[email protected]/assert/assert_throws.ts";
import { assert } from "https://deno.land/[email protected]/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();
});

0 comments on commit 484c45a

Please sign in to comment.