pi-chrome-search 0.1.1 → 2.0.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 (62) hide show
  1. package/README.md +37 -76
  2. package/assets/anchor.html +2 -2
  3. package/package.json +4 -1
  4. package/src/cdp/bytes.ts +14 -14
  5. package/src/cdp/client.ts +687 -0
  6. package/src/cdp/page.ts +716 -0
  7. package/src/cdp/readiness.ts +119 -0
  8. package/src/cdp/shared.ts +70 -0
  9. package/src/cdp/types.ts +30 -1
  10. package/src/cli/doctor.ts +14 -17
  11. package/src/commands/focus.ts +2 -2
  12. package/src/config/defaults.ts +158 -0
  13. package/src/config/doc.ts +180 -0
  14. package/src/config/env.ts +58 -0
  15. package/src/config/index.ts +10 -0
  16. package/src/config/load.ts +40 -0
  17. package/src/config/schema.ts +264 -0
  18. package/src/engines/duckduckgo.ts +7 -10
  19. package/src/engines/google.ts +11 -21
  20. package/src/engines/images.ts +1 -1
  21. package/src/engines/index.ts +5 -13
  22. package/src/engines/types.ts +29 -6
  23. package/src/engines/yandex.ts +11 -18
  24. package/src/extract/claims.ts +1 -1
  25. package/src/extract/compact.ts +29 -3
  26. package/src/extract/goto.ts +124 -2
  27. package/src/extract/text.ts +43 -0
  28. package/src/index.ts +52 -80
  29. package/src/runtime/artifacts.ts +6 -11
  30. package/src/runtime/attention.ts +2 -2
  31. package/src/runtime/budget.ts +142 -0
  32. package/src/runtime/cache.ts +1 -3
  33. package/src/runtime/doctor.ts +17 -43
  34. package/src/runtime/error-codes.ts +19 -8
  35. package/src/runtime/errors-doc.ts +45 -0
  36. package/src/runtime/limiter.ts +33 -221
  37. package/src/runtime/log.ts +2 -2
  38. package/src/runtime/panel.ts +30 -4
  39. package/src/runtime/types.ts +2 -17
  40. package/src/{cdp/discovery.ts → session/discover.ts} +3 -3
  41. package/src/session/index.ts +34 -0
  42. package/src/session/lock.ts +346 -0
  43. package/src/{cdp → session}/page-lock.ts +12 -13
  44. package/src/session/session.ts +684 -0
  45. package/src/session/tabs.ts +277 -0
  46. package/src/tools/image_search.ts +21 -7
  47. package/src/tools/index.ts +18 -7
  48. package/src/tools/surface.ts +97 -0
  49. package/src/tools/types.ts +2 -5
  50. package/src/tools/web_doctor.ts +2 -2
  51. package/src/tools/web_read.ts +159 -114
  52. package/src/tools/web_research.ts +33 -207
  53. package/src/tools/web_search.ts +208 -419
  54. package/src/version.ts +12 -3
  55. package/src/cdp/browser.ts +0 -1286
  56. package/src/cdp/session.ts +0 -1446
  57. package/src/config.ts +0 -651
  58. package/src/reflex/classifier.ts +0 -1578
  59. package/src/reflex/index.ts +0 -172
  60. package/src/reflex/policy.ts +0 -792
  61. package/src/reflex/rules.ts +0 -557
  62. package/src/reflex/types.ts +0 -117
