pi-browser-harness 0.10.2 → 0.12.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 (91) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +21 -10
  3. package/package.json +6 -3
  4. package/skills/pi-browser-harness/SKILL.md +30 -1
  5. package/src/cdp/commands.ts +260 -0
  6. package/src/cdp/console-buffer.ts +10 -85
  7. package/src/cdp/discovery.ts +31 -71
  8. package/src/cdp/errors.ts +4 -0
  9. package/src/cdp/events.ts +170 -0
  10. package/src/cdp/network-buffer.ts +42 -109
  11. package/src/cdp/ownership.ts +0 -17
  12. package/src/cdp/record-store.ts +61 -0
  13. package/src/cdp/session.ts +205 -193
  14. package/src/cdp/target-factory.ts +10 -80
  15. package/src/cdp/types.ts +21 -12
  16. package/src/client.ts +39 -95
  17. package/src/daemon/bridge.ts +375 -265
  18. package/src/daemon/index.ts +0 -28
  19. package/src/daemon/protocol.ts +6 -68
  20. package/src/daemon/queue.ts +85 -0
  21. package/src/daemon/server.ts +24 -99
  22. package/src/daemon/spawn.ts +90 -82
  23. package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +24 -52
  24. package/src/domains/ax-tree.ts +179 -0
  25. package/src/domains/box.ts +51 -0
  26. package/src/domains/cdp-call.ts +55 -0
  27. package/src/domains/click.ts +19 -37
  28. package/src/domains/console.ts +48 -4
  29. package/src/domains/dialog.ts +8 -5
  30. package/src/domains/drag.ts +8 -7
  31. package/src/domains/element-call.ts +117 -0
  32. package/src/domains/files.ts +75 -30
  33. package/src/domains/fill-engine.ts +80 -0
  34. package/src/domains/form-batch.ts +64 -0
  35. package/src/domains/form.ts +115 -112
  36. package/src/domains/headers.ts +89 -0
  37. package/src/domains/history.ts +16 -12
  38. package/src/domains/isolated-tab.ts +26 -55
  39. package/src/domains/js.ts +32 -51
  40. package/src/domains/keyboard.ts +38 -34
  41. package/src/domains/navigate.ts +16 -26
  42. package/src/domains/network.ts +61 -13
  43. package/src/domains/page.ts +25 -10
  44. package/src/domains/readpage/read-page.ts +74 -20
  45. package/src/domains/readpage/readability.ts +0 -24
  46. package/src/domains/ref-resolve.ts +24 -58
  47. package/src/domains/render.ts +6 -27
  48. package/src/domains/screenshot-capture.ts +80 -0
  49. package/src/domains/screenshot.ts +9 -90
  50. package/src/domains/scroll.ts +10 -12
  51. package/src/domains/search/google-serp.ts +0 -29
  52. package/src/domains/search/web-search.ts +48 -21
  53. package/src/domains/setup.ts +1 -13
  54. package/src/domains/snapshot.ts +36 -280
  55. package/src/domains/tabs.ts +15 -16
  56. package/src/domains/target.ts +35 -0
  57. package/src/domains/viewport.ts +5 -4
  58. package/src/index.ts +23 -69
  59. package/src/profile/bind.ts +8 -57
  60. package/src/profile/browser-process.ts +75 -126
  61. package/src/profile/command.ts +50 -29
  62. package/src/profile/launch.ts +22 -40
  63. package/src/profile/list.ts +14 -54
  64. package/src/profile/paths.ts +2 -65
  65. package/src/profile/store.ts +35 -56
  66. package/src/prompt.ts +22 -122
  67. package/src/registry.ts +33 -33
  68. package/src/schemas/parse.ts +23 -0
  69. package/src/setup.ts +0 -25
  70. package/src/state.ts +48 -18
  71. package/src/util/debug.ts +8 -0
  72. package/src/util/guards.ts +30 -0
  73. package/src/util/js-template.ts +1 -16
  74. package/src/util/mutex.ts +0 -5
  75. package/src/util/paths.ts +11 -1
  76. package/src/util/result.ts +0 -6
  77. package/src/util/sharp-shim.ts +12 -19
  78. package/src/util/tool.ts +17 -42
  79. package/src/util/truncate.ts +6 -0
  80. package/tsconfig.json +1 -16
  81. package/skills/deep-research/SKILL.md +0 -66
  82. package/src/cdp/event-queue.ts +0 -128
  83. package/src/cdp/transport.ts +0 -156
  84. package/src/cdp/window-binding.ts +0 -15
  85. package/src/deep-research.ts +0 -41
  86. package/src/domains/forms.ts +0 -355
  87. package/src/domains/readpage/capture.ts +0 -46
  88. package/src/domains/search/extract.ts +0 -44
  89. package/src/profile/picker.ts +0 -73
  90. package/src/util/keycodes.ts +0 -10
  91. package/src/util/time.ts +0 -29
