cdp-toolkit 2.1.0 → 2.3.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/CHANGELOG.md +33 -0
- package/README.md +21 -7
- package/dist/bidi/mux-daemon.js +3775 -0
- package/dist/cli.js +6573 -5644
- package/dist/index.js +373 -210
- package/dist/mcp.js +8334 -7383
- package/package.json +4 -2
- package/src/bidi/driver.ts +329 -30
- package/src/bidi/mux-daemon.ts +122 -0
- package/src/bidi/mux.ts +610 -0
- package/src/bidi/session-lease.ts +95 -3
- package/src/cdp/driver.ts +1 -1
- package/src/driver.ts +3 -0
- package/src/index.ts +6 -0
- package/src/manifest.ts +65 -0
- package/src/toolDocs.ts +20 -0
- package/src/toolGroups.ts +1 -1
- package/src/tools/focus-emulation.ts +185 -0
- package/src/version.ts +1 -1
package/src/bidi/mux.ts
ADDED
|
@@ -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
|
+
}
|