// One responsibility: a Chrome DevTools Protocol connection — request framing, response // correlation, and the per-call deadline — over a TRANSPORT that moves whole messages. Two exist: // the pipe a launched Chrome is driven over (`cdp-pipe.ts`, what the e2e driver uses) and a remote // browser's WebSocket (`cdpConnect` below). Launching is `cdp-launch.ts`; the page surface is // `cdp-e2e-page.ts`; this file knows nothing about either. // // **No library, and that is the point rather than an economy.** `packages/scraping/src/cdp-port.ts` // declares a ~25-method port because `ScrapePage` is a full scraping surface, and its intended // implementation is `puppeteer-core`. `E2eBrowserPage` is FIVE methods, and CDP's wire format is // one JSON object with an `id` — so the whole thing an e2e driver needs is this file plus two // small ones, on Bun's native `WebSocket`, with no dependency to add to a repo whose first // non-negotiable is that Bun's natives replace most of them. import { assert, stringField } from '@ultimat3/core'; import { CdpCallFailedError, CdpTimeoutError } from './cdp-errors'; import { wireWatch } from './cdp-wire-watch'; /** One CDP result. `unknown` because every payload here is somebody else's JSON. */ export interface CdpResult { readonly result?: Record | undefined; } export interface CdpConnection { /** * Send one command. `sessionId` targets an attached page rather than the browser itself. * `deadlineMs` replaces the connection's per-call deadline for THIS call — the launcher's first * call is a cold start, not a call, and is the only caller that passes one. */ send( method: string, params?: Record, sessionId?: string, deadlineMs?: number, ): Promise; /** * Wait for the next occurrence of one CDP **event**, or for the deadline. Answers `true` when the * event arrived and `false` when it did not — it never throws, because every caller has a better * assertion to fail on than "the event was late". * * It exists because a command's reply is not always the signal. `Page.navigate`'s reply is * DROPPED whenever the navigation swaps the render process — measured on Chrome 150: the page * loads, the server is hit, a later `Runtime.evaluate` answers, and the navigate frame never * comes back at all. A driver that treated the reply as the completion signal waits out its full * deadline on the most ordinary navigation there is. */ once(method: string, sessionId: string | undefined, timeoutMs: number): Promise; /** * Subscribe to every occurrence of one CDP event, until the returned function is called. * * `once` cannot express what this is for: a target that attaches AFTER the driver stopped * listening is a service worker whose network conditions nobody set, which is an `offline()` * that silently does nothing to the one thing serving the page. * * The listener is also handed the event's `sessionId` (`undefined` for a browser-level event), so * a subscriber that owns ONE page can ignore the frames every other attached target sends. */ on(method: string, listener: CdpEventListener): () => void; close(): void; } /** One event's params, and the session it arrived on. */ export type CdpEventListener = ( params: Record, sessionId: string | undefined, ) => void; interface Pending { readonly resolve: (value: CdpResult) => void; readonly reject: (reason: Error) => void; readonly timer: ReturnType; } /** A CDP error frame: `{ error: { code, message } }`, both fields somebody else's. */ const errorText = (frame: Record): string | undefined => { const error = frame['error']; if (typeof error !== 'object' || error === null) return undefined; const message = (error as Record)['message']; return typeof message === 'string' ? message : 'the browser refused the call'; }; /** * What a connection needs of the wire: whole messages out, whole messages in, and a close. Framing * is the transport's — a WebSocket frames per message, the pipe splits on NUL — so correlation and * deadlines are written once, over either. */ export interface CdpTransport { send(text: string): void; close(): void; /** Installed exactly once, by the connection, before the first `send`. */ listen(handlers: { readonly message: (text: string) => void; readonly closed: (reason: string) => void; }): void; } export interface CdpConnectionOptions { readonly endpoint: string; /** Per-call deadline. A CDP call that never answers is a suite that never finishes. */ readonly timeoutMs: number; } /** * What went wrong, in the socket's own words when it has any. Bun hands `onerror` an `ErrorEvent` * whose `message` is the only place the reason lives — "Failed to connect", "Expected 101 status * code" — and a refusal that drops it names the step and not the cause. */ const failure = (event: unknown, what: string): string => { const message = stringField(event, 'message'); return message === undefined || message === '' ? what : `${what}: ${message}`; }; /** * A remote browser over its WebSocket url. NOT the e2e driver's wire: measured in the dummy's * `offline-feed` suite on Bun 1.4.0, Bun's WebSocket client handed `onmessage` text spliced out of * several frames — 64 unparseable frames in one run, one of them the reply to a `Runtime.evaluate` * that then waited out its whole deadline. A launched Chrome is driven over `cdp-pipe.ts` instead. */ export async function cdpConnect(options: CdpConnectionOptions): Promise { assert( options.endpoint.startsWith('ws://') || options.endpoint.startsWith('wss://'), `the CDP endpoint is ${options.endpoint === '' ? 'empty' : 'not a WebSocket url'}`, 'pass the `webSocketDebuggerUrl` Chrome prints on stderr, or the one /json/version answers', ); const socket = new WebSocket(options.endpoint); await new Promise((resolve, reject) => { // The socket is CLOSED on both failures: a handshake that timed out otherwise kept dialling // in the background, holding a descriptor and a pending connection for a caller that gave up. const timer = setTimeout(() => { socket.close(); reject(new CdpTimeoutError({ method: 'connect', timeoutMs: options.timeoutMs })); }, options.timeoutMs); socket.onopen = (): void => { clearTimeout(timer); resolve(); }; // `onerror` is the handshake's only until `listen` below reassigns it: a failure BEFORE open // has no pending call to abandon, and rejecting is the only way the caller hears about it. socket.onerror = (event: Event): void => { clearTimeout(timer); socket.close(); reject( new CdpCallFailedError({ method: 'connect', detail: failure(event, 'the browser refused the connection'), }), ); }; }); const transport: CdpTransport = { send: (text) => socket.send(text), close: () => socket.close(), listen(handlers) { socket.onmessage = (event: MessageEvent): void => { handlers.message(typeof event.data === 'string' ? event.data : ''); }; socket.onclose = (): void => handlers.closed('the browser closed the CDP connection'); socket.onerror = (event: Event): void => handlers.closed(failure(event, 'the CDP connection failed')); }, }; return cdpConnectOver(transport, options.timeoutMs); } /** A connection over any transport — correlation, deadlines and events, written once. */ export function cdpConnectOver(transport: CdpTransport, timeoutMs: number): CdpConnection { const pending = new Map(); const waiters = new Set<(method: string, sessionId: string | undefined) => void>(); const listeners = new Map>(); let nextId = 0; let closed = false; // What a call that is never answered reports instead of a bare "did not answer". const watch = wireWatch(); // Every in-flight call is settled on close. Without this a suite whose browser died waits out // one full deadline per call and reports a timeout, where the true fault is a dead browser. const abandon = (reason: string): void => { closed = true; for (const [, one] of pending) { clearTimeout(one.timer); one.reject(new CdpCallFailedError({ method: 'the connection', detail: reason })); } pending.clear(); // A waiter is a "did this happen" question, and on a dead connection the answer is no. Its // own timer settles it, so nothing is left hanging; clearing the set only stops a late frame // from resolving a waiter whose connection has gone. waiters.clear(); }; const message = (raw: string): void => { let frame: Record; try { frame = JSON.parse(raw) as Record; } catch { // An unparseable frame is the browser's, not ours, and there is no call to fail with it: // correlation is by `id`, and a frame we cannot read has none. COUNTED, though: the call // whose reply it was waits out its deadline, and its timeout says frames were dropped. watch.drop(); return; } watch.frame(frame); const id = frame['id']; if (typeof id !== 'number') { const method = frame['method']; if (typeof method !== 'string') return; const on = typeof frame['sessionId'] === 'string' ? frame['sessionId'] : undefined; for (const waiter of [...waiters]) waiter(method, on); const subscribed = listeners.get(method); if (subscribed !== undefined) { const params = frame['params']; const payload: Record = typeof params === 'object' && params !== null ? (params as Record) : {}; // A copy, because a listener may unsubscribe itself while this loop is running. for (const listener of [...subscribed]) listener(payload, on); } return; } const one = pending.get(id); if (one === undefined) return; pending.delete(id); clearTimeout(one.timer); const failed = errorText(frame); if (failed !== undefined) { one.reject(new CdpCallFailedError({ method: `call ${String(id)}`, detail: failed })); return; } const result = frame['result']; one.resolve({ result: typeof result === 'object' && result !== null ? (result as Record) : undefined, }); }; transport.listen({ message, closed: abandon }); return { send(method, params = {}, sessionId, deadlineMs = timeoutMs): Promise { if (closed) { return Promise.reject( new CdpCallFailedError({ method, detail: 'the CDP connection is already closed' }), ); } nextId += 1; const id = nextId; return new Promise((resolve, reject) => { const sentAt = watch.mark(sessionId); const timer = setTimeout(() => { pending.delete(id); const observed = watch.observe(sentAt, sessionId, !closed); reject(new CdpTimeoutError({ method, timeoutMs: deadlineMs, observed })); }, deadlineMs); pending.set(id, { resolve, reject, timer }); transport.send( JSON.stringify({ id, method, params, ...(sessionId === undefined ? {} : { sessionId }) }), ); }); }, once(method, sessionId, timeoutMs): Promise { if (closed) return Promise.resolve(false); return new Promise((resolve) => { const waiter = (seen: string, on: string | undefined): void => { if (seen !== method) return; if (sessionId !== undefined && on !== sessionId) return; clearTimeout(timer); waiters.delete(waiter); resolve(true); }; const timer = setTimeout(() => { waiters.delete(waiter); resolve(false); }, timeoutMs); waiters.add(waiter); }); }, on(method, listener): () => void { const subscribed = listeners.get(method) ?? new Set(); subscribed.add(listener); listeners.set(method, subscribed); return () => { subscribed.delete(listener); if (subscribed.size === 0) listeners.delete(method); }; }, close(): void { listeners.clear(); abandon('the driver closed the CDP connection'); transport.close(); }, }; }