aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmail.com>2026-07-28 21:50:38 +0530
committerAman Kumar <145339995+amankumarsingh77@users.noreply.github.com>2026-08-02 19:12:46 +0530
commit893a9d338ceb5d37aefece1df637c9a5888a120e (patch)
tree5332fab5d515fce62e44ea68ee5408ddd128db44 /src
parent6d300099a86cfdb92b7869dc2b4f33094554b24c (diff)
refactor: one CDP transport, not two
createCdpTransport was a second full WebSocket-to-Chrome implementation reachable only through client.ts's `?? createCdpTransport()` fallback, which production never took — every session and every automated test passes the daemon transport. transport.ts now holds only the CdpTransport interface, and the client requires a transport instead of inventing one. The "Session with given id not found" match that both transports carried becomes classifyRemoteError in cdp/errors.ts, next to the error kind it produces. test/manual/profile-concurrency-test.ts was the sole caller of the fallback: it drives its own Chrome on a private port. It now spawns a daemon per scenario with BU_CDP_WS set, and refuses to run when a daemon is already up, since that one is bound to a different browser. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/cdp/daemon-transport.ts14
-rw-r--r--src/cdp/errors.ts4
-rw-r--r--src/cdp/transport.ts136
-rw-r--r--src/client.ts5
4 files changed, 14 insertions, 145 deletions
diff --git a/src/cdp/daemon-transport.ts b/src/cdp/daemon-transport.ts
index 14f78c4..eb2bbd5 100644
--- a/src/cdp/daemon-transport.ts
+++ b/src/cdp/daemon-transport.ts
@@ -1,8 +1,8 @@
import { createConnection, type Socket } from "node:net";
import { createInterface } from "node:readline";
import { type Result, err, ok } from "../util/result";
-import { type CdpError, cdpError } from "../cdp/errors";
-import type { CdpTransport } from "../cdp/transport";
+import { type CdpError, cdpError, classifyRemoteError } from "../cdp/errors";
+import { DEFAULT_TIMEOUT_MS, type CdpTransport } from "../cdp/transport";
import type { CdpEvent } from "../cdp/types";
import { type Pending, makeEventQueue, makeOnClose, rejectAllPending, sendWithTimeout } from "./event-queue";
import {
@@ -91,13 +91,7 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
clearTimeout(p.timer);
if (msg.error) {
- p.resolve(err(cdpError(
- msg.error.message.includes("Session with given id not found")
- ? "session_not_found"
- : "remote_error",
- msg.error.message,
- p.method,
- )));
+ p.resolve(err(cdpError(classifyRemoteError(msg.error.message), msg.error.message, p.method)));
} else {
p.resolve(ok(msg.result));
}
@@ -149,7 +143,7 @@ export const createDaemonTransport = (clientId: string): CdpTransport => {
params: Record<string, unknown>,
opts?: { sessionId?: string | null; timeoutMs?: number },
): Promise<Result<unknown, CdpError>> => {
- const timeoutMs = opts?.timeoutMs ?? 15_000;
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const sessionId = opts?.sessionId ?? null;
const sock = socket;
diff --git a/src/cdp/errors.ts b/src/cdp/errors.ts
index cf1dab7..bf77bbd 100644
--- a/src/cdp/errors.ts
+++ b/src/cdp/errors.ts
@@ -12,6 +12,10 @@ export type CdpError = {
readonly method?: string;
};
+// Chrome reports a dead session as a generic protocol error, so the message text is the only signal that a reattach — not a retry — is what will fix it.
+export const classifyRemoteError = (message: string): CdpErrorKind =>
+ message.includes("Session with given id not found") ? "session_not_found" : "remote_error";
+
export const cdpError = (
kind: CdpErrorKind,
message: string,
diff --git a/src/cdp/transport.ts b/src/cdp/transport.ts
index 33b9412..4036ff7 100644
--- a/src/cdp/transport.ts
+++ b/src/cdp/transport.ts
@@ -1,11 +1,8 @@
-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";
+import type { Result } from "../util/result";
+import type { CdpError } from "./errors";
+import type { CdpEvent } from "./types";
-const DEFAULT_TIMEOUT_MS = 15_000;
+export const DEFAULT_TIMEOUT_MS = 15_000;
export type CdpTransport = {
connect(url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>>;
@@ -19,128 +16,3 @@ export type CdpTransport = {
state(): "open" | "closed" | "connecting";
onClose(cb: () => void): () => void;
};
-
-export const createCdpTransport = (): CdpTransport => {
- let ws: WebSocket | null = null;
- let nextId = 1;
- const pending = new Map<number, Pending>();
- 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<Result<void, CdpError>> {
- const timeoutMs = opts.timeoutMs ?? 10_000;
- return new Promise((resolve) => {
- let settled = false;
- const settle = (r: Result<void, CdpError>): 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<void> {
- if (ws) {
- try { ws.close(1000, "Shutdown"); } catch {}
- ws = null;
- }
- cleanup("close() called");
- return Promise.resolve();
- },
- request(method, params, opts = {}): Promise<Result<unknown, CdpError>> {
- 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<string, unknown> = { 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<CdpEvent> {
- // 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),
- };
-};
diff --git a/src/client.ts b/src/client.ts
index 5367489..f1d2cb7 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -7,7 +7,6 @@ import { type Mutex, createMutex } from "./util/mutex";
import { discoverEndpoint } from "./cdp/discovery";
import { type CdpError, cdpError } from "./cdp/errors";
import type { CdpTransport } from "./cdp/transport";
-import { createCdpTransport } from "./cdp/transport";
import { type CdpSession, createCdpSession } from "./cdp/session";
import { type OwnershipRegistry, createOwnershipRegistry } from "./cdp/ownership";
import { ensureHarnessWindow, openHarnessTab } from "./cdp/target-factory";
@@ -17,7 +16,7 @@ import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
export type BrowserClientOptions = {
readonly namespace: string;
- readonly transport?: CdpTransport;
+ readonly transport: CdpTransport;
readonly remote?: { readonly cdpUrl: string; readonly browserId: string };
readonly initialOwnership?: {
readonly ownedTargetIds?: ReadonlyArray<string>;
@@ -96,7 +95,7 @@ const parsePageInfoPayload = (raw: string): Result<PageInfo, CdpError> => {
};
export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => {
- const transport = opts.transport ?? createCdpTransport();
+ const transport = opts.transport;
const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string; harnessWindowId?: number } = {};
if (opts.initialOwnership?.ownedTargetIds !== undefined) {
ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds;