pi-browser-harness 0.2.0 → 0.3.1

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/src/daemon.ts DELETED
@@ -1,881 +0,0 @@
1
- /**
2
- * BrowserDaemon — manages a direct CDP WebSocket connection to Chrome.
3
- *
4
- * Architecture:
5
- * Node.js → CDP WebSocket (ws://localhost:9222/...) → Chrome
6
- *
7
- * No Python proxy, no Unix socket. Direct CDP over a single persistent
8
- * WebSocket with message-ID multiplexing, auto-reconnect, and event buffering.
9
- */
10
-
11
- import { readFile, writeFile } from "node:fs/promises";
12
- import { homedir } from "node:os";
13
- import { join } from "node:path";
14
- import { connect as netConnect } from "node:net";
15
- import WebSocket from "ws";
16
- import {
17
- type CDPEvent,
18
- type DaemonStatus,
19
- type DialogInfo,
20
- type PageInfo,
21
- type PageInfoResult,
22
- type RemoteConfig,
23
- type TabInfo,
24
- isInternalUrl,
25
- } from "./protocol";
26
-
27
- // ── Constants ────────────────────────────────────────────────────────────────
28
-
29
- const CDP_CONNECT_TIMEOUT_MS = 10_000;
30
- const CDP_REQUEST_TIMEOUT_MS = 15_000;
31
- const CDP_RECONNECT_BASE_MS = 500;
32
- const CDP_MAX_RECONNECT_ATTEMPTS = 5;
33
- const PORT_PROBE_DEADLINE_MS = 30_000;
34
- const PORT_PROBE_INTERVAL_MS = 1_000;
35
-
36
- /**
37
- * Discover Chrome's CDP WebSocket URL by reading the per-profile
38
- * `DevToolsActivePort` file written when remote debugging is enabled
39
- * via chrome://inspect/#remote-debugging. Mirrors browser-harness/daemon.py.
40
- */
41
- async function discoverWsUrl(): Promise<string> {
42
- const dirs = chromeProfileDirs();
43
- const tried: string[] = [];
44
- for (const base of dirs) {
45
- const portFile = join(base, "DevToolsActivePort");
46
- let raw: string;
47
- try {
48
- raw = await readFile(portFile, "utf8");
49
- } catch {
50
- continue;
51
- }
52
- tried.push(base);
53
- const lines = raw.trim().split("\n");
54
- if (lines.length < 2) continue;
55
- const port = lines[0].trim();
56
- const path = lines[1].trim();
57
- if (!port || !path) continue;
58
-
59
- await waitForPort(Number(port));
60
- return `ws://127.0.0.1:${port}${path}`;
61
- }
62
- throw new Error(
63
- `DevToolsActivePort not found in ${dirs.join(", ")} — open chrome://inspect/#remote-debugging in your browser, tick the checkbox, click Allow, then retry. Or set BU_CDP_WS to a remote browser endpoint.`,
64
- );
65
- }
66
-
67
- /** Probe TCP port until reachable or deadline expires. */
68
- async function waitForPort(port: number): Promise<void> {
69
- const deadline = Date.now() + PORT_PROBE_DEADLINE_MS;
70
- let lastErr: Error | null = null;
71
- while (Date.now() < deadline) {
72
- try {
73
- await new Promise<void>((resolve, reject) => {
74
- const sock = netConnect({ host: "127.0.0.1", port });
75
- const onError = (err: Error) => {
76
- sock.destroy();
77
- reject(err);
78
- };
79
- sock.setTimeout(1000, () => onError(new Error("probe timeout")));
80
- sock.once("error", onError);
81
- sock.once("connect", () => {
82
- sock.end();
83
- resolve();
84
- });
85
- });
86
- return;
87
- } catch (err) {
88
- lastErr = err instanceof Error ? err : new Error(String(err));
89
- await new Promise((r) => setTimeout(r, PORT_PROBE_INTERVAL_MS));
90
- }
91
- }
92
- throw new Error(
93
- `Chrome's remote-debugging page is open, but DevTools is not live yet on 127.0.0.1:${port} — if Chrome opened a profile picker, choose your normal profile first, then tick the checkbox and click Allow if shown (last error: ${lastErr?.message ?? "unknown"})`,
94
- );
95
- }
96
-
97
- // Chrome / Edge / Chromium profile directories that may contain DevToolsActivePort.
98
- // Mirrors browser-harness/daemon.py PROFILES.
99
- function chromeProfileDirs(): string[] {
100
- const home = homedir();
101
- return [
102
- join(home, "Library/Application Support/Google/Chrome"),
103
- join(home, "Library/Application Support/Microsoft Edge"),
104
- join(home, "Library/Application Support/Microsoft Edge Beta"),
105
- join(home, "Library/Application Support/Microsoft Edge Dev"),
106
- join(home, "Library/Application Support/Microsoft Edge Canary"),
107
- join(home, ".config/google-chrome"),
108
- join(home, ".config/chromium"),
109
- join(home, ".config/chromium-browser"),
110
- join(home, ".config/microsoft-edge"),
111
- join(home, ".config/microsoft-edge-beta"),
112
- join(home, ".config/microsoft-edge-dev"),
113
- join(home, ".var/app/org.chromium.Chromium/config/chromium"),
114
- join(home, ".var/app/com.google.Chrome/config/google-chrome"),
115
- join(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser"),
116
- join(home, ".var/app/com.microsoft.Edge/config/microsoft-edge"),
117
- join(home, "AppData/Local/Google/Chrome/User Data"),
118
- join(home, "AppData/Local/Chromium/User Data"),
119
- join(home, "AppData/Local/Microsoft/Edge/User Data"),
120
- join(home, "AppData/Local/Microsoft/Edge Beta/User Data"),
121
- join(home, "AppData/Local/Microsoft/Edge Dev/User Data"),
122
- join(home, "AppData/Local/Microsoft/Edge SxS/User Data"),
123
- ];
124
- }
125
-
126
- const VIRTUAL_KEY_CODES: Record<string, number> = {
127
- Enter: 13,
128
- Tab: 9,
129
- Backspace: 8,
130
- Escape: 27,
131
- Delete: 46,
132
- " ": 32,
133
- ArrowLeft: 37,
134
- ArrowUp: 38,
135
- ArrowRight: 39,
136
- ArrowDown: 40,
137
- Home: 36,
138
- End: 35,
139
- PageUp: 33,
140
- PageDown: 34,
141
- };
142
-
143
- // ── BrowserDaemon ────────────────────────────────────────────────────────────
144
-
145
- export class BrowserDaemon {
146
- readonly namespace: string;
147
-
148
- private ws: WebSocket | null = null;
149
- private _sessionId: string | null = null;
150
- private _targetId: string | null = null;
151
- private _currentDialog: DialogInfo | null = null;
152
- private _eventBuffer: CDPEvent[] = [];
153
- private _pending = new Map<number, PendingRequest>();
154
- private _nextId = 1;
155
- private _connectResolve: ((v: void) => void) | null = null;
156
- private _connectReject: ((err: Error) => void) | null = null;
157
- private _connectTimer: ReturnType<typeof setTimeout> | null = null;
158
- private _reconnectAttempts = 0;
159
- private _stopping = false;
160
- private _consentObserved = false;
161
- private remote: RemoteConfig | null = null;
162
-
163
- // ── Performance optimizations ──────────────────────────────────────────
164
- private _lastHealthCheck = 0;
165
- private readonly HEALTH_CHECK_TTL_MS = 30_000;
166
- private _cachedPageInfo: PageInfo | null = null;
167
- private _cachedPageInfoTime = 0;
168
- private readonly PAGE_INFO_TTL_MS = 1_000;
169
- private _lastOperationFailed = false;
170
-
171
- constructor(namespace = "default", remote?: RemoteConfig) {
172
- this.namespace = namespace;
173
- this.remote = remote ?? null;
174
- }
175
-
176
- // ── Public getters ───────────────────────────────────────────────────────
177
-
178
- get sessionId(): string | null {
179
- return this._sessionId;
180
- }
181
-
182
- getStatus(): DaemonStatus {
183
- return {
184
- alive: this._sessionId !== null && this.ws?.readyState === WebSocket.OPEN,
185
- sessionId: this._sessionId,
186
- pid: null,
187
- namespace: this.namespace,
188
- socketPath: "",
189
- remoteBrowserId: this.remote?.browserId,
190
- };
191
- }
192
-
193
- // ── Lifecycle ────────────────────────────────────────────────────────────
194
-
195
- /**
196
- * Connect to Chrome via CDP WebSocket.
197
- * Idempotent — returns immediately if already connected and healthy.
198
- */
199
- async start(): Promise<void> {
200
- if (this._sessionId && this.ws?.readyState === WebSocket.OPEN) {
201
- return; // Already connected
202
- }
203
-
204
- this._stopping = false;
205
- this._reconnectAttempts = 0;
206
-
207
- try {
208
- await this.connect();
209
- } catch (err) {
210
- const msg = err instanceof Error ? err.message : String(err);
211
- if (this.needsChromeSetup(msg)) {
212
- throw new Error(
213
- `Chrome remote debugging not enabled. Open chrome://inspect/#remote-debugging and tick the Allow checkbox, then retry.\n\nDetails: ${msg}`,
214
- );
215
- }
216
- throw err;
217
- }
218
- }
219
-
220
- /**
221
- * Gracefully disconnect from Chrome.
222
- */
223
- async stop(): Promise<void> {
224
- this._stopping = true;
225
-
226
- // Reject any pending connect
227
- if (this._connectReject) {
228
- this._connectReject(new Error("Daemon stopped"));
229
- this._connectReject = null;
230
- }
231
- if (this._connectTimer) {
232
- clearTimeout(this._connectTimer);
233
- this._connectTimer = null;
234
- }
235
-
236
- // Reject all pending requests
237
- for (const [id, req] of this._pending) {
238
- req.reject(new Error("Daemon stopped"));
239
- this._pending.delete(id);
240
- }
241
-
242
- // Close the WebSocket
243
- if (this.ws) {
244
- try {
245
- this.ws.close(1000, "Shutdown");
246
- } catch {
247
- // best-effort
248
- }
249
- this.ws = null;
250
- }
251
-
252
- this._sessionId = null;
253
- this._targetId = null;
254
- this._currentDialog = null;
255
- this._eventBuffer = [];
256
- this._consentObserved = false;
257
- }
258
-
259
- /**
260
- * Ensure daemon is alive and healthy. Reconnects if stale.
261
- * Call before every tool execution.
262
- *
263
- * OPTIMIZED: Fast path checks WebSocket state + short TTL. Only does
264
- * a full CDP health check every HEALTH_CHECK_TTL_MS (30s) or after a
265
- * failed operation. This eliminates ~5ms per tool call.
266
- */
267
- async ensureAlive(): Promise<void> {
268
- const isOpen = this.ws?.readyState === WebSocket.OPEN;
269
- if (!isOpen || !this._sessionId) {
270
- await this.stop();
271
- await this.start();
272
- return;
273
- }
274
-
275
- // Fast path: if recent health check passed and no failures, skip CDP roundtrip
276
- const now = Date.now();
277
- if (!this._lastOperationFailed && (now - this._lastHealthCheck) < this.HEALTH_CHECK_TTL_MS) {
278
- return;
279
- }
280
-
281
- // Full health check with two-stage probe (as before)
282
- try {
283
- await this.sendRawCdp("Target.getTargets", {}, null, 2_000);
284
- this._lastHealthCheck = now;
285
- this._lastOperationFailed = false;
286
- return;
287
- } catch {
288
- // probe 1 failed — could be transient, could be consent-pending
289
- }
290
- try {
291
- await this.sendRawCdp("Target.getTargets", {}, null);
292
- this._lastHealthCheck = now;
293
- this._lastOperationFailed = false;
294
- return;
295
- } catch {
296
- // genuinely stale — fall through to reconnect
297
- }
298
-
299
- await this.stop();
300
- await this.start();
301
- }
302
-
303
- // ── CDP Communication ────────────────────────────────────────────────────
304
-
305
- /**
306
- * Send a CDP method and return the result.
307
- * Handles session management — Target.* methods skip session,
308
- * others use explicit sessionId or the daemon's default session.
309
- */
310
- async cdp(
311
- method: string,
312
- params?: Record<string, unknown>,
313
- sessionId?: string | null,
314
- ): Promise<unknown> {
315
- // Browser-level Target.* calls must NOT use a session.
316
- // Everything else uses the explicit sessionId (any session attached
317
- // with flatten:true on this connection is reachable via the top-level
318
- // sessionId field — that's what flat-protocol mode means).
319
- const effectiveSid = method.startsWith("Target.") ? null : (sessionId ?? this._sessionId);
320
-
321
- try {
322
- const result = await this.sendRawCdp(method, params ?? {}, effectiveSid);
323
- this._lastOperationFailed = false;
324
- return result;
325
- } catch (err) {
326
- this._lastOperationFailed = true;
327
- // Auto-recover from stale default session — retry once
328
- if (err instanceof SessionNotFoundError && effectiveSid === this._sessionId) {
329
- await this.attachFirstPage();
330
- return this.sendRawCdp(method, params ?? {}, this._sessionId);
331
- }
332
- throw err;
333
- }
334
- }
335
-
336
- // ── High-level Browser Helpers ────────────────────────────────────────────
337
-
338
- /** Get page state or dialog info. Uses short-lived cache to eliminate redundant evaluateJS calls. */
339
- async getPageInfo(): Promise<PageInfoResult> {
340
- // Check for buffered dialog first
341
- if (this._currentDialog) {
342
- const dialog = this._currentDialog;
343
- this._currentDialog = null; // consume it
344
- return { dialog };
345
- }
346
-
347
- // Return cached page info if still fresh (eliminates redundant CDP roundtrips)
348
- const now = Date.now();
349
- if (this._cachedPageInfo && (now - this._cachedPageInfoTime) < this.PAGE_INFO_TTL_MS) {
350
- return this._cachedPageInfo;
351
- }
352
-
353
- const js = `JSON.stringify({url:location.href,title:document.title,w:innerWidth,h:innerHeight,sx:scrollX,sy:scrollY,pw:document.documentElement.scrollWidth,ph:document.documentElement.scrollHeight})`;
354
- const raw = await this.evaluateJS(js);
355
- const info = JSON.parse(raw as string) as PageInfo;
356
-
357
- // Cache for subsequent calls
358
- this._cachedPageInfo = info;
359
- this._cachedPageInfoTime = now;
360
- return info;
361
- }
362
-
363
- /** Invalidate page info cache (call after navigation). */
364
- invalidatePageInfoCache(): void {
365
- this._cachedPageInfo = null;
366
- this._cachedPageInfoTime = 0;
367
- }
368
-
369
- /** Mark last operation as failed so ensureAlive re-checks health next time. */
370
- markOperationFailed(): void {
371
- this._lastOperationFailed = true;
372
- }
373
-
374
- /**
375
- * Capture a screenshot and write to disk.
376
- *
377
- * OPTIMIZED: Supports JPEG with configurable quality for faster CDP transfer.
378
- * JPEG q80 is 2-5x smaller than PNG for photo-heavy pages with negligible quality loss,
379
- * which speeds up both the CDP transfer and the LLM reading the image.
380
- */
381
- async captureScreenshot(
382
- outputPath: string,
383
- fullPage = false,
384
- format: "png" | "jpeg" = "png",
385
- quality = 80,
386
- ): Promise<string> {
387
- const params: Record<string, unknown> = {
388
- format,
389
- captureBeyondViewport: fullPage,
390
- };
391
- if (format === "jpeg") {
392
- params.quality = quality;
393
- }
394
- const result = (await this.cdp("Page.captureScreenshot", params)) as { data: string };
395
-
396
- const buf = Buffer.from(result.data, "base64");
397
- await writeFile(outputPath, buf);
398
- return outputPath;
399
- }
400
-
401
- /**
402
- * Wait for page load using CDP events instead of polling.
403
- * Dramatically faster than polling readyState every 300ms — especially
404
- * for pages that are already loaded (detected instantly via events).
405
- */
406
- async waitForLoad(timeoutMs = 15_000): Promise<boolean> {
407
- // Fast path: check if already loaded
408
- try {
409
- const state = await this.evaluateJS("document.readyState");
410
- if (state === "complete") return true;
411
- } catch {
412
- // proceed to event-based wait
413
- }
414
-
415
- // Drain any buffered events from before we started waiting
416
- await this.drainEvents();
417
-
418
- const deadline = Date.now() + timeoutMs;
419
- while (Date.now() < deadline) {
420
- const events = await this.drainEvents();
421
- for (const ev of events) {
422
- if (ev.method === "Page.loadEventFired") return true;
423
- if (ev.method === "Page.frameStoppedLoading") {
424
- // Frame stopped — verify readyState
425
- try {
426
- const state = await this.evaluateJS("document.readyState");
427
- if (state === "complete") return true;
428
- } catch {
429
- // keep waiting
430
- }
431
- }
432
- }
433
- // Short sleep between event drain cycles (50ms vs 300ms baseline)
434
- await new Promise((r) => setTimeout(r, 50));
435
- }
436
-
437
- // Timeout fallback: final readyState check
438
- try {
439
- const state = await this.evaluateJS("document.readyState");
440
- return state === "complete";
441
- } catch {
442
- return false;
443
- }
444
- }
445
-
446
- /** List browser tabs */
447
- async listTabs(includeChrome = true): Promise<TabInfo[]> {
448
- const result = (await this.cdp("Target.getTargets")) as {
449
- targetInfos: Array<{ targetId: string; type: string; title: string; url: string }>;
450
- };
451
-
452
- const tabs: TabInfo[] = [];
453
- for (const t of result.targetInfos) {
454
- if (t.type !== "page") continue;
455
- if (!includeChrome && isInternalUrl(t.url)) continue;
456
- tabs.push({ targetId: t.targetId, title: t.title || "", url: t.url || "" });
457
- }
458
- return tabs;
459
- }
460
-
461
- /** Get current tab info — uses the targetId we attached to. */
462
- async currentTab(): Promise<TabInfo> {
463
- if (!this._targetId) {
464
- throw new Error("No tab attached. Call newTab/switchTab first.");
465
- }
466
- const result = (await this.cdp("Target.getTargetInfo", {
467
- targetId: this._targetId,
468
- })) as {
469
- targetInfo: { targetId: string; url: string; title: string };
470
- };
471
- const t = result.targetInfo;
472
- return { targetId: t.targetId, url: t.url || "", title: t.title || "" };
473
- }
474
-
475
- /** Create a new tab and switch to it. Optionally navigate. */
476
- async newTab(url?: string): Promise<string> {
477
- const result = (await this.cdp("Target.createTarget", {
478
- url: "about:blank",
479
- })) as { targetId: string };
480
-
481
- await this.switchTab(result.targetId);
482
- this.invalidatePageInfoCache();
483
- if (url && url !== "about:blank") {
484
- await this.cdp("Page.navigate", { url });
485
- this.invalidatePageInfoCache();
486
- }
487
- return result.targetId;
488
- }
489
-
490
- /**
491
- * Activate and attach to a tab.
492
- *
493
- * OPTIMIZED: Enables core domains in parallel (Promise.all) instead of
494
- * sequential await. Reduces tab-switch latency by ~30%.
495
- */
496
- async switchTab(targetId: string): Promise<void> {
497
- await this.cdp("Target.activateTarget", { targetId });
498
- const result = (await this.cdp("Target.attachToTarget", {
499
- targetId,
500
- flatten: true,
501
- })) as { sessionId: string };
502
-
503
- this._sessionId = result.sessionId;
504
- this._targetId = targetId;
505
- this.invalidatePageInfoCache();
506
-
507
- // Enable core domains in parallel (pipelined over single WS connection)
508
- await Promise.all(
509
- ["Page", "DOM", "Runtime", "Network"].map((domain) =>
510
- this.cdp(`${domain}.enable`).catch(() => {})
511
- )
512
- );
513
-
514
- // Mark the tab with a green circle for user visibility
515
- try {
516
- await this.cdp("Runtime.evaluate", {
517
- expression: `if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`,
518
- });
519
- } catch {
520
- // best-effort
521
- }
522
- }
523
-
524
- /** Evaluate JavaScript in the page (or a specific iframe target) */
525
- async evaluateJS(expression: string, targetId?: string): Promise<unknown> {
526
- // Auto-wrap return statements in IIFE
527
- if (expression.includes("return ") && !expression.trim().startsWith("(")) {
528
- expression = `(function(){${expression}})()`;
529
- }
530
-
531
- const sid = targetId || null;
532
- const result = (await this.cdp(
533
- "Runtime.evaluate",
534
- {
535
- expression,
536
- returnByValue: true,
537
- awaitPromise: true,
538
- },
539
- targetId ? sid : undefined,
540
- )) as { result?: { value?: unknown }; exceptionDetails?: unknown };
541
-
542
- if (result.exceptionDetails) {
543
- throw new Error(`JS evaluation failed: ${JSON.stringify(result.exceptionDetails)}`);
544
- }
545
- return result.result?.value;
546
- }
547
-
548
- /** Drain buffered CDP events from the internal buffer */
549
- async drainEvents(): Promise<CDPEvent[]> {
550
- const events = this._eventBuffer;
551
- this._eventBuffer = [];
552
- return events;
553
- }
554
-
555
- /** Get virtual key code for a key name or character */
556
- getVirtualKeyCode(key: string): number {
557
- return VIRTUAL_KEY_CODES[key] ?? (key.length === 1 ? key.charCodeAt(0) : 0);
558
- }
559
-
560
- /** Get the key code string for CDP */
561
- getKeyCode(key: string): string {
562
- return key.length === 1 && !VIRTUAL_KEY_CODES[key] ? key : key;
563
- }
564
-
565
- // ── Internal: WebSocket Connection ────────────────────────────────────────
566
-
567
- private async connect(): Promise<void> {
568
- const timeoutMs = CDP_CONNECT_TIMEOUT_MS;
569
- const wsUrl = this.remote?.cdpUrl
570
- ? this.remote.cdpUrl
571
- : process.env.BU_CDP_WS
572
- ? process.env.BU_CDP_WS
573
- : await discoverWsUrl();
574
-
575
- if (!this.remote) {
576
- this.remote = {
577
- cdpUrl: wsUrl,
578
- browserId: wsUrl.split("/").pop() || "unknown",
579
- };
580
- }
581
-
582
- await this.openWebSocket(wsUrl, timeoutMs);
583
- await this.attachFirstPage();
584
- }
585
-
586
- private openWebSocket(url: string, timeoutMs: number): Promise<void> {
587
- return new Promise((resolve, reject) => {
588
- this._connectResolve = resolve;
589
- this._connectReject = reject;
590
-
591
- this._connectTimer = setTimeout(() => {
592
- this._connectTimer = null;
593
- this._connectReject = null;
594
- if (this.ws) {
595
- try { this.ws.close(); } catch { /* ignore */ }
596
- this.ws = null;
597
- }
598
- reject(new Error(`CDP WebSocket connection timed out after ${timeoutMs}ms`));
599
- }, timeoutMs);
600
-
601
- try {
602
- this.ws = new WebSocket(url);
603
- } catch (err) {
604
- clearTimeout(this._connectTimer!);
605
- this._connectTimer = null;
606
- reject(err);
607
- return;
608
- }
609
-
610
- this.ws.onopen = () => {
611
- if (this._connectTimer) {
612
- clearTimeout(this._connectTimer);
613
- this._connectTimer = null;
614
- }
615
- this._connectResolve = null;
616
- this._connectReject = null;
617
- // NOTE: do not clear _reconnectAttempts here. Chrome accepts the
618
- // WS upgrade before the user clicks Allow on the remote-debugging
619
- // consent dialog, so a successful onopen does not prove the
620
- // connection is usable. We only consider the connection healthy
621
- // once a CDP roundtrip completes — see attachFirstPage.
622
- resolve();
623
- };
624
-
625
- this.ws.onmessage = (event: WebSocket.MessageEvent) => {
626
- const data = event.data;
627
- this.handleMessage(typeof data === "string" ? data : data.toString());
628
- };
629
-
630
- this.ws.onerror = (_event: WebSocket.ErrorEvent) => {
631
- // Only reject during the connect phase; otherwise it means a disconnect
632
- if (this._connectReject) {
633
- const err = new Error("CDP WebSocket error during connection");
634
- this._connectReject(err);
635
- this._connectReject = null;
636
- if (this._connectTimer) {
637
- clearTimeout(this._connectTimer);
638
- this._connectTimer = null;
639
- }
640
- }
641
- };
642
-
643
- this.ws.onclose = (_event: WebSocket.CloseEvent) => {
644
- // If we're shutting down intentionally, don't reconnect
645
- if (this._stopping) return;
646
-
647
- // If we never saw a successful CDP roundtrip on this connection,
648
- // the close almost certainly means the user denied (or hasn't yet
649
- // accepted) Chrome's "Allow remote debugging?" prompt. Reconnecting
650
- // would just spawn another popup. Bail out and let the next tool
651
- // call surface a clear error.
652
- if (!this._consentObserved) {
653
- this._sessionId = null;
654
- return;
655
- }
656
-
657
- // Auto-reconnect with backoff
658
- if (this._reconnectAttempts < CDP_MAX_RECONNECT_ATTEMPTS) {
659
- this._sessionId = null;
660
- // Reset for the new connection — it must re-prove itself.
661
- this._consentObserved = false;
662
- const delay = Math.min(
663
- CDP_RECONNECT_BASE_MS * 2 ** this._reconnectAttempts,
664
- 15_000,
665
- );
666
- this._reconnectAttempts++;
667
- setTimeout(() => {
668
- if (!this._stopping) {
669
- this.start().catch(() => {
670
- // Reconnection ultimately failed — tools will report errors
671
- });
672
- }
673
- }, delay);
674
- }
675
- };
676
- });
677
- }
678
-
679
- // ── Internal: Message Handling ────────────────────────────────────────────
680
-
681
- private handleMessage(data: string): void {
682
- let msg: CDPMessage;
683
- try {
684
- msg = JSON.parse(data);
685
- } catch {
686
- return; // Corrupt frame, ignore
687
- }
688
-
689
- // CDP events have no `id` — they have a `method` field
690
- if (msg.id === undefined) {
691
- // Dialog detection
692
- if (msg.method === "Page.javascriptDialogOpening") {
693
- this._currentDialog = {
694
- type: (msg.params?.type as DialogInfo["type"]) || "alert",
695
- message: (msg.params?.message as string) || "",
696
- defaultPrompt: msg.params?.defaultPrompt as string | undefined,
697
- };
698
- return;
699
- }
700
-
701
- // Dialog closed — clear the stored dialog
702
- if (msg.method === "Page.javascriptDialogClosed") {
703
- this._currentDialog = null;
704
- return;
705
- }
706
-
707
- // Buffer other events for drainEvents()
708
- this._eventBuffer.push({
709
- method: msg.method!,
710
- params: msg.params as unknown,
711
- session_id: msg.sessionId as string | undefined,
712
- });
713
- return;
714
- }
715
-
716
- // It's a response to a pending request
717
- const pending = this._pending.get(msg.id);
718
- if (!pending) return; // Stale response, ignore
719
-
720
- this._pending.delete(msg.id);
721
- clearTimeout(pending.timer);
722
-
723
- if (msg.error) {
724
- // Handle stale session recovery
725
- const errMsg = msg.error.message || String(msg.error);
726
- if (
727
- errMsg.includes("Session with given id not found") &&
728
- pending.sessionId === this._sessionId
729
- ) {
730
- // Schedule recovery and reject — caller can retry
731
- pending.reject(new SessionNotFoundError(errMsg));
732
- return;
733
- }
734
- pending.reject(new Error(`CDP error (${pending.method}): ${errMsg}`));
735
- return;
736
- }
737
-
738
- pending.resolve(msg.result);
739
- }
740
-
741
- /** Send a CDP command directly to the browser session */
742
- private sendRawCdp(
743
- method: string,
744
- params: Record<string, unknown>,
745
- sessionId: string | null,
746
- timeoutMs: number = CDP_REQUEST_TIMEOUT_MS,
747
- ): Promise<unknown> {
748
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
749
- return Promise.reject(new Error("Browser not connected. Is Chrome running?"));
750
- }
751
-
752
- const id = this._nextId++;
753
- const payload: Record<string, unknown> = { id, method, params };
754
- if (sessionId) payload.sessionId = sessionId;
755
- const msg = JSON.stringify(payload);
756
-
757
- return new Promise((resolve, reject) => {
758
- const timer = setTimeout(() => {
759
- this._pending.delete(id);
760
- reject(new Error(`CDP timeout after ${timeoutMs}ms: ${method}`));
761
- }, timeoutMs);
762
-
763
- this._pending.set(id, { resolve, reject, timer, method, sessionId });
764
- try {
765
- this.ws!.send(msg);
766
- } catch (err) {
767
- clearTimeout(timer);
768
- this._pending.delete(id);
769
- reject(err);
770
- }
771
- });
772
- }
773
-
774
- /** Attach to the first real page target. Creates about:blank if none exist. */
775
- private async attachFirstPage(): Promise<string> {
776
- const result = (await this.sendRawCdp("Target.getTargets", {}, null)) as {
777
- targetInfos: Array<{ targetId: string; type: string; url: string }>;
778
- };
779
-
780
- // First CDP roundtrip succeeded — consent has been granted. Only now
781
- // is it safe to reset the reconnect counter; otherwise a ws that opens
782
- // but is silently held by Chrome's consent dialog would let us loop
783
- // forever stacking popups on top of each other.
784
- this._consentObserved = true;
785
- this._reconnectAttempts = 0;
786
-
787
- const pages = result.targetInfos.filter(
788
- (t) => t.type === "page" && !isInternalUrl(t.url),
789
- );
790
-
791
- if (pages.length === 0) {
792
- const created = (await this.sendRawCdp(
793
- "Target.createTarget",
794
- { url: "about:blank" },
795
- null,
796
- )) as { targetId: string };
797
- pages.push({ targetId: created.targetId, type: "page", url: "about:blank" });
798
- }
799
-
800
- const attachResult = (await this.sendRawCdp(
801
- "Target.attachToTarget",
802
- { targetId: pages[0].targetId, flatten: true },
803
- null,
804
- )) as { sessionId: string };
805
-
806
- this._sessionId = attachResult.sessionId;
807
- this._targetId = pages[0].targetId;
808
-
809
- // Enable core domains
810
- for (const domain of ["Page", "DOM", "Runtime", "Network"]) {
811
- try {
812
- await this.sendRawCdp(`${domain}.enable`, {}, this._sessionId);
813
- } catch {
814
- // best-effort
815
- }
816
- }
817
-
818
- // Mark tab
819
- try {
820
- await this.sendRawCdp(
821
- "Runtime.evaluate",
822
- {
823
- expression: `if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`,
824
- },
825
- this._sessionId,
826
- );
827
- } catch {
828
- // best-effort
829
- }
830
-
831
- this._lastHealthCheck = Date.now();
832
- this._lastOperationFailed = false;
833
- return attachResult.sessionId;
834
- }
835
-
836
- /** Auto-recover from a stale session error by re-attaching */
837
- async recoverSession(): Promise<void> {
838
- await this.attachFirstPage();
839
- }
840
-
841
- // ── Error diagnostics ────────────────────────────────────────────────────
842
-
843
- private needsChromeSetup(errorMsg: string): boolean {
844
- const lower = errorMsg.toLowerCase();
845
- return (
846
- lower.includes("devtoolsactiveport") ||
847
- lower.includes("cannot reach chrome") ||
848
- lower.includes("ecode") && lower.includes("econnrefused") ||
849
- (lower.includes("ws handshake") &&
850
- (lower.includes("403") || lower.includes("timed out")))
851
- );
852
- }
853
- }
854
-
855
- // ── Internal Types ───────────────────────────────────────────────────────────
856
-
857
- interface PendingRequest {
858
- resolve: (value: unknown) => void;
859
- reject: (err: Error) => void;
860
- timer: ReturnType<typeof setTimeout>;
861
- method: string;
862
- sessionId: string | null;
863
- }
864
-
865
- interface CDPMessage {
866
- id?: number;
867
- method?: string;
868
- params?: Record<string, unknown>;
869
- result?: unknown;
870
- error?: { message: string; code?: number };
871
- sessionId?: string;
872
- }
873
-
874
- // ── Error Classes ────────────────────────────────────────────────────────────
875
-
876
- class SessionNotFoundError extends Error {
877
- constructor(msg: string) {
878
- super(msg);
879
- this.name = "SessionNotFoundError";
880
- }
881
- }