@@ -1,22 +1,12 @@
1
- /**
2
- * CdpBridge — persistent WebSocket to Chrome, ID multiplexing, event routing.
3
- *
4
- * A single long-lived process connection to Chrome that proxies CDP traffic
5
- * for multiple pi clients connected via the IpcServer. One Allow dialog ever.
6
- *
7
- * References:
8
- * - CDP message format: https://github.com/aslushnikov/getting-started-with-cdp#protocol-fundamentals
9
- * - CDP sessions + IDs: https://github.com/aslushnikov/getting-started-with-cdp#targets--sessions
10
- * - Target domain: https://chromedevtools.github.io/devtools-protocol/tot/Target/
11
- */
12
-
13
1
  import WebSocket from "ws";
14
2
  import { discoverWsUrl } from "../cdp/discovery";
3
+ import { isCdpRawMessage } from "../cdp/types";
15
4
  import type { CdpRawMessage } from "../cdp/types";
16
5
  import type { WireRequest, WireResponse, WireEvent } from "./protocol";
17
6
  import { CDP_CONNECT_TIMEOUT_MS, CDP_COMMAND_TIMEOUT_MS } from "./protocol";
18
-
19
- // ── Types ──────────────────────────────────────────────────────────────────────
7
+ import { asString, isRecord } from "../util/guards";
8
+ import type { CdpError } from "../cdp/errors";
9
+ import type { Result } from "../util/result";
20
10
 
21
11
  export type SendToClient = (clientId: string, msg: WireResponse | WireEvent) => void;
22
12
 
@@ -25,146 +15,153 @@ export type EventHandler = (event: WireEvent, targetClientIds: string[]) => void
25
15
  export type CloseHandler = () => void;
26
16
 
27
17
  export type CdpBridge = {
28
- /** Start the bridge. Resolves when the daemon IPC + Chrome discovery loop
29
- * are initialized (not when Chrome is connected — that may take retries). */
30
18
  start(): Promise<void>;
31
19
  stop(): Promise<void>;
32
20
  handleRequest(req: WireRequest, clientId: string, send: SendToClient): Promise<void>;
33
- /** True when the Chrome WebSocket is open and ready. */
34
21
  isAlive(): boolean;
35
- recordSession(clientId: string, sessionId: string): void;
36
- releaseSession(sessionId: string): void;
37
22
  getSessionOwner(sessionId: string): string | undefined;
38
23
  removeClient(clientId: string): void;
39
24
  onEvent(handler: EventHandler): void;
40
25
  onClose(handler: CloseHandler): void;
41
26
  };
42
27
 
