cdp-toolkit 2.0.0 → 2.2.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.
@@ -0,0 +1,610 @@
1
+ /**
2
+ * The holder-hosted BiDi session multiplexer (design brief
3
+ * docs/design/2026-09-04-firefox-multi-agent-session-mux.md §2.1).
4
+ *
5
+ * WHY THIS EXISTS: Firefox serves exactly ONE WebDriver BiDi session, bound to
6
+ * exactly one socket — a second socket is refused at the HTTP upgrade, and a
7
+ * second session.new is refused with "Maximum number of active sessions". So a
8
+ * second cdp-toolkit PROCESS pointed at the same Firefox cannot drive it at
9
+ * all. The fix is that the process which wins the session slot (the "holder")
10
+ * also hosts a loopback WebSocket server that itself speaks BiDi, and every
11
+ * other process dials THAT with the same connectBidiSessionUrl it would use
12
+ * against Firefox. From a joiner's point of view a joined connection is
13
+ * indistinguishable from a real one: it does session.new, gets capabilities
14
+ * back, sends commands, receives events.
15
+ *
16
+ * WHY HAND-ROLLED RFC 6455: CONTRACT.md rule 1 — zero runtime dependencies.
17
+ * Node's global WebSocket is a CLIENT only; there is no server in the standard
18
+ * library, so the server half is `node:http` + the 'upgrade' event + about a
19
+ * hundred lines of framing here. It only ever binds loopback, and it only ever
20
+ * speaks text frames, which keeps the surface small enough to hand-verify.
21
+ *
22
+ * WHAT IS LOCAL AND WHAT IS FORWARDED: session.new/end/subscribe/unsubscribe/
23
+ * status are answered HERE (forwarding session.new would have Firefox refuse
24
+ * it, and forwarding session.end would kill everyone's session). Every other
25
+ * command is forwarded on the single upstream connection with the client's id
26
+ * mapped back onto the reply, so ids from different joiners never collide.
27
+ */
28
+ import { createServer, type Server, type Socket } from "node:net";
29
+ import { createHash } from "node:crypto";
30
+ import { BidiError, type BidiConnection } from "./client.ts";
31
+ import type { BidiEventName } from "./protocol.ts";
32
+
33
+ /** RFC 6455 §1.3: the fixed GUID concatenated with Sec-WebSocket-Key. */
34
+ const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
35
+ /** A single BiDi message larger than this is a bug or an attack, not a payload. */
36
+ const MAX_MESSAGE_BYTES = 64 * 1024 * 1024;
37
+ /** Deliberately far longer than a joiner's own 15 s send timer: the joiner's
38
+ * timeout must win the race, the mux must never be the thing that gives up. */
39
+ const FORWARD_TIMEOUT_MS = 120_000;
40
+
41
+ export interface BidiMux {
42
+ /** ws://127.0.0.1:<ephemeral>/session — what joiners dial. */
43
+ endpoint: string;
44
+ /** Live joined client sockets. */
45
+ clients(): number;
46
+ /** Close every client socket (code 1001 "holder closing") and stop listening. Idempotent. */
47
+ close(): Promise<void>;
48
+ }
49
+
50
+ /* ------------------------------------------------------------------ */
51
+ /* Minimal RFC 6455 server */
52
+ /* ------------------------------------------------------------------ */
53
+
54
+ /** One accepted client socket, framing hidden behind send/close callbacks. */
55
+ export interface WsClient {
56
+ /** Send one unmasked text frame. No-op once the socket is gone. */
57
+ send(text: string): void;
58
+ /** Start the close handshake with `code`, then drop the socket. Idempotent. */
59
+ close(code: number, reason?: string): void;
60
+ /** Set by the owner before any data can arrive (assigned in onConnection). */
61
+ onMessage?: (message: string) => void;
62
+ onClose?: () => void;
63
+ }
64
+
65
+ export interface WsServer {
66
+ port: number;
67
+ clients: Set<WsClient>;
68
+ /** Destroy every client socket and stop listening. Resolves on 'close'. */
69
+ close(): Promise<void>;
70
+ }
71
+
72
+ function acceptKey(key: string): string {
73
+ return createHash("sha1").update(key + WS_GUID).digest("base64");
74
+ }
75
+
76
+ /** Build one unmasked server frame. Server frames are never masked (RFC 6455 §5.1). */
77
+ function encodeFrame(opcode: number, payload: Buffer): Buffer {
78
+ const len = payload.length;
79
+ let header: Buffer;
80
+ if (len < 126) {
81
+ header = Buffer.alloc(2);
82
+ header[1] = len;
83
+ } else if (len < 65536) {
84
+ header = Buffer.alloc(4);
85
+ header[1] = 126;
86
+ header.writeUInt16BE(len, 2);
87
+ } else {
88
+ header = Buffer.alloc(10);
89
+ header[1] = 127;
90
+ header.writeBigUInt64BE(BigInt(len), 2);
91
+ }
92
+ header[0] = 0x80 | opcode; // FIN + opcode
93
+ return Buffer.concat([header, payload]);
94
+ }
95
+
96
+ function encodeClose(code: number, reason: string): Buffer {
97
+ const r = Buffer.from(reason, "utf8");
98
+ const body = Buffer.alloc(2 + r.length);
99
+ body.writeUInt16BE(code, 0);
100
+ r.copy(body, 2);
101
+ return encodeFrame(0x8, body);
102
+ }
103
+
104
+
105
+ /**
106
+ * Bind a loopback WebSocket server. `onConnection` receives each accepted
107
+ * client BEFORE any message is dispatched, so assigning onMessage there can
108
+ * never miss a frame (dispatch only happens on a later pass through the frame
109
+ * loop, after the handshake response has been written).
110
+ *
111
+ * WHY node:net AND A HAND-PARSED HANDSHAKE, not node:http's 'upgrade' event:
112
+ * under Bun 1.3.14 the Duplex handed to an 'upgrade' listener silently
113
+ * swallows writes — the 101 response never reaches the wire and every client
114
+ * hangs at connect (the same code works on Node 24). Measured 2026-09-04 with
115
+ * a raw net client on both runtimes. The handshake is one request line plus
116
+ * headers, so parsing it here costs ~25 lines and removes a whole runtime
117
+ * dependency's worth of behavioral difference.
118
+ */
119
+ export function createWsServer(opts: {
120
+ host?: string;
121
+ port?: number;
122
+ path?: string;
123
+ onConnection: (client: WsClient) => void;
124
+ }): Promise<WsServer> {
125
+ const host = opts.host ?? "127.0.0.1";
126
+ const path = opts.path ?? "/session";
127
+ const clients = new Set<WsClient>();
128
+ const sockets = new Set<Socket>();
129
+
130
+ const server: Server = createServer((socket: Socket) => {
131
+ sockets.add(socket);
132
+ socket.setNoDelay(true);
133
+
134
+ let buf: Buffer = Buffer.alloc(0);
135
+ let upgraded = false;
136
+ let fragments: Buffer[] = [];
137
+ let fragmentOpcode = -1;
138
+ let dead = false;
139
+ let client: WsClient | undefined;
140
+
141
+ const drop = (): void => {
142
+ if (dead) return;
143
+ dead = true;
144
+ sockets.delete(socket);
145
+ if (client) {
146
+ clients.delete(client);
147
+ try {
148
+ client.onClose?.();
149
+ } catch {
150
+ /* a handler must never take down the socket layer */
151
+ }
152
+ }
153
+ };
154
+
155
+ const dispatch = (payload: Buffer): void => {
156
+ try {
157
+ client?.onMessage?.(payload.toString("utf8"));
158
+ } catch {
159
+ /* a message handler's failure must not kill the connection */
160
+ }
161
+ };
162
+
163
+ /** Returns false when the socket has been closed and parsing must stop. */
164
+ function handshake(): boolean {
165
+ const end = buf.indexOf("\r\n\r\n");
166
+ if (end === -1) {
167
+ // A client that never finishes its request line is not a WebSocket.
168
+ if (buf.length > 16 * 1024) {
169
+ socket.end("HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n");
170
+ drop();
171
+ return false;
172
+ }
173
+ return false;
174
+ }
175
+ const header = buf.subarray(0, end).toString("latin1");
176
+ buf = buf.subarray(end + 4);
177
+ const lines = header.split("\r\n");
178
+ const reqPath = (lines[0] ?? "").split(" ")[1]?.split("?")[0] ?? "";
179
+ const headers = new Map<string, string>();
180
+ for (const line of lines.slice(1)) {
181
+ const i = line.indexOf(":");
182
+ if (i > 0) headers.set(line.slice(0, i).trim().toLowerCase(), line.slice(i + 1).trim());
183
+ }
184
+ if (reqPath !== path) {
185
+ socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
186
+ drop();
187
+ return false;
188
+ }
189
+ const key = headers.get("sec-websocket-key");
190
+ if (!key || (headers.get("upgrade") ?? "").toLowerCase() !== "websocket") {
191
+ socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
192
+ drop();
193
+ return false;
194
+ }
195
+ socket.write(
196
+ "HTTP/1.1 101 Switching Protocols\r\n" +
197
+ "Upgrade: websocket\r\n" +
198
+ "Connection: Upgrade\r\n" +
199
+ `Sec-WebSocket-Accept: ${acceptKey(key)}\r\n\r\n`,
200
+ );
201
+ upgraded = true;
202
+ const c: WsClient = {
203
+ send(text: string): void {
204
+ if (dead || socket.destroyed) return;
205
+ try {
206
+ socket.write(encodeFrame(0x1, Buffer.from(text, "utf8")));
207
+ } catch {
208
+ /* peer vanished mid-write; the 'close' event will clean up */
209
+ }
210
+ },
211
+ close(code: number, reason = ""): void {
212
+ if (dead || socket.destroyed) {
213
+ drop();
214
+ return;
215
+ }
216
+ try {
217
+ socket.write(encodeClose(code, reason));
218
+ } catch {
219
+ /* ignore */
220
+ }
221
+ drop();
222
+ try {
223
+ socket.end();
224
+ } catch {
225
+ /* ignore */
226
+ }
227
+ },
228
+ };
229
+ client = c;
230
+ clients.add(c);
231
+ opts.onConnection(c);
232
+ return true;
233
+ }
234
+
235
+ socket.on("data", (chunk: Buffer) => {
236
+ // NEVER throw out of a socket handler: an uncaught throw here is an
237
+ // unhandled 'error' on the process, not a failed request.
238
+ try {
239
+ buf = buf.length === 0 ? chunk : Buffer.concat([buf, chunk]);
240
+ if (!upgraded && !handshake()) return;
241
+ const c = client;
242
+ if (!c) return;
243
+ for (;;) {
244
+ if (dead) return;
245
+ if (buf.length < 2) return;
246
+ const b0 = buf[0]!;
247
+ const b1 = buf[1]!;
248
+ const fin = (b0 & 0x80) !== 0;
249
+ const opcode = b0 & 0x0f;
250
+ const masked = (b1 & 0x80) !== 0;
251
+ let len = b1 & 0x7f;
252
+ let off = 2;
253
+ if (len === 126) {
254
+ if (buf.length < off + 2) return;
255
+ len = buf.readUInt16BE(off);
256
+ off += 2;
257
+ } else if (len === 127) {
258
+ if (buf.length < off + 8) return;
259
+ const big = buf.readBigUInt64BE(off);
260
+ if (big > BigInt(MAX_MESSAGE_BYTES)) {
261
+ c.close(1009, "message too big");
262
+ return;
263
+ }
264
+ len = Number(big);
265
+ off += 8;
266
+ }
267
+ if (len > MAX_MESSAGE_BYTES) {
268
+ c.close(1009, "message too big");
269
+ return;
270
+ }
271
+ let maskKey: Buffer | undefined;
272
+ if (masked) {
273
+ if (buf.length < off + 4) return;
274
+ maskKey = buf.subarray(off, off + 4);
275
+ off += 4;
276
+ }
277
+ if (buf.length < off + len) return;
278
+ const payload = Buffer.from(buf.subarray(off, off + len));
279
+ buf = buf.subarray(off + len);
280
+ if (maskKey) {
281
+ for (let i = 0; i < payload.length; i++) payload[i] = payload[i]! ^ maskKey[i % 4]!;
282
+ }
283
+
284
+ if (opcode === 0x8) {
285
+ // Close handshake: echo the close, then let go of the socket.
286
+ const code = payload.length >= 2 ? payload.readUInt16BE(0) : 1000;
287
+ c.close(code === 1005 ? 1000 : code, "");
288
+ return;
289
+ }
290
+ if (opcode === 0x9) {
291
+ if (!socket.destroyed) socket.write(encodeFrame(0xa, payload));
292
+ continue;
293
+ }
294
+ if (opcode === 0xa) continue; // unsolicited pong: ignore
295
+ if (opcode === 0x2) {
296
+ c.close(1003, "binary frames unsupported");
297
+ return;
298
+ }
299
+ if (opcode === 0x1 || opcode === 0x0) {
300
+ if (opcode === 0x1) {
301
+ if (fragmentOpcode !== -1) {
302
+ c.close(1002, "interleaved fragments");
303
+ return;
304
+ }
305
+ if (fin) {
306
+ dispatch(payload);
307
+ continue;
308
+ }
309
+ fragmentOpcode = 0x1;
310
+ fragments = [payload];
311
+ continue;
312
+ }
313
+ if (fragmentOpcode === -1) {
314
+ c.close(1002, "continuation without start");
315
+ return;
316
+ }
317
+ fragments.push(payload);
318
+ const total = fragments.reduce((n, f) => n + f.length, 0);
319
+ if (total > MAX_MESSAGE_BYTES) {
320
+ c.close(1009, "message too big");
321
+ return;
322
+ }
323
+ if (fin) {
324
+ const whole = Buffer.concat(fragments);
325
+ fragments = [];
326
+ fragmentOpcode = -1;
327
+ dispatch(whole);
328
+ }
329
+ continue;
330
+ }
331
+ c.close(1002, "unsupported opcode");
332
+ return;
333
+ }
334
+ } catch {
335
+ try {
336
+ client?.close(1002, "protocol error");
337
+ } catch {
338
+ /* ignore */
339
+ }
340
+ drop();
341
+ }
342
+ });
343
+
344
+ socket.on("error", () => drop());
345
+ socket.on("close", () => drop());
346
+ });
347
+ return new Promise<WsServer>((resolve, reject) => {
348
+ server.once("error", reject);
349
+ server.listen(opts.port ?? 0, host, () => {
350
+ const addr = server.address();
351
+ const port = typeof addr === "object" && addr ? addr.port : 0;
352
+ let closing: Promise<void> | undefined;
353
+ resolve({
354
+ port,
355
+ clients,
356
+ close(): Promise<void> {
357
+ if (closing) return closing;
358
+ closing = new Promise<void>((res) => {
359
+ let done = false;
360
+ const finish = (): void => {
361
+ if (done) return;
362
+ done = true;
363
+ clearInterval(poll);
364
+ res();
365
+ };
366
+ // WHY THE POLL AND NOT JUST THE CALLBACK. Measured on Bun 1.3.14 (2026-09-04): once
367
+ // this server has accepted and lost a WebSocket client, neither server.close()'s
368
+ // callback nor the 'close' event ever fires, even with zero live clients and every
369
+ // tracked socket destroyed — a close() that hangs forever. `server.listening` DOES flip
370
+ // to false, and the listener really is shut, so that is the observable we resolve on.
371
+ // The callback/event paths are kept first because on Node they fire immediately.
372
+ const poll = setInterval(() => {
373
+ if (!server.listening) finish();
374
+ }, 20);
375
+ poll.unref?.();
376
+ server.close(finish);
377
+ server.once("close", finish);
378
+ for (const s of sockets) {
379
+ try {
380
+ s.destroy();
381
+ } catch {
382
+ /* ignore */
383
+ }
384
+ }
385
+ sockets.clear();
386
+ });
387
+ return closing;
388
+ },
389
+ });
390
+ });
391
+ });
392
+ }
393
+
394
+ /* ------------------------------------------------------------------ */
395
+ /* The multiplexer */
396
+ /* ------------------------------------------------------------------ */
397
+
398
+ interface IncomingCommand {
399
+ id?: unknown;
400
+ method?: unknown;
401
+ params?: unknown;
402
+ }
403
+
404
+ interface MuxClientState {
405
+ sessionId: string;
406
+ subscribed: Set<string>;
407
+ }
408
+
409
+ /**
410
+ * Host a BiDi multiplexer over ONE established upstream BidiConnection (the
411
+ * real Firefox session). Loopback only. Zero runtime deps: RFC 6455 server
412
+ * over node:http upgrade + hand framing.
413
+ */
414
+ export async function startBidiMux(
415
+ upstream: BidiConnection,
416
+ opts: { host?: string; port?: number } = {},
417
+ ): Promise<BidiMux> {
418
+ const host = opts.host ?? "127.0.0.1";
419
+ const states = new Map<WsClient, MuxClientState>();
420
+ /** event name -> { count, off } — the REAL upstream subscription is
421
+ * refcounted across joiners, so Firefox sees one subscribe and one
422
+ * unsubscribe no matter how many joiners want the event. */
423
+ const subs = new Map<string, { count: number; off: () => void }>();
424
+ let sessionSeq = 0;
425
+ let closed = false;
426
+ let closing: Promise<void> | undefined;
427
+
428
+ const server = await createWsServer({
429
+ host,
430
+ port: opts.port ?? 0,
431
+ path: "/session",
432
+ onConnection: (client) => {
433
+ states.set(client, { sessionId: `mux-${++sessionSeq}`, subscribed: new Set() });
434
+ client.onMessage = (raw) => void handleMessage(client, raw);
435
+ client.onClose = () => releaseClient(client);
436
+ },
437
+ });
438
+
439
+ const endpoint = `ws://${host}:${server.port}/session`;
440
+
441
+ function send(client: WsClient, obj: unknown): void {
442
+ client.send(JSON.stringify(obj));
443
+ }
444
+
445
+ function fanOut(event: string, params: unknown): void {
446
+ for (const [client, state] of states) {
447
+ if (state.subscribed.has(event)) send(client, { type: "event", method: event, params });
448
+ }
449
+ }
450
+
451
+ function subscribeOne(client: WsClient, event: string): void {
452
+ const state = states.get(client);
453
+ if (!state || state.subscribed.has(event)) return;
454
+ state.subscribed.add(event);
455
+ const existing = subs.get(event);
456
+ if (existing) {
457
+ existing.count++;
458
+ return;
459
+ }
460
+ const off = upstream.on(event as BidiEventName, (params: unknown) => fanOut(event, params));
461
+ subs.set(event, { count: 1, off });
462
+ }
463
+
464
+ function unsubscribeOne(client: WsClient, event: string): void {
465
+ const state = states.get(client);
466
+ if (!state || !state.subscribed.has(event)) return;
467
+ state.subscribed.delete(event);
468
+ const entry = subs.get(event);
469
+ if (!entry) return;
470
+ entry.count--;
471
+ if (entry.count <= 0) {
472
+ subs.delete(event);
473
+ try {
474
+ entry.off();
475
+ } catch {
476
+ /* teardown is best effort */
477
+ }
478
+ }
479
+ }
480
+
481
+ function releaseClient(client: WsClient): void {
482
+ const state = states.get(client);
483
+ if (!state) return;
484
+ for (const event of [...state.subscribed]) unsubscribeOne(client, event);
485
+ states.delete(client);
486
+ }
487
+
488
+ function eventsOf(params: unknown): string[] {
489
+ const raw = (params as { events?: unknown } | undefined)?.events;
490
+ return Array.isArray(raw) ? raw.filter((e): e is string => typeof e === "string") : [];
491
+ }
492
+
493
+ async function handleMessage(client: WsClient, raw: string): Promise<void> {
494
+ let msg: IncomingCommand;
495
+ try {
496
+ msg = JSON.parse(raw) as IncomingCommand;
497
+ } catch {
498
+ client.close(1002, "invalid JSON");
499
+ return;
500
+ }
501
+ const id = msg.id;
502
+ const method = msg.method;
503
+ if (typeof id !== "number" || typeof method !== "string") {
504
+ // Not a command we can answer, and answering the wrong id is worse than
505
+ // silence: a joiner's own send() timer will surface the loss.
506
+ return;
507
+ }
508
+ const params = msg.params;
509
+ // A joiner that skipped session.new still gets a state (created on
510
+ // connect), so every local command is answerable at any time.
511
+ const state = states.get(client);
512
+
513
+ if (method === "session.new") {
514
+ send(client, {
515
+ type: "success",
516
+ id,
517
+ result: { sessionId: state?.sessionId ?? `mux-${++sessionSeq}`, capabilities: upstream.capabilities },
518
+ });
519
+ return;
520
+ }
521
+ if (method === "session.end") {
522
+ // Local ONLY: ending the real session would take every other joiner
523
+ // down with it, and the joiner's tabs are browser state, not session
524
+ // state, so they survive untouched.
525
+ releaseClient(client);
526
+ if (state) states.set(client, { sessionId: state.sessionId, subscribed: new Set() });
527
+ send(client, { type: "success", id, result: {} });
528
+ return;
529
+ }
530
+ if (method === "session.subscribe" || method === "session.unsubscribe") {
531
+ // A `contexts` argument is accepted and treated as global: this
532
+ // toolkit's BidiConnection.on() only ever subscribes by event name.
533
+ for (const event of eventsOf(params)) {
534
+ if (method === "session.subscribe") subscribeOne(client, event);
535
+ else unsubscribeOne(client, event);
536
+ }
537
+ send(client, { type: "success", id, result: {} });
538
+ return;
539
+ }
540
+ if (method === "session.status") {
541
+ send(client, { type: "success", id, result: { ready: false, message: "multiplexed" } });
542
+ return;
543
+ }
544
+
545
+ try {
546
+ const result = await upstream.send(method as never, params as never, {
547
+ timeoutMs: FORWARD_TIMEOUT_MS,
548
+ });
549
+ send(client, { type: "success", id, result });
550
+ } catch (e) {
551
+ const err = e as BidiError;
552
+ const message = err?.message ?? String(e);
553
+ send(client, {
554
+ type: "error",
555
+ id,
556
+ error: (err instanceof BidiError ? err.code : undefined) ?? "unknown error",
557
+ message,
558
+ });
559
+ // A forward that failed because the upstream socket is gone is the
560
+ // earliest signal we get — BidiConnection exposes no onclose hook.
561
+ if (/connection closed|connection disposed|connection not open/.test(message)) {
562
+ void closeAll();
563
+ }
564
+ }
565
+ }
566
+
567
+ // The forwarded-rejection path above only fires when someone sends a
568
+ // command; an IDLE joiner would never learn the holder died. This unref'd
569
+ // poll is the other half — it costs nothing and never keeps a process alive.
570
+ const watchdog = setInterval(() => {
571
+ if (!upstream.isOpen) void closeAll();
572
+ }, 250);
573
+ watchdog.unref?.();
574
+
575
+ function closeAll(): Promise<void> {
576
+ if (closing) return closing;
577
+ closed = true;
578
+ clearInterval(watchdog);
579
+ closing = (async () => {
580
+ for (const [client] of [...states]) {
581
+ try {
582
+ client.close(1001, "holder closing");
583
+ } catch {
584
+ /* ignore */
585
+ }
586
+ }
587
+ states.clear();
588
+ for (const [, entry] of subs) {
589
+ try {
590
+ entry.off();
591
+ } catch {
592
+ /* ignore */
593
+ }
594
+ }
595
+ subs.clear();
596
+ await server.close();
597
+ })();
598
+ return closing;
599
+ }
600
+
601
+ return {
602
+ endpoint,
603
+ clients(): number {
604
+ return closed ? 0 : server.clients.size;
605
+ },
606
+ close(): Promise<void> {
607
+ return closeAll();
608
+ },
609
+ };
610
+ }