cdp-toolkit 1.9.1 → 1.9.5
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/CHANGELOG.md +434 -0
- package/CONTRACT.md +59 -2
- package/README.md +68 -3
- package/dist/cli.js +2857 -114
- package/dist/index.js +934 -78
- package/dist/mcp.js +1130 -115
- package/package.json +2 -1
- package/src/backend.ts +156 -21
- package/src/bidi/client.ts +48 -10
- package/src/bidi/driver.ts +230 -33
- package/src/bidi/launch.ts +23 -13
- package/src/cdp/driver.ts +594 -9
- package/src/cli.ts +107 -10
- package/src/driver.ts +254 -3
- package/src/manifest.ts +20 -4
- package/src/mcp.ts +21 -12
- package/src/png.ts +809 -0
- package/src/shared-tools.ts +313 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdp-toolkit",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.5",
|
|
4
4
|
"description": "A lightweight chrome-devtools-mcp alternative: an MCP server + CLI that drives the tabs you name, one target per call over one 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. 45 tools including built-in network mocking, a cookie group, tab leases with human-activity detection, real HTML5 drag-and-drop, raw mouse/scroll dispatch, downloads, permission grants, and tab-to-video screen recording so several agents can drive one browser without stealing each other's tabs or a human's. No Puppeteer, no all-target fan-out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
"screencast:smoke": "bun run ./test/screencast-smoke.ts",
|
|
79
79
|
"mcp:smoke": "bun run ./test/mcp-smoke.ts",
|
|
80
80
|
"firefox:smoke": "bun run ./test/firefox-smoke.ts",
|
|
81
|
+
"firefox:attach:smoke": "bun run ./test/firefox-attach-smoke.ts",
|
|
81
82
|
"lease:smoke": "bun run ./test/lease-smoke.ts",
|
|
82
83
|
"staleness:smoke": "bun run ./test/staleness-smoke.ts",
|
|
83
84
|
"input:smoke": "bun run ./test/input-smoke.ts",
|
package/src/backend.ts
CHANGED
|
@@ -6,27 +6,42 @@
|
|
|
6
6
|
* ships to npm and today's users must see zero behavior change unless they
|
|
7
7
|
* opt in.
|
|
8
8
|
*
|
|
9
|
-
* Firefox
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
9
|
+
* Firefox runs in one of TWO modes, and the difference between them is process
|
|
10
|
+
* ownership:
|
|
11
|
+
* - LAUNCH (default): this module spawns a throwaway-profile Firefox and OWNS
|
|
12
|
+
* it. dispose() closes the BiDi session THEN kills the spawned binary, in
|
|
13
|
+
* that order, so no invocation can leak a process.
|
|
14
|
+
* - ATTACH (`--connect` / CDP_FIREFOX_ENDPOINT): the user already started
|
|
15
|
+
* Firefox with --remote-debugging-port, so it carries their real logged-in
|
|
16
|
+
* profile and we merely connect to its BiDi endpoint. NOTHING on this path
|
|
17
|
+
* owns a process: dispose() ends the BiDi session (freeing Firefox's single
|
|
18
|
+
* session slot) and closes the socket, and MUST NEVER kill the browser.
|
|
19
|
+
* What is impossible is RELAUNCHING the binary to open a debug port on an
|
|
20
|
+
* already-running instance (bidi/launch.ts's header), not attaching to a
|
|
21
|
+
* port that is already open.
|
|
22
|
+
* The Firefox driver is `lifetime: "session"` (ADR-001), so session ownership
|
|
23
|
+
* also differs by entry point:
|
|
24
|
+
* - CLI: one process per invocation. startFirefoxSession() launches or
|
|
25
|
+
* attaches, the caller runs exactly one tool call, then
|
|
26
|
+
* disposeFirefoxSession() tears the session (and, in launch mode only, the
|
|
27
|
+
* spawned process) down before the process exits.
|
|
28
|
+
* - MCP: long-lived. getOrCreateFirefoxSession() launches/attaches at most
|
|
29
|
+
* once and memoizes the session at module scope; disposeFirefoxSession()
|
|
30
|
+
* runs on shutdown (SIGINT/SIGTERM/stdin close), never per tool call.
|
|
31
|
+
* Both paths funnel through the same FirefoxSession, whose dispose() is
|
|
32
|
+
* idempotent in either mode.
|
|
20
33
|
*/
|
|
21
34
|
import type { BrowserDriver, DriverKind } from "./driver.ts";
|
|
22
|
-
import { createFirefoxDriver } from "./bidi/driver.ts";
|
|
35
|
+
import { createFirefoxDriver, createFirefoxDriverForEndpoint } from "./bidi/driver.ts";
|
|
23
36
|
import { launchFirefox } from "./bidi/launch.ts";
|
|
24
37
|
|
|
25
|
-
/** Resolve the selected backend from argv (already flag-scanned) + env, defaulting to chrome.
|
|
26
|
-
|
|
38
|
+
/** Resolve the selected backend from argv (already flag-scanned) + env, defaulting to chrome.
|
|
39
|
+
* `env` is injectable ONLY so a test (and resolveBackend, which must read one consistent env)
|
|
40
|
+
* can pass a fixed object; every caller that omits it gets exactly today's process.env read. */
|
|
41
|
+
export function resolveBrowserKind(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): DriverKind {
|
|
27
42
|
const flagIdx = argv.indexOf("--browser");
|
|
28
43
|
const flagVal = flagIdx !== -1 ? argv[flagIdx + 1] : undefined;
|
|
29
|
-
const raw = flagVal ??
|
|
44
|
+
const raw = flagVal ?? env.CDP_BROWSER ?? "chrome";
|
|
30
45
|
if (raw !== "chrome" && raw !== "firefox") {
|
|
31
46
|
throw new Error(`unknown --browser '${raw}': expected 'chrome' or 'firefox' (or set CDP_BROWSER)`);
|
|
32
47
|
}
|
|
@@ -43,13 +58,130 @@ export function stripBrowserFlag(argv: readonly string[]): string[] {
|
|
|
43
58
|
return out;
|
|
44
59
|
}
|
|
45
60
|
|
|
61
|
+
/* ------------------------------- Firefox attach endpoint ------------------------------- */
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Normalize the three accepted spellings of a Firefox attach endpoint into the one thing the
|
|
65
|
+
* transport takes, a ws URL:
|
|
66
|
+
* `9223` -> ws://127.0.0.1:9223/session
|
|
67
|
+
* `127.0.0.1:9223` -> ws://127.0.0.1:9223/session
|
|
68
|
+
* `ws://host:9223/session` -> verbatim (also wss://, also a non-default path)
|
|
69
|
+
* The bare-port and host:port spellings exist because they are what the user typed into Firefox's
|
|
70
|
+
* own `--remote-debugging-port`; the ws URL exists because BiDi's endpoint is not discoverable over
|
|
71
|
+
* HTTP the way CDP's is (bidi/client.ts's footer), so a non-loopback or proxied setup has no other
|
|
72
|
+
* way to say where the socket is. Throws with the accepted spellings on anything else, rather than
|
|
73
|
+
* handing the transport a URL that will fail later as an opaque connect timeout.
|
|
74
|
+
*/
|
|
75
|
+
export function normalizeBidiEndpoint(raw: string): string {
|
|
76
|
+
const value = raw.trim();
|
|
77
|
+
if (/^wss?:\/\//i.test(value)) {
|
|
78
|
+
let url: URL;
|
|
79
|
+
try {
|
|
80
|
+
url = new URL(value);
|
|
81
|
+
} catch {
|
|
82
|
+
throw new Error(`invalid Firefox endpoint '${raw}': not a parseable ws:// URL`);
|
|
83
|
+
}
|
|
84
|
+
// Firefox serves BiDi at /session; a user who pasted just the origin means that path, and
|
|
85
|
+
// silently connecting to "/" would fail as a bare handshake error with no hint why.
|
|
86
|
+
if (url.pathname === "" || url.pathname === "/") url.pathname = "/session";
|
|
87
|
+
return url.toString();
|
|
88
|
+
}
|
|
89
|
+
if (/^\d+$/.test(value)) return `ws://127.0.0.1:${assertPort(value, raw)}/session`;
|
|
90
|
+
const hostPort = /^([^:/]+):(\d+)$/.exec(value);
|
|
91
|
+
if (hostPort) return `ws://${hostPort[1]}:${assertPort(hostPort[2]!, raw)}/session`;
|
|
92
|
+
throw new Error(`invalid Firefox endpoint '${raw}': expected a port (9223), host:port, or a ws:// URL`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Port validation shared by the bare-port and host:port spellings. Rejects 0 as well as >65535:
|
|
96
|
+
* 0 means "pick any free port" to a listener and nothing at all to a client dialing out. */
|
|
97
|
+
function assertPort(digits: string, raw: string): number {
|
|
98
|
+
const port = Number(digits);
|
|
99
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
100
|
+
throw new Error(`invalid Firefox endpoint '${raw}': port must be 1-65535`);
|
|
101
|
+
}
|
|
102
|
+
return port;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve the attach endpoint, or undefined for launch mode. Flag beats env, mirroring
|
|
107
|
+
* resolveBrowserKind's precedence: `--connect <port|host:port|ws-url>` then CDP_FIREFOX_ENDPOINT.
|
|
108
|
+
* An empty value counts as unset so an exported-but-blank env var does not force attach mode.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveFirefoxEndpoint(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
111
|
+
const flagIdx = argv.indexOf("--connect");
|
|
112
|
+
const raw = (flagIdx !== -1 ? argv[flagIdx + 1] : undefined) ?? env.CDP_FIREFOX_ENDPOINT;
|
|
113
|
+
if (raw === undefined || raw === "") return undefined;
|
|
114
|
+
return normalizeBidiEndpoint(raw);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Strip a `--connect <value>` pair out of argv so it never reaches tool-arg parsing, exactly as
|
|
118
|
+
* stripBrowserFlag does for --browser. */
|
|
119
|
+
export function stripConnectFlag(argv: readonly string[]): string[] {
|
|
120
|
+
const out: string[] = [];
|
|
121
|
+
for (let i = 0; i < argv.length; i++) {
|
|
122
|
+
if (argv[i] === "--connect") { i++; continue; }
|
|
123
|
+
out.push(argv[i]!);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** What one entry point needs to know about the backend: which driver, and (Firefox only) whether
|
|
129
|
+
* to attach to an existing endpoint instead of launching. */
|
|
130
|
+
export interface BackendSelection {
|
|
131
|
+
kind: DriverKind;
|
|
132
|
+
endpoint?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The single backend-selection entry point for cli.ts and mcp.ts: kind + optional attach endpoint.
|
|
137
|
+
*
|
|
138
|
+
* An endpoint IMPLIES Firefox, because there is nothing else it could mean — CDP has no such
|
|
139
|
+
* option here (Chrome's attach is its own --remote-debugging-port convention on the CDP client) and
|
|
140
|
+
* a user who passes one has already told us which browser they started. The one case that must
|
|
141
|
+
* fail loudly rather than be silently overridden is an EXPLICIT `--browser chrome`/CDP_BROWSER=chrome
|
|
142
|
+
* alongside an endpoint: honoring either half of that would silently ignore the other.
|
|
143
|
+
*/
|
|
144
|
+
export function resolveBackend(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): BackendSelection {
|
|
145
|
+
const endpoint = resolveFirefoxEndpoint(argv, env);
|
|
146
|
+
let kind = resolveBrowserKind(argv, env);
|
|
147
|
+
const explicitBrowser = argv.includes("--browser") || env.CDP_BROWSER !== undefined;
|
|
148
|
+
if (endpoint !== undefined) {
|
|
149
|
+
if (explicitBrowser && kind === "chrome") {
|
|
150
|
+
throw new Error("--connect / CDP_FIREFOX_ENDPOINT is a Firefox attach option and is not valid with the chrome backend");
|
|
151
|
+
}
|
|
152
|
+
kind = "firefox";
|
|
153
|
+
}
|
|
154
|
+
return endpoint !== undefined ? { kind, endpoint } : { kind };
|
|
155
|
+
}
|
|
156
|
+
|
|
46
157
|
export interface FirefoxSession {
|
|
47
158
|
driver: BrowserDriver;
|
|
48
159
|
dispose(): Promise<void>;
|
|
49
160
|
}
|
|
50
161
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
162
|
+
/**
|
|
163
|
+
* Start a Firefox session and construct its Driver. Caller owns dispose().
|
|
164
|
+
* With `endpoint`, ATTACHES to the user's already-running Firefox; without it, LAUNCHES a fresh
|
|
165
|
+
* throwaway-profile one. The two dispose() bodies differ in exactly one way, and it is the safety
|
|
166
|
+
* invariant of this whole feature: only the launch path closes a process.
|
|
167
|
+
*/
|
|
168
|
+
export async function startFirefoxSession(opts?: { headless?: boolean; endpoint?: string }): Promise<FirefoxSession> {
|
|
169
|
+
if (opts?.endpoint !== undefined) {
|
|
170
|
+
const driver = createFirefoxDriverForEndpoint(opts.endpoint);
|
|
171
|
+
let disposed = false;
|
|
172
|
+
return {
|
|
173
|
+
driver,
|
|
174
|
+
async dispose(): Promise<void> {
|
|
175
|
+
if (disposed) return;
|
|
176
|
+
disposed = true;
|
|
177
|
+
// The ONLY teardown in attach mode. driver.dispose() sends session.end (so Firefox frees
|
|
178
|
+
// its single session slot for the next attach) and closes the socket. There is deliberately
|
|
179
|
+
// no process close here: we did not start this browser, and killing a user's real
|
|
180
|
+
// logged-in Firefox because a CLI invocation ended would be unforgivable.
|
|
181
|
+
await driver.dispose().catch(() => undefined);
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
53
185
|
const launched = await launchFirefox({ headless: opts?.headless });
|
|
54
186
|
const driver = createFirefoxDriver(launched.port);
|
|
55
187
|
let disposed = false;
|
|
@@ -71,13 +203,16 @@ export async function startFirefoxSession(opts?: { headless?: boolean }): Promis
|
|
|
71
203
|
|
|
72
204
|
let sessionPromise: Promise<FirefoxSession> | undefined;
|
|
73
205
|
|
|
74
|
-
/** Lazily
|
|
75
|
-
|
|
76
|
-
|
|
206
|
+
/** Lazily start the Firefox session at most once per server process; every caller shares it.
|
|
207
|
+
* `endpoint` is read only on the first call, which is correct: the backend selection is fixed at
|
|
208
|
+
* server startup (mcp.ts resolves it from argv/env once), never per tool call. */
|
|
209
|
+
export function getOrCreateFirefoxSession(opts?: { endpoint?: string }): Promise<FirefoxSession> {
|
|
210
|
+
if (!sessionPromise) sessionPromise = startFirefoxSession({ endpoint: opts?.endpoint });
|
|
77
211
|
return sessionPromise;
|
|
78
212
|
}
|
|
79
213
|
|
|
80
|
-
/** Idempotent shutdown hook: no-op if Firefox was
|
|
214
|
+
/** Idempotent shutdown hook: no-op if no Firefox session was ever started this process. Covers both
|
|
215
|
+
* modes — in attach mode it ends the BiDi session without touching the user's browser process. */
|
|
81
216
|
export async function disposeFirefoxSession(): Promise<void> {
|
|
82
217
|
if (!sessionPromise) return;
|
|
83
218
|
const p = sessionPromise;
|
package/src/bidi/client.ts
CHANGED
|
@@ -286,27 +286,62 @@ export class BidiConnection {
|
|
|
286
286
|
}
|
|
287
287
|
|
|
288
288
|
/**
|
|
289
|
-
* Open a BiDi connection to
|
|
290
|
-
* one step
|
|
291
|
-
*
|
|
292
|
-
*
|
|
289
|
+
* Open a BiDi connection to an explicit WebSocket endpoint and create its
|
|
290
|
+
* session in one step. THE URL IS THE IDENTITY: a Firefox this toolkit
|
|
291
|
+
* launched is ws://127.0.0.1:<port>/session, but attach mode (backend.ts's
|
|
292
|
+
* normalizeBidiEndpoint) may hand over any host/port/path the user's own
|
|
293
|
+
* Firefox is listening on, so nothing here reconstructs a URL from a port.
|
|
294
|
+
* This module never launches Firefox; the endpoint must already be listening.
|
|
293
295
|
* The returned connection is retained once (holders === 1): the caller owns
|
|
294
296
|
* that first retain and must release() or dispose() it.
|
|
297
|
+
*
|
|
298
|
+
* NOTE for attach callers: Firefox permits exactly ONE active BiDi session, and
|
|
299
|
+
* closing a socket does NOT free that slot (verified on 153.0.3) — only
|
|
300
|
+
* session.end does. A second session.new against a Firefox that still holds an
|
|
301
|
+
* abandoned session fails with "Maximum number of active sessions"; driver.ts
|
|
302
|
+
* turns that into an actionable "disconnected" DriverError.
|
|
295
303
|
*/
|
|
296
|
-
export async function
|
|
297
|
-
|
|
304
|
+
export async function connectBidiSessionUrl(
|
|
305
|
+
wsUrl: string,
|
|
298
306
|
opts: {
|
|
299
307
|
timeoutMs?: number;
|
|
300
308
|
capabilities?: BidiCommands["session.new"]["params"]["capabilities"];
|
|
301
309
|
} = {},
|
|
302
310
|
): Promise<BidiConnection> {
|
|
303
|
-
const conn = new BidiConnection(
|
|
311
|
+
const conn = new BidiConnection(wsUrl, { timeoutMs: opts.timeoutMs });
|
|
304
312
|
await conn.connect();
|
|
305
|
-
|
|
313
|
+
try {
|
|
314
|
+
await conn.newSession(opts.capabilities ?? {});
|
|
315
|
+
} catch (e) {
|
|
316
|
+
// A REJECTED session.new still leaves an OPEN socket, and an open WebSocket is a live handle
|
|
317
|
+
// that keeps the event loop alive: without this the CLI printed "Maximum number of active
|
|
318
|
+
// sessions" and then hung forever instead of exiting (measured against Firefox 153.0.3 while
|
|
319
|
+
// a second client held the one session slot). Close it before rethrowing so a failed connect
|
|
320
|
+
// costs an error and nothing else.
|
|
321
|
+
conn.dispose();
|
|
322
|
+
throw e;
|
|
323
|
+
}
|
|
306
324
|
conn.retain();
|
|
307
325
|
return conn;
|
|
308
326
|
}
|
|
309
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Port-shaped convenience wrapper over connectBidiSessionUrl, for the launched
|
|
330
|
+
* Firefox whose endpoint is always the loopback shape ws://127.0.0.1:<port>/session.
|
|
331
|
+
* Pass a port from launch.ts's launchFirefox() or any other running Firefox with
|
|
332
|
+
* the debug port open on loopback. Kept exported for back-compat with callers
|
|
333
|
+
* that only ever hold a port.
|
|
334
|
+
*/
|
|
335
|
+
export async function connectBidiSession(
|
|
336
|
+
port: number,
|
|
337
|
+
opts: {
|
|
338
|
+
timeoutMs?: number;
|
|
339
|
+
capabilities?: BidiCommands["session.new"]["params"]["capabilities"];
|
|
340
|
+
} = {},
|
|
341
|
+
): Promise<BidiConnection> {
|
|
342
|
+
return connectBidiSessionUrl(`ws://127.0.0.1:${port}/session`, opts);
|
|
343
|
+
}
|
|
344
|
+
|
|
310
345
|
/*
|
|
311
346
|
* BiDi modules/commands used: session (session.new, session.subscribe,
|
|
312
347
|
* session.unsubscribe). All other modules (browsingContext, script, input,
|
|
@@ -314,6 +349,9 @@ export async function connectBidiSession(
|
|
|
314
349
|
* typed send()/on()/waitFor() surface above but this file itself calls none
|
|
315
350
|
* of their commands directly, that is left to the driver and tool layers
|
|
316
351
|
* built on top. Parity gap vs client.ts: there is no browserWsUrl/listTargets
|
|
317
|
-
* equivalent here
|
|
318
|
-
*
|
|
352
|
+
* equivalent here and no HTTP discovery step at all — the ws URL IS the
|
|
353
|
+
* endpoint identity. For a Firefox this toolkit launched that URL is derived
|
|
354
|
+
* from a port (the fixed loopback shape ws://127.0.0.1:<port>/session); for an
|
|
355
|
+
* attached one it is whatever the user configured, so it may carry a different
|
|
356
|
+
* host, port, or path and must be passed through verbatim.
|
|
319
357
|
*/
|