43
- // ── ID Multiplexer ────────────────────────────────────────────────────────────
44
-
45
- interface PendingEntry {
46
- clientId: string;
47
- localId: number;
48
- }
49
-
50
- class IdMultiplexer {
51
- private nextId = 1;
52
- private localToRemote = new Map<string, Map<number, number>>();
53
- private pending = new Map<number, PendingEntry>();
54
-
55
- allocate(clientId: string, localId: number): number {
56
- const daemonId = this.nextId++;
57
- let m = this.localToRemote.get(clientId);
58
- if (!m) { m = new Map(); this.localToRemote.set(clientId, m); }
59
- m.set(localId, daemonId);
60
- this.pending.set(daemonId, { clientId, localId });
61
- return daemonId;
62
- }
63
-
64
- resolve(daemonId: number): PendingEntry | null {
65
- const e = this.pending.get(daemonId);
66
- if (!e) return null;
67
- this.pending.delete(daemonId);
68
- const m = this.localToRemote.get(e.clientId);
69
- if (m) m.delete(e.localId);
70
- return e;
71
- }
72
-
73
- clearClient(clientId: string): void {
74
- const m = this.localToRemote.get(clientId);
75
- if (m) { for (const [, did] of m) this.pending.delete(did); }
76
- this.localToRemote.delete(clientId);
77
- }
78
- }
79
-
80
- // ── Event Router ───────────────────────────────────────────────────────────────
81
-
82
- class EventRouter {
83
- private owners = new Map<string, string>(); // sessionId → clientId
84
- private clientSessions = new Map<string, Set<string>>(); // clientId → sessions
85
-
86
- record(clientId: string, sessionId: string): void {
87
- const prev = this.owners.get(sessionId);
88
- if (prev && prev !== clientId) {
89
- this.clientSessions.get(prev)?.delete(sessionId);
90
- }
91
- this.owners.set(sessionId, clientId);
92
- let s = this.clientSessions.get(clientId);
93
- if (!s) { s = new Set(); this.clientSessions.set(clientId, s); }
94
- s.add(sessionId);
95
- }
96
-
97
- release(sessionId: string): void {
98
- const owner = this.owners.get(sessionId);
99
- if (owner) this.clientSessions.get(owner)?.delete(sessionId);
100
- this.owners.delete(sessionId);
101
- }
102
-
103
- getOwner(sessionId: string): string | undefined {
104
- return this.owners.get(sessionId);
105
- }
106
-
107
- removeClient(clientId: string): void {
108
- const sessions = this.clientSessions.get(clientId);
109
- if (sessions) { for (const sid of sessions) this.owners.delete(sid); }
110
- this.clientSessions.delete(clientId);
111
- }
112
-
113
- /**
114
- * Route a CDP event to client(s).
115
- * Session-scoped → only the owning client.
116
- * Browser-level (no sessionId) → all connected clients.
117
- */
118
- route(event: WireEvent): string[] {
119
- if (event.sessionId) {
120
- const owner = this.owners.get(event.sessionId);
121
- return owner ? [owner] : [];
122
- }
123
- return [...this.clientSessions.keys()];
124
- }
28
+ export type InFlight = {
29
+ readonly clientId: string;
30
+ readonly localId: number;
31
+ readonly send: SendToClient;
32
+ readonly timer: ReturnType<typeof setTimeout>;
33
+ readonly isAttach: boolean;
34
+ };
35
+
36
+ // One map, keyed by the daemon-side id: the client that asked, the id it used, and everything needed to answer it.
37
+ 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;
42
+ };
43
+
44
+ export const createIdMultiplexer = (): IdMultiplexer => {
45
+ let nextId = 1;
46
+ const inFlight = new Map<number, InFlight>();
47
+
48
+ return {
49
+ allocate(entry, arm) {
50
+ const daemonId = nextId++;
51
+ inFlight.set(daemonId, { ...entry, timer: arm(daemonId) });
52
+ return daemonId;
53
+ },
54
+ take(daemonId) {
55
+ const e = inFlight.get(daemonId);
56
+ if (!e) return undefined;
57
+ inFlight.delete(daemonId);
58
+ clearTimeout(e.timer);
59
+ return e;
60
+ },
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) {
69
+ for (const [daemonId, e] of inFlight) {
70
+ if (e.clientId !== clientId) continue;
71
+ clearTimeout(e.timer);
72
+ inFlight.delete(daemonId);
73
+ }
74
+ },
75
+ };
76
+ };
77
+
78
+ 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[];
84
+ };
85
+
86
+ export const createEventRouter = (): EventRouter => {
87
+ const owners = new Map<string, string>();
88
+ const clientSessions = new Map<string, Set<string>>();
89
+
90
+ return {
91
+ record(clientId, sessionId) {
92
+ const prev = owners.get(sessionId);
93
+ if (prev && prev !== clientId) clientSessions.get(prev)?.delete(sessionId);
94
+ owners.set(sessionId, clientId);
95
+ let s = clientSessions.get(clientId);
96
+ if (!s) { s = new Set(); clientSessions.set(clientId, s); }
97
+ s.add(sessionId);
98
+ },
99
+ release(sessionId) {
100
+ const owner = owners.get(sessionId);
101
+ if (owner) clientSessions.get(owner)?.delete(sessionId);
102
+ owners.delete(sessionId);
103
+ },
104
+ getOwner(sessionId) {
105
+ return owners.get(sessionId);
106
+ },
107
+ removeClient(clientId) {
108
+ const sessions = clientSessions.get(clientId);
109
+ if (sessions) { for (const sid of sessions) owners.delete(sid); }
110
+ clientSessions.delete(clientId);
111
+ },
112
+ route(event) {
113
+ if (event.sessionId) {
114
+ const owner = owners.get(event.sessionId);
115
+ return owner ? [owner] : [];
116
+ }
117
+ return [...clientSessions.keys()];
118
+ },
119
+ };
120
+ };
125
121
 
