pi-browser-harness 0.1.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.
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +212 -0
- package/assets/hero.png +0 -0
- package/package.json +68 -0
- package/skills/pi-browser-harness/SKILL.md +213 -0
- package/src/daemon.ts +767 -0
- package/src/index.ts +223 -0
- package/src/prompt.ts +106 -0
- package/src/protocol.ts +77 -0
- package/src/renderers.ts +21 -0
- package/src/setup.ts +97 -0
- package/src/state.ts +77 -0
- package/src/tools.ts +1358 -0
- package/tsconfig.json +18 -0
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,767 @@
|
|
|
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
|
+
constructor(namespace = "default", remote?: RemoteConfig) {
|
|
164
|
+
this.namespace = namespace;
|
|
165
|
+
this.remote = remote ?? null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Public getters ───────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
get sessionId(): string | null {
|
|
171
|
+
return this._sessionId;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
getStatus(): DaemonStatus {
|
|
175
|
+
return {
|
|
176
|
+
alive: this._sessionId !== null && this.ws?.readyState === WebSocket.OPEN,
|
|
177
|
+
sessionId: this._sessionId,
|
|
178
|
+
pid: null,
|
|
179
|
+
namespace: this.namespace,
|
|
180
|
+
socketPath: "",
|
|
181
|
+
remoteBrowserId: this.remote?.browserId,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Connect to Chrome via CDP WebSocket.
|
|
189
|
+
* Idempotent — returns immediately if already connected and healthy.
|
|
190
|
+
*/
|
|
191
|
+
async start(): Promise<void> {
|
|
192
|
+
if (this._sessionId && this.ws?.readyState === WebSocket.OPEN) {
|
|
193
|
+
return; // Already connected
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
this._stopping = false;
|
|
197
|
+
this._reconnectAttempts = 0;
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
await this.connect();
|
|
201
|
+
} catch (err) {
|
|
202
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
203
|
+
if (this.needsChromeSetup(msg)) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Chrome remote debugging not enabled. Open chrome://inspect/#remote-debugging and tick the Allow checkbox, then retry.\n\nDetails: ${msg}`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Gracefully disconnect from Chrome.
|
|
214
|
+
*/
|
|
215
|
+
async stop(): Promise<void> {
|
|
216
|
+
this._stopping = true;
|
|
217
|
+
|
|
218
|
+
// Reject any pending connect
|
|
219
|
+
if (this._connectReject) {
|
|
220
|
+
this._connectReject(new Error("Daemon stopped"));
|
|
221
|
+
this._connectReject = null;
|
|
222
|
+
}
|
|
223
|
+
if (this._connectTimer) {
|
|
224
|
+
clearTimeout(this._connectTimer);
|
|
225
|
+
this._connectTimer = null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Reject all pending requests
|
|
229
|
+
for (const [id, req] of this._pending) {
|
|
230
|
+
req.reject(new Error("Daemon stopped"));
|
|
231
|
+
this._pending.delete(id);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Close the WebSocket
|
|
235
|
+
if (this.ws) {
|
|
236
|
+
try {
|
|
237
|
+
this.ws.close(1000, "Shutdown");
|
|
238
|
+
} catch {
|
|
239
|
+
// best-effort
|
|
240
|
+
}
|
|
241
|
+
this.ws = null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
this._sessionId = null;
|
|
245
|
+
this._targetId = null;
|
|
246
|
+
this._currentDialog = null;
|
|
247
|
+
this._eventBuffer = [];
|
|
248
|
+
this._consentObserved = false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Ensure daemon is alive and healthy. Reconnects if stale.
|
|
253
|
+
* Call before every tool execution.
|
|
254
|
+
*/
|
|
255
|
+
async ensureAlive(): Promise<void> {
|
|
256
|
+
const isOpen = this.ws?.readyState === WebSocket.OPEN;
|
|
257
|
+
if (isOpen && this._sessionId) {
|
|
258
|
+
// Two-stage health check. The first probe uses a short timeout so we
|
|
259
|
+
// don't spend 15s waiting on a ws that's been silenced by Chrome's
|
|
260
|
+
// consent dialog. A short-timeout failure is not proof the ws is
|
|
261
|
+
// dead — give it one more chance with the full timeout before
|
|
262
|
+
// tearing down. Tearing down here is what causes the popup storm:
|
|
263
|
+
// every reconnect re-prompts the user for remote-debugging consent.
|
|
264
|
+
try {
|
|
265
|
+
await this.sendRawCdp("Target.getTargets", {}, null, 2_000);
|
|
266
|
+
return;
|
|
267
|
+
} catch {
|
|
268
|
+
// probe 1 failed — could be transient, could be consent-pending
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
await this.sendRawCdp("Target.getTargets", {}, null);
|
|
272
|
+
return;
|
|
273
|
+
} catch {
|
|
274
|
+
// genuinely stale — fall through to reconnect
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
await this.stop();
|
|
279
|
+
await this.start();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── CDP Communication ────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Send a CDP method and return the result.
|
|
286
|
+
* Handles session management — Target.* methods skip session,
|
|
287
|
+
* others use explicit sessionId or the daemon's default session.
|
|
288
|
+
*/
|
|
289
|
+
async cdp(
|
|
290
|
+
method: string,
|
|
291
|
+
params?: Record<string, unknown>,
|
|
292
|
+
sessionId?: string | null,
|
|
293
|
+
): Promise<unknown> {
|
|
294
|
+
// Browser-level Target.* calls must NOT use a session.
|
|
295
|
+
// Everything else uses the explicit sessionId (any session attached
|
|
296
|
+
// with flatten:true on this connection is reachable via the top-level
|
|
297
|
+
// sessionId field — that's what flat-protocol mode means).
|
|
298
|
+
const effectiveSid = method.startsWith("Target.") ? null : (sessionId ?? this._sessionId);
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
return await this.sendRawCdp(method, params ?? {}, effectiveSid);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
// Auto-recover from stale default session — retry once
|
|
304
|
+
if (err instanceof SessionNotFoundError && effectiveSid === this._sessionId) {
|
|
305
|
+
await this.attachFirstPage();
|
|
306
|
+
return this.sendRawCdp(method, params ?? {}, this._sessionId);
|
|
307
|
+
}
|
|
308
|
+
throw err;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── High-level Browser Helpers ────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
/** Get page state or dialog info */
|
|
315
|
+
async getPageInfo(): Promise<PageInfoResult> {
|
|
316
|
+
// Check for buffered dialog first
|
|
317
|
+
if (this._currentDialog) {
|
|
318
|
+
const dialog = this._currentDialog;
|
|
319
|
+
this._currentDialog = null; // consume it
|
|
320
|
+
return { dialog };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
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})`;
|
|
324
|
+
const raw = await this.evaluateJS(js);
|
|
325
|
+
return JSON.parse(raw as string) as PageInfo;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Capture a PNG screenshot, write to path, return path */
|
|
329
|
+
async captureScreenshot(outputPath: string, fullPage = false): Promise<string> {
|
|
330
|
+
const result = (await this.cdp("Page.captureScreenshot", {
|
|
331
|
+
format: "png",
|
|
332
|
+
captureBeyondViewport: fullPage,
|
|
333
|
+
})) as { data: string };
|
|
334
|
+
|
|
335
|
+
const buf = Buffer.from(result.data, "base64");
|
|
336
|
+
await writeFile(outputPath, buf);
|
|
337
|
+
return outputPath;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** List browser tabs */
|
|
341
|
+
async listTabs(includeChrome = true): Promise<TabInfo[]> {
|
|
342
|
+
const result = (await this.cdp("Target.getTargets")) as {
|
|
343
|
+
targetInfos: Array<{ targetId: string; type: string; title: string; url: string }>;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const tabs: TabInfo[] = [];
|
|
347
|
+
for (const t of result.targetInfos) {
|
|
348
|
+
if (t.type !== "page") continue;
|
|
349
|
+
if (!includeChrome && isInternalUrl(t.url)) continue;
|
|
350
|
+
tabs.push({ targetId: t.targetId, title: t.title || "", url: t.url || "" });
|
|
351
|
+
}
|
|
352
|
+
return tabs;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Get current tab info — uses the targetId we attached to. */
|
|
356
|
+
async currentTab(): Promise<TabInfo> {
|
|
357
|
+
if (!this._targetId) {
|
|
358
|
+
throw new Error("No tab attached. Call newTab/switchTab first.");
|
|
359
|
+
}
|
|
360
|
+
const result = (await this.cdp("Target.getTargetInfo", {
|
|
361
|
+
targetId: this._targetId,
|
|
362
|
+
})) as {
|
|
363
|
+
targetInfo: { targetId: string; url: string; title: string };
|
|
364
|
+
};
|
|
365
|
+
const t = result.targetInfo;
|
|
366
|
+
return { targetId: t.targetId, url: t.url || "", title: t.title || "" };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Create a new tab and switch to it. Optionally navigate. */
|
|
370
|
+
async newTab(url?: string): Promise<string> {
|
|
371
|
+
const result = (await this.cdp("Target.createTarget", {
|
|
372
|
+
url: "about:blank",
|
|
373
|
+
})) as { targetId: string };
|
|
374
|
+
|
|
375
|
+
await this.switchTab(result.targetId);
|
|
376
|
+
if (url && url !== "about:blank") {
|
|
377
|
+
await this.cdp("Page.navigate", { url });
|
|
378
|
+
}
|
|
379
|
+
return result.targetId;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Activate and attach to a tab */
|
|
383
|
+
async switchTab(targetId: string): Promise<void> {
|
|
384
|
+
await this.cdp("Target.activateTarget", { targetId });
|
|
385
|
+
const result = (await this.cdp("Target.attachToTarget", {
|
|
386
|
+
targetId,
|
|
387
|
+
flatten: true,
|
|
388
|
+
})) as { sessionId: string };
|
|
389
|
+
|
|
390
|
+
this._sessionId = result.sessionId;
|
|
391
|
+
this._targetId = targetId;
|
|
392
|
+
|
|
393
|
+
// Enable core domains for the new session
|
|
394
|
+
for (const domain of ["Page", "DOM", "Runtime", "Network"]) {
|
|
395
|
+
try {
|
|
396
|
+
await this.cdp(`${domain}.enable`);
|
|
397
|
+
} catch {
|
|
398
|
+
// best-effort
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Mark the tab with a green circle for user visibility
|
|
403
|
+
try {
|
|
404
|
+
await this.cdp("Runtime.evaluate", {
|
|
405
|
+
expression: `if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`,
|
|
406
|
+
});
|
|
407
|
+
} catch {
|
|
408
|
+
// best-effort
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Evaluate JavaScript in the page (or a specific iframe target) */
|
|
413
|
+
async evaluateJS(expression: string, targetId?: string): Promise<unknown> {
|
|
414
|
+
// Auto-wrap return statements in IIFE
|
|
415
|
+
if (expression.includes("return ") && !expression.trim().startsWith("(")) {
|
|
416
|
+
expression = `(function(){${expression}})()`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const sid = targetId || null;
|
|
420
|
+
const result = (await this.cdp(
|
|
421
|
+
"Runtime.evaluate",
|
|
422
|
+
{
|
|
423
|
+
expression,
|
|
424
|
+
returnByValue: true,
|
|
425
|
+
awaitPromise: true,
|
|
426
|
+
},
|
|
427
|
+
targetId ? sid : undefined,
|
|
428
|
+
)) as { result?: { value?: unknown }; exceptionDetails?: unknown };
|
|
429
|
+
|
|
430
|
+
if (result.exceptionDetails) {
|
|
431
|
+
throw new Error(`JS evaluation failed: ${JSON.stringify(result.exceptionDetails)}`);
|
|
432
|
+
}
|
|
433
|
+
return result.result?.value;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Drain buffered CDP events from the internal buffer */
|
|
437
|
+
async drainEvents(): Promise<CDPEvent[]> {
|
|
438
|
+
const events = this._eventBuffer;
|
|
439
|
+
this._eventBuffer = [];
|
|
440
|
+
return events;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Get virtual key code for a key name or character */
|
|
444
|
+
getVirtualKeyCode(key: string): number {
|
|
445
|
+
return VIRTUAL_KEY_CODES[key] ?? (key.length === 1 ? key.charCodeAt(0) : 0);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Get the key code string for CDP */
|
|
449
|
+
getKeyCode(key: string): string {
|
|
450
|
+
return key.length === 1 && !VIRTUAL_KEY_CODES[key] ? key : key;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ── Internal: WebSocket Connection ────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
private async connect(): Promise<void> {
|
|
456
|
+
const timeoutMs = CDP_CONNECT_TIMEOUT_MS;
|
|
457
|
+
const wsUrl = this.remote?.cdpUrl
|
|
458
|
+
? this.remote.cdpUrl
|
|
459
|
+
: process.env.BU_CDP_WS
|
|
460
|
+
? process.env.BU_CDP_WS
|
|
461
|
+
: await discoverWsUrl();
|
|
462
|
+
|
|
463
|
+
if (!this.remote) {
|
|
464
|
+
this.remote = {
|
|
465
|
+
cdpUrl: wsUrl,
|
|
466
|
+
browserId: wsUrl.split("/").pop() || "unknown",
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
await this.openWebSocket(wsUrl, timeoutMs);
|
|
471
|
+
await this.attachFirstPage();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private openWebSocket(url: string, timeoutMs: number): Promise<void> {
|
|
475
|
+
return new Promise((resolve, reject) => {
|
|
476
|
+
this._connectResolve = resolve;
|
|
477
|
+
this._connectReject = reject;
|
|
478
|
+
|
|
479
|
+
this._connectTimer = setTimeout(() => {
|
|
480
|
+
this._connectTimer = null;
|
|
481
|
+
this._connectReject = null;
|
|
482
|
+
if (this.ws) {
|
|
483
|
+
try { this.ws.close(); } catch { /* ignore */ }
|
|
484
|
+
this.ws = null;
|
|
485
|
+
}
|
|
486
|
+
reject(new Error(`CDP WebSocket connection timed out after ${timeoutMs}ms`));
|
|
487
|
+
}, timeoutMs);
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
this.ws = new WebSocket(url);
|
|
491
|
+
} catch (err) {
|
|
492
|
+
clearTimeout(this._connectTimer!);
|
|
493
|
+
this._connectTimer = null;
|
|
494
|
+
reject(err);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
this.ws.onopen = () => {
|
|
499
|
+
if (this._connectTimer) {
|
|
500
|
+
clearTimeout(this._connectTimer);
|
|
501
|
+
this._connectTimer = null;
|
|
502
|
+
}
|
|
503
|
+
this._connectResolve = null;
|
|
504
|
+
this._connectReject = null;
|
|
505
|
+
// NOTE: do not clear _reconnectAttempts here. Chrome accepts the
|
|
506
|
+
// WS upgrade before the user clicks Allow on the remote-debugging
|
|
507
|
+
// consent dialog, so a successful onopen does not prove the
|
|
508
|
+
// connection is usable. We only consider the connection healthy
|
|
509
|
+
// once a CDP roundtrip completes — see attachFirstPage.
|
|
510
|
+
resolve();
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
this.ws.onmessage = (event: WebSocket.MessageEvent) => {
|
|
514
|
+
const data = event.data;
|
|
515
|
+
this.handleMessage(typeof data === "string" ? data : data.toString());
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
this.ws.onerror = (_event: WebSocket.ErrorEvent) => {
|
|
519
|
+
// Only reject during the connect phase; otherwise it means a disconnect
|
|
520
|
+
if (this._connectReject) {
|
|
521
|
+
const err = new Error("CDP WebSocket error during connection");
|
|
522
|
+
this._connectReject(err);
|
|
523
|
+
this._connectReject = null;
|
|
524
|
+
if (this._connectTimer) {
|
|
525
|
+
clearTimeout(this._connectTimer);
|
|
526
|
+
this._connectTimer = null;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
this.ws.onclose = (_event: WebSocket.CloseEvent) => {
|
|
532
|
+
// If we're shutting down intentionally, don't reconnect
|
|
533
|
+
if (this._stopping) return;
|
|
534
|
+
|
|
535
|
+
// If we never saw a successful CDP roundtrip on this connection,
|
|
536
|
+
// the close almost certainly means the user denied (or hasn't yet
|
|
537
|
+
// accepted) Chrome's "Allow remote debugging?" prompt. Reconnecting
|
|
538
|
+
// would just spawn another popup. Bail out and let the next tool
|
|
539
|
+
// call surface a clear error.
|
|
540
|
+
if (!this._consentObserved) {
|
|
541
|
+
this._sessionId = null;
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Auto-reconnect with backoff
|
|
546
|
+
if (this._reconnectAttempts < CDP_MAX_RECONNECT_ATTEMPTS) {
|
|
547
|
+
this._sessionId = null;
|
|
548
|
+
// Reset for the new connection — it must re-prove itself.
|
|
549
|
+
this._consentObserved = false;
|
|
550
|
+
const delay = Math.min(
|
|
551
|
+
CDP_RECONNECT_BASE_MS * 2 ** this._reconnectAttempts,
|
|
552
|
+
15_000,
|
|
553
|
+
);
|
|
554
|
+
this._reconnectAttempts++;
|
|
555
|
+
setTimeout(() => {
|
|
556
|
+
if (!this._stopping) {
|
|
557
|
+
this.start().catch(() => {
|
|
558
|
+
// Reconnection ultimately failed — tools will report errors
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}, delay);
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ── Internal: Message Handling ────────────────────────────────────────────
|
|
568
|
+
|
|
569
|
+
private handleMessage(data: string): void {
|
|
570
|
+
let msg: CDPMessage;
|
|
571
|
+
try {
|
|
572
|
+
msg = JSON.parse(data);
|
|
573
|
+
} catch {
|
|
574
|
+
return; // Corrupt frame, ignore
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// CDP events have no `id` — they have a `method` field
|
|
578
|
+
if (msg.id === undefined) {
|
|
579
|
+
// Dialog detection
|
|
580
|
+
if (msg.method === "Page.javascriptDialogOpening") {
|
|
581
|
+
this._currentDialog = {
|
|
582
|
+
type: (msg.params?.type as DialogInfo["type"]) || "alert",
|
|
583
|
+
message: (msg.params?.message as string) || "",
|
|
584
|
+
defaultPrompt: msg.params?.defaultPrompt as string | undefined,
|
|
585
|
+
};
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Dialog closed — clear the stored dialog
|
|
590
|
+
if (msg.method === "Page.javascriptDialogClosed") {
|
|
591
|
+
this._currentDialog = null;
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Buffer other events for drainEvents()
|
|
596
|
+
this._eventBuffer.push({
|
|
597
|
+
method: msg.method!,
|
|
598
|
+
params: msg.params as unknown,
|
|
599
|
+
session_id: msg.sessionId as string | undefined,
|
|
600
|
+
});
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// It's a response to a pending request
|
|
605
|
+
const pending = this._pending.get(msg.id);
|
|
606
|
+
if (!pending) return; // Stale response, ignore
|
|
607
|
+
|
|
608
|
+
this._pending.delete(msg.id);
|
|
609
|
+
clearTimeout(pending.timer);
|
|
610
|
+
|
|
611
|
+
if (msg.error) {
|
|
612
|
+
// Handle stale session recovery
|
|
613
|
+
const errMsg = msg.error.message || String(msg.error);
|
|
614
|
+
if (
|
|
615
|
+
errMsg.includes("Session with given id not found") &&
|
|
616
|
+
pending.sessionId === this._sessionId
|
|
617
|
+
) {
|
|
618
|
+
// Schedule recovery and reject — caller can retry
|
|
619
|
+
pending.reject(new SessionNotFoundError(errMsg));
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
pending.reject(new Error(`CDP error (${pending.method}): ${errMsg}`));
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
pending.resolve(msg.result);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Send a CDP command directly to the browser session */
|
|
630
|
+
private sendRawCdp(
|
|
631
|
+
method: string,
|
|
632
|
+
params: Record<string, unknown>,
|
|
633
|
+
sessionId: string | null,
|
|
634
|
+
timeoutMs: number = CDP_REQUEST_TIMEOUT_MS,
|
|
635
|
+
): Promise<unknown> {
|
|
636
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
637
|
+
return Promise.reject(new Error("Browser not connected. Is Chrome running?"));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const id = this._nextId++;
|
|
641
|
+
const payload: Record<string, unknown> = { id, method, params };
|
|
642
|
+
if (sessionId) payload.sessionId = sessionId;
|
|
643
|
+
const msg = JSON.stringify(payload);
|
|
644
|
+
|
|
645
|
+
return new Promise((resolve, reject) => {
|
|
646
|
+
const timer = setTimeout(() => {
|
|
647
|
+
this._pending.delete(id);
|
|
648
|
+
reject(new Error(`CDP timeout after ${timeoutMs}ms: ${method}`));
|
|
649
|
+
}, timeoutMs);
|
|
650
|
+
|
|
651
|
+
this._pending.set(id, { resolve, reject, timer, method, sessionId });
|
|
652
|
+
try {
|
|
653
|
+
this.ws!.send(msg);
|
|
654
|
+
} catch (err) {
|
|
655
|
+
clearTimeout(timer);
|
|
656
|
+
this._pending.delete(id);
|
|
657
|
+
reject(err);
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Attach to the first real page target. Creates about:blank if none exist. */
|
|
663
|
+
private async attachFirstPage(): Promise<string> {
|
|
664
|
+
const result = (await this.sendRawCdp("Target.getTargets", {}, null)) as {
|
|
665
|
+
targetInfos: Array<{ targetId: string; type: string; url: string }>;
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
// First CDP roundtrip succeeded — consent has been granted. Only now
|
|
669
|
+
// is it safe to reset the reconnect counter; otherwise a ws that opens
|
|
670
|
+
// but is silently held by Chrome's consent dialog would let us loop
|
|
671
|
+
// forever stacking popups on top of each other.
|
|
672
|
+
this._consentObserved = true;
|
|
673
|
+
this._reconnectAttempts = 0;
|
|
674
|
+
|
|
675
|
+
const pages = result.targetInfos.filter(
|
|
676
|
+
(t) => t.type === "page" && !isInternalUrl(t.url),
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
if (pages.length === 0) {
|
|
680
|
+
const created = (await this.sendRawCdp(
|
|
681
|
+
"Target.createTarget",
|
|
682
|
+
{ url: "about:blank" },
|
|
683
|
+
null,
|
|
684
|
+
)) as { targetId: string };
|
|
685
|
+
pages.push({ targetId: created.targetId, type: "page", url: "about:blank" });
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const attachResult = (await this.sendRawCdp(
|
|
689
|
+
"Target.attachToTarget",
|
|
690
|
+
{ targetId: pages[0].targetId, flatten: true },
|
|
691
|
+
null,
|
|
692
|
+
)) as { sessionId: string };
|
|
693
|
+
|
|
694
|
+
this._sessionId = attachResult.sessionId;
|
|
695
|
+
this._targetId = pages[0].targetId;
|
|
696
|
+
|
|
697
|
+
// Enable core domains
|
|
698
|
+
for (const domain of ["Page", "DOM", "Runtime", "Network"]) {
|
|
699
|
+
try {
|
|
700
|
+
await this.sendRawCdp(`${domain}.enable`, {}, this._sessionId);
|
|
701
|
+
} catch {
|
|
702
|
+
// best-effort
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Mark tab
|
|
707
|
+
try {
|
|
708
|
+
await this.sendRawCdp(
|
|
709
|
+
"Runtime.evaluate",
|
|
710
|
+
{
|
|
711
|
+
expression: `if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`,
|
|
712
|
+
},
|
|
713
|
+
this._sessionId,
|
|
714
|
+
);
|
|
715
|
+
} catch {
|
|
716
|
+
// best-effort
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
return attachResult.sessionId;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Auto-recover from a stale session error by re-attaching */
|
|
723
|
+
async recoverSession(): Promise<void> {
|
|
724
|
+
await this.attachFirstPage();
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ── Error diagnostics ────────────────────────────────────────────────────
|
|
728
|
+
|
|
729
|
+
private needsChromeSetup(errorMsg: string): boolean {
|
|
730
|
+
const lower = errorMsg.toLowerCase();
|
|
731
|
+
return (
|
|
732
|
+
lower.includes("devtoolsactiveport") ||
|
|
733
|
+
lower.includes("cannot reach chrome") ||
|
|
734
|
+
lower.includes("ecode") && lower.includes("econnrefused") ||
|
|
735
|
+
(lower.includes("ws handshake") &&
|
|
736
|
+
(lower.includes("403") || lower.includes("timed out")))
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ── Internal Types ───────────────────────────────────────────────────────────
|
|
742
|
+
|
|
743
|
+
interface PendingRequest {
|
|
744
|
+
resolve: (value: unknown) => void;
|
|
745
|
+
reject: (err: Error) => void;
|
|
746
|
+
timer: ReturnType<typeof setTimeout>;
|
|
747
|
+
method: string;
|
|
748
|
+
sessionId: string | null;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
interface CDPMessage {
|
|
752
|
+
id?: number;
|
|
753
|
+
method?: string;
|
|
754
|
+
params?: Record<string, unknown>;
|
|
755
|
+
result?: unknown;
|
|
756
|
+
error?: { message: string; code?: number };
|
|
757
|
+
sessionId?: string;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// ── Error Classes ────────────────────────────────────────────────────────────
|
|
761
|
+
|
|
762
|
+
class SessionNotFoundError extends Error {
|
|
763
|
+
constructor(msg: string) {
|
|
764
|
+
super(msg);
|
|
765
|
+
this.name = "SessionNotFoundError";
|
|
766
|
+
}
|
|
767
|
+
}
|