import WebSocket from "ws"; import { type Result, err, ok } from "../util/result"; import { type CdpError, cdpError } from "./errors"; import { isCdpRawMessage } from "./types"; import type { CdpEvent, CdpRawMessage } from "./types"; import { type Pending, makeEventQueue, makeOnClose, rejectAllPending, sendWithTimeout } from "./event-queue"; const DEFAULT_TIMEOUT_MS = 15_000; export type CdpTransport = { connect(url: string, opts?: { timeoutMs?: number }): Promise>; close(): Promise; request( method: string, params: Record, opts?: { sessionId?: string | null; timeoutMs?: number }, ): Promise>; events(): AsyncIterable; state(): "open" | "closed" | "connecting"; onClose(cb: () => void): () => void; }; export const createCdpTransport = (): CdpTransport => { let ws: WebSocket | null = null; let nextId = 1; const pending = new Map(); const closeListeners = new Set<() => void>(); let queue = makeEventQueue(); const handleMessage = (raw: string): void => { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return; } if (!isCdpRawMessage(parsed)) return; const msg: CdpRawMessage = parsed; if (msg.id === undefined) { if (msg.method) { queue.push({ method: msg.method, params: msg.params, ...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}), }); } return; } const p = pending.get(msg.id); if (!p) return; pending.delete(msg.id); clearTimeout(p.timer); if (msg.error) { const kind = msg.error.message.includes("Session with given id not found") ? "session_not_found" : "remote_error"; p.resolve(err(cdpError(kind, msg.error.message, p.method))); return; } p.resolve(ok(msg.result)); }; const cleanup = (reason: string): void => { rejectAllPending(pending, reason); queue.end(); queue = makeEventQueue(); for (const cb of closeListeners) cb(); }; return { connect(url, opts = {}): Promise> { const timeoutMs = opts.timeoutMs ?? 10_000; return new Promise((resolve) => { let settled = false; const settle = (r: Result): void => { if (settled) return; settled = true; resolve(r); }; try { ws = new WebSocket(url); } catch (e) { settle(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e)))); return; } const timer = setTimeout(() => { ws?.close(); ws = null; settle(err(cdpError("timeout", `CDP WebSocket connection timed out after ${timeoutMs}ms`))); }, timeoutMs); ws.onopen = () => { clearTimeout(timer); settle(ok(undefined)); }; ws.onmessage = (ev: WebSocket.MessageEvent) => { const data = ev.data; handleMessage(typeof data === "string" ? data : data.toString()); }; ws.onerror = () => { clearTimeout(timer); settle(err(cdpError("transport_closed", "CDP WebSocket error during connection"))); }; ws.onclose = () => { clearTimeout(timer); ws = null; cleanup("WebSocket closed"); settle(err(cdpError("transport_closed", "CDP WebSocket closed"))); }; }); }, close(): Promise { if (ws) { try { ws.close(1000, "Shutdown"); } catch {} ws = null; } cleanup("close() called"); return Promise.resolve(); }, request(method, params, opts = {}): Promise> { const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; const sessionId = opts.sessionId ?? null; if (!ws || ws.readyState !== WebSocket.OPEN) { return Promise.resolve(err(cdpError("transport_closed", "Browser not connected. Is Chrome running?", method))); } // Bind to a local const so the non-null holds across microtasks between the readyState check and send(). const sock = ws; const id = nextId++; const payload: Record = { id, method, params }; if (sessionId) payload["sessionId"] = sessionId; const json = JSON.stringify(payload); return sendWithTimeout(pending, id, method, timeoutMs, "CDP", () => sock.send(json)); }, events(): AsyncIterable { // Single-consumer: a second for-await loop over this iterable silently steals events from the first. return queue.iter; }, state(): "open" | "closed" | "connecting" { if (!ws) return "closed"; if (ws.readyState === WebSocket.CONNECTING) return "connecting"; if (ws.readyState === WebSocket.OPEN) return "open"; return "closed"; }, onClose: makeOnClose(closeListeners), }; };