126
- allClients(): string[] {
127
- return [...this.clientSessions.keys()];
128
- }
129
- }
122
+ type CdpBridgeDependencies = {
123
+ discoverWsUrl?: () => Promise<Result<string, CdpError>>;
124
+ createWebSocket?: (url: string) => WebSocket;
125
+ connectTimeoutMs?: number;
126
+ onAttemptSettled?: () => void;
127
+ };
130
128
 
131
- // ── Implementation ─────────────────────────────────────────────────────────────
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;
132
137
 
133
- export const createCdpBridge = (): CdpBridge => {
134
138
  let ws: WebSocket | null = null;
135
139
  let wsUrl: string | null = null;
136
- const mux = new IdMultiplexer();
137
- const router = new EventRouter();
140
+ const mux = createIdMultiplexer();
141
+ const router = createEventRouter();
138
142
  let eventHandler: EventHandler | null = null;
139
143
  let closeHandler: CloseHandler | null = null;
140
144
 
141
- // daemonId → { send, timer, isAttach }
142
- const callbacks = new Map<number, {
143
- send: SendToClient;
144
- clientId: string;
145
- localId: number;
146
- timer: ReturnType<typeof setTimeout>;
147
- isAttach: boolean;
148
- }>();
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 }>();
149
148
 
150
- // ── Handle raw CDP messages from Chrome ─────────────────────────────────
149
+ let stopped = false;
150
+ let connecting = false;
151
+ let attemptGeneration = 0;
152
+ let activeAttempt = 0;
153
+ let activeSocket: WebSocket | null = null;
151
154
 
