pi-browser-harness 0.12.0 → 0.13.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +86 -0
  3. package/package.json +12 -6
  4. package/skills/pi-browser-harness/SKILL.md +57 -2
  5. package/src/cdp/commands.ts +200 -14
  6. package/src/cdp/console-buffer.ts +37 -23
  7. package/src/cdp/discovery.ts +57 -25
  8. package/src/cdp/events.ts +8 -3
  9. package/src/cdp/network-buffer.ts +38 -32
  10. package/src/cdp/ownership.ts +22 -4
  11. package/src/cdp/record-store.ts +2 -2
  12. package/src/cdp/session.ts +320 -217
  13. package/src/cdp/target-factory.ts +34 -15
  14. package/src/cdp/types.ts +17 -1
  15. package/src/client.ts +76 -43
  16. package/src/daemon/bridge.ts +110 -349
  17. package/src/daemon/connection.ts +323 -0
  18. package/src/daemon/index.ts +77 -48
  19. package/src/daemon/lines.ts +40 -0
  20. package/src/daemon/main.ts +12 -0
  21. package/src/daemon/protocol.ts +55 -25
  22. package/src/daemon/server.ts +100 -63
  23. package/src/daemon/socket.ts +10 -0
  24. package/src/daemon/spawn.ts +21 -22
  25. package/src/daemon/transport.ts +80 -49
  26. package/src/domains/ax-tree.ts +8 -8
  27. package/src/domains/cdp-call.ts +14 -7
  28. package/src/domains/click.ts +2 -2
  29. package/src/domains/concurrency.ts +50 -0
  30. package/src/domains/console.ts +20 -64
  31. package/src/domains/drag.ts +26 -28
  32. package/src/domains/element-call.ts +102 -68
  33. package/src/domains/files.ts +13 -9
  34. package/src/domains/form-batch.ts +13 -10
  35. package/src/domains/form.ts +78 -107
  36. package/src/domains/headers.ts +2 -2
  37. package/src/domains/history.ts +4 -4
  38. package/src/domains/io.ts +35 -0
  39. package/src/domains/isolated-tab.ts +6 -12
  40. package/src/domains/js.ts +65 -44
  41. package/src/domains/keyboard.ts +14 -11
  42. package/src/domains/navigate.ts +102 -66
  43. package/src/domains/network.ts +55 -95
  44. package/src/domains/page/fill.page.ts +139 -0
  45. package/src/domains/page/form.page.ts +61 -0
  46. package/src/domains/page/inject.ts +37 -0
  47. package/src/domains/page/keyboard.page.ts +16 -0
  48. package/src/domains/page/live-value.page.ts +10 -0
  49. package/src/domains/page/read-page.page.ts +51 -0
  50. package/src/domains/page/serp.page.ts +39 -0
  51. package/src/domains/page.ts +18 -51
  52. package/src/domains/poll.ts +42 -0
  53. package/src/domains/readpage/read-page.ts +48 -53
  54. package/src/domains/ref-resolve.ts +43 -29
  55. package/src/domains/ref-signature.ts +11 -0
  56. package/src/domains/render.ts +12 -23
  57. package/src/domains/screenshot-capture.ts +4 -2
  58. package/src/domains/screenshot.ts +13 -10
  59. package/src/domains/scroll.ts +4 -4
  60. package/src/domains/search/google-serp.ts +1 -1
  61. package/src/domains/search/web-search.ts +38 -33
  62. package/src/domains/snapshot.ts +71 -73
  63. package/src/domains/tab-resolve.ts +36 -0
  64. package/src/domains/tabs.ts +18 -69
  65. package/src/domains/target.ts +1 -1
  66. package/src/index.ts +90 -33
  67. package/src/profile/bind.ts +45 -14
  68. package/src/profile/browser-process.ts +50 -12
  69. package/src/profile/command.ts +20 -12
  70. package/src/profile/launch.ts +13 -9
  71. package/src/profile/list.ts +9 -13
  72. package/src/profile/store.ts +2 -1
  73. package/src/prompt.ts +32 -7
  74. package/src/registry.ts +65 -4
  75. package/src/schemas/parse.ts +2 -2
  76. package/src/setup.ts +28 -19
  77. package/src/state.ts +2 -1
  78. package/src/util/debug.ts +7 -1
  79. package/src/util/guards.ts +3 -0
  80. package/src/util/mutex.ts +1 -1
  81. package/src/util/paths.ts +3 -2
  82. package/src/util/result.ts +8 -0
  83. package/src/util/sharp-shim.ts +18 -11
  84. package/src/util/sleep.ts +18 -0
  85. package/src/util/tool.ts +163 -114
  86. package/src/util/truncate.ts +1 -1
  87. package/tsconfig.json +2 -1
  88. package/src/domains/fill-engine.ts +0 -80
