/** * A minimal CDP client — just enough to open a page, navigate it, read the DOM, * and close it again. * * Deliberately dependency-free: Node 22 ships a global `WebSocket`, so this * needs no `ws`. That matters because an extension installed by `pi install` * gets its own `node_modules`, and fewer moving parts there is fewer things to * go wrong on a machine that is not the one it was written on. * * The browser is *not* ours. It is a shared Chrome someone may be looking at, * recycled hourly (`me.soarez.chrome-cdp-recycle`). Two consequences run * through this file: * * 1. The websocket URL is re-resolved from `/json/version` on every connect, * never cached. Chrome re-mints its browser UUID on each launch, so a * pinned ws:// URL breaks the first time the browser is recycled. * 2. Every page this opens is created in the background and closed again, * including on error paths, so a failed search does not leave tabs behind * in someone else's window. */ import { BrowserUnavailableError, SearchAbortedError, messageOf } from "./errors.ts"; import type { CdpTarget } from "./endpoint.ts"; /** How long a single CDP command may take before the socket is presumed dead. */ const COMMAND_TIMEOUT_MS = 15_000; /** How long to wait for /json/version and for the websocket handshake. */ const CONNECT_TIMEOUT_MS = 5_000; type CdpMessage = { id?: number; method?: string; params?: Record; sessionId?: string; result?: Record; error?: { code?: number; message?: string }; }; type Pending = { resolve: (value: Record) => void; reject: (reason: Error) => void; timer: ReturnType; }; /** * Reject if `signal` fires before `promise` settles, cleaning up the listener * either way. Node's own abort-aware APIs are not available here — the * websocket predates them — so aborts are raced in rather than plumbed through. */ export const raceAbort = (promise: Promise, signal: AbortSignal | undefined): Promise => { if (!signal) return promise; if (signal.aborted) return Promise.reject(abortError(signal)); return new Promise((resolve, reject) => { const onAbort = (): void => reject(abortError(signal)); signal.addEventListener("abort", onAbort, { once: true }); promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort)); }); }; const abortError = (signal: AbortSignal): Error => { const reason: unknown = signal.reason; if (reason instanceof Error && reason.name === "TimeoutError") { return new SearchAbortedError("search timed out"); } return new SearchAbortedError("search was cancelled"); }; /** Ask the browser for its current websocket endpoint. Never cached — see above. */ const queryWebSocketUrl = async (target: CdpTarget, signal?: AbortSignal): Promise => { const url = `http://${target.host}:${target.port}/json/version`; let payload: unknown; try { const res = await fetch(url, { signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(CONNECT_TIMEOUT_MS)]) : AbortSignal.timeout(CONNECT_TIMEOUT_MS), }); if (!res.ok) { throw new BrowserUnavailableError(`${url} answered HTTP ${res.status}`); } payload = await res.json(); } catch (e) { if (e instanceof BrowserUnavailableError) throw e; if (signal?.aborted) throw abortError(signal); throw new BrowserUnavailableError( `No CDP endpoint answered at ${url} (${messageOf(e)}). Is the browser running and reachable on the overlay?`, ); } const ws = (payload as Record | null)?.["webSocketDebuggerUrl"]; if (typeof ws !== "string" || ws === "") { throw new BrowserUnavailableError(`${url} answered without a webSocketDebuggerUrl`); } return ws; }; /** A live browser-level CDP connection. */ export class BrowserConnection { #ws: WebSocket; #nextId = 1; #pending = new Map(); #listeners = new Set<(msg: CdpMessage) => void>(); #closedReason: string | null = null; readonly target: CdpTarget; private constructor(ws: WebSocket, target: CdpTarget) { this.#ws = ws; this.target = target; ws.addEventListener("message", (ev: MessageEvent) => { let msg: CdpMessage; try { msg = JSON.parse(String(ev.data)) as CdpMessage; } catch { return; } if (msg.id !== undefined) { const pending = this.#pending.get(msg.id); if (!pending) return; this.#pending.delete(msg.id); clearTimeout(pending.timer); if (msg.error) { pending.reject(new BrowserUnavailableError(`CDP error: ${msg.error.message ?? "unknown"}`)); } else { pending.resolve(msg.result ?? {}); } return; } for (const listener of [...this.#listeners]) listener(msg); }); const fail = (reason: string): void => { this.#closedReason ??= reason; for (const [, pending] of this.#pending) { clearTimeout(pending.timer); pending.reject(new BrowserUnavailableError(reason)); } this.#pending.clear(); }; ws.addEventListener("close", () => fail("CDP connection closed (the browser may have been recycled)")); ws.addEventListener("error", () => fail("CDP connection failed")); } /** Resolve the endpoint afresh and dial it. */ static async connect(target: CdpTarget, signal?: AbortSignal): Promise { const wsUrl = await queryWebSocketUrl(target, signal); const ws = new WebSocket(wsUrl); try { await raceAbort( new Promise((resolve, reject) => { const timer = setTimeout( () => reject(new BrowserUnavailableError(`timed out opening ${wsUrl}`)), CONNECT_TIMEOUT_MS, ); ws.addEventListener( "open", () => { clearTimeout(timer); resolve(); }, { once: true }, ); ws.addEventListener( "error", () => { clearTimeout(timer); reject(new BrowserUnavailableError(`could not open ${wsUrl}`)); }, { once: true }, ); }), signal, ); } catch (e) { try { ws.close(); } catch { // Already failed; nothing useful to do with a second failure. } throw e; } return new BrowserConnection(ws, target); } /** False once the socket has closed or failed — the reconnect trigger. */ get isOpen(): boolean { return this.#closedReason === null && this.#ws.readyState === 1 /* OPEN */; } send(method: string, params: Record = {}, sessionId?: string): Promise> { if (!this.isOpen) { return Promise.reject(new BrowserUnavailableError(this.#closedReason ?? "CDP connection is not open")); } const id = this.#nextId++; const payload: CdpMessage = sessionId ? { id, method, params, sessionId } : { id, method, params }; return new Promise>((resolve, reject) => { const timer = setTimeout(() => { this.#pending.delete(id); reject(new BrowserUnavailableError(`CDP command ${method} timed out after ${COMMAND_TIMEOUT_MS}ms`)); }, COMMAND_TIMEOUT_MS); this.#pending.set(id, { resolve, reject, timer }); try { this.#ws.send(JSON.stringify(payload)); } catch (e) { this.#pending.delete(id); clearTimeout(timer); reject(new BrowserUnavailableError(`could not send ${method}: ${messageOf(e)}`)); } }); } /** Subscribe to unsolicited protocol events. Returns an unsubscribe function. */ onEvent(listener: (msg: CdpMessage) => void): () => void { this.#listeners.add(listener); return () => this.#listeners.delete(listener); } close(): void { this.#closedReason ??= "CDP connection closed by this extension"; this.#listeners.clear(); try { this.#ws.close(); } catch { // Closing an already-dead socket is not a failure worth reporting. } } } /** * One throwaway page. * * Created in the background so it does not steal focus from whoever is using * the browser, and closed by {@link close} on every path. */ export class PageSession { readonly #conn: BrowserConnection; readonly #targetId: string; readonly #sessionId: string; #closed = false; private constructor(conn: BrowserConnection, targetId: string, sessionId: string) { this.#conn = conn; this.#targetId = targetId; this.#sessionId = sessionId; } static async open(conn: BrowserConnection, signal?: AbortSignal): Promise { if (signal?.aborted) throw abortError(signal); // Deliberately NOT raced against the abort signal. raceAbort abandons the // promise, it cannot cancel the command — and this command's side effect is // a tab. Abandoning it would leave a tab open in a browser someone else is // using, with nothing left holding its targetId to close it. It is a fast // command already bounded by COMMAND_TIMEOUT_MS, so waiting it out costs // little and guarantees we own whatever it created. const created = await conn.send("Target.createTarget", { url: "about:blank", background: true }); const targetId = created["targetId"]; if (typeof targetId !== "string") { throw new BrowserUnavailableError("Target.createTarget did not return a targetId"); } // From here on the page exists, so every failure — including a late abort — // must still close it. try { if (signal?.aborted) throw abortError(signal); const attached = await raceAbort( conn.send("Target.attachToTarget", { targetId, flatten: true }), signal, ); const sessionId = attached["sessionId"]; if (typeof sessionId !== "string") { throw new BrowserUnavailableError("Target.attachToTarget did not return a sessionId"); } const page = new PageSession(conn, targetId, sessionId); await raceAbort(conn.send("Page.enable", {}, sessionId), signal); return page; } catch (e) { await conn.send("Target.closeTarget", { targetId }).catch(() => {}); throw e; } } /** Navigate and wait for the load event, or for `signal` to fire. */ async navigate(url: string, signal?: AbortSignal): Promise { // Subscribed before navigating, so a page that loads faster than the // Page.navigate reply cannot slip its load event past us. let off: () => void = () => {}; const loaded = new Promise((resolve) => { off = this.#conn.onEvent((msg) => { if (msg.sessionId === this.#sessionId && msg.method === "Page.loadEventFired") resolve(); }); }); try { const nav = await raceAbort(this.#conn.send("Page.navigate", { url }, this.#sessionId), signal); const errorText = nav["errorText"]; if (typeof errorText === "string" && errorText !== "") { throw new BrowserUnavailableError(`navigation to ${url} failed: ${errorText}`); } try { await raceAbort(loaded, signal); } catch (e) { // Stop the in-flight load so the page is not still fetching while we // tear it down; the tab is closed either way, this just makes it quicker. await this.#conn.send("Page.stopLoading", {}, this.#sessionId).catch(() => {}); throw e; } } finally { // Unsubscribing here rather than inside the listener: on the failure paths // the listener never fires, and a subscription left on a connection that // outlives many searches accumulates. off(); } } /** Evaluate an expression in the page and return its value by value. */ async evaluate(expression: string, signal?: AbortSignal): Promise { const res = await raceAbort( this.#conn.send( "Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }, this.#sessionId, ), signal, ); const exception = res["exceptionDetails"] as { text?: string; exception?: { description?: string } } | undefined; if (exception) { const text = exception.exception?.description ?? exception.text ?? "unknown error"; throw new BrowserUnavailableError(`page script failed: ${text}`); } return (res["result"] as { value?: T } | undefined)?.value as T; } /** * Close the tab. Idempotent and never throws: it runs on error paths, where a * second failure would mask the one worth reporting. */ async close(): Promise { if (this.#closed) return; this.#closed = true; await this.#conn.send("Target.closeTarget", { targetId: this.#targetId }).catch(() => {}); } }