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/CONTRACT.md +1 -1
- package/README.md +34 -0
- package/dist/cli.js +3156 -1290
- package/dist/index.js +1116 -927
- package/dist/mcp.js +4472 -2626
- package/package.json +12 -6
- package/src/backend.ts +86 -0
- package/src/bidi/client.ts +319 -0
- package/src/bidi/driver.ts +823 -0
- package/src/bidi/launch.ts +192 -0
- package/src/bidi/protocol.ts +782 -0
- package/src/bidi/snapshot.ts +419 -0
- package/src/bidi-tools.ts +193 -0
- package/src/capabilities.ts +43 -0
- package/src/cdp/driver.ts +660 -0
- package/src/cli.ts +72 -14
- package/src/driver.ts +424 -0
- package/src/firefox-tools.ts +14 -0
- package/src/index.ts +49 -41
- package/src/mcp.ts +59 -9
- package/src/shared-tools.ts +420 -0
- package/src/tools/dialogs.ts +0 -236
- package/src/tools/emulation.ts +0 -212
- package/src/tools/input.ts +0 -399
- package/src/tools/pages.ts +0 -159
- package/src/tools/screenshot.ts +0 -180
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdp-toolkit",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "A lightweight chrome-devtools-mcp alternative: a single-target
|
|
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": "
|
|
54
|
-
"cdp": "
|
|
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
|
+
*/
|