@@ -1,12 +1,28 @@
1
- import WebSocket from "ws";
2
- import { discoverWsUrl } from "../cdp/discovery";
3
1
  import { isCdpRawMessage } from "../cdp/types";
4
2
  import type { CdpRawMessage } from "../cdp/types";
5
3
  import type { WireRequest, WireResponse, WireEvent } from "./protocol";
6
- import { CDP_CONNECT_TIMEOUT_MS, CDP_COMMAND_TIMEOUT_MS } from "./protocol";
4
+ import { CDP_COMMAND_TIMEOUT_MS, DAEMON_RECONNECT_METHOD } from "./protocol";
7
5
  import { asString, isRecord } from "../util/guards";
8
- import type { CdpError } from "../cdp/errors";
9
- import type { Result } from "../util/result";
6
+ import { type ChromeConnectionDependencies, type ConnectionStatus, createChromeConnection } from "./connection";
7
+
8
+ // The text a tool sees when the daemon has no Chrome connection to offer; it tells the model
9
+ // (and through it the user) whether to wait, to look at Chrome, or to run /browser-setup.
10
+ export const describeDisconnected = (status: ConnectionStatus): string => {
11
+ switch (status.state) {
12
+ case "open":
13
+ return "Chrome not connected";
14
+ case "connecting":
15
+ return `Chrome not connected: a connection attempt has been waiting ${Math.round(status.pendingForMs / 1000)}s. `
16
+ + "If Chrome is showing an \"Allow remote debugging\" prompt, accept it; no further prompts will be opened while this attempt is pending.";
17
+ case "cooldown":
18
+ return `Chrome not connected: the last attempt failed (${status.lastFailure}). `
19
+ + `The next automatic attempt is in ${Math.ceil(status.retryInMs / 1000)}s; run /browser-setup to retry now.`;
20
+ case "disconnected":
21
+ return status.lastFailure ? `Chrome not connected: ${status.lastFailure}` : "Chrome not connected";
22
+ case "stopped":
23
+ return "Chrome not connected: the daemon is shutting down";
24
+ }
25
+ };
10
26
 
11
27
  export type SendToClient = (clientId: string, msg: WireResponse | WireEvent) => void;
12
28
 
@@ -15,14 +31,16 @@ export type EventHandler = (event: WireEvent, targetClientIds: string[]) => void
15
31
  export type CloseHandler = () => void;
16
32
 
17
33
  export type CdpBridge = {
18
- start(): Promise<void>;
19
- stop(): Promise<void>;
20
- handleRequest(req: WireRequest, clientId: string, send: SendToClient): Promise<void>;
21
- isAlive(): boolean;
22
- getSessionOwner(sessionId: string): string | undefined;
23
- removeClient(clientId: string): void;
24
- onEvent(handler: EventHandler): void;
25
- onClose(handler: CloseHandler): void;
34
+ readonly start: () => Promise<void>;
35
+ readonly stop: () => Promise<void>;
36
+ readonly handleRequest: (req: WireRequest, clientId: string, send: SendToClient) => Promise<void>;
37
+ readonly isAlive: () => boolean;
38
+ // Not used by the daemon itself; test/manual/bridge-test.ts asserts session ownership through it.
39
+ readonly getSessionOwner: (sessionId: string) => string | undefined;
40
+ readonly removeClient: (clientId: string) => void;
41
+ readonly onEvent: (handler: EventHandler) => void;
42
+ // Fires when an established Chrome connection drops; never for an attempt that failed to open.
43
+ readonly onClose: (handler: CloseHandler) => void;
26
44
  };
27
45
 
