From 495de0d5283dd3e4a6ef715b596c4a2892e95915 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Mon, 3 Aug 2026 21:18:02 +0100 Subject: Web search for pi through an existing Chrome over CDP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attaches to a browser that is already running — never launches one — using the same endpoint configuration as pi-browser-harness, so a single /browser-target choice governs both packages. One tool, castle_cdp_search, deliberately not named web_search so it coexists with pi-web-access rather than shadowing it. Notes from validating against castle's Chrome: - tbs=qdr:*, the parameter Google's own Tools menu writes, renders an empty page on this profile; the older as_qdr=* works. Any date filter combined with udm=14 is also empty, so recency drops udm. - Target.createTarget must not be raced against the abort signal: raceAbort abandons the promise but cannot cancel the command, and the command's side effect is a tab nothing is left holding. - A search cancelled while queued has to be removed from the semaphore queue, or the slot handed to it later is never counted back. - A decaying rate-limit block stops serving /sorry/ and returns an empty results page instead, indistinguishable from a genuine zero-hit search. --- src/cdp.ts | 342 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 src/cdp.ts (limited to 'src/cdp.ts') diff --git a/src/cdp.ts b/src/cdp.ts new file mode 100644 index 0000000..aa01b56 --- /dev/null +++ b/src/cdp.ts @@ -0,0 +1,342 @@ +/** + * 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(() => {}); + } +} -- cgit v1.3.1