152
155
  const onChromeMessage = (raw: string): void => {
153
- let msg: CdpRawMessage;
154
- try { msg = JSON.parse(raw); } catch { return; }
156
+ let parsed: unknown;
157
+ try { parsed = JSON.parse(raw); } catch { return; }
158
+ if (!isCdpRawMessage(parsed)) return;
159
+ const msg: CdpRawMessage = parsed;
155
160
 
156
- // ---- Response (has id) ----
157
161
  if (msg.id !== undefined) {
158
- const cb = callbacks.get(msg.id);
159
- if (!cb) {
160
- // Stale response — client already timed out or disconnected.
161
- // Still clean up the multiplexer mapping.
162
- mux.resolve(msg.id);
163
- return;
164
- }
165
- clearTimeout(cb.timer);
166
- callbacks.delete(msg.id);
167
-
162
+ generations.delete(msg.id);
163
+ const cb = mux.take(msg.id);
164
+ if (!cb) return;
168
165
  const localId = cb.localId;
169
166
 
170
167
  if (msg.error) {
@@ -174,24 +171,19 @@ export const createCdpBridge = (): CdpBridge => {
174
171
  error: { code: msg.error.code ?? -1, message: msg.error.message },
175
172
  });
176
173
  } else {
177
- // Track session ownership for attach responses
178
174
  if (cb.isAttach && msg.result) {
179
- const result = msg.result as { sessionId?: string };
180
- if (result.sessionId) {
181
- router.record(cb.clientId, result.sessionId);
175
+ const sessionId = isRecord(msg.result) ? asString(msg.result["sessionId"]) : undefined;
176
+ if (sessionId) {
177
+ router.record(cb.clientId, sessionId);
182
178
  }
183
179
  }
184
180
  cb.send(cb.clientId, { type: "response", id: localId, result: msg.result });
185
181
  }
186
-
187
- mux.resolve(msg.id);
188
182
  return;
189
183
  }
190
184
 
191
- // ---- Event (no id, has method) ----
192
185
  if (!msg.method) return;
193
186
 
194
- // Inspector.detached — release session ownership
195
187
  if (msg.method === "Inspector.detached" && msg.sessionId) {
196
188
  router.release(msg.sessionId);
197
189
  }
@@ -203,7 +195,6 @@ export const createCdpBridge = (): CdpBridge => {
203
195
  ...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}),
204
196
  };
205
197
 
206
- // Route through the event handler for the IpcServer to broadcast
207
198
  if (eventHandler) {
208
199
  const targets = router.route(wireEvent);
209
200
  if (targets.length > 0) {
@@ -212,149 +203,269 @@ export const createCdpBridge = (): CdpBridge => {
212
203
  }
213
204
  };
214
205
 
215
- // ── Connect to Chrome ───────────────────────────────────────────────────
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
+ };
216
221
 
217
- // ── Auto-reconnecting Chrome connection ───────────────────────────────
222
+ const rejectAttemptCallbacks = (generation: number): void => {
223
+ rejectCallbacksWhere((info) => info.generation === generation);
224
+ };
218
225
 
219
- let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
220
- let reconnectAttempt = 0;
221
- let stopped = false;
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
+ };
222
248
 
223
- /**
224
- * Try to discover Chrome and open a WebSocket. On failure, schedule
225
- * an exponential backoff retry (capped at 60s). When Chrome finally
226
- * becomes available, the bridge connects automatically — no manual
227
- * daemon restart needed.
228
- */
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.
229
252
  const tryConnect = (): void => {
230
- if (stopped) return;
231
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
253
+ if (stopped || connecting || (ws && ws.readyState === WebSocket.OPEN)) return;
232
254
 
233
- const attempt = async (): Promise<void> => {
234
- // Already connected — nothing to do.
235
- if (ws && ws.readyState === WebSocket.OPEN) return;
255
+ const generation = ++attemptGeneration;
256
+ activeAttempt = generation;
257
+ connecting = true;
236
258
 
237
- // Resolve URL (discover if not cached, or clear cache on retry)
238
- let url = wsUrl;
239
- if (!url || reconnectAttempt > 0) {
240
- const d = await discoverWsUrl();
241
- if (!d.success) { scheduleRetry(); return; }
242
- url = d.data;
243
- wsUrl = url;
244
- }
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 = () => {};
245
264
 
246
- const settledPromise = new Promise<void>((settle) => {
247
- let settled = false;
248
- const settleOnce = (): void => {
265
+ const settledPromise = new Promise<void>((resolve) => {
266
+ settleAttempt = (): void => {
249
267
  if (settled) return;
250
268
  settled = true;
251
- settle();
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();
252
280
  };
281
+ });
253
282
 
254
- let sock: WebSocket;
255
- try {
256
- sock = new WebSocket(url!, { perMessageDeflate: false });
257
- } catch {
258
- scheduleRetry();
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);
259
312
  return;
260
313
  }
261
314
 
262
- const timer = setTimeout(() => {
263
- sock.close();
264
- settleOnce();
265
- }, CDP_CONNECT_TIMEOUT_MS);
315
+ if (sock && sock.readyState !== WebSocket.CLOSED) {
316
+ closeSocket(sock);
317
+ }
318
+ failCurrentAttempt(true);
319
+ }, connectTimeoutMs);
266
320
 
267
- sock.on("open", () => {
268
- clearTimeout(timer);
269
- ws = sock;
270
- reconnectAttempt = 0;
271
- // Enable target discovery so we see page events
272
- ws.send(JSON.stringify({ id: 0, method: "Target.setDiscoverTargets", params: { discover: true } }));
273
- console.log("[pi-browser-daemon] Connected to Chrome ✓");
274
- settleOnce();
275
- });
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
+ }
276
334
 
277
- sock.on("message", (data: WebSocket.Data) => {
278
- onChromeMessage(typeof data === "string" ? data : data.toString());
279
- });
335
+ if (!isCurrentAttempt(generation) || stopped) {
336
+ failCurrentAttempt(false);
337
+ return;
338
+ }
280
339
 
281
- sock.on("error", () => {
282
- clearTimeout(timer);
283
- settleOnce();
284
- });
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
+ }
285
352
 