28
46
  export type InFlight = {
@@ -31,14 +49,16 @@ export type InFlight = {
31
49
  readonly send: SendToClient;
32
50
  readonly timer: ReturnType<typeof setTimeout>;
33
51
  readonly isAttach: boolean;
52
+ // The Chrome connection the request went out on, so that only its loss fails the request.
53
+ readonly generation: number;
34
54
  };
35
55
 
36
56
  // One map, keyed by the daemon-side id: the client that asked, the id it used, and everything needed to answer it.
37
57
  export type IdMultiplexer = {
38
- allocate(entry: Omit<InFlight, "timer">, arm: (daemonId: number) => InFlight["timer"]): number;
39
- take(daemonId: number): InFlight | undefined;
40
- takeAll(): ReadonlyArray<InFlight>;
41
- clearClient(clientId: string): void;
58
+ readonly allocate: (entry: Omit<InFlight, "timer">, arm: (daemonId: number) => InFlight["timer"]) => number;
59
+ readonly take: (daemonId: number) => InFlight | undefined;
60
+ // Removes every matching entry and cancels its timeout, so nothing taken can be answered later.
61
+ readonly takeWhere: (pred: (entry: InFlight) => boolean) => ReadonlyArray<InFlight>;
42
62
  };
43
63
 
44
64
  export const createIdMultiplexer = (): IdMultiplexer => {
@@ -58,29 +78,25 @@ export const createIdMultiplexer = (): IdMultiplexer => {
58
78
  clearTimeout(e.timer);
59
79
  return e;
60
80
  },
61
- takeAll() {
62
- const all = [...inFlight.values()];
63
- inFlight.clear();
64
- for (const e of all) clearTimeout(e.timer);
65
- return all;
66
- },
67
- // A disconnected client must not be answered later: dropping its entries also cancels their timeouts.
68
- clearClient(clientId) {
81
+ takeWhere(pred) {
82
+ const taken: InFlight[] = [];
69
83
  for (const [daemonId, e] of inFlight) {
70
- if (e.clientId !== clientId) continue;
84
+ if (!pred(e)) continue;
71
85
  clearTimeout(e.timer);
72
86
  inFlight.delete(daemonId);
87
+ taken.push(e);
73
88
  }
89
+ return taken;
74
90
  },
75
91
  };
76
92
  };
77
93
 
78
94
  export type EventRouter = {
79
- record(clientId: string, sessionId: string): void;
80
- release(sessionId: string): void;
81
- getOwner(sessionId: string): string | undefined;
82
- removeClient(clientId: string): void;
83
- route(event: WireEvent): string[];
95
+ readonly record: (clientId: string, sessionId: string) => void;
96
+ readonly release: (sessionId: string) => void;
97
+ readonly getOwner: (sessionId: string) => string | undefined;
98
+ readonly removeClient: (clientId: string) => void;
99
+ readonly route: (event: WireEvent) => string[];
84
100
  };
85
101
 
86
102
  export const createEventRouter = (): EventRouter => {
@@ -119,39 +135,22 @@ export const createEventRouter = (): EventRouter => {
119
135
  };
120
136
  };
121
137
 
122
- type CdpBridgeDependencies = {
123
- discoverWsUrl?: () => Promise<Result<string, CdpError>>;
124
- createWebSocket?: (url: string) => WebSocket;
125
- connectTimeoutMs?: number;
126
- onAttemptSettled?: () => void;
127
- };
138
+ const errorResponse = (id: number, message: string): WireResponse => ({
139
+ type: "response",
140
+ id,
141
+ error: { code: -32000, message },
142
+ });
128
143
 
129
- export const createCdpBridge = (options: CdpBridgeDependencies = {}): CdpBridge => {
130
- const {
131
- discoverWsUrl: discoverWsUrlImpl = discoverWsUrl,
132
- createWebSocket: createWebSocketImpl = (url: string): WebSocket =>
133
- new WebSocket(url, { perMessageDeflate: false }),
134
- connectTimeoutMs = CDP_CONNECT_TIMEOUT_MS,
135
- onAttemptSettled,
136
- } = options;
144
+ const failAll = (entries: ReadonlyArray<InFlight>, message: string): void => {
145
+ for (const e of entries) e.send(e.clientId, errorResponse(e.localId, message));
146
+ };
137
147
 
138
- let ws: WebSocket | null = null;
139
- let wsUrl: string | null = null;
148
+ export const createCdpBridge = (options: ChromeConnectionDependencies = {}): CdpBridge => {
140
149
  const mux = createIdMultiplexer();
141
150
  const router = createEventRouter();
142
151
  let eventHandler: EventHandler | null = null;
143
152
  let closeHandler: CloseHandler | null = null;
144
153
 
145
- // daemonId -> { generation, clientId }: lets a failed attempt reject only its own
146
- // in-flight requests, and a gone client's entries be dropped without late answers.
147
- const generations = new Map<number, { generation: number; clientId: string }>();
148
-
149
- let stopped = false;
150
- let connecting = false;
151
- let attemptGeneration = 0;
152
- let activeAttempt = 0;
153
- let activeSocket: WebSocket | null = null;
154
-
155
154
  const onChromeMessage = (raw: string): void => {
156
155
  let parsed: unknown;
157
156
  try { parsed = JSON.parse(raw); } catch { return; }
@@ -159,26 +158,22 @@ export const createCdpBridge = (options: CdpBridgeDependencies = {}): CdpBridge
159
158
  const msg: CdpRawMessage = parsed;
160
159
 
161
160
  if (msg.id !== undefined) {
162
- generations.delete(msg.id);
163
161
  const cb = mux.take(msg.id);
164
162
  if (!cb) return;
165
- const localId = cb.localId;
166
163
 
167
164
  if (msg.error) {
168
165
  cb.send(cb.clientId, {
169
166
  type: "response",
170
- id: localId,
167
+ id: cb.localId,
171
168
  error: { code: msg.error.code ?? -1, message: msg.error.message },
172
169
  });
173
- } else {
174
- if (cb.isAttach && msg.result) {
175
- const sessionId = isRecord(msg.result) ? asString(msg.result["sessionId"]) : undefined;
176
- if (sessionId) {
177
- router.record(cb.clientId, sessionId);
178
- }
179
- }
180
- cb.send(cb.clientId, { type: "response", id: localId, result: msg.result });
170
+ return;
181
171
  }
172
+ if (cb.isAttach && isRecord(msg.result)) {
173
+ const sessionId = asString(msg.result["sessionId"]);
174
+ if (sessionId !== undefined) router.record(cb.clientId, sessionId);
175
+ }
176
+ cb.send(cb.clientId, { type: "response", id: cb.localId, result: msg.result });
182
177
  return;
183
178
  }
184
179
 
@@ -203,245 +198,27 @@ export const createCdpBridge = (options: CdpBridgeDependencies = {}): CdpBridge
203
198
  }
204
199
  };
205
200
 
206
- const rejectCallbacksWhere = (
207
- pred: (info: { generation: number; clientId: string }) => boolean,
208
- ): void => {
209
- for (const [daemonId, info] of generations) {
210
- if (!pred(info)) continue;
211
- generations.delete(daemonId);
212
- const cb = mux.take(daemonId);
213
- if (!cb) continue;
214
- cb.send(cb.clientId, {
215
- type: "response",
216
- id: cb.localId,
217
- error: { code: -32000, message: "Chrome disconnected" },
218
- });
219
- }
220
- };
221
-
222
- const rejectAttemptCallbacks = (generation: number): void => {
223
- rejectCallbacksWhere((info) => info.generation === generation);
224
- };
225
-
226
- const rejectAllPendingCallbacks = (): void => {
227
- rejectCallbacksWhere(() => true);
228
- };
229
-
230
- const closeSocket = (socket: WebSocket): void => {
231
- try {
232
- socket.close();
233
- } catch {}
234
- };
235
-
236
- const isCurrentAttempt = (generation: number): boolean =>
237
- generation === activeAttempt && !stopped;
238
-
239
- const isCurrentAttemptSocket = (generation: number, socket: WebSocket): boolean =>
240
- isCurrentAttempt(generation) && activeSocket === socket;
241
-
242
- const clearActiveAttempt = (generation: number): void => {
243
- if (activeAttempt === generation) {
244
- activeAttempt = 0;
245
- activeSocket = null;
246
- }
247
- };
248
-
249
- // On-demand Chrome connection. Attempts happen only from start() and from
250
- // handleRequest() while disconnected; a failed or disconnected bridge stays
251
- // disconnected until the next explicit browser demand. No background retry loop.
252
- const tryConnect = (): void => {
253
- if (stopped || connecting || (ws && ws.readyState === WebSocket.OPEN)) return;
254
-
255
- const generation = ++attemptGeneration;
256
- activeAttempt = generation;
257
- connecting = true;
258
-
259
- const attempt = async (): Promise<void> => {
260
- let settled = false;
261
- let sock: WebSocket | null = null;
262
- let timeout: ReturnType<typeof setTimeout> | null = null;
263
- let settleAttempt: () => void = () => {};
264
-
265
- const settledPromise = new Promise<void>((resolve) => {
266
- settleAttempt = (): void => {
267
- if (settled) return;
268
- settled = true;
269
- if (timeout) {
270
- clearTimeout(timeout);
271
- timeout = null;
272
- }
273
- if (generation === activeAttempt && !stopped) {
274
- wsUrl = null;
275
- connecting = false;
276
- }
277
- rejectAttemptCallbacks(generation);
278
- onAttemptSettled?.();
279
- resolve();
280
- };
281
- });
282
-
283
- const clearAttemptTimeout = (): void => {
284
- if (timeout) {
285
- clearTimeout(timeout);
286
- timeout = null;
287
- }
288
- };
289
-
290
- const failCurrentAttempt = (notifyClose: boolean): void => {
291
- clearAttemptTimeout();
292
- const isCurrent = isCurrentAttempt(generation);
293
- if (!isCurrent) {
294
- rejectAttemptCallbacks(generation);
295
- return;
296
- }
297
-
298
- clearActiveAttempt(generation);
299
- if (ws === sock) ws = null;
300
- wsUrl = null;
301
- connecting = false;
302
- // Not via settleAttempt: a connection that opened already settled its attempt, so
303
- // its requests would otherwise hang to the command timeout instead of failing now.
304
- rejectAttemptCallbacks(generation);
305
- if (notifyClose) closeHandler?.();
306
- settleAttempt();
307
- };
308
-
309
- timeout = setTimeout(() => {
310
- if (!isCurrentAttempt(generation) || stopped) {
311
- failCurrentAttempt(false);
312
- return;
313
- }
314
-
315
- if (sock && sock.readyState !== WebSocket.CLOSED) {
316
- closeSocket(sock);
317
- }
318
- failCurrentAttempt(true);
319
- }, connectTimeoutMs);
320
-
321
- let url = wsUrl;
322
- if (!url) {
323
- const d = await discoverWsUrlImpl();
324
- if (!isCurrentAttempt(generation) || stopped) {
325
- failCurrentAttempt(false);
326
- return;
327
- }
328
- if (!d.success) {
329
- failCurrentAttempt(false);
330
- return;
331
- }
332
- url = d.data;
333
- }
334
-
335
- if (!isCurrentAttempt(generation) || stopped) {
336
- failCurrentAttempt(false);
337
- return;
338
- }
339
-
340
- try {
341
- sock = createWebSocketImpl(url);
342
- } catch {
343
- failCurrentAttempt(false);
344
- return;
345
- }
346
-
347
- if (!isCurrentAttempt(generation) || stopped) {
348
- closeSocket(sock);
349
- failCurrentAttempt(false);
350
- return;
351
- }
352
-
353
- wsUrl = url;
354
- activeSocket = sock;
355
-
356
- const onOpen = (): void => {
357
- if (!sock || !isCurrentAttemptSocket(generation, sock)) {
358
- clearAttemptTimeout();
359
- if (sock && sock.readyState !== WebSocket.CLOSED) {
360
- closeSocket(sock);
361
- }
362
- failCurrentAttempt(false);
363
- return;
364
- }
365
-
366
- clearAttemptTimeout();
367
- ws = sock;
368
- try {
369
- ws.send(JSON.stringify({ id: 0, method: "Target.setDiscoverTargets", params: { discover: true } }));
370
- } catch {
371
- failCurrentAttempt(true);
372
- if (sock.readyState !== WebSocket.CLOSED) {
373
- closeSocket(sock);
374
- }
375
- return;
376
- }
377
- console.log("[pi-browser-daemon] Connected to Chrome ✓");
378
- settleAttempt();
379
- };
380
-
381
- const onMessage = (data: WebSocket.Data): void => {
382
- if (!sock || !isCurrentAttemptSocket(generation, sock)) return;
383
- onChromeMessage(typeof data === "string" ? data : data.toString());
384
- };
385
-
386
- const onError = (): void => {
387
- clearAttemptTimeout();
388
- const notifyClose = !!sock && isCurrentAttemptSocket(generation, sock);
389
- failCurrentAttempt(notifyClose);
390
- if (sock && sock.readyState !== WebSocket.CLOSED) {
391
- closeSocket(sock);
392
- }
393
- };
394
-
395
- const onClose = (): void => {
396
- clearAttemptTimeout();
397
- const notifyClose = !!sock && isCurrentAttemptSocket(generation, sock);
398
- failCurrentAttempt(notifyClose);
399
- };
400
-
401
- sock.on("open", onOpen);
402
- sock.on("message", onMessage);
403
- sock.on("error", onError);
404
- sock.on("close", onClose);
405
-
406
- // The promise must always settle even on constructor errors.
407
- await settledPromise;
408
- };
409
-
410
- attempt().catch(() => {
411
- if (isCurrentAttempt(generation)) {
412
- connecting = false;
413
- }
414
- });
415
- };
416
-
417
- const start = async (): Promise<void> => {
418
- stopped = false;
419
- tryConnect();
420
- };
201
+ const connection = createChromeConnection(options, {
202
+ onMessage: onChromeMessage,
203
+ onEnd: (generation, reason) => {
204
+ // Requests that went out on this socket can never be answered now; fail them at once
205
+ // rather than leaving them to the command timeout.
206
+ failAll(mux.takeWhere((e) => e.generation === generation), "Chrome disconnected");
207
+ if (reason === "dropped") closeHandler?.();
208
+ },
209
+ });
421
210
 
422
- const stop = async (): Promise<void> => {
423
- stopped = true;
424
- attemptGeneration += 1;
425
- activeAttempt = 0;
426
- activeSocket = null;
427
- connecting = false;
428
- rejectAllPendingCallbacks();
429
- if (ws) {
430
- try {
431
- ws.close(1000, "Shutdown");
432
- } catch {}
433
- ws = null;
434
- }
435
- wsUrl = null;
211
+ // Kicks off the first attempt without waiting for it: the daemon serves its IPC socket
212
+ // meanwhile, and handleRequest() joins the attempt in flight.
213
+ const start = (): Promise<void> => {
214
+ void connection.start();
215
+ return Promise.resolve();
436
216
  };
437
217
 
438
- const waitForConnection = async (): Promise<boolean> => {
439
- const deadline = Date.now() + connectTimeoutMs;
440
- while (connecting && Date.now() < deadline) {
441
- if (ws && ws.readyState === WebSocket.OPEN) return true;
442
- await new Promise((r) => setTimeout(r, 5));
443
- }
444
- return ws !== null && ws.readyState === WebSocket.OPEN;
218
+ const stop = (): Promise<void> => {
219
+ connection.stop();
220
+ failAll(mux.takeWhere(() => true), "Chrome disconnected");
221
+ return Promise.resolve();
445
222
  };
446
223
 
447
224
  const handleRequest = async (
@@ -449,50 +226,40 @@ export const createCdpBridge = (options: CdpBridgeDependencies = {}): CdpBridge
449
226
  clientId: string,
450
227
  send: SendToClient,
451
228
  ): Promise<void> => {
452
- let activeSocket = ws;
453
- if (!activeSocket || activeSocket.readyState !== WebSocket.OPEN) {
454
- // Connection attempts happen only in response to explicit browser work.
455
- tryConnect();
456
- const ready = await waitForConnection();
457
- if (!ready) {
458
- send(clientId, {
459
- type: "response",
460
- id: req.id,
461
- error: { code: -32000, message: "Chrome not connected" },
462
- });
463
- return;
464
- }
229
+ // The one request the daemon answers itself: a human asked for a fresh dial.
230
+ if (req.method === DAEMON_RECONNECT_METHOD) {
231
+ const open = await connection.reconnectNow();
232
+ send(clientId, open
233
+ ? { type: "response", id: req.id, result: { connected: true } }
234
+ : errorResponse(req.id, describeDisconnected(connection.status())));
235
+ return;
465
236
  }
466
-
467
- activeSocket = ws;
468
- if (!activeSocket || activeSocket.readyState !== WebSocket.OPEN) {
469
- send(clientId, {
470
- type: "response",
471
- id: req.id,
472
- error: { code: -32000, message: "Chrome not connected" },
473
- });
237
+ // Connection attempts happen only in response to explicit browser work.
238
+ if (!connection.isOpen() && !(await connection.connect())) {
239
+ send(clientId, errorResponse(req.id, describeDisconnected(connection.status())));
474
240
  return;
475
241
  }
476
242
 
477
- const isDetach = req.method === "Target.detachFromTarget";
478
- if (isDetach && req.sessionId) router.release(req.sessionId);
479
-
480
- const isAttach = req.method === "Target.attachToTarget";
243
+ // The session being detached travels in params (src/client.ts sends the envelope sessionId as null).
244
+ if (req.method === "Target.detachFromTarget") {
245
+ const sessionId = asString(req.params?.["sessionId"]);
246
+ if (sessionId !== undefined) router.release(sessionId);
247
+ }
481
248
 
482
249
  const daemonId = mux.allocate(
483
- { clientId, localId: req.id, send, isAttach },
250
+ {
251
+ clientId,
252
+ localId: req.id,
253
+ send,
254
+ isAttach: req.method === "Target.attachToTarget",
255
+ generation: connection.generation(),
256
+ },
484
257
  (id) =>
485
258
  setTimeout(() => {
486
259
  mux.take(id);
487
- generations.delete(id);
488
- send(clientId, {
489
- type: "response",
490
- id: req.id,
491
- error: { code: -32000, message: `Timeout after ${CDP_COMMAND_TIMEOUT_MS}ms: ${req.method}` },
492
- });
260
+ send(clientId, errorResponse(req.id, `Timeout after ${CDP_COMMAND_TIMEOUT_MS}ms: ${req.method}`));
493
261
  }, CDP_COMMAND_TIMEOUT_MS),
494
262
  );
495
- generations.set(daemonId, { generation: attemptGeneration, clientId });
496
263
 
497
264
  const payload: Record<string, unknown> = {
498
265
  id: daemonId,
@@ -501,16 +268,10 @@ export const createCdpBridge = (options: CdpBridgeDependencies = {}): CdpBridge
501
268
  };
502
269
  if (req.sessionId) payload["sessionId"] = req.sessionId;
503
270
 
504
- try {
505
- activeSocket.send(JSON.stringify(payload));
506
- } catch (e) {
271
+ const sent = connection.send(JSON.stringify(payload));
272
+ if (!sent.success) {
507
273
  mux.take(daemonId);
508
- generations.delete(daemonId);
509
- send(clientId, {
510
- type: "response",
511
- id: req.id,
512
- error: { code: -32000, message: e instanceof Error ? e.message : String(e) },
513
- });
274
+ send(clientId, errorResponse(req.id, sent.error));
514
275
  }
515
276
  };
516
277
 
@@ -518,11 +279,11 @@ export const createCdpBridge = (options: CdpBridgeDependencies = {}): CdpBridge
518
279
  start,
519
280
  stop,
520
281
  handleRequest,
521
- isAlive: () => ws !== null && ws.readyState === WebSocket.OPEN,
282
+ isAlive: () => connection.isOpen(),
522
283
  getSessionOwner: (sid) => router.getOwner(sid),
523
284
  removeClient: (cid) => {
524
- rejectCallbacksWhere((info) => info.clientId === cid);
525
- mux.clearClient(cid);
285
+ // A gone client must never be answered: drop its requests silently and release its sessions.
286
+ mux.takeWhere((e) => e.clientId === cid);
526
287
  router.removeClient(cid);
527
288
  },
528
289
  onEvent: (h) => { eventHandler = h; },