pi-browser-harness 0.2.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +84 -0
- package/package.json +4 -1
- package/skills/pi-browser-harness/SKILL.md +64 -6
- package/src/cdp/discovery.ts +95 -0
- package/src/cdp/errors.ts +19 -0
- package/src/cdp/session.ts +139 -0
- package/src/cdp/transport.ts +221 -0
- package/src/cdp/types.ts +72 -0
- package/src/client.ts +238 -0
- package/src/domains/click.ts +73 -0
- package/src/domains/dialog.ts +31 -0
- package/src/domains/drag.ts +66 -0
- package/src/domains/files.ts +194 -0
- package/src/domains/history.ts +76 -0
- package/src/domains/js.ts +197 -0
- package/src/domains/keyboard.ts +127 -0
- package/src/domains/navigate.ts +146 -0
- package/src/domains/network.ts +80 -0
- package/src/domains/page.ts +88 -0
- package/src/domains/screenshot.ts +150 -0
- package/src/domains/scroll.ts +60 -0
- package/src/domains/tabs.ts +121 -0
- package/src/domains/viewport.ts +40 -0
- package/src/index.ts +65 -142
- package/src/prompt.ts +27 -4
- package/src/registry.ts +55 -0
- package/src/schemas/common.ts +11 -0
- package/src/setup.ts +43 -26
- package/src/state.ts +22 -54
- package/src/util/js-template.ts +27 -0
- package/src/util/keycodes.ts +10 -0
- package/src/util/mutex.ts +25 -0
- package/src/util/paths.ts +21 -0
- package/src/util/result.ts +15 -0
- package/src/util/sharp-shim.ts +49 -0
- package/src/util/time.ts +29 -0
- package/src/util/tool.ts +193 -0
- package/src/util/truncate.ts +35 -0
- package/tsconfig.json +10 -1
- package/src/daemon.ts +0 -881
- package/src/protocol.ts +0 -77
- package/src/renderers.ts +0 -21
- package/src/tools.ts +0 -1363
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
|
-
}
|