286
- sock.on("close", () => {
287
- clearTimeout(timer);
288
- ws = null;
289
- // Reject all pending callbacks
290
- for (const [daemonId, cb] of callbacks) {
291
- clearTimeout(cb.timer);
292
- cb.send(cb.clientId, {
293
- type: "response",
294
- id: cb.localId,
295
- error: { code: -32000, message: "Chrome disconnected" },
296
- });
297
- callbacks.delete(daemonId);
298
- mux.resolve(daemonId);
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);
299
361
  }
300
- closeHandler?.();
301
- // Auto-reconnect on unexpected close
302
- scheduleRetry();
303
- });
304
- });
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
+ };
305
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.
306
407
  await settledPromise;
307
- // If connection didn't succeed, schedule a retry
308
- if (!ws || ws.readyState !== WebSocket.OPEN) {
309
- scheduleRetry();
310
- }
311
408
  };
312
409
 
313
- attempt().catch(() => scheduleRetry());
314
- };
315
-
316
- /** Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 60s (capped). */
317
- const scheduleRetry = (): void => {
318
- if (stopped) return;
319
- const delay = Math.min(1000 * Math.pow(2, Math.min(reconnectAttempt, 6)), 60_000);
320
- reconnectAttempt++;
321
- wsUrl = null; // force re-discovery on next attempt
322
- reconnectTimer = setTimeout(tryConnect, delay);
410
+ attempt().catch(() => {
411
+ if (isCurrentAttempt(generation)) {
412
+ connecting = false;
413
+ }
414
+ });
323
415
  };
324
416
 
325
417
  const start = async (): Promise<void> => {
326
418
  stopped = false;
327
- // Don't await — the retry loop runs in the background.
328
- // The daemon is "started" when the IPC server is listening, not when
329
- // Chrome is connected.
330
419
  tryConnect();
331
420
  };
332
421
 
333
422
  const stop = async (): Promise<void> => {
334
423
  stopped = true;
335
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
336
- for (const [, cb] of callbacks) clearTimeout(cb.timer);
337
- callbacks.clear();
338
- if (ws) { try { ws.close(1000, "Shutdown"); } catch {} ws = null; }
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
+ }
339
435
  wsUrl = null;
340
436
  };
