/** * Minimal Chrome DevTools Protocol client. * * Uses Node's built-in global WebSocket + fetch (Node 22+), so this extension * has zero runtime dependencies. */ export const DEFAULT_CDP_PORT = 9222; export interface CdpTarget { id: string; type: string; title: string; url: string; webSocketDebuggerUrl?: string; } export class CdpError extends Error { constructor(message: string) { super(message); this.name = "CdpError"; } } /** * The question never reached the server. * * Kept distinct from a generic transport error because it has a property no other * failure has: because the user-message count did not move, resending is provably * not a duplicate. Every other recovery has to worry about whether its first * attempt silently succeeded; this one does not. */ export class DeliveryError extends CdpError { /** How long we waited for the user-message count to move. */ readonly waitedMs: number; constructor(message: string, waitedMs: number) { super(message); this.name = "DeliveryError"; this.waitedMs = waitedMs; } } /** * A requirement this process cannot satisfy, as distinct from a browser that is not there. * * Both used to look identical. `browserStatus` caught everything and returned `running: false`, * so running the tooling under a Node without global `fetch` reported "the browser is not * running" for a browser that was running fine -- and the launcher, after polling for thirty * seconds, reported that the DevTools endpoint had not come up. It had. Observed live: the * shell's `node` was v16.14.2 while pi ran on v22, and every probe said the same wrong thing. * * A false negative that names the wrong cause is worse than an error, because it sends the * reader somewhere there is nothing to find. */ export class RuntimeRequirementError extends CdpError { constructor(message: string) { super(message); this.name = "RuntimeRequirementError"; } } /** Global `fetch` is Node 18+. Checked rather than assumed, so the failure is legible. */ export function requireFetch(): void { if (typeof fetch !== "function") { throw new RuntimeRequirementError( `桥的 CDP 工具需要 Node 18+(全局 fetch),当前是 ${process.version}。` + "这不是浏览器的问题:请用较新的 Node 运行,例如 pi 自带的那个。", ); } } async function httpJson(path: string, port: number, method = "GET", signal?: AbortSignal): Promise { requireFetch(); const res = await fetch(`http://127.0.0.1:${port}${path}`, { method, signal }); if (!res.ok) throw new CdpError(`CDP ${method} ${path} -> HTTP ${res.status}`); return (await res.json()) as T; } export interface BrowserVersion { Browser: string; "Protocol-Version": string; webSocketDebuggerUrl: string; } export async function cdpVersion(port = DEFAULT_CDP_PORT, signal?: AbortSignal): Promise { return httpJson("/json/version", port, "GET", signal); } export async function listTargets(port = DEFAULT_CDP_PORT, signal?: AbortSignal): Promise { return httpJson("/json/list", port, "GET", signal); } export async function activateTarget(targetId: string, port = DEFAULT_CDP_PORT): Promise { try { await httpJson(`/json/activate/${targetId}`, port); } catch { // best effort only } } export async function openTab(url: string, port = DEFAULT_CDP_PORT): Promise { return httpJson(`/json/new?${encodeURIComponent(url)}`, port, "PUT"); } export async function closeTarget(targetId: string, port = DEFAULT_CDP_PORT): Promise { try { await httpJson(`/json/close/${targetId}`, port); } catch { // best effort only } } interface PendingCall { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout; } export interface EvaluateOptions { awaitPromise?: boolean; userGesture?: boolean; returnByValue?: boolean; timeoutMs?: number; } export interface EvaluateResult { value: T; raw: unknown; } /** One WebSocket connection to one CDP target (page, or the browser itself). */ export class CdpSession { private ws: WebSocket | null = null; private nextId = 1; private pending = new Map(); private closed = false; private constructor( readonly url: string, private readonly label: string, ) {} static async attach(webSocketDebuggerUrl: string, label = "target", signal?: AbortSignal): Promise { const session = new CdpSession(webSocketDebuggerUrl, label); await session.connect(signal); return session; } private connect(signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { let settled = false; let ws: WebSocket; let timer: NodeJS.Timeout | undefined; const settle = (callback: () => void): void => { if (settled) return; settled = true; if (timer) clearTimeout(timer); signal?.removeEventListener("abort", abort); callback(); }; const abort = (): void => { settle(() => { try { ws.close(); } catch { /* ignore */ } reject(new CdpError(`CDP connection to ${this.label} cancelled`)); }); }; try { ws = new WebSocket(this.url); } catch (error) { reject(new CdpError(`Failed to open WebSocket to ${this.label}: ${String(error)}`)); return; } this.ws = ws; timer = setTimeout(() => { settle(() => { try { ws.close(); } catch { /* ignore */ } reject(new CdpError(`Timed out connecting to ${this.label}`)); }); }, 10_000); signal?.addEventListener("abort", abort, { once: true }); if (signal?.aborted) abort(); ws.addEventListener("open", () => { settle(resolve); }); ws.addEventListener("error", () => { settle(() => reject( new CdpError( `Cannot connect to ${this.label}. Is the browser still running with --remote-debugging-port?`, ), )); }); ws.addEventListener("message", (event: MessageEvent) => this.onMessage(event)); ws.addEventListener("close", () => { this.closed = true; this.failAll(new CdpError(`CDP connection to ${this.label} closed`)); }); }); } private onMessage(event: MessageEvent): void { let payload: { id?: number; result?: unknown; error?: { message?: string } }; try { const data = event.data; const text = typeof data === "string" ? data : Buffer.from(data as ArrayBuffer).toString("utf8"); payload = JSON.parse(text); } catch { return; } if (typeof payload.id !== "number") return; // ignore CDP events const call = this.pending.get(payload.id); if (!call) return; this.pending.delete(payload.id); clearTimeout(call.timer); if (payload.error) { call.reject(new CdpError(payload.error.message ?? "CDP command failed")); } else { call.resolve(payload.result); } } private failAll(error: Error): void { for (const [, call] of this.pending) { clearTimeout(call.timer); call.reject(error); } this.pending.clear(); } send>( method: string, params: Record = {}, timeoutMs = 30_000, ): Promise { if (this.closed || !this.ws || this.ws.readyState !== 1) { return Promise.reject(new CdpError(`CDP connection to ${this.label} is not open`)); } const id = this.nextId++; return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new CdpError(`CDP ${method} timed out after ${timeoutMs}ms`)); }, timeoutMs); this.pending.set(id, { resolve: (value) => resolve(value as T), reject, timer, }); this.ws?.send(JSON.stringify({ id, method, params })); }); } /** Run an expression in the page and return its value. */ async evaluate(expression: string, options: EvaluateOptions = {}): Promise { const result = await this.send<{ result?: { value?: unknown; description?: string }; exceptionDetails?: { text?: string; exception?: { description?: string } }; }>( "Runtime.evaluate", { expression, awaitPromise: options.awaitPromise ?? true, userGesture: options.userGesture ?? true, returnByValue: options.returnByValue ?? true, }, options.timeoutMs ?? 30_000, ); if (result.exceptionDetails) { const detail = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "unknown page exception"; throw new CdpError(`Page exception: ${detail}`); } return result.result?.value as T; } /** Grant clipboard permissions for an origin (browser-level target only). */ async grantClipboard(origin: string): Promise { await this.send("Browser.grantPermissions", { origin, permissions: ["clipboardReadWrite", "clipboardSanitizedWrite"], }); } bringToFront(): Promise { return this.send("Page.bringToFront", {}, 10_000); } async close(): Promise { this.closed = true; this.failAll(new CdpError("CDP session closed by client")); try { this.ws?.close(); } catch { /* ignore */ } this.ws = null; } }