// One headless firefox per load, driven over webdriver bidi: started on first use, one tab per call, closed when idle. import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; type BidiMessage = { id?: number; type?: string; result?: unknown; error?: string; message?: string; }; type Session = { request(method: string, params: object): Promise; readonly closed: Promise; close(): Promise; }; const COMMAND_TIMEOUT_MS = 30_000; async function launch(profiles: string): Promise { const profile = await mkdtemp(join(profiles, "berry-firefox-")); const child = new Deno.Command("firefox", { args: [ "--headless", "--no-remote", "--profile", profile, "--remote-debugging-port", "0", "about:blank", ], stdin: "null", stdout: "null", stderr: "piped", }).spawn(); const exited = child.status.then(() => rm(profile, { recursive: true, force: true })); // keep draining stderr, or firefox blocks on a full pipe const endpoint = await new Promise((resolve, reject) => { const timeout = setTimeout( () => reject(new Error("Firefox did not start within 10 seconds")), 10_000, ); let seen = ""; void (async () => { for await (const chunk of child.stderr.pipeThrough(new TextDecoderStream())) { seen = (seen + chunk).slice(-16_384); const match = seen.match(/WebDriver BiDi listening on (ws:\/\/\S+)/); if (match) { clearTimeout(timeout); resolve(`${match[1]}/session`); } } clearTimeout(timeout); reject(new Error(seen.trim() || "Firefox exited before starting")); })(); }); const socket = new WebSocket(endpoint); await new Promise((resolve, reject) => { socket.addEventListener("open", () => resolve(), { once: true }); socket.addEventListener("error", () => reject(new Error("Could not connect to Firefox")), { once: true, }); }); let nextId = 0; const pending = new Map(); socket.addEventListener("message", (event) => { const message = JSON.parse(String(event.data)) as BidiMessage; const request = message.id === undefined ? undefined : pending.get(message.id); if (request === undefined) return; pending.delete(message.id!); if (message.type === "success") request.resolve(message.result); else request.reject(new Error(message.message || message.error || "Firefox command failed")); }); const closed = new Promise((resolve) => socket.addEventListener("close", () => { for (const request of pending.values()) { request.reject(new Error("Firefox connection closed")); } pending.clear(); resolve(); }) ); const session: Session = { request(method: string, params: object) { const id = ++nextId; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { pending.delete(id); reject(new Error(`Firefox command ${method} timed out`)); }, COMMAND_TIMEOUT_MS); pending.set(id, { resolve: (value) => (clearTimeout(timeout), resolve(value as R)), reject: (error) => (clearTimeout(timeout), reject(error)), }); socket.send(JSON.stringify({ id, method, params })); }); }, closed, async close() { socket.close(); try { child.kill(); } catch { // already gone } const killer = setTimeout(() => { try { child.kill("SIGKILL"); } catch { // already gone } }, 2_000); await exited; clearTimeout(killer); }, }; await session.request("session.new", { capabilities: { alwaysMatch: { timeouts: { pageLoad: 25_000, script: 25_000 } } }, }); return session; } /** `work`, unless `signal` aborts first; then the abort's reason, at once. */ function raced(work: Promise, signal?: AbortSignal): Promise { if (signal === undefined) return work; return new Promise((resolve, reject) => { const abort = () => reject(signal.reason); // it may have fired already, while the browser was starting if (signal.aborted) abort(); signal.addEventListener("abort", abort, { once: true }); work.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)); }); } export type Browser = { /** Load `url` in a fresh tab and return the json value `expression` resolves to there. */ evaluate(url: string, expression: string, signal?: AbortSignal): Promise; close(): Promise; }; /** `profiles` is where each browser keeps its profile while it runs. */ export function createBrowser({ idleMs = 60_000, profiles = tmpdir() } = {}): Browser { let session: Promise | undefined; let active = 0; let idle: ReturnType | undefined; function acquire(): Promise { if (session === undefined) { const started = launch(profiles); session = started; // a crashed or closed browser starts afresh on the next call started.then((s) => s.closed.then(() => session === started && (session = undefined)), () => { if (session === started) session = undefined; }); } return session; } let closing = Promise.resolve(); /** Close the running browser; a later call waits for an earlier one instead of finding nothing to do. */ function shutdown() { const current = session; session = undefined; closing = closing.then(async () => await (await current?.catch(() => undefined))?.close()); return closing; } return { async evaluate(url: string, expression: string, signal?: AbortSignal): Promise { signal?.throwIfAborted(); active++; clearTimeout(idle); try { const browser = await acquire(); const { context } = await browser.request<{ context: string }>("browsingContext.create", { type: "tab", }); const work = (async () => { await browser.request("browsingContext.navigate", { context, url, wait: "complete" }); const evaluation = await browser.request< | { type: "success"; result: { type: string; value?: unknown } } | { type: "exception"; exceptionDetails: { text: string } } >("script.evaluate", { expression, target: { context }, awaitPromise: true, userActivation: false, resultOwnership: "none", }); if (evaluation.type === "exception") throw new Error(evaluation.exceptionDetails.text); if (evaluation.result.type !== "string") { throw new Error(`Firefox returned ${evaluation.result.type}, not text`); } return JSON.parse(evaluation.result.value as string) as T; })(); try { return await raced(work, signal); } finally { // closing the tab also stops whatever an abandoned call is still doing in it const closing = browser.request("browsingContext.close", { context }).catch(() => {}); if (!signal?.aborted) await closing; } } finally { if (--active === 0) { idle = setTimeout(shutdown, idleMs); Deno.unrefTimer(idle); } } }, async close() { clearTimeout(idle); await shutdown(); }, }; }