341
437
 
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;
445
+ };
446
+
342
447
  const handleRequest = async (
343
448
  req: WireRequest,
344
449
  clientId: string,
345
450
  send: SendToClient,
346
451
  ): Promise<void> => {
347
- // ponytail: wait for Chrome to connect (user may still be clicking "Allow").
348
- // Bridge retries in background; this polls so the client doesn't fail first try.
349
- if (!ws || ws.readyState !== WebSocket.OPEN) {
350
- const deadline = Date.now() + 15_000;
351
- while (Date.now() < deadline) {
352
- await new Promise((r) => setTimeout(r, 250));
353
- if (ws && ws.readyState === WebSocket.OPEN) break;
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;
354
464
  }
355
465
  }
356
466
 
357
- if (!ws || ws.readyState !== WebSocket.OPEN) {
467
+ activeSocket = ws;
468
+ if (!activeSocket || activeSocket.readyState !== WebSocket.OPEN) {
358
469
  send(clientId, {
359
470
  type: "response",
360
471
  id: req.id,
@@ -363,27 +474,25 @@ export const createCdpBridge = (): CdpBridge => {
363
474
  return;
364
475
  }
365
476
 
366
- const daemonId = mux.allocate(clientId, req.id);
367
-
368
- // Store callback for the response
369
- const timer = setTimeout(() => {
370
- callbacks.delete(daemonId);
371
- mux.resolve(daemonId);
372
- send(clientId, {
373
- type: "response",
374
- id: req.id,
375
- error: { code: -32000, message: `Timeout after ${CDP_COMMAND_TIMEOUT_MS}ms: ${req.method}` },
376
- });
377
- }, CDP_COMMAND_TIMEOUT_MS);
378
-
379
- const isAttach = req.method === "Target.attachToTarget";
380
477
  const isDetach = req.method === "Target.detachFromTarget";
478
+ if (isDetach && req.sessionId) router.release(req.sessionId);
381
479
 
382
- if (isDetach && req.sessionId) {
383
- router.release(req.sessionId);
384
- }
480
+ const isAttach = req.method === "Target.attachToTarget";
385
481
 
386
- callbacks.set(daemonId, { send, clientId, localId: req.id, timer, isAttach });
482
+ const daemonId = mux.allocate(
483
+ { clientId, localId: req.id, send, isAttach },
484
+ (id) =>
485
+ setTimeout(() => {
486
+ 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
+ });
493
+ }, CDP_COMMAND_TIMEOUT_MS),
494
+ );
495
+ generations.set(daemonId, { generation: attemptGeneration, clientId });
387
496
 
388
497
  const payload: Record<string, unknown> = {
389
498
  id: daemonId,
@@ -393,11 +502,10 @@ export const createCdpBridge = (): CdpBridge => {
393
502
  if (req.sessionId) payload["sessionId"] = req.sessionId;
394
503
 
395
504
  try {
396
- ws.send(JSON.stringify(payload));
505
+ activeSocket.send(JSON.stringify(payload));
397
506
  } catch (e) {
398
- clearTimeout(timer);
399
- callbacks.delete(daemonId);
400
- mux.resolve(daemonId);
507
+ mux.take(daemonId);
508
+ generations.delete(daemonId);
401
509
  send(clientId, {
402
510
  type: "response",
403
511
  id: req.id,
@@ -411,10 +519,12 @@ export const createCdpBridge = (): CdpBridge => {
411
519
  stop,
412
520
  handleRequest,
413
521
  isAlive: () => ws !== null && ws.readyState === WebSocket.OPEN,
414
- recordSession: (cid, sid) => router.record(cid, sid),
415
- releaseSession: (sid) => router.release(sid),
416
522
  getSessionOwner: (sid) => router.getOwner(sid),
417
- removeClient: (cid) => { mux.clearClient(cid); router.removeClient(cid); },
523
+ removeClient: (cid) => {
524
+ rejectCallbacksWhere((info) => info.clientId === cid);
525
+ mux.clearClient(cid);
526
+ router.removeClient(cid);
527
+ },
418
528
  onEvent: (h) => { eventHandler = h; },
419
529
  onClose: (h) => { closeHandler = h; },
420
530
  };