import type { Environment } from "@berry/api"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { run } from "./runner.ts"; type Reply = { id?: number; type?: string; result?: unknown; message?: string; error?: string; method?: string; params?: Record; }; export type Session = { request(method: string, params: object): Promise; close(): Promise; readonly closed: Promise; }; /** A target-side Firefox session, spoken to through its runner's stdin and stdout. */ export async function launch( env: Environment, onEvent: (method: string, params: Record) => void, signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); env.lifetime?.throwIfAborted(); if (env.platform === "win32") { throw new Error("The browser requires Firefox and a POSIX shell on the target machine."); } const child = await env.spawn( ["deno", "eval", "--quiet", "--no-config", "--no-lock", `await (${run.toString()})();`], {}, BACKGROUND_CONTEXT, ); const input = child.stdin.getWriter(); const encoder = new TextEncoder(); const ready = Promise.withResolvers(); const closed = Promise.withResolvers(); const pending = new Map(); let nextId = 0; let failure: Error | undefined; let stderr = ""; let closing: Promise | undefined; const fail = (error: unknown) => { if (failure) return; failure = error instanceof Error ? error : new Error(String(error)); ready.reject(failure); for (const request of pending.values()) request.reject(failure); pending.clear(); closed.resolve(); void close(); }; const close = () => closing ??= (async () => { env.lifetime?.removeEventListener("abort", disconnected); const timeout = setTimeout(() => child.kill("SIGKILL"), 3_000); try { await input.close().catch(() => {}); await child.exited; await Promise.allSettled([reading, errors]); } finally { clearTimeout(timeout); } })(); const disconnected = () => fail(env.lifetime?.reason ?? new Error("Target connection closed.")); const aborted = () => fail(signal!.reason); const errors = (async () => { const decoder = new TextDecoder(); for await (const chunk of child.stderr) { stderr = (stderr + decoder.decode(chunk, { stream: true })).slice(-16_384); } })(); void errors.catch(fail); const reading = (async () => { const decoder = new TextDecoder(); let buffered = ""; for await (const chunk of child.stdout) { buffered += decoder.decode(chunk, { stream: true }); let newline: number; while ((newline = buffered.indexOf("\n")) >= 0) { const message = JSON.parse(buffered.slice(0, newline)) as Reply; buffered = buffered.slice(newline + 1); if (message.type === "ready") ready.resolve(); else if (message.id === undefined) { if (message.type === "error") throw new Error(message.message); if (message.method && message.params) onEvent(message.method, message.params); } else { const request = pending.get(message.id); if (!request) continue; pending.delete(message.id); if (message.type === "success") request.resolve(message.result); else { request.reject( new Error( `${message.error ?? "Firefox error"}: ${message.message ?? "Command failed"}`, ), ); } } } } await errors.catch(() => {}); throw new Error(stderr.trim() || "Firefox connection closed."); })(); void reading.catch(fail); env.lifetime?.addEventListener("abort", disconnected, { once: true }); signal?.addEventListener("abort", aborted, { once: true }); const timeout = setTimeout( () => fail(new Error("Browser runner did not start within 25 seconds.")), 25_000, ); try { if (signal?.aborted) aborted(); if (env.lifetime?.aborted) disconnected(); await ready.promise; const session: Session = { closed: closed.promise, close, request(method: string, params: object): Promise { if (failure || closing) { return Promise.reject(failure ?? new Error("Firefox connection closed.")); } const id = ++nextId; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { pending.delete(id); reject(new Error(`Firefox command ${method} timed out.`)); }, 30_000); pending.set(id, { resolve: (value) => { clearTimeout(timeout); resolve(value as T); }, reject: (error) => { clearTimeout(timeout); reject(error); }, }); void input.write(encoder.encode(JSON.stringify({ id, method, params }) + "\n")).catch( fail, ); }); }, }; await session.request("session.new", { capabilities: { alwaysMatch: {} } }); await session.request("session.subscribe", { events: [ "log.entryAdded", "network.fetchError", "network.responseCompleted", "browsingContext.navigationStarted", "browsingContext.domContentLoaded", "browsingContext.navigationFailed", "browsingContext.fragmentNavigated", ], }); return session; } catch (error) { await close(); throw error; } finally { clearTimeout(timeout); signal?.removeEventListener("abort", aborted); } }