cdp-toolkit 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cdp-toolkit",
3
- "version": "1.0.0",
4
- "description": "A lightweight chrome-devtools-mcp alternative: a single-target Chrome DevTools Protocol MCP server + CLI that drives one tab over a direct WebSocket with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. 33 tools including built-in network mocking. No Puppeteer, no all-target fan-out.",
3
+ "version": "1.1.0",
4
+ "description": "A lightweight chrome-devtools-mcp alternative: a single-target MCP server + CLI that drives one tab over a direct WebSocket with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 33 tools including built-in network mocking. No Puppeteer, no all-target fan-out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": {
@@ -11,7 +11,7 @@
11
11
  "homepage": "https://github.com/sblattj/cdp-toolkit#readme",
12
12
  "repository": {
13
13
  "type": "git",
14
- "url": "https://github.com/sblattj/cdp-toolkit.git"
14
+ "url": "git+https://github.com/sblattj/cdp-toolkit.git"
15
15
  },
16
16
  "bugs": {
17
17
  "url": "https://github.com/sblattj/cdp-toolkit/issues"
@@ -32,6 +32,11 @@
32
32
  "web-automation",
33
33
  "headless-chrome",
34
34
  "chrome-automation",
35
+ "firefox",
36
+ "firefox-automation",
37
+ "headless-firefox",
38
+ "webdriver-bidi",
39
+ "bidi",
35
40
  "network-mocking",
36
41
  "api-mocking",
37
42
  "claude-code",
@@ -50,8 +55,8 @@
50
55
  "./package.json": "./package.json"
51
56
  },
52
57
  "bin": {
53
- "cdp-toolkit": "./dist/mcp.js",
54
- "cdp": "./dist/cli.js"
58
+ "cdp-toolkit": "dist/mcp.js",
59
+ "cdp": "dist/cli.js"
55
60
  },
56
61
  "files": [
57
62
  "dist/",
@@ -70,7 +75,8 @@
70
75
  "test": "bun test",
71
76
  "smoke": "bun run ./test/smoke.ts",
72
77
  "mock:smoke": "bun run ./test/mock-smoke.ts",
73
- "mcp:smoke": "bun run ./test/mcp-smoke.ts"
78
+ "mcp:smoke": "bun run ./test/mcp-smoke.ts",
79
+ "firefox:smoke": "bun run ./test/firefox-smoke.ts"
74
80
  },
75
81
  "devDependencies": {
76
82
  "@types/node": "22.10.10",
package/src/backend.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Backend selection + Firefox process/driver lifetime, shared by cli.ts and mcp.ts.
3
+ *
4
+ * Selection precedence: explicit `--browser chrome|firefox` flag, else the
5
+ * `CDP_BROWSER` env var, else "chrome". Defaulting to chrome is REQUIRED: this
6
+ * ships to npm and today's users must see zero behavior change unless they
7
+ * opt in.
8
+ *
9
+ * Firefox cannot be attached to (see bidi/launch.ts's header) and its driver
10
+ * is `lifetime: "session"` (ADR-001), so ownership differs by entry point:
11
+ * - CLI: one process per invocation. startFirefoxSession() launches, the
12
+ * caller runs exactly one tool call, then disposeFirefoxSession() tears
13
+ * the session AND the spawned process down before the process exits.
14
+ * - MCP: long-lived. getOrCreateFirefoxSession() launches at most once and
15
+ * memoizes the session at module scope; disposeFirefoxSession() runs on
16
+ * shutdown (SIGINT/SIGTERM/stdin close), never per tool call.
17
+ * Both paths funnel through the same FirefoxSession so neither can leak a
18
+ * Firefox process: dispose() always closes the BiDi session THEN kills the
19
+ * spawned binary, in that order, and is idempotent.
20
+ */
21
+ import type { BrowserDriver, DriverKind } from "./driver.ts";
22
+ import { createFirefoxDriver } from "./bidi/driver.ts";
23
+ import { launchFirefox } from "./bidi/launch.ts";
24
+
25
+ /** Resolve the selected backend from argv (already flag-scanned) + env, defaulting to chrome. */
26
+ export function resolveBrowserKind(argv: readonly string[]): DriverKind {
27
+ const flagIdx = argv.indexOf("--browser");
28
+ const flagVal = flagIdx !== -1 ? argv[flagIdx + 1] : undefined;
29
+ const raw = flagVal ?? process.env.CDP_BROWSER ?? "chrome";
30
+ if (raw !== "chrome" && raw !== "firefox") {
31
+ throw new Error(`unknown --browser '${raw}': expected 'chrome' or 'firefox' (or set CDP_BROWSER)`);
32
+ }
33
+ return raw;
34
+ }
35
+
36
+ /** Strip a `--browser <value>` pair out of argv so it never reaches tool-arg parsing. */
37
+ export function stripBrowserFlag(argv: readonly string[]): string[] {
38
+ const out: string[] = [];
39
+ for (let i = 0; i < argv.length; i++) {
40
+ if (argv[i] === "--browser") { i++; continue; }
41
+ out.push(argv[i]!);
42
+ }
43
+ return out;
44
+ }
45
+
46
+ export interface FirefoxSession {
47
+ driver: BrowserDriver;
48
+ dispose(): Promise<void>;
49
+ }
50
+
51
+ /** Launch a fresh Firefox process and construct its Driver. Caller owns dispose(). */
52
+ export async function startFirefoxSession(opts?: { headless?: boolean }): Promise<FirefoxSession> {
53
+ const launched = await launchFirefox({ headless: opts?.headless });
54
+ const driver = createFirefoxDriver(launched.port);
55
+ let disposed = false;
56
+ return {
57
+ driver,
58
+ async dispose(): Promise<void> {
59
+ if (disposed) return;
60
+ disposed = true;
61
+ // Close the BiDi session before killing the process: dispose() is a clean
62
+ // protocol-level teardown, close() is the fallback that guarantees no
63
+ // process survives even if the session was already dead.
64
+ await driver.dispose().catch(() => undefined);
65
+ await launched.close();
66
+ },
67
+ };
68
+ }
69
+
70
+ /* -------------------------- MCP-only: one memoized session -------------------------- */
71
+
72
+ let sessionPromise: Promise<FirefoxSession> | undefined;
73
+
74
+ /** Lazily launch Firefox at most once per server process; every caller shares it. */
75
+ export function getOrCreateFirefoxSession(): Promise<FirefoxSession> {
76
+ if (!sessionPromise) sessionPromise = startFirefoxSession();
77
+ return sessionPromise;
78
+ }
79
+
80
+ /** Idempotent shutdown hook: no-op if Firefox was never launched this process. */
81
+ export async function disposeFirefoxSession(): Promise<void> {
82
+ if (!sessionPromise) return;
83
+ const p = sessionPromise;
84
+ sessionPromise = undefined;
85
+ await p.then((s) => s.dispose()).catch(() => undefined);
86
+ }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * WebDriver BiDi transport for the Firefox backend, the counterpart to
3
+ * src/client.ts's raw CDP transport. Zero runtime dependencies, uses
4
+ * Node's global WebSocket (Node >= 22; verified on Node 25.9). See
5
+ * CONTRACT.md and driver.ts's ADR-001 for the session lifetime this
6
+ * transport exists to support.
7
+ */
8
+ import type { BidiCommands, BidiErrorCode, BidiEventName, BidiEvents, BidiMethod } from "./protocol.ts";
9
+
10
+ /** Default per-command timeout (ms). Override with BIDI_TIMEOUT_MS. */
11
+ export const DEFAULT_TIMEOUT_MS = Number(process.env.BIDI_TIMEOUT_MS ?? 15_000);
12
+
13
+ export class BidiError extends Error {
14
+ constructor(
15
+ message: string,
16
+ readonly code?: BidiErrorCode,
17
+ readonly data?: unknown,
18
+ ) {
19
+ super(message);
20
+ this.name = "BidiError";
21
+ }
22
+ }
23
+
24
+ type BidiEventHandler = (params: never) => void;
25
+
26
+ interface RawSuccess { type: "success"; id: number; result: unknown }
27
+ interface RawErrorMsg { type: "error"; id: number | null; error: BidiErrorCode; message: string }
28
+ interface RawEvent { type: "event"; method: string; params: unknown }
29
+ type RawMessage = RawSuccess | RawErrorMsg | RawEvent;
30
+
31
+ interface PendingCall {
32
+ method: string;
33
+ start: number;
34
+ timer: ReturnType<typeof setTimeout>;
35
+ resolve: (raw: RawSuccess | RawErrorMsg) => void;
36
+ reject: (e: Error) => void;
37
+ }
38
+
39
+ /**
40
+ * A single WebDriver BiDi WebSocket connection.
41
+ *
42
+ * THE STRUCTURAL DIFFERENCE FROM CDP: a BiDi session is bound to exactly one
43
+ * socket. Dropping this connection and re-dialing does not resume the
44
+ * session, a fresh socket sending any command with the old sessionId gets
45
+ * "invalid session id" (verified against Firefox 153.0.3). So this class,
46
+ * once its session is established, IS the session: there is no reconnect
47
+ * path, only retain()/release() bookkeeping around one long-lived socket
48
+ * that closes exclusively via dispose(). ADR-001 (driver.ts) calls this the
49
+ * "session" LifetimeModel and depends on exactly this property: tool calls
50
+ * retain()/release() a shared connection instead of opening and closing
51
+ * their own.
52
+ *
53
+ * Event subscription bookkeeping lives entirely inside on()/waitFor(): BiDi
54
+ * has no per-domain "enable" the way CDP has Network.enable, only explicit
55
+ * session.subscribe / session.unsubscribe by event name. CALLERS NEVER
56
+ * SUBSCRIBE BY HAND. Each event name is refcounted here: the first listener
57
+ * added triggers session.subscribe, the last one removed triggers
58
+ * session.unsubscribe.
59
+ */
60
+ export class BidiConnection {
61
+ private ws?: WebSocket;
62
+ private nextId = 0;
63
+ private sessId?: string;
64
+ private caps?: unknown;
65
+ private closed = false;
66
+ private refCount = 0;
67
+
68
+ private readonly pending = new Map<number, PendingCall>();
69
+ private readonly listeners = new Map<string, Set<BidiEventHandler>>();
70
+
71
+ constructor(
72
+ readonly wsUrl: string,
73
+ private readonly opts: { timeoutMs?: number } = {},
74
+ ) {}
75
+
76
+ /** The session id from session.new. Undefined until newSession() resolves. */
77
+ get sessionId(): string | undefined {
78
+ return this.sessId;
79
+ }
80
+
81
+ /** The capabilities object returned by session.new. */
82
+ get capabilities(): unknown {
83
+ return this.caps;
84
+ }
85
+
86
+ /** Current holder count, see retain()/release(). */
87
+ get holders(): number {
88
+ return this.refCount;
89
+ }
90
+
91
+ connect(): Promise<this> {
92
+ return new Promise((resolve, reject) => {
93
+ const ws = new WebSocket(this.wsUrl);
94
+ this.ws = ws;
95
+ const openTimer = setTimeout(
96
+ () => reject(new BidiError(`connect timeout: ${this.wsUrl}`)),
97
+ this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
98
+ );
99
+ ws.onopen = () => {
100
+ clearTimeout(openTimer);
101
+ resolve(this);
102
+ };
103
+ ws.onerror = (e: Event) => {
104
+ clearTimeout(openTimer);
105
+ const msg = (e as ErrorEvent)?.message ?? "websocket error";
106
+ if (this.pending.size === 0) reject(new BidiError(msg));
107
+ this.rejectAll(new BidiError(msg));
108
+ };
109
+ ws.onclose = () => {
110
+ this.closed = true;
111
+ this.rejectAll(new BidiError("connection closed"));
112
+ };
113
+ ws.onmessage = (ev: MessageEvent) => this.onMessage(String(ev.data));
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Create the BiDi session on this socket (session.new). Call once,
119
+ * immediately after connect(); everything else on this connection assumes
120
+ * a session already exists. Stores the returned sessionId/capabilities.
121
+ */
122
+ async newSession(
123
+ capabilities: BidiCommands["session.new"]["params"]["capabilities"] = {},
124
+ ): Promise<string> {
125
+ const result = await this.send("session.new", { capabilities });
126
+ this.sessId = result.sessionId;
127
+ this.caps = result.capabilities;
128
+ return result.sessionId;
129
+ }
130
+
131
+ private onMessage(raw: string): void {
132
+ let msg: RawMessage;
133
+ try {
134
+ msg = JSON.parse(raw);
135
+ } catch {
136
+ return;
137
+ }
138
+ if (msg.type === "success" || msg.type === "error") {
139
+ const id = msg.id;
140
+ if (typeof id !== "number" || !this.pending.has(id)) return;
141
+ const p = this.pending.get(id)!;
142
+ clearTimeout(p.timer);
143
+ this.pending.delete(id);
144
+ p.resolve(msg);
145
+ return;
146
+ }
147
+ if (msg.type === "event") {
148
+ const set = this.listeners.get(msg.method);
149
+ if (set) for (const h of set) h(msg.params as never);
150
+ }
151
+ }
152
+
153
+ /** Every in-flight promise rejects, never hangs, when the socket dies. */
154
+ private rejectAll(err: Error): void {
155
+ for (const [, p] of this.pending) {
156
+ clearTimeout(p.timer);
157
+ p.reject(err);
158
+ }
159
+ this.pending.clear();
160
+ }
161
+
162
+ /**
163
+ * Send a BiDi command and await its result. Generic over `method` alone:
164
+ * params and result both come from BidiCommands[M], so a caller gets full
165
+ * inference from just the method-name string literal. Rejects on a BiDi
166
+ * error response (typed BidiError carrying the wire code) or on timeout.
167
+ */
168
+ send<M extends BidiMethod>(
169
+ method: M,
170
+ params: BidiCommands[M]["params"],
171
+ opts: { timeoutMs?: number } = {},
172
+ ): Promise<BidiCommands[M]["result"]> {
173
+ if (this.closed || !this.ws) return Promise.reject(new BidiError("connection not open"));
174
+ const id = ++this.nextId;
175
+ const timeoutMs = opts.timeoutMs ?? this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
176
+ const payload = { id, method, params };
177
+ return new Promise((resolve, reject) => {
178
+ const start = Date.now();
179
+ const timer = setTimeout(() => {
180
+ this.pending.delete(id);
181
+ const elapsed = Date.now() - start;
182
+ reject(new BidiError(`'${method}' timed out after ${elapsed}ms`));
183
+ }, timeoutMs);
184
+ this.pending.set(id, {
185
+ method,
186
+ start,
187
+ timer,
188
+ resolve: (raw) => {
189
+ if (raw.type === "error") reject(new BidiError(`${method}: ${raw.message}`, raw.error));
190
+ else resolve(raw.result as BidiCommands[M]["result"]);
191
+ },
192
+ reject,
193
+ });
194
+ this.ws!.send(JSON.stringify(payload));
195
+ });
196
+ }
197
+
198
+ /**
199
+ * Subscribe to a BiDi event. Handles session.subscribe / session.unsubscribe
200
+ * bookkeeping internally: refcounted per event name, subscribes on the
201
+ * first listener, unsubscribes when the last one is removed. Returns an
202
+ * unsubscribe function; callers never call session.subscribe themselves.
203
+ */
204
+ on<E extends BidiEventName>(event: E, handler: (params: BidiEvents[E]) => void): () => void {
205
+ let set = this.listeners.get(event);
206
+ if (!set) {
207
+ set = new Set();
208
+ this.listeners.set(event, set);
209
+ }
210
+ const first = set.size === 0;
211
+ set.add(handler as BidiEventHandler);
212
+ if (first) {
213
+ this.send("session.subscribe", { events: [event] }).catch(() => {
214
+ /* best effort: a failed subscribe surfaces as missing events, not a thrown error here */
215
+ });
216
+ }
217
+
218
+ let removed = false;
219
+ return () => {
220
+ if (removed) return;
221
+ removed = true;
222
+ const s = this.listeners.get(event);
223
+ s?.delete(handler as BidiEventHandler);
224
+ if (s && s.size === 0) {
225
+ this.send("session.unsubscribe", { events: [event] }).catch(() => {
226
+ /* best effort on teardown */
227
+ });
228
+ }
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Resolve when an event matching `event` (and optional predicate) fires,
234
+ * or reject on timeout. Subscribes via on(), so the refcounting above
235
+ * applies here too, an unmatched waitFor still unsubscribes cleanly.
236
+ */
237
+ waitFor<E extends BidiEventName>(
238
+ event: E,
239
+ predicate?: (params: BidiEvents[E]) => boolean,
240
+ timeoutMs = this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
241
+ ): Promise<BidiEvents[E]> {
242
+ return new Promise((resolve, reject) => {
243
+ const timer = setTimeout(() => {
244
+ off();
245
+ reject(new BidiError(`waitFor('${event}') timed out after ${timeoutMs}ms`));
246
+ }, timeoutMs);
247
+ const off = this.on(event, (params) => {
248
+ if (!predicate || predicate(params)) {
249
+ clearTimeout(timer);
250
+ off();
251
+ resolve(params);
252
+ }
253
+ });
254
+ });
255
+ }
256
+
257
+ /**
258
+ * Add a holder. Under the "session" LifetimeModel (driver.ts) the socket
259
+ * outlives individual tool calls, so acquiring a page handle retains this
260
+ * connection instead of opening a new one.
261
+ */
262
+ retain(): void {
263
+ this.refCount++;
264
+ }
265
+
266
+ /** Remove a holder. Does NOT close the socket, only dispose() does that. */
267
+ release(): void {
268
+ if (this.refCount > 0) this.refCount--;
269
+ }
270
+
271
+ /**
272
+ * Close the socket. Idempotent. Every in-flight send()/waitFor() and any
273
+ * future call rejects with a clear "disposed" BidiError rather than
274
+ * hanging, which is the whole point of this transport.
275
+ */
276
+ dispose(): void {
277
+ if (this.closed) return;
278
+ this.closed = true;
279
+ this.rejectAll(new BidiError("connection disposed"));
280
+ try {
281
+ this.ws?.close();
282
+ } catch {
283
+ /* ignore */
284
+ }
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Open a BiDi connection to a Firefox debug port and create its session in
290
+ * one step, per the established endpoint shape ws://127.0.0.1:<port>/session.
291
+ * This module does not launch Firefox; pass a port from launch.ts's
292
+ * launchFirefox() or any other running Firefox with the debug port open.
293
+ * The returned connection is retained once (holders === 1): the caller owns
294
+ * that first retain and must release() or dispose() it.
295
+ */
296
+ export async function connectBidiSession(
297
+ port: number,
298
+ opts: {
299
+ timeoutMs?: number;
300
+ capabilities?: BidiCommands["session.new"]["params"]["capabilities"];
301
+ } = {},
302
+ ): Promise<BidiConnection> {
303
+ const conn = new BidiConnection(`ws://127.0.0.1:${port}/session`, { timeoutMs: opts.timeoutMs });
304
+ await conn.connect();
305
+ await conn.newSession(opts.capabilities ?? {});
306
+ conn.retain();
307
+ return conn;
308
+ }
309
+
310
+ /*
311
+ * BiDi modules/commands used: session (session.new, session.subscribe,
312
+ * session.unsubscribe). All other modules (browsingContext, script, input,
313
+ * network, storage, log, emulation) are exercised generically through the
314
+ * typed send()/on()/waitFor() surface above but this file itself calls none
315
+ * of their commands directly, that is left to the driver and tool layers
316
+ * built on top. Parity gap vs client.ts: there is no browserWsUrl/listTargets
317
+ * equivalent here, BiDi's endpoint discovery is the single fixed
318
+ * ws://127.0.0.1:<port>/session path, so no HTTP discovery step exists.
319
+ */