@@ -0,0 +1,687 @@
1
+ /**
2
+ * Raw CDP client over `ws` (SPEC S8.4, S12; PLAN P3).
3
+ *
4
+ * This is the only place that speaks the DevTools protocol. It attaches to the
5
+ * user's already-running Chrome; it never launches, restarts or kills a browser
6
+ * and never injects scripts. Adapters (P3) see `CdpPage` from `types.ts` and
7
+ * nothing else, so the transport can be swapped or faked.
8
+ *
9
+ * Two behaviours are deliberate and load-bearing:
10
+ *
11
+ * - **Flattened sessions.** Every command after `Target.attachToTarget` carries
12
+ * a `sessionId`; target-scoped events come back with the same id. A single
13
+ * browser socket can therefore drive several tabs without a second CDP
14
+ * connection (Chrome's `--remote-debugging-pipe`-style multiplexing).
15
+ *
16
+ * - **One reconnect, never a loop.** A dropped socket is a normal event when
17
+ * the user suspends a laptop or Chrome restarts its browser process. Trying
18
+ * forever would turn a dead endpoint into a busy loop; trying zero times
19
+ * would kill every in-flight search. We retry exactly once, re-attach the
20
+ * live targets (Chrome allocates *new* session ids), and after a second drop
21
+ * fail honestly with `E_CDP_DISCONNECTED`. `close()` is intentional and
22
+ * never reconnects.
23
+ */
24
+
25
+ import { WebSocket, type RawData } from "ws";
26
+
27
+ import type { Stage } from "../runtime/error-codes.ts";
28
+ import { WebSearchError } from "../runtime/error-codes.ts";
29
+ import { fail } from "../runtime/errors.ts";
30
+ import type { Clock, Logger } from "../runtime/types.ts";
31
+ import { asString, asWebSearchError, isRecord, positiveTimeout } from "./shared.ts";
32
+ import type { BrowserInfo, CdpClient as CdpClientContract, CdpTarget } from "./types.ts";
33
+
34
+ export const DEFAULT_CONNECT_TIMEOUT_MS = 5_000;
35
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
36
+
37
+ export interface CdpClientOptions {
38
+ wsUrl: string;
39
+ connectTimeoutMs?: number;
40
+ requestTimeoutMs?: number;
41
+ /** Used for human typing delays; tests inject `FakeClock`. */
42
+ clock?: Clock;
43
+ log?: Logger;
44
+ }
45
+
46
+ interface CdpErrorPayload {
47
+ code?: unknown;
48
+ message?: unknown;
49
+ data?: unknown;
50
+ }
51
+
52
+ interface PendingRequest {
53
+ method: string;
54
+ sessionId?: string;
55
+ /** Stage at send time, so a socket drop blames the command, not the connect. */
56
+ stage: Stage;
57
+ resolve: (value: unknown) => void;
58
+ reject: (error: WebSearchError) => void;
59
+ timer: ReturnType<typeof setTimeout>;
60
+ }
61
+
62
+ type EventHandler = (params: Record<string, unknown>, sessionId?: string) => void;
63
+ type SessionState = "connecting" | "open" | "reconnecting" | "closed";
64
+
65
+ function rawToString(raw: RawData): string {
66
+ if (Array.isArray(raw)) return Buffer.concat(raw).toString("utf8");
67
+ if (raw instanceof ArrayBuffer) return Buffer.from(raw).toString("utf8");
68
+ return raw.toString("utf8");
69
+ }
70
+
71
+ function parseMessage(raw: RawData): unknown {
72
+ try {
73
+ return JSON.parse(rawToString(raw)) as unknown;
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ }
78
+
79
+ /** Which stage a CDP failure belongs to, for the error renderer. */
80
+ function stageForMethod(method: string): Stage {
81
+ if (method.startsWith("Page.navigate") || method.startsWith("Page.frame")) return "navigate";
82
+ if (method.startsWith("Runtime.")) return "extract";
83
+ if (method.startsWith("Network.")) return "read";
84
+ // P12: a paused request/body belongs to the read pipeline, not to "connect".
85
+ if (method.startsWith("Fetch.")) return "read";
86
+ if (method === "Page.captureScreenshot") return "render";
87
+ if (method.startsWith("Input.")) return "resolve";
88
+ return "connect";
89
+ }
90
+
91
+ /**
92
+ * Map a CDP protocol error onto the frozen taxonomy. Chrome uses -32000 for a
93
+ * vanished target/session (target closed, no target with given id) and -32001
94
+ * for a session id it does not know — e.g. after a reconnect, until we
95
+ * re-attach. -32601 is an older/newer Chrome without the command.
96
+ */
97
+ function cdpError(method: string, sessionId: string | undefined, payload: CdpErrorPayload): WebSearchError {
98
+ const stage = stageForMethod(method);
99
+ const code = typeof payload.code === "number" ? payload.code : undefined;
100
+ const message = asString(payload.message) ?? "CDP error";
101
+ const detail = `${method}${sessionId === undefined ? "" : ` (session ${sessionId})`}: ${message}`;
102
+
103
+ if (code === -32001 || /session with given id/i.test(message)) {
104
+ return fail("E_CDP_DISCONNECTED", stage, {
105
+ message: detail,
106
+ hint:
107
+ "the tab session is gone (the browser reconnected or the tab was detached); " +
108
+ "retry once — if it repeats, run /web-doctor",
109
+ });
110
+ }
111
+ if (code === -32000 && /(no target|target closed|target (?:is )?gone)/i.test(message)) {
112
+ return fail("E_TARGET_LOST", stage, {
113
+ message: detail,
114
+ hint: "the owned tab was closed or navigated away; retry to reopen it",
115
+ });
116
+ }
117
+ if (code === -32601) {
118
+ return fail("E_UNSUPPORTED", stage, {
119
+ message: detail,
120
+ hint: "this Chrome build does not implement the CDP command; update Chrome or run /web-doctor",
121
+ });
122
+ }
123
+ return fail("E_PARSE", stage, {
124
+ message: detail,
125
+ hint: "the browser rejected the CDP call; inspect the artifact and the command parameters",
126
+ });
127
+ }
128
+
129
+ export class CdpClient implements CdpClientContract {
130
+ readonly wsUrl: string;
131
+ readonly clock: Clock | undefined;
132
+
133
+ #connectTimeoutMs: number;
134
+ #requestTimeoutMs: number;
135
+ #log: Logger | undefined;
136
+ #socket: WebSocket | undefined;
137
+ #state: SessionState = "closed";
138
+ #nextId = 1;
139
+ #pending = new Map<number, PendingRequest>();
140
+ #handlers = new Map<string, Set<EventHandler>>();
141
+ /** targetId → sessionId for every target this client attached. */
142
+ #attached = new Map<string, string>();
143
+ /** targetId → last known URL, so `CdpPage.url()` is not empty right after create/attach. */
144
+ #targetUrls = new Map<string, string>();
145
+ #reconnectUsed = false;
146
+ /** TRUE from socket reopen until every live target is attached again. */
147
+ #reattaching = false;
148
+
149
+ private constructor(options: CdpClientOptions) {
150
+ this.wsUrl = options.wsUrl;
151
+ this.clock = options.clock;
152
+ this.#connectTimeoutMs = positiveTimeout(options.connectTimeoutMs, DEFAULT_CONNECT_TIMEOUT_MS);
153
+ this.#requestTimeoutMs = positiveTimeout(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS);
154
+ this.#log = options.log;
155
+
156
+ // Chrome tears a flattened target down with `Target.detachedFromTarget`;
157
+ // `Inspector.detached` is the legacy shape some builds still emit. Both
158
+ // prune the mapping so the next page command fails fast instead of hanging.
159
+ this.on("Target.detachedFromTarget", (params, sessionId) => {
160
+ const payload = isRecord(params) ? params : {};
161
+ const lostSession = asString(payload.sessionId) ?? sessionId;
162
+ const lostTarget = asString(payload.targetId);
163
+ this.#targetGone(lostSession, lostTarget);
164
+ });
165
+ this.on("Inspector.detached", (_params, sessionId) => {
166
+ this.#targetGone(sessionId, undefined);
167
+ });
168
+ }
169
+
170
+ static async connect(options: CdpClientOptions): Promise<CdpClient> {
171
+ const client = new CdpClient(options);
172
+ try {
173
+ await client.#openSocket();
174
+ } catch (error) {
175
+ client.#terminateSocket();
176
+ client.#state = "closed";
177
+ throw asWebSearchError(error, "E_CDP_UNAVAILABLE", "connect");
178
+ }
179
+ return client;
180
+ }
181
+
182
+ isConnected(): boolean {
183
+ return (
184
+ this.#state === "open" &&
185
+ !this.#reattaching &&
186
+ this.#socket !== undefined &&
187
+ this.#socket.readyState === WebSocket.OPEN
188
+ );
189
+ }
190
+
191
+ /** Current flattened session for a target, if this client still holds one. */
192
+ sessionIdFor(targetId: string): string | undefined {
193
+ return this.#attached.get(targetId);
194
+ }
195
+
196
+ /**
197
+ * Last URL this client saw for a target. `createCdpPage` seeds `url()` from it,
198
+ * because Chrome does not return the URL from `attachToTarget` and a page
199
+ * created for a freshly opened tab would otherwise report `""` until its
200
+ * first navigation commits.
201
+ */
202
+ targetUrlFor(targetId: string): string | undefined {
203
+ return this.#targetUrls.get(targetId);
204
+ }
205
+
206
+ async version(): Promise<BrowserInfo> {
207
+ const result = await this.send<Record<string, unknown>>("Browser.getVersion");
208
+ const info = isRecord(result) ? result : {};
209
+ return {
210
+ product: asString(info.product) ?? "",
211
+ protocolVersion: asString(info.protocolVersion) ?? "",
212
+ userAgent: asString(info.userAgent) ?? "",
213
+ revision: asString(info.revision) ?? "",
214
+ jsVersion: asString(info.jsVersion) ?? "",
215
+ };
216
+ }
217
+
218
+ async targets(): Promise<CdpTarget[]> {
219
+ const result = await this.send<Record<string, unknown>>("Target.getTargets");
220
+ const infos = isRecord(result) && Array.isArray(result.targetInfos) ? result.targetInfos : [];
221
+ const targets: CdpTarget[] = [];
222
+ for (const info of infos) {
223
+ if (!isRecord(info)) continue;
224
+ const id = asString(info.targetId);
225
+ if (id === undefined) continue;
226
+ const url = asString(info.url) ?? "";
227
+ this.#targetUrls.set(id, url);
228
+ targets.push({
229
+ id,
230
+ type: asString(info.type) ?? "",
231
+ title: asString(info.title) ?? "",
232
+ url,
233
+ ...(info.attached === true ? { attached: true } : {}),
234
+ });
235
+ }
236
+ return targets;
237
+ }
238
+
239
+ async attach(targetId: string): Promise<{ targetId: string; sessionId: string }> {
240
+ const result = await this.send<Record<string, unknown>>("Target.attachToTarget", {
241
+ targetId,
242
+ flatten: true,
243
+ });
244
+ const sessionId = isRecord(result) ? asString(result.sessionId) : undefined;
245
+ if (sessionId === undefined) {
246
+ throw fail("E_CDP_DISCONNECTED", "connect", {
247
+ message: `Target.attachToTarget returned no sessionId for ${targetId}`,
248
+ hint: "the browser did not allocate a flattened session; retry or run /web-doctor",
249
+ });
250
+ }
251
+ this.#attached.set(targetId, sessionId);
252
+ await this.#rememberTargetUrl(targetId);
253
+ return { targetId, sessionId };
254
+ }
255
+
256
+ /**
257
+ * Best effort: `attachToTarget` returns only a session id, so the URL has to
258
+ * come from `Target.getTargetInfo`. A failure here must not fail the attach
259
+ * (the page still works; `url()` is simply empty until the first navigate).
260
+ */
261
+ async #rememberTargetUrl(targetId: string): Promise<void> {
262
+ try {
263
+ const result = await this.send<Record<string, unknown>>("Target.getTargetInfo", { targetId });
264
+ const info = isRecord(result) && isRecord(result.targetInfo) ? result.targetInfo : undefined;
265
+ const url = info === undefined ? undefined : asString(info.url);
266
+ if (url !== undefined) this.#targetUrls.set(targetId, url);
267
+ } catch {
268
+ // Swallowed on purpose; see the comment above.
269
+ }
270
+ }
271
+
272
+ async detach(sessionId: string): Promise<void> {
273
+ await this.send("Target.detachFromTarget", { sessionId });
274
+ this.#forgetSession(sessionId);
275
+ }
276
+
277
+ async createTarget(url: string, opts: { newWindow?: boolean; background?: boolean } = {}): Promise<string> {
278
+ const params: Record<string, unknown> = { url };
279
+ // `newWindow: true` is the anchor/tab-pool lane; Chrome's default is a tab.
280
+ if (opts.newWindow === true) params.newWindow = true;
281
+ // P12-A: a capture target must not steal the user's foreground tab. Newer
282
+ // Chrome honours `background`; older builds ignore the unknown param.
283
+ if (opts.background === true) params.background = true;
284
+ const result = await this.send<Record<string, unknown>>("Target.createTarget", params);
285
+ const targetId = isRecord(result) ? asString(result.targetId) : undefined;
286
+ if (targetId === undefined) {
287
+ throw fail("E_CDP_DISCONNECTED", "connect", {
288
+ message: `Target.createTarget returned no targetId for ${url}`,
289
+ });
290
+ }
291
+ // The tab may still be on `about:blank` while the request loads, but the
292
+ // requested URL is the only thing Chrome tells us synchronously; it makes
293
+ // `page.url()` useful immediately after `createCdpPage`.
294
+ this.#targetUrls.set(targetId, url);
295
+ return targetId;
296
+ }
297
+
298
+ async closeTarget(targetId: string): Promise<void> {
299
+ await this.send("Target.closeTarget", { targetId });
300
+ this.#targetUrls.delete(targetId);
301
+ for (const [attachedTarget, sessionId] of [...this.#attached]) {
302
+ if (attachedTarget !== targetId) continue;
303
+ this.#attached.delete(attachedTarget);
304
+ this.#rejectPendingForSession(
305
+ sessionId,
306
+ fail("E_TARGET_LOST", "navigate", { message: `target ${targetId} was closed` }),
307
+ );
308
+ }
309
+ }
310
+
311
+ async send<T>(method: string, params: Record<string, unknown> = {}, sessionId?: string): Promise<T> {
312
+ const socket = this.#socket;
313
+ if (this.#state !== "open" || socket === undefined || socket.readyState !== WebSocket.OPEN) {
314
+ throw this.#notConnected(method);
315
+ }
316
+
317
+ const id = this.#nextId++;
318
+ const stage = stageForMethod(method);
319
+ return await new Promise<T>((resolve, reject) => {
320
+ const timer = setTimeout(() => {
321
+ this.#pending.delete(id);
322
+ reject(
323
+ fail("E_TIMEOUT", stage, {
324
+ message: `${method} timed out after ${this.#requestTimeoutMs}ms`,
325
+ hint: "the browser did not answer; retry, or raise requestTimeoutMs in config",
326
+ }),
327
+ );
328
+ }, this.#requestTimeoutMs);
329
+ timer.unref();
330
+
331
+ this.#pending.set(id, {
332
+ method,
333
+ ...(sessionId === undefined ? {} : { sessionId }),
334
+ stage,
335
+ resolve: (value: unknown) => resolve(value as T),
336
+ reject,
337
+ timer,
338
+ });
339
+
340
+ const message: Record<string, unknown> = { id, method, params };
341
+ if (sessionId !== undefined) message.sessionId = sessionId;
342
+ try {
343
+ socket.send(JSON.stringify(message));
344
+ } catch (error) {
345
+ clearTimeout(timer);
346
+ this.#pending.delete(id);
347
+ reject(
348
+ fail("E_CDP_DISCONNECTED", stage, {
349
+ message: `failed to send ${method}: ${error instanceof Error ? error.message : String(error)}`,
350
+ }),
351
+ );
352
+ }
353
+ });
354
+ }
355
+
356
+ /** Subscribe to a CDP event. The returned function removes the subscription. */
357
+ on(method: string, handler: EventHandler): () => void {
358
+ let handlers = this.#handlers.get(method);
359
+ if (handlers === undefined) {
360
+ handlers = new Set();
361
+ this.#handlers.set(method, handlers);
362
+ }
363
+ handlers.add(handler);
364
+ let subscribed = true;
365
+ return () => {
366
+ if (!subscribed) return;
367
+ subscribed = false;
368
+ const current = this.#handlers.get(method);
369
+ if (current === undefined) return;
370
+ current.delete(handler);
371
+ if (current.size === 0) this.#handlers.delete(method);
372
+ };
373
+ }
374
+
375
+ async close(): Promise<void> {
376
+ if (this.#state === "closed") return;
377
+ this.#state = "closed";
378
+
379
+ this.#failPending((stage) =>
380
+ fail("E_CDP_DISCONNECTED", stage, {
381
+ message: "CDP session closed by the caller",
382
+ hint: "the session was closed on purpose; open a new one if the search should continue",
383
+ }),
384
+ );
385
+ this.#handlers.clear();
386
+
387
+ const socket = this.#socket;
388
+ this.#socket = undefined;
389
+ if (socket === undefined || socket.readyState === WebSocket.CLOSED) return;
390
+ await new Promise<void>((resolve) => {
391
+ const timer = setTimeout(() => {
392
+ socket.terminate();
393
+ resolve();
394
+ }, 1_000);
395
+ socket.once("close", () => {
396
+ clearTimeout(timer);
397
+ resolve();
398
+ });
399
+ socket.close();
400
+ });
401
+ }
402
+
403
+ // --- transport -----------------------------------------------------------
404
+
405
+ async #openSocket(): Promise<void> {
406
+ this.#state = "connecting";
407
+ const socket = new WebSocket(this.wsUrl);
408
+ this.#socket = socket;
409
+
410
+ // Permanent listeners: message traffic and a no-op error listener (a
411
+ // WebSocket `error` without a listener would crash the process). The close
412
+ // handler decides between a reconnect and a final failure.
413
+ socket.on("message", (raw) => this.#onMessage(socket, raw));
414
+ socket.on("error", () => undefined);
415
+ socket.on("close", () => this.#onClose(socket));
416
+
417
+ await new Promise<void>((resolve, reject) => {
418
+ let settled = false;
419
+ const timer = setTimeout(() => {
420
+ if (settled) return;
421
+ settled = true;
422
+ cleanup();
423
+ socket.terminate();
424
+ reject(
425
+ fail("E_CDP_UNAVAILABLE", "connect", {
426
+ message: `CDP connect to ${this.wsUrl} timed out after ${this.#connectTimeoutMs}ms`,
427
+ }),
428
+ );
429
+ }, this.#connectTimeoutMs);
430
+ timer.unref();
431
+
432
+ const cleanup = (): void => {
433
+ clearTimeout(timer);
434
+ socket.off("open", onOpen);
435
+ socket.off("error", onError);
436
+ socket.off("close", onCloseEarly);
437
+ };
438
+ const onOpen = (): void => {
439
+ if (settled) return;
440
+ settled = true;
441
+ cleanup();
442
+ resolve();
443
+ };
444
+ const onError = (error: Error): void => {
445
+ if (settled) return;
446
+ settled = true;
447
+ cleanup();
448
+ socket.terminate();
449
+ reject(
450
+ fail("E_CDP_UNAVAILABLE", "connect", {
451
+ message: `CDP connect to ${this.wsUrl} failed: ${error.message}`,
452
+ }),
453
+ );
454
+ };
455
+ const onCloseEarly = (): void => {
456
+ if (settled) return;
457
+ settled = true;
458
+ cleanup();
459
+ reject(
460
+ fail("E_CDP_DISCONNECTED", "connect", {
461
+ message: `CDP socket to ${this.wsUrl} closed before it was ready`,
462
+ }),
463
+ );
464
+ };
465
+
466
+ socket.on("open", onOpen);
467
+ socket.on("error", onError);
468
+ socket.on("close", onCloseEarly);
469
+ });
470
+
471
+ // `close()` may have won the race while the handshake was in flight; an
472
+ // intentional close must never be undone by a late socket open.
473
+ if (this.#closedDuringHandshake()) {
474
+ socket.terminate();
475
+ throw fail("E_CDP_DISCONNECTED", "connect", {
476
+ message: "CDP session was closed while connecting",
477
+ });
478
+ }
479
+ this.#state = "open";
480
+ }
481
+
482
+ /** Read through a method so the compiler's entry-edge narrowing cannot hide it. */
483
+ #closedDuringHandshake(): boolean {
484
+ return this.#state === "closed";
485
+ }
486
+
487
+ #terminateSocket(): void {
488
+ const socket = this.#socket;
489
+ this.#socket = undefined;
490
+ if (socket !== undefined && socket.readyState !== WebSocket.CLOSED) socket.terminate();
491
+ }
492
+
493
+ #notConnected(method: string): WebSearchError {
494
+ const stage = stageForMethod(method);
495
+ if (this.#reconnectUsed) {
496
+ return fail("E_CDP_DISCONNECTED", stage, {
497
+ message: `CDP socket is down (method ${method}); the single reconnect attempt was already used`,
498
+ hint:
499
+ "the connection dropped twice; run /web-doctor, make sure Chrome is still running, " +
500
+ "then retry the search",
501
+ });
502
+ }
503
+ return fail("E_CDP_DISCONNECTED", stage, {
504
+ message: `CDP socket is not open (method ${method})`,
505
+ hint: "retry once; if it repeats, run /web-doctor and check that Chrome is still running",
506
+ });
507
+ }
508
+
509
+ #onMessage(socket: WebSocket, raw: RawData): void {
510
+ if (this.#socket !== socket) return;
511
+ const message = parseMessage(raw);
512
+ if (!isRecord(message)) return;
513
+
514
+ const id = message.id;
515
+ if (typeof id === "number") {
516
+ this.#settle(id, message);
517
+ return;
518
+ }
519
+
520
+ const method = asString(message.method);
521
+ if (method === undefined) return;
522
+ const params = isRecord(message.params) ? message.params : {};
523
+ const sessionId = asString(message.sessionId);
524
+ this.#dispatchEvent(method, params, sessionId);
525
+ }
526
+
527
+ #settle(id: number, message: Record<string, unknown>): void {
528
+ const pending = this.#pending.get(id);
529
+ if (pending === undefined) return;
530
+ clearTimeout(pending.timer);
531
+ this.#pending.delete(id);
532
+
533
+ const error = message.error;
534
+ if (isRecord(error)) {
535
+ pending.reject(cdpError(pending.method, pending.sessionId, error as CdpErrorPayload));
536
+ return;
537
+ }
538
+ pending.resolve(message.result ?? {});
539
+ }
540
+
541
+ #dispatchEvent(method: string, params: Record<string, unknown>, sessionId?: string): void {
542
+ const handlers = this.#handlers.get(method);
543
+ if (handlers === undefined) return;
544
+ for (const handler of [...handlers]) {
545
+ try {
546
+ handler(params, sessionId);
547
+ } catch {
548
+ // A listener bug must not take down the whole CDP socket. The error is
549
+ // swallowed deliberately: handlers are internal and their failures are
550
+ // logged by the caller that owns them.
551
+ }
552
+ }
553
+ }
554
+
555
+ #onClose(socket: WebSocket): void {
556
+ if (this.#socket !== socket) return;
557
+ this.#socket = undefined;
558
+ if (this.#state === "closed" || this.#state === "connecting" || this.#state === "reconnecting") {
559
+ return;
560
+ }
561
+
562
+ this.#state = "reconnecting";
563
+ this.#failPending((stage) =>
564
+ fail("E_CDP_DISCONNECTED", stage, {
565
+ message: "CDP socket closed unexpectedly; reconnecting once",
566
+ hint: "the connection dropped; the client re-attaches the owned tabs once",
567
+ }),
568
+ );
569
+ void this.#reconnect();
570
+ }
571
+
572
+ async #reconnect(): Promise<void> {
573
+ if (this.#reconnectUsed) {
574
+ this.#shutdown();
575
+ return;
576
+ }
577
+ this.#reconnectUsed = true;
578
+ this.#reattaching = true;
579
+
580
+ try {
581
+ await this.#openSocket();
582
+ } catch {
583
+ this.#reattaching = false;
584
+ this.#shutdown();
585
+ return;
586
+ }
587
+
588
+ // Chrome forgets flattened sessions when the owning socket dies, so every
589
+ // live target needs a fresh attach. A target that vanished meanwhile is
590
+ // dropped; its page fails with E_TARGET_LOST on the next command.
591
+ const targetIds = [...this.#attached.keys()];
592
+ for (const targetId of targetIds) {
593
+ try {
594
+ await this.attach(targetId);
595
+ } catch {
596
+ this.#attached.delete(targetId);
597
+ }
598
+ }
599
+ this.#reattaching = false;
600
+
601
+ if (this.#state === "closed") return;
602
+
603
+ const log = this.#log;
604
+ if (log !== undefined) {
605
+ const now = this.clock?.now() ?? Date.now();
606
+ void log
607
+ .write({
608
+ ts: new Date(now).toISOString(),
609
+ event: "cdp_reconnect",
610
+ targets: targetIds.length,
611
+ })
612
+ .catch(() => undefined);
613
+ }
614
+ }
615
+
616
+ /** Final state after a second drop or a failed reconnect: no more retries. */
617
+ #shutdown(): void {
618
+ this.#state = "closed";
619
+ this.#reattaching = false;
620
+ const socket = this.#socket;
621
+ this.#socket = undefined;
622
+ if (socket !== undefined && socket.readyState !== WebSocket.CLOSED) socket.terminate();
623
+ this.#failPending((stage) =>
624
+ fail("E_CDP_DISCONNECTED", stage, {
625
+ message: "CDP connection dropped again after one reconnect attempt",
626
+ hint:
627
+ "the single reconnect was already used; run /web-doctor and check that Chrome is " +
628
+ "still running, then retry the search",
629
+ }),
630
+ );
631
+ this.#handlers.clear();
632
+ }
633
+
634
+ /**
635
+ * Reject everything in flight. The stage comes from each request, not from
636
+ * the connection: a navigate killed by a dropped socket must not be logged as
637
+ * a connect failure (the doctor groups failures by stage).
638
+ */
639
+ #failPending(makeError: (stage: Stage) => WebSearchError): void {
640
+ const pending = [...this.#pending.values()];
641
+ this.#pending.clear();
642
+ for (const request of pending) {
643
+ clearTimeout(request.timer);
644
+ request.reject(makeError(request.stage));
645
+ }
646
+ }
647
+
648
+ #rejectPendingForSession(sessionId: string, error: WebSearchError): void {
649
+ for (const [id, request] of [...this.#pending]) {
650
+ if (request.sessionId !== sessionId) continue;
651
+ clearTimeout(request.timer);
652
+ this.#pending.delete(id);
653
+ request.reject(error);
654
+ }
655
+ }
656
+
657
+ #forgetSession(sessionId: string): void {
658
+ for (const [targetId, attachedSession] of [...this.#attached]) {
659
+ if (attachedSession === sessionId) this.#attached.delete(targetId);
660
+ }
661
+ }
662
+
663
+ /**
664
+ * A target/session detached (target closed, tab crashed, or Chrome dropped
665
+ * the session). Drop the mapping and reject in-flight commands so a caller
666
+ * never waits for a response that cannot arrive.
667
+ */
668
+ #targetGone(sessionId: string | undefined, targetId: string | undefined): void {
669
+ if (sessionId === undefined && targetId === undefined) return;
670
+
671
+ let lostTarget = targetId;
672
+ for (const [attachedTarget, attachedSession] of [...this.#attached]) {
673
+ const matches = targetId !== undefined ? attachedTarget === targetId : attachedSession === sessionId;
674
+ if (!matches) continue;
675
+ this.#attached.delete(attachedTarget);
676
+ lostTarget ??= attachedTarget;
677
+ if (sessionId === undefined) continue;
678
+ this.#rejectPendingForSession(
679
+ sessionId,
680
+ fail("E_TARGET_LOST", "navigate", {
681
+ message: `target ${attachedTarget} was detached (session ${sessionId})`,
682
+ }),
683
+ );
684
+ }
685
+ }
686
+ }
687
+