@xnng/browser-relay 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +445 -0
  3. package/docs/README.zh-CN.md +421 -0
  4. package/docs/benchmarks/browser-gaps-codex-ax.json +418 -0
  5. package/docs/benchmarks/browser-gaps-relay-after.json +655 -0
  6. package/docs/benchmarks/browser-gaps-relay-baseline.json +679 -0
  7. package/docs/benchmarks/browser-readiness-cost.json +106 -0
  8. package/docs/benchmarks/browser-runtime-balanced-headed.json +412 -0
  9. package/docs/benchmarks/browser-runtime-balanced-rtt50.json +412 -0
  10. package/docs/benchmarks/browser-runtime-balanced.json +412 -0
  11. package/docs/benchmarks/browser-runtime-rtt50.json +194 -0
  12. package/docs/benchmarks/browser-runtime.json +254 -0
  13. package/docs/benchmarks/browser-use-parity.json +54 -0
  14. package/docs/benchmarks/codex-extension-audit.json +131 -0
  15. package/docs/benchmarks/codex-native-protocol.md +69 -0
  16. package/docs/benchmarks/codex-native-replay.js +116 -0
  17. package/docs/benchmarks/codex-native-status.json +81 -0
  18. package/docs/benchmarks/codex-native.json +1003 -0
  19. package/docs/benchmarks/extension-sessions.png +0 -0
  20. package/docs/benchmarks/extension-tasks.png +0 -0
  21. package/docs/benchmarks/iframe-routing-regression.json +37 -0
  22. package/docs/browser-use-comparison.md +331 -0
  23. package/docs/browser-use-gap-audit.md +141 -0
  24. package/docs/browser-use-parity.md +105 -0
  25. package/docs/demo/intranet.html +103 -0
  26. package/docs/releases/v1.5.0.md +59 -0
  27. package/docs/releases/v1.5.1.md +16 -0
  28. package/docs/releases/v1.5.2.md +42 -0
  29. package/docs/releases/v1.5.3.md +33 -0
  30. package/docs/releases/v1.5.4.md +42 -0
  31. package/docs/releases/v1.6.0.md +16 -0
  32. package/docs/remote-control-hub.md +523 -0
  33. package/extension/activity.js +328 -0
  34. package/extension/automation.js +1839 -0
  35. package/extension/background.js +1854 -0
  36. package/extension/i18n.js +149 -0
  37. package/extension/icons/icon128.png +0 -0
  38. package/extension/icons/icon16.png +0 -0
  39. package/extension/icons/icon32.png +0 -0
  40. package/extension/icons/icon48.png +0 -0
  41. package/extension/manifest.json +47 -0
  42. package/extension/observations.js +109 -0
  43. package/extension/options.html +289 -0
  44. package/extension/options.js +269 -0
  45. package/extension/popup.html +74 -0
  46. package/extension/popup.js +105 -0
  47. package/extension/protocol.js +45 -0
  48. package/extension/remote-auth.js +18 -0
  49. package/extension/sessions.js +134 -0
  50. package/extension/snapshot.js +161 -0
  51. package/extension/task-groups.js +108 -0
  52. package/extension/tasks.js +186 -0
  53. package/extension/wait.js +89 -0
  54. package/hub/README.md +42 -0
  55. package/hub/package-lock.json +1544 -0
  56. package/hub/package.json +13 -0
  57. package/hub/src/rpc.js +41 -0
  58. package/hub/src/worker.js +322 -0
  59. package/hub/wrangler.example.toml +19 -0
  60. package/package.json +83 -0
  61. package/server/cdp-bridge.js +200 -0
  62. package/server/cli.js +1798 -0
  63. package/server/hub-server.js +258 -0
  64. package/server/install.js +250 -0
  65. package/server/mcp-server.js +504 -0
  66. package/server/npx-runner.js +96 -0
  67. package/server/relay-server.js +1356 -0
  68. package/server/remote-protocol.js +76 -0
  69. package/server/runtime-worker.js +166 -0
  70. package/server/script-runtime.js +189 -0
  71. package/server/sdk.js +307 -0
  72. package/server/service-state.js +103 -0
  73. package/server/snapshot.js +161 -0
  74. package/server/uninstall.js +61 -0
  75. package/server/windows-service-entry.js +58 -0
  76. package/server/windows-service.js +360 -0
  77. package/skills/browser-relay/SKILL.md +192 -0
  78. package/skills/browser-relay/references/legacy-api.md +163 -0
  79. package/skills/browser-relay/references/runtime.md +240 -0
@@ -0,0 +1,1356 @@
1
+ #!/usr/bin/env node
2
+ import { createServer } from "node:http";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { randomUUID } from "node:crypto";
7
+ import { WebSocketServer, WebSocket } from "ws";
8
+ import { SNAPSHOT_JS } from "./snapshot.js";
9
+ import { buildWaitExpression, normalizeWaitOptions } from "../extension/wait.js";
10
+ import { createCdpBridge } from "./cdp-bridge.js";
11
+ import { isAutomationPath, isTaskRequest, PROTOCOL_VERSION } from "../extension/protocol.js";
12
+ let executorInfo = null;
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Config
16
+ // ---------------------------------------------------------------------------
17
+ const __pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
18
+ const RELAY_HOST = process.env.BROWSER_RELAY_HOST || "127.0.0.1";
19
+ const RELAY_PORT = parseInt(process.env.BROWSER_RELAY_PORT || "18795", 10);
20
+ const RELAY_VERSION = JSON.parse(readFileSync(join(__pkgDir, "package.json"), "utf-8")).version;
21
+ const PING_INTERVAL_MS = 5_000;
22
+ const COMMAND_TIMEOUT_MS = 30_000;
23
+ const EXTENSION_GRACE_MS = 20_000;
24
+ const MAX_BODY_SIZE = 64 * 1024; // 64KB
25
+ const MAX_CONSOLE_ENTRIES = 1_000;
26
+ const MAX_DOWNLOAD_ENTRIES = 1_000;
27
+ const MAX_NETWORK_ENTRIES = 1_000;
28
+
29
+ const serverStartTime = Date.now();
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Logging
33
+ // ---------------------------------------------------------------------------
34
+ function log(event, level, data = {}) {
35
+ const timestamp = new Date().toISOString();
36
+ console.log(JSON.stringify({ timestamp, event, level, ...data }));
37
+ }
38
+
39
+ const LOG = {
40
+ info: (event, data) => log(event, "info", data),
41
+ warn: (event, data) => log(event, "warn", data),
42
+ error: (event, data) => log(event, "error", data),
43
+ };
44
+
45
+ class ApiError extends Error {
46
+ constructor(status, code, message, { retryable = false, details = undefined } = {}) {
47
+ super(message);
48
+ this.name = "ApiError";
49
+ this.status = status;
50
+ this.code = code;
51
+ this.retryable = retryable;
52
+ this.details = details;
53
+ }
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Extension WebSocket state
58
+ // ---------------------------------------------------------------------------
59
+ let extensionWs = null;
60
+ let extensionConnectedSince = null;
61
+ let extensionRemoteAddress = null;
62
+ let extensionProtocolError = null;
63
+
64
+ const PUBLIC_TAB_ID_PATTERN = /^t_[A-Za-z0-9_-]{10}$/;
65
+ const connectedTargets = new Map(); // sessionId -> { sessionId, tabId, targetId, targetInfo }
66
+ let nextExtensionId = 1;
67
+ let commandsSent = 0;
68
+ const pendingCommands = new Map();
69
+ const cdpBridge = createCdpBridge({targets:()=>connectedTargets,send:sendToExtension,ensure:ensureExtension});
70
+ let nextConsoleEntryId = 1;
71
+ let consoleEntries = [];
72
+ const consoleEnabledSessions = new Set();
73
+ let nextNetworkEntryId = 1;
74
+ let networkEntries = [];
75
+ const networkEnabledSessions = new Set();
76
+ let nextDownloadEntryId = 1;
77
+ let downloadEntries = [];
78
+
79
+ let pingTimer = null;
80
+ let graceTimer = null;
81
+ const reconnectWaiters = new Set();
82
+
83
+ function extensionConnected() {
84
+ return extensionWs?.readyState === WebSocket.OPEN;
85
+ }
86
+
87
+ function flushReconnectWaiters(connected) {
88
+ for (const waiter of reconnectWaiters) waiter(connected);
89
+ reconnectWaiters.clear();
90
+ }
91
+
92
+ function clearGraceTimer() {
93
+ if (graceTimer) { clearTimeout(graceTimer); graceTimer = null; }
94
+ }
95
+
96
+ function scheduleGraceCleanup() {
97
+ clearGraceTimer();
98
+ graceTimer = setTimeout(() => {
99
+ graceTimer = null;
100
+ if (!extensionConnected()) { connectedTargets.clear(); flushReconnectWaiters(false); }
101
+ }, EXTENSION_GRACE_MS);
102
+ }
103
+
104
+ function waitForExtension(timeoutMs = 3_000) {
105
+ if (extensionConnected()) return Promise.resolve(true);
106
+ return new Promise((resolve) => {
107
+ let settled = false;
108
+ const waiter = (connected) => { if (settled) return; settled = true; clearTimeout(timer); reconnectWaiters.delete(waiter); resolve(connected); };
109
+ const timer = setTimeout(() => waiter(false), timeoutMs);
110
+ reconnectWaiters.add(waiter);
111
+ });
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Send CDP command to extension
116
+ // ---------------------------------------------------------------------------
117
+ function sendToExtension(method, params, sessionId) {
118
+ const ws = extensionWs;
119
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
120
+ return Promise.reject(new ApiError(503, "extension_not_connected", "Extension not connected", { retryable: true }));
121
+ }
122
+ const id = nextExtensionId++;
123
+ commandsSent++;
124
+ const payload = { id, method: "forwardCDPCommand", params: { method, params, ...(sessionId ? { sessionId } : {}) } };
125
+ return new Promise((resolve, reject) => {
126
+ const timer = setTimeout(() => {
127
+ pendingCommands.delete(id);
128
+ reject(new ApiError(504, "cdp_timeout", `CDP command timeout: ${method}`, { retryable: true, details: { method } }));
129
+ }, COMMAND_TIMEOUT_MS);
130
+ pendingCommands.set(id, {
131
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
132
+ reject: (e) => { clearTimeout(timer); reject(e); },
133
+ timer,
134
+ });
135
+ try { ws.send(JSON.stringify(payload)); }
136
+ catch (err) { clearTimeout(timer); pendingCommands.delete(id); reject(err instanceof Error ? err : new Error(String(err))); }
137
+ });
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Resolve target
142
+ // ---------------------------------------------------------------------------
143
+ function resolveSession(tabId) {
144
+ if (extensionProtocolError) {
145
+ throw new ApiError(409, "extension_upgrade_required", extensionProtocolError);
146
+ }
147
+ if (tabId) {
148
+ for (const t of connectedTargets.values()) { if (t.tabId === tabId) return t.sessionId; }
149
+ throw new ApiError(404, "tab_not_found", `No attached tab with id: ${tabId}`, { details: { tabId } });
150
+ }
151
+ let last = null;
152
+ for (const t of connectedTargets.values()) last = t;
153
+ if (!last) throw new ApiError(409, "no_attached_tabs", "No attached tabs. Install the Browser Relay extension and open a tab.", { retryable: true });
154
+ return last.sessionId;
155
+ }
156
+
157
+ function resolveTab(tabId) {
158
+ return resolveSession(tabId);
159
+ }
160
+
161
+ function boundedNumber(value, fallback, min, max) {
162
+ const n = Number(value);
163
+ if (!Number.isFinite(n)) return fallback;
164
+ return Math.max(min, Math.min(max, n));
165
+ }
166
+
167
+ function targetForSession(sessionId) {
168
+ return connectedTargets.get(sessionId) || null;
169
+ }
170
+
171
+ function remoteObjectValue(obj) {
172
+ if (!obj || typeof obj !== "object") return "";
173
+ if ("value" in obj) return obj.value;
174
+ if ("unserializableValue" in obj) return obj.unserializableValue;
175
+ return obj.description || obj.type || "";
176
+ }
177
+
178
+ function stringifyConsoleValue(value) {
179
+ if (typeof value === "string") return value;
180
+ if (value === undefined) return "undefined";
181
+ try { return JSON.stringify(value); }
182
+ catch { return String(value); }
183
+ }
184
+
185
+ function appendConsoleEntry(entry) {
186
+ consoleEntries.push({ id: nextConsoleEntryId++, receivedAt: new Date().toISOString(), ...entry });
187
+ if (consoleEntries.length > MAX_CONSOLE_ENTRIES) {
188
+ consoleEntries = consoleEntries.slice(-MAX_CONSOLE_ENTRIES);
189
+ }
190
+ }
191
+
192
+ function appendConsoleEvent(sessionId, method, params = {}, eventTabId = "") {
193
+ const target = targetForSession(sessionId);
194
+ const base = {
195
+ sessionId: sessionId || "",
196
+ tabId: target?.tabId || (PUBLIC_TAB_ID_PATTERN.test(eventTabId) ? eventTabId : ""),
197
+ url: target?.targetInfo?.url || "",
198
+ title: target?.targetInfo?.title || "",
199
+ };
200
+
201
+ if (method === "Runtime.consoleAPICalled") {
202
+ const args = (params.args || []).map(remoteObjectValue);
203
+ appendConsoleEntry({
204
+ ...base,
205
+ source: "runtime",
206
+ level: params.type || "log",
207
+ text: args.map(stringifyConsoleValue).join(" "),
208
+ args,
209
+ stackTrace: params.stackTrace || null,
210
+ timestamp: params.timestamp || null,
211
+ });
212
+ return;
213
+ }
214
+
215
+ if (method === "Runtime.exceptionThrown") {
216
+ const details = params.exceptionDetails || {};
217
+ appendConsoleEntry({
218
+ ...base,
219
+ source: "runtime",
220
+ level: "error",
221
+ text: details.exception?.description || details.text || "Uncaught exception",
222
+ exceptionDetails: details,
223
+ timestamp: params.timestamp || null,
224
+ });
225
+ return;
226
+ }
227
+
228
+ if (method === "Log.entryAdded") {
229
+ const entry = params.entry || {};
230
+ appendConsoleEntry({
231
+ ...base,
232
+ source: entry.source || "log",
233
+ level: entry.level || "info",
234
+ text: entry.text || "",
235
+ lineNumber: entry.lineNumber,
236
+ url: entry.url || base.url,
237
+ networkRequestId: entry.networkRequestId,
238
+ timestamp: entry.timestamp || null,
239
+ });
240
+ }
241
+ }
242
+
243
+ const SENSITIVE_NETWORK_HEADERS = new Set([
244
+ "authorization",
245
+ "cookie",
246
+ "proxy-authorization",
247
+ "set-cookie",
248
+ ]);
249
+
250
+ function redactNetworkHeaders(headers = {}) {
251
+ const redacted = {};
252
+ if (!headers || typeof headers !== "object") return redacted;
253
+ for (const [name, value] of Object.entries(headers)) {
254
+ redacted[name] = SENSITIVE_NETWORK_HEADERS.has(String(name).toLowerCase()) ? "[redacted]" : value;
255
+ }
256
+ return redacted;
257
+ }
258
+
259
+ function appendNetworkEntry(entry) {
260
+ networkEntries.push({ id: nextNetworkEntryId++, receivedAt: new Date().toISOString(), ...entry });
261
+ if (networkEntries.length > MAX_NETWORK_ENTRIES) {
262
+ networkEntries = networkEntries.slice(-MAX_NETWORK_ENTRIES);
263
+ }
264
+ }
265
+
266
+ function appendNetworkEvent(sessionId, method, params = {}, eventTabId = "") {
267
+ const target = targetForSession(sessionId);
268
+ const base = {
269
+ sessionId: sessionId || "",
270
+ tabId: target?.tabId || (PUBLIC_TAB_ID_PATTERN.test(eventTabId) ? eventTabId : ""),
271
+ pageUrl: target?.targetInfo?.url || "",
272
+ title: target?.targetInfo?.title || "",
273
+ requestId: params.requestId || "",
274
+ timestamp: params.timestamp ?? null,
275
+ };
276
+
277
+ if (method === "Network.requestWillBeSent") {
278
+ const request = params.request || {};
279
+ appendNetworkEntry({
280
+ ...base,
281
+ type: "request",
282
+ url: request.url || params.documentURL || "",
283
+ method: request.method || "",
284
+ documentURL: params.documentURL || "",
285
+ frameId: params.frameId || "",
286
+ resourceType: params.type || "",
287
+ wallTime: params.wallTime ?? null,
288
+ initiator: params.initiator || null,
289
+ request: {
290
+ url: request.url || "",
291
+ method: request.method || "",
292
+ headers: redactNetworkHeaders(request.headers),
293
+ },
294
+ });
295
+ return;
296
+ }
297
+
298
+ if (method === "Network.responseReceived") {
299
+ const response = params.response || {};
300
+ appendNetworkEntry({
301
+ ...base,
302
+ type: "response",
303
+ url: response.url || "",
304
+ status: response.status ?? null,
305
+ statusText: response.statusText || "",
306
+ mimeType: response.mimeType || "",
307
+ protocol: response.protocol || "",
308
+ remoteIPAddress: response.remoteIPAddress || "",
309
+ remotePort: response.remotePort ?? null,
310
+ fromDiskCache: !!response.fromDiskCache,
311
+ fromServiceWorker: !!response.fromServiceWorker,
312
+ resourceType: params.type || "",
313
+ response: {
314
+ url: response.url || "",
315
+ status: response.status ?? null,
316
+ statusText: response.statusText || "",
317
+ headers: redactNetworkHeaders(response.headers),
318
+ mimeType: response.mimeType || "",
319
+ },
320
+ });
321
+ return;
322
+ }
323
+
324
+ if (method === "Network.loadingFinished") {
325
+ appendNetworkEntry({
326
+ ...base,
327
+ type: "finished",
328
+ encodedDataLength: params.encodedDataLength ?? null,
329
+ });
330
+ return;
331
+ }
332
+
333
+ if (method === "Network.loadingFailed") {
334
+ appendNetworkEntry({
335
+ ...base,
336
+ type: "failed",
337
+ resourceType: params.type || "",
338
+ errorText: params.errorText || "",
339
+ canceled: !!params.canceled,
340
+ blockedReason: params.blockedReason || "",
341
+ });
342
+ }
343
+ }
344
+
345
+ async function enableConsoleCapture(sessionId) {
346
+ if (!sessionId || consoleEnabledSessions.has(sessionId)) return;
347
+ consoleEnabledSessions.add(sessionId);
348
+ try { await sendToExtension("Runtime.enable", {}, sessionId); }
349
+ catch (err) {
350
+ consoleEnabledSessions.delete(sessionId);
351
+ throw err;
352
+ }
353
+ await sendToExtension("Log.enable", {}, sessionId).catch(() => {});
354
+ }
355
+
356
+ async function enableNetworkCapture(sessionId) {
357
+ if (!sessionId || networkEnabledSessions.has(sessionId)) return;
358
+ networkEnabledSessions.add(sessionId);
359
+ try { await sendToExtension("Network.enable", {}, sessionId); }
360
+ catch (err) {
361
+ networkEnabledSessions.delete(sessionId);
362
+ throw err;
363
+ }
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Handle extension messages
368
+ // ---------------------------------------------------------------------------
369
+ function appendDownloadEvent(type, params = {}) {
370
+ const entry = {
371
+ eventId: nextDownloadEntryId++,
372
+ receivedAt: new Date().toISOString(),
373
+ type,
374
+ ...params,
375
+ };
376
+ if (params.id !== undefined && entry.downloadId === undefined) {
377
+ entry.downloadId = params.id;
378
+ }
379
+ downloadEntries.push(entry);
380
+ if (downloadEntries.length > MAX_DOWNLOAD_ENTRIES) {
381
+ downloadEntries = downloadEntries.slice(-MAX_DOWNLOAD_ENTRIES);
382
+ }
383
+ }
384
+
385
+ function onExtensionMessage(data) {
386
+ let msg;
387
+ try { msg = JSON.parse(typeof data === "string" ? data : data.toString()); } catch { return; }
388
+
389
+ if (msg?.method === 'BrowserRelay.hello') {
390
+ const info=msg.params;
391
+ if (Number.isInteger(info?.protocolVersion) && typeof info.extensionVersion==='string' && typeof info.runtimeId==='string' && Array.isArray(info.features))
392
+ executorInfo={protocolVersion:info.protocolVersion,extensionVersion:info.extensionVersion,runtimeId:info.runtimeId,features:info.features};
393
+ return;
394
+ }
395
+ if (msg?.method === "pong") return;
396
+
397
+ if (typeof msg?.id === "number" && (msg.result !== undefined || msg.error !== undefined)) {
398
+ const pending = pendingCommands.get(msg.id);
399
+ if (!pending) return;
400
+ pendingCommands.delete(msg.id);
401
+ if (msg.error) pending.reject(typeof msg.error === "object" ? new ApiError(msg.error.status || 500,msg.error.code || "cdp_error",msg.error.message || "Browser command failed") : new Error(String(msg.error)));
402
+ else pending.resolve(msg.result);
403
+ return;
404
+ }
405
+
406
+ if (msg?.method === "forwardCDPEvent") {
407
+ const cdpMethod = msg.params?.method;
408
+ const cdpParams = msg.params?.params;
409
+ const eventSessionId = msg.params?.sessionId;
410
+ const eventTabId = msg.params?.tabId;
411
+ cdpBridge.event(cdpMethod, cdpParams, eventSessionId);
412
+
413
+ if (eventSessionId && (
414
+ cdpMethod === "Runtime.consoleAPICalled" ||
415
+ cdpMethod === "Runtime.exceptionThrown" ||
416
+ cdpMethod === "Log.entryAdded"
417
+ )) {
418
+ appendConsoleEvent(eventSessionId, cdpMethod, cdpParams, eventTabId);
419
+ }
420
+
421
+ if (eventSessionId && (
422
+ cdpMethod === "Network.requestWillBeSent" ||
423
+ cdpMethod === "Network.responseReceived" ||
424
+ cdpMethod === "Network.loadingFinished" ||
425
+ cdpMethod === "Network.loadingFailed"
426
+ )) {
427
+ appendNetworkEvent(eventSessionId, cdpMethod, cdpParams, eventTabId);
428
+ }
429
+
430
+ if (cdpMethod === "BrowserRelay.downloadCreated") {
431
+ appendDownloadEvent("created", cdpParams);
432
+ return;
433
+ }
434
+ if (cdpMethod === "BrowserRelay.downloadChanged") {
435
+ appendDownloadEvent("changed", cdpParams);
436
+ return;
437
+ }
438
+ if (cdpMethod === "BrowserRelay.downloadErased") {
439
+ appendDownloadEvent("erased", cdpParams);
440
+ return;
441
+ }
442
+
443
+ if (cdpMethod === "Target.attachedToTarget") {
444
+ const { sessionId, tabId, targetInfo } = cdpParams || {};
445
+ if (sessionId && PUBLIC_TAB_ID_PATTERN.test(tabId) && targetInfo?.targetId && (targetInfo?.type ?? "page") === "page") {
446
+ const duplicate = [...connectedTargets.values()].find((target) => target.tabId === tabId && target.sessionId !== sessionId);
447
+ if (duplicate) {
448
+ LOG.warn("target.attach.duplicate_tab_id", { tabId, sessionId });
449
+ return;
450
+ }
451
+ connectedTargets.set(sessionId, { sessionId, tabId, targetId: targetInfo.targetId, targetInfo });
452
+ void enableConsoleCapture(sessionId).catch((err) => LOG.warn("console.enable.failed", { sessionId, error: err.message || String(err) }));
453
+ void enableNetworkCapture(sessionId).catch((err) => LOG.warn("network.enable.failed", { sessionId, error: err.message || String(err) }));
454
+ } else if (sessionId && targetInfo?.targetId && (targetInfo?.type ?? "page") === "page" && !PUBLIC_TAB_ID_PATTERN.test(eventTabId)) {
455
+ extensionProtocolError = "The Browser Relay extension is too old for short tab IDs. Reload the extension and try again.";
456
+ LOG.warn("target.attach.invalid_tab_id", { sessionId });
457
+ }
458
+ } else if (cdpMethod === "Target.detachedFromTarget") {
459
+ const { sessionId, targetId } = cdpParams || {};
460
+ if (sessionId) { connectedTargets.delete(sessionId); consoleEnabledSessions.delete(sessionId); networkEnabledSessions.delete(sessionId); }
461
+ else if (targetId) { for (const [sid, t] of connectedTargets) { if (t.targetId === targetId) { connectedTargets.delete(sid); consoleEnabledSessions.delete(sid); networkEnabledSessions.delete(sid); } } }
462
+ } else if (cdpMethod === "Target.targetInfoChanged") {
463
+ const info = cdpParams?.targetInfo;
464
+ if (info?.targetId) { for (const [sid, t] of connectedTargets) { if (t.targetId === info.targetId) { connectedTargets.set(sid, { ...t, targetInfo: { ...t.targetInfo, ...info } }); } } }
465
+ } else if (cdpMethod === "Target.targetDestroyed" || cdpMethod === "Target.targetCrashed") {
466
+ const targetId = cdpParams?.targetId;
467
+ if (targetId) { for (const [sid, t] of connectedTargets) { if (t.targetId === targetId) { connectedTargets.delete(sid); consoleEnabledSessions.delete(sid); networkEnabledSessions.delete(sid); } } }
468
+ }
469
+ }
470
+ }
471
+
472
+ // ---------------------------------------------------------------------------
473
+ // HTTP helpers
474
+ // ---------------------------------------------------------------------------
475
+ function jsonResponse(res, status, body) {
476
+ const payload = JSON.stringify(body);
477
+ res.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) });
478
+ res.end(payload);
479
+ }
480
+
481
+ function defaultErrorCode(status, message) {
482
+ if (status === 400) return "bad_request";
483
+ if (status === 404) return String(message).startsWith("Unknown API endpoint:") ? "endpoint_not_found" : "not_found";
484
+ if (status === 503) return "service_unavailable";
485
+ return "internal_error";
486
+ }
487
+
488
+ function errorResponse(res, status, message, options = {}) {
489
+ const code = options.code || defaultErrorCode(status, message);
490
+ const body = {
491
+ ok: false,
492
+ error: message,
493
+ message,
494
+ code,
495
+ status,
496
+ retryable: options.retryable === true,
497
+ };
498
+ if (options.details !== undefined) body.details = options.details;
499
+ jsonResponse(res, status, body);
500
+ }
501
+
502
+ function writeError(res, err) {
503
+ if (err instanceof ApiError) {
504
+ return errorResponse(res, err.status, err.message, {
505
+ code: err.code,
506
+ retryable: err.retryable,
507
+ details: err.details,
508
+ });
509
+ }
510
+ const message = err instanceof Error ? err.message : String(err);
511
+ return errorResponse(res, 500, message, { code: "internal_error" });
512
+ }
513
+
514
+ function validationError(res, message, field) {
515
+ return errorResponse(res, 400, message, {
516
+ code: "invalid_request",
517
+ details: field ? { field } : undefined,
518
+ });
519
+ }
520
+
521
+ function elementNotFound(selector) {
522
+ return {
523
+ ok: false,
524
+ error: `Element not found: ${selector}`,
525
+ message: `Element not found: ${selector}`,
526
+ code: "element_not_found",
527
+ status: 200,
528
+ retryable: false,
529
+ details: { selector },
530
+ };
531
+ }
532
+
533
+ async function readBody(req) {
534
+ const chunks = []; let totalSize = 0;
535
+ for await (const chunk of req) {
536
+ totalSize += chunk.length;
537
+ if (totalSize > MAX_BODY_SIZE) throw new ApiError(413, "request_body_too_large", "Request body too large (max 64KB)");
538
+ chunks.push(chunk);
539
+ }
540
+ const raw = Buffer.concat(chunks).toString("utf-8");
541
+ if (!raw.trim()) return {};
542
+ try { return JSON.parse(raw); } catch { throw new ApiError(400, "invalid_json", "Invalid JSON in request body"); }
543
+ }
544
+
545
+ async function ensureExtension() {
546
+ if (extensionConnected()) return;
547
+ const reconnected = await waitForExtension(3_000);
548
+ if (!reconnected || !extensionConnected()) {
549
+ throw new ApiError(503, "extension_not_connected", "Extension not connected. Is the browser running with the extension?", { retryable: true });
550
+ }
551
+ }
552
+
553
+ const NAMED_KEYS = {
554
+ enter: { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 },
555
+ return: { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 },
556
+ escape: { key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 },
557
+ esc: { key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 },
558
+ tab: { key: "Tab", code: "Tab", windowsVirtualKeyCode: 9 },
559
+ backspace: { key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 },
560
+ delete: { key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 },
561
+ del: { key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 },
562
+ arrowup: { key: "ArrowUp", code: "ArrowUp", windowsVirtualKeyCode: 38 },
563
+ up: { key: "ArrowUp", code: "ArrowUp", windowsVirtualKeyCode: 38 },
564
+ arrowdown: { key: "ArrowDown", code: "ArrowDown", windowsVirtualKeyCode: 40 },
565
+ down: { key: "ArrowDown", code: "ArrowDown", windowsVirtualKeyCode: 40 },
566
+ arrowleft: { key: "ArrowLeft", code: "ArrowLeft", windowsVirtualKeyCode: 37 },
567
+ left: { key: "ArrowLeft", code: "ArrowLeft", windowsVirtualKeyCode: 37 },
568
+ arrowright: { key: "ArrowRight", code: "ArrowRight", windowsVirtualKeyCode: 39 },
569
+ right: { key: "ArrowRight", code: "ArrowRight", windowsVirtualKeyCode: 39 },
570
+ home: { key: "Home", code: "Home", windowsVirtualKeyCode: 36 },
571
+ end: { key: "End", code: "End", windowsVirtualKeyCode: 35 },
572
+ pageup: { key: "PageUp", code: "PageUp", windowsVirtualKeyCode: 33 },
573
+ pagedown: { key: "PageDown", code: "PageDown", windowsVirtualKeyCode: 34 },
574
+ space: { key: " ", code: "Space", windowsVirtualKeyCode: 32, text: " " },
575
+ };
576
+
577
+ function modifierMaskFrom(body, comboParts = []) {
578
+ let modifiers = Number.isInteger(body.modifiers) ? body.modifiers : 0;
579
+ const names = new Set(comboParts.map((part) => part.toLowerCase()));
580
+ if (body.alt === true || names.has("alt") || names.has("option")) modifiers |= 1;
581
+ if (body.ctrl === true || body.control === true || names.has("ctrl") || names.has("control")) modifiers |= 2;
582
+ if (body.meta === true || body.cmd === true || body.command === true || names.has("meta") || names.has("cmd") || names.has("command")) modifiers |= 4;
583
+ if (body.shift === true || names.has("shift")) modifiers |= 8;
584
+ return modifiers;
585
+ }
586
+
587
+ function keyDefinition(rawKey, modifiers, explicitText) {
588
+ const raw = String(rawKey || "").trim();
589
+ if (!raw) return null;
590
+ const named = NAMED_KEYS[raw.toLowerCase()];
591
+ if (named) return { ...named, text: explicitText ?? named.text ?? "" };
592
+
593
+ if (raw.length === 1) {
594
+ const upper = raw.toUpperCase();
595
+ const hasCommandModifier = (modifiers & 1) || (modifiers & 2) || (modifiers & 4);
596
+ if (/^[A-Z]$/i.test(raw)) {
597
+ const key = hasCommandModifier && !(modifiers & 8) ? raw.toLowerCase() : raw;
598
+ return {
599
+ key,
600
+ code: `Key${upper}`,
601
+ windowsVirtualKeyCode: upper.charCodeAt(0),
602
+ text: explicitText ?? (hasCommandModifier ? "" : raw),
603
+ };
604
+ }
605
+ if (/^\d$/.test(raw)) {
606
+ return {
607
+ key: raw,
608
+ code: `Digit${raw}`,
609
+ windowsVirtualKeyCode: raw.charCodeAt(0),
610
+ text: explicitText ?? (hasCommandModifier ? "" : raw),
611
+ };
612
+ }
613
+ return {
614
+ key: raw,
615
+ code: raw === " " ? "Space" : "",
616
+ windowsVirtualKeyCode: raw.charCodeAt(0),
617
+ text: explicitText ?? (hasCommandModifier ? "" : raw),
618
+ };
619
+ }
620
+
621
+ return { key: raw, code: raw, windowsVirtualKeyCode: 0, text: explicitText ?? "" };
622
+ }
623
+
624
+ function normalizeKeyInput(body) {
625
+ let rawCombo = "";
626
+ if (typeof body.combo === "string" && body.combo.trim()) rawCombo = body.combo;
627
+ else if (typeof body.key === "string" && body.key.trim()) rawCombo = body.key;
628
+ if (!rawCombo) return null;
629
+ const parts = rawCombo.split("+").map((part) => part.trim()).filter(Boolean);
630
+ const keyPart = parts.length ? parts[parts.length - 1] : rawCombo;
631
+ const modifiers = modifierMaskFrom(body, parts.slice(0, -1));
632
+ const explicitText = typeof body.text === "string" ? body.text : undefined;
633
+ const definition = keyDefinition(keyPart, modifiers, explicitText);
634
+ if (!definition) return null;
635
+ return {
636
+ key: definition.key,
637
+ code: body.code || definition.code || definition.key,
638
+ windowsVirtualKeyCode: body.windowsVirtualKeyCode || definition.windowsVirtualKeyCode || 0,
639
+ nativeVirtualKeyCode: body.nativeVirtualKeyCode || body.windowsVirtualKeyCode || definition.windowsVirtualKeyCode || 0,
640
+ text: definition.text || "",
641
+ modifiers,
642
+ };
643
+ }
644
+
645
+ async function dispatchKeyPress(sessionId, input) {
646
+ const base = {
647
+ key: input.key,
648
+ code: input.code,
649
+ windowsVirtualKeyCode: input.windowsVirtualKeyCode,
650
+ nativeVirtualKeyCode: input.nativeVirtualKeyCode,
651
+ modifiers: input.modifiers,
652
+ };
653
+ const down = input.text ? { ...base, text: input.text, unmodifiedText: input.text } : base;
654
+ await sendToExtension("Input.dispatchKeyEvent", { type: "keyDown", ...down }, sessionId);
655
+ await sendToExtension("Input.dispatchKeyEvent", { type: "keyUp", ...base }, sessionId);
656
+ }
657
+
658
+ // ---------------------------------------------------------------------------
659
+ // API route handlers
660
+ // ---------------------------------------------------------------------------
661
+
662
+ async function handleTabs(_req, res) {
663
+ if (extensionProtocolError) {
664
+ throw new ApiError(409, "extension_upgrade_required", extensionProtocolError);
665
+ }
666
+ const tabs = [];
667
+ for (const t of connectedTargets.values()) {
668
+ tabs.push({ id: t.tabId, title: t.targetInfo?.title || "", url: t.targetInfo?.url || "" });
669
+ }
670
+ jsonResponse(res, 200, { ok: true, tabs });
671
+ }
672
+
673
+ async function handleConsole(req, res) {
674
+ await ensureExtension();
675
+ const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
676
+ const tabId = url.searchParams.get("tabId") || undefined;
677
+ const level = url.searchParams.get("level") || undefined;
678
+ const clear = url.searchParams.get("clear") === "true";
679
+ const limit = boundedNumber(url.searchParams.get("limit"), 100, 0, 1_000);
680
+
681
+ if (tabId) {
682
+ await enableConsoleCapture(resolveTab(tabId)).catch(() => {});
683
+ } else {
684
+ await Promise.all([...connectedTargets.keys()].map((sessionId) => enableConsoleCapture(sessionId).catch(() => {})));
685
+ }
686
+
687
+ let entries = consoleEntries;
688
+ if (tabId) entries = entries.filter((entry) => entry.tabId === tabId);
689
+ if (level) entries = entries.filter((entry) => entry.level === level);
690
+ const matchedTotal = entries.length;
691
+ const selected = limit === 0 ? [] : entries.slice(-limit);
692
+
693
+ if (clear) {
694
+ const ids = new Set(selected.map((entry) => entry.id));
695
+ consoleEntries = consoleEntries.filter((entry) => !ids.has(entry.id));
696
+ }
697
+
698
+ jsonResponse(res, 200, { ok: true, entries: selected, count: selected.length, total: matchedTotal, storedTotal: consoleEntries.length });
699
+ }
700
+
701
+ async function handleConsoleClear(req, res) {
702
+ await ensureExtension();
703
+ const body = await readBody(req);
704
+ const tabId = body.tabId;
705
+ const level = body.level;
706
+ const before = consoleEntries.length;
707
+ consoleEntries = consoleEntries.filter((entry) => {
708
+ if (tabId && entry.tabId !== tabId) return true;
709
+ if (level && entry.level !== level) return true;
710
+ return false;
711
+ });
712
+ jsonResponse(res, 200, { ok: true, cleared: before - consoleEntries.length, total: consoleEntries.length });
713
+ }
714
+
715
+ function filteredNetworkEntries(filters) {
716
+ let entries = networkEntries;
717
+ if (filters.tabId) entries = entries.filter((entry) => entry.tabId === filters.tabId);
718
+ if (filters.type) entries = entries.filter((entry) => entry.type === filters.type);
719
+ if (filters.method) entries = entries.filter((entry) => String(entry.method || entry.request?.method || "").toUpperCase() === String(filters.method).toUpperCase());
720
+ if (filters.status) entries = entries.filter((entry) => Number(entry.status ?? entry.response?.status) === Number(filters.status));
721
+ if (filters.requestId) entries = entries.filter((entry) => entry.requestId === filters.requestId);
722
+ if (filters.url) entries = entries.filter((entry) => String(entry.url || entry.request?.url || entry.response?.url || "").includes(filters.url));
723
+ return entries;
724
+ }
725
+
726
+ async function handleNetwork(req, res) {
727
+ await ensureExtension();
728
+ const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
729
+ await Promise.all([...connectedTargets.keys()].map((sessionId) => enableNetworkCapture(sessionId).catch(() => {})));
730
+
731
+ const filters = {
732
+ tabId: url.searchParams.get("tabId") || undefined,
733
+ type: url.searchParams.get("type") || undefined,
734
+ method: url.searchParams.get("method") || undefined,
735
+ status: url.searchParams.get("status") || undefined,
736
+ requestId: url.searchParams.get("requestId") || undefined,
737
+ url: url.searchParams.get("url") || undefined,
738
+ };
739
+ const clear = url.searchParams.get("clear") === "true";
740
+ const limit = boundedNumber(url.searchParams.get("limit"), 100, 0, MAX_NETWORK_ENTRIES);
741
+ const matched = filteredNetworkEntries(filters);
742
+ const selected = limit === 0 ? [] : matched.slice(-limit);
743
+
744
+ if (clear) {
745
+ const ids = new Set(selected.map((entry) => entry.id));
746
+ networkEntries = networkEntries.filter((entry) => !ids.has(entry.id));
747
+ }
748
+
749
+ jsonResponse(res, 200, { ok: true, entries: selected, count: selected.length, total: matched.length, storedTotal: networkEntries.length });
750
+ }
751
+
752
+ async function handleNetworkClear(req, res) {
753
+ await ensureExtension();
754
+ const body = await readBody(req);
755
+ const filters = {
756
+ tabId: body.tabId,
757
+ type: body.type,
758
+ method: body.method,
759
+ status: body.status,
760
+ requestId: body.requestId,
761
+ url: body.url,
762
+ };
763
+ const before = networkEntries.length;
764
+ if (Object.values(filters).some((value) => value !== undefined && value !== null && value !== "")) {
765
+ const ids = new Set(filteredNetworkEntries(filters).map((entry) => entry.id));
766
+ networkEntries = networkEntries.filter((entry) => !ids.has(entry.id));
767
+ } else {
768
+ networkEntries = [];
769
+ }
770
+ jsonResponse(res, 200, { ok: true, cleared: before - networkEntries.length, total: networkEntries.length });
771
+ }
772
+
773
+ async function handleNavigate(req, res) {
774
+ const body = await readBody(req);
775
+ const url = body.url;
776
+ if (!url || typeof url !== "string") return validationError(res, "url is required", "url");
777
+ await ensureExtension();
778
+ let sessionId;
779
+ try {
780
+ sessionId = resolveTab(body.tabId);
781
+ } catch (err) {
782
+ // Cold start: nothing is attached to navigate. When the caller didn't target
783
+ // a specific tab, open a fresh blank tab and navigate that instead of failing.
784
+ if (err instanceof ApiError && err.code === "no_attached_tabs" && !body.tabId) {
785
+ const created = await sendToExtension("Target.createTarget", { url: "about:blank" });
786
+ sessionId = resolveSession(created?.tabId);
787
+ } else {
788
+ throw err;
789
+ }
790
+ }
791
+ const result = await sendToExtension("Page.navigate", { url }, sessionId);
792
+ await new Promise((r) => setTimeout(r, 500));
793
+ let title = "", finalUrl = url;
794
+ try {
795
+ const titleResult = await sendToExtension("Runtime.evaluate", { expression: "document.title", returnByValue: true }, sessionId);
796
+ title = titleResult?.result?.value || "";
797
+ const urlResult = await sendToExtension("Runtime.evaluate", { expression: "location.href", returnByValue: true }, sessionId);
798
+ finalUrl = urlResult?.result?.value || url;
799
+ } catch { /* non-critical */ }
800
+ jsonResponse(res, 200, { ok: true, url: finalUrl, title, ...result });
801
+ }
802
+
803
+ async function handleEval(req, res) {
804
+ const body = await readBody(req);
805
+ const expression = body.expression;
806
+ if (!expression || typeof expression !== "string") return validationError(res, "expression is required", "expression");
807
+ await ensureExtension();
808
+ const sessionId = resolveTab(body.tabId);
809
+ const returnByValue = body.returnByValue !== false;
810
+ const result = await sendToExtension("Runtime.evaluate", { expression, returnByValue, awaitPromise: true }, sessionId);
811
+ jsonResponse(res, 200, { ok: true, result: result?.result || null, exceptionDetails: result?.exceptionDetails || null });
812
+ }
813
+
814
+ async function handleSnapshot(req, res) {
815
+ await ensureExtension();
816
+ const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
817
+ const tabId = url.searchParams.get("tabId") || undefined;
818
+ const format = url.searchParams.get("format") || "text";
819
+ const maxLength = parseInt(url.searchParams.get("maxLength") || "100000", 10);
820
+ const sessionId = resolveTab(tabId);
821
+
822
+ if (format === "html") {
823
+ const result = await sendToExtension("Runtime.evaluate", { expression: "document.documentElement.outerHTML", returnByValue: true }, sessionId);
824
+ let html = result?.result?.value || "";
825
+ const truncated = html.length > maxLength;
826
+ if (truncated) html = html.slice(0, maxLength);
827
+ const titleResult = await sendToExtension("Runtime.evaluate", { expression: "document.title", returnByValue: true }, sessionId);
828
+ const urlResult = await sendToExtension("Runtime.evaluate", { expression: "location.href", returnByValue: true }, sessionId);
829
+ return jsonResponse(res, 200, { ok: true, url: urlResult?.result?.value || "", title: titleResult?.result?.value || "", html, truncated });
830
+ }
831
+
832
+ const jsWithMaxLen = `var __maxLength = ${maxLength};\n${SNAPSHOT_JS}`;
833
+ const result = await sendToExtension("Runtime.evaluate", { expression: jsWithMaxLen, returnByValue: true, awaitPromise: false }, sessionId);
834
+ let snapshot = "", truncated = false;
835
+ try {
836
+ const parsed = JSON.parse(result?.result?.value || "{}");
837
+ snapshot = parsed.snapshot || ""; truncated = parsed.truncated || false;
838
+ } catch { snapshot = result?.result?.value || ""; }
839
+ const titleResult = await sendToExtension("Runtime.evaluate", { expression: "document.title", returnByValue: true }, sessionId);
840
+ const urlResult = await sendToExtension("Runtime.evaluate", { expression: "location.href", returnByValue: true }, sessionId);
841
+ jsonResponse(res, 200, { ok: true, url: urlResult?.result?.value || "", title: titleResult?.result?.value || "", snapshot, truncated });
842
+ }
843
+
844
+ async function handleWait(req, res) {
845
+ const body = await readBody(req);
846
+ const options = normalizeWaitOptions(body);
847
+ if (!options.ok) {
848
+ throw new ApiError(400, "invalid_request", options.message, {
849
+ details: { field: options.field },
850
+ });
851
+ }
852
+
853
+ await ensureExtension();
854
+ const sessionId = resolveTab(options.tabId);
855
+ const expression = buildWaitExpression(options.selector, options.state);
856
+ const startedAt = Date.now();
857
+ let attempts = 0;
858
+
859
+ while (true) {
860
+ if (!connectedTargets.has(sessionId)) {
861
+ throw new ApiError(404, "tab_not_found", "The target tab was detached while waiting", {
862
+ details: { tabId: options.tabId ?? null },
863
+ });
864
+ }
865
+
866
+ attempts += 1;
867
+ let result;
868
+ try {
869
+ result = await sendToExtension("Runtime.evaluate", {
870
+ expression,
871
+ returnByValue: true,
872
+ awaitPromise: false,
873
+ }, sessionId);
874
+ } catch (error) {
875
+ if (error instanceof ApiError) throw error;
876
+ throw new ApiError(409, "wait_evaluation_failed", error instanceof Error ? error.message : String(error), {
877
+ retryable: true,
878
+ details: { selector: options.selector, state: options.state },
879
+ });
880
+ }
881
+
882
+ if (result?.exceptionDetails) {
883
+ throw new ApiError(409, "wait_evaluation_failed", "The page could not evaluate the wait condition", {
884
+ retryable: true,
885
+ details: { selector: options.selector, state: options.state },
886
+ });
887
+ }
888
+
889
+ const evaluation = result?.result?.value;
890
+ if (!evaluation || typeof evaluation !== "object") {
891
+ throw new ApiError(409, "wait_evaluation_failed", "The page returned an invalid wait result", {
892
+ retryable: true,
893
+ details: { selector: options.selector, state: options.state },
894
+ });
895
+ }
896
+ if (evaluation.invalidSelector) {
897
+ throw new ApiError(400, "invalid_selector", `Invalid CSS selector: ${options.selector}`, {
898
+ details: { selector: options.selector },
899
+ });
900
+ }
901
+
902
+ const elapsedMs = Date.now() - startedAt;
903
+ if (evaluation.matched === true) {
904
+ return jsonResponse(res, 200, {
905
+ ok: true,
906
+ matched: true,
907
+ selector: options.selector,
908
+ state: options.state,
909
+ elapsedMs,
910
+ attempts,
911
+ matchCount: evaluation.matchCount ?? null,
912
+ visibleCount: evaluation.visibleCount ?? null,
913
+ });
914
+ }
915
+
916
+ if (elapsedMs >= options.timeoutMs) {
917
+ throw new ApiError(408, "wait_timeout", `Timed out waiting for selector: ${options.selector}`, {
918
+ retryable: true,
919
+ details: {
920
+ selector: options.selector,
921
+ state: options.state,
922
+ timeoutMs: options.timeoutMs,
923
+ elapsedMs,
924
+ attempts,
925
+ },
926
+ });
927
+ }
928
+
929
+ await new Promise((resolve) => setTimeout(resolve, Math.min(options.pollMs, options.timeoutMs - elapsedMs)));
930
+ }
931
+ }
932
+
933
+ async function handleClick(req, res) {
934
+ const body = await readBody(req);
935
+ const selector = body.selector;
936
+ if (!selector || typeof selector !== "string") return validationError(res, "selector is required", "selector");
937
+ await ensureExtension();
938
+ const sessionId = resolveTab(body.tabId);
939
+
940
+ const findJs = `(function() { var el = document.querySelector(${JSON.stringify(selector)}); if (!el) return JSON.stringify({ found: false }); var rect = el.getBoundingClientRect(); return JSON.stringify({ found: true, x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, text: (el.innerText || el.textContent || '').trim().slice(0, 100) }); })()`;
941
+ const findResult = await sendToExtension("Runtime.evaluate", { expression: findJs, returnByValue: true }, sessionId);
942
+ const elInfo = JSON.parse(findResult?.result?.value || '{\"found\":false}');
943
+ if (!elInfo.found) return jsonResponse(res, 200, elementNotFound(selector));
944
+
945
+ const button = body.button || "left";
946
+ const clickCount = body.doubleClick ? 2 : 1;
947
+
948
+ await sendToExtension("Runtime.evaluate", { expression: `document.querySelector(${JSON.stringify(selector)})?.scrollIntoView({block:'center'})`, returnByValue: true }, sessionId).catch(() => {});
949
+
950
+ const findResult2 = await sendToExtension("Runtime.evaluate", { expression: findJs, returnByValue: true }, sessionId);
951
+ const elInfo2 = JSON.parse(findResult2?.result?.value || '{\"found\":false}');
952
+
953
+ // Chrome ignores CDP mouse input for background tabs. A normal DOM click
954
+ // preserves the shared-browser workflow without forcing the tab forward.
955
+ // Foreground tabs still receive trusted mouse events below.
956
+ const visibilityResult = await sendToExtension("Runtime.evaluate", { expression: "document.visibilityState", returnByValue: true }, sessionId);
957
+ const visibility = visibilityResult?.result?.value;
958
+ if (visibility === "hidden" && button === "left" && clickCount === 1) {
959
+ const domClickJs = `(function() { var el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.click(); return true; })()`;
960
+ const domClickResult = await sendToExtension("Runtime.evaluate", { expression: domClickJs, returnByValue: true }, sessionId);
961
+ if (domClickResult?.result?.value) {
962
+ return jsonResponse(res, 200, { ok: true, clicked: true, strategy: "dom", elementText: elInfo2.text || elInfo.text || "", selector });
963
+ }
964
+ }
965
+
966
+ const fx = Math.round(elInfo2.found ? elInfo2.x : elInfo.x);
967
+ const fy = Math.round(elInfo2.found ? elInfo2.y : elInfo.y);
968
+
969
+ await sendToExtension("Input.dispatchMouseEvent", { type: "mouseMoved", x: fx, y: fy }, sessionId);
970
+ await sendToExtension("Input.dispatchMouseEvent", { type: "mousePressed", x: fx, y: fy, button, clickCount }, sessionId);
971
+ await sendToExtension("Input.dispatchMouseEvent", { type: "mouseReleased", x: fx, y: fy, button, clickCount }, sessionId);
972
+
973
+ jsonResponse(res, 200, { ok: true, clicked: true, strategy: "mouse", elementText: elInfo2.text || elInfo.text || "", selector });
974
+ }
975
+
976
+ async function handleType(req, res) {
977
+ const body = await readBody(req);
978
+ const text = body.text;
979
+ if (typeof text !== "string") return validationError(res, "text is required", "text");
980
+ await ensureExtension();
981
+ const sessionId = resolveTab(body.tabId);
982
+ const selector = body.selector;
983
+ const submit = body.submit || false;
984
+ const clear = body.clear || false;
985
+
986
+ if (selector) {
987
+ const focusJs = `(function() { var el = document.querySelector(${JSON.stringify(selector)}); if (!el) return JSON.stringify({ found: false }); el.focus(); return JSON.stringify({ found: true }); })()`;
988
+ const focusResult = await sendToExtension("Runtime.evaluate", { expression: focusJs, returnByValue: true }, sessionId);
989
+ const info = JSON.parse(focusResult?.result?.value || '{\"found\":false}');
990
+ if (!info.found) return jsonResponse(res, 200, elementNotFound(selector));
991
+ }
992
+
993
+ if (clear) {
994
+ await sendToExtension("Input.dispatchKeyEvent", { type: "keyDown", key: "a", code: "KeyA", modifiers: 2 }, sessionId);
995
+ await sendToExtension("Input.dispatchKeyEvent", { type: "keyUp", key: "a", code: "KeyA", modifiers: 2 }, sessionId);
996
+ await sendToExtension("Input.dispatchKeyEvent", { type: "keyDown", key: "Backspace", code: "Backspace" }, sessionId);
997
+ await sendToExtension("Input.dispatchKeyEvent", { type: "keyUp", key: "Backspace", code: "Backspace" }, sessionId);
998
+ }
999
+
1000
+ await sendToExtension("Input.insertText", { text }, sessionId);
1001
+
1002
+ if (submit) {
1003
+ await sendToExtension("Input.dispatchKeyEvent", { type: "keyDown", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }, sessionId);
1004
+ await sendToExtension("Input.dispatchKeyEvent", { type: "keyUp", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }, sessionId);
1005
+ }
1006
+
1007
+ jsonResponse(res, 200, { ok: true, typed: true });
1008
+ }
1009
+
1010
+ async function handleKey(req, res) {
1011
+ const body = await readBody(req);
1012
+ const input = normalizeKeyInput(body);
1013
+ if (!input) return validationError(res, "key or combo is required", "key");
1014
+ await ensureExtension();
1015
+ const sessionId = resolveTab(body.tabId);
1016
+ await dispatchKeyPress(sessionId, input);
1017
+ jsonResponse(res, 200, { ok: true, pressed: true, ...input });
1018
+ }
1019
+
1020
+ async function handleScreenshot(req, res) {
1021
+ await ensureExtension();
1022
+ const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
1023
+ const body = req.method === "POST" ? await readBody(req) : {};
1024
+ const tabId = body.tabId || url.searchParams.get("tabId") || undefined;
1025
+ const fullPage = body.fullPage === true || url.searchParams.get("fullPage") === "true";
1026
+ const sessionId = resolveTab(tabId);
1027
+ let fallbackError = null;
1028
+
1029
+ if (fullPage) {
1030
+ let width = null;
1031
+ let height = null;
1032
+ try {
1033
+ const metrics = await sendToExtension("Page.getLayoutMetrics", {}, sessionId);
1034
+ const size = metrics?.cssContentSize || metrics?.contentSize || metrics?.cssLayoutViewport || metrics?.layoutViewport;
1035
+ const rawWidth = Number(size?.width);
1036
+ const rawHeight = Number(size?.height);
1037
+ if (Number.isFinite(rawWidth) && Number.isFinite(rawHeight) && rawWidth > 0 && rawHeight > 0) {
1038
+ width = Math.ceil(rawWidth);
1039
+ height = Math.ceil(rawHeight);
1040
+ const result = await sendToExtension("Page.captureScreenshot", {
1041
+ format: "png",
1042
+ captureBeyondViewport: true,
1043
+ clip: { x: 0, y: 0, width, height, scale: 1 },
1044
+ }, sessionId);
1045
+ const data = result?.data || "";
1046
+ return jsonResponse(res, 200, { ok: true, data, format: "png", fullPage: true, strategy: "fullPageClip", width, height, bytes: Buffer.byteLength(data, "base64") });
1047
+ }
1048
+ } catch (err) {
1049
+ fallbackError = err instanceof Error ? err.message : String(err);
1050
+ }
1051
+
1052
+ const result = await sendToExtension("Page.captureScreenshot", { format: "png", captureBeyondViewport: true }, sessionId);
1053
+ const data = result?.data || "";
1054
+ return jsonResponse(res, 200, { ok: true, data, format: "png", fullPage: true, strategy: "captureBeyondViewport", width, height, bytes: Buffer.byteLength(data, "base64"), fallbackError });
1055
+ }
1056
+
1057
+ const result = await sendToExtension("Page.captureScreenshot", { format: "png", captureBeyondViewport: false }, sessionId);
1058
+ const data = result?.data || "";
1059
+ jsonResponse(res, 200, { ok: true, data, format: "png", fullPage: false, strategy: "viewport", bytes: Buffer.byteLength(data, "base64") });
1060
+ }
1061
+
1062
+ async function handleScroll(req, res) {
1063
+ await ensureExtension();
1064
+ const body = await readBody(req);
1065
+ const sessionId = resolveTab(body.tabId);
1066
+ const direction = body.direction || "down";
1067
+ const amount = body.amount || 800;
1068
+ const js = direction === "bottom"
1069
+ ? "window.scrollTo(0, document.body.scrollHeight)"
1070
+ : direction === "top"
1071
+ ? "window.scrollTo(0, 0)"
1072
+ : `window.scrollBy(0, ${direction === "down" ? amount : -amount})`;
1073
+ await sendToExtension("Runtime.evaluate", { expression: js, returnByValue: true }, sessionId);
1074
+ jsonResponse(res, 200, { ok: true, scrolled: true, direction });
1075
+ }
1076
+
1077
+ async function handleDownload(req, res) {
1078
+ const body = await readBody(req);
1079
+ const selector = body.selector;
1080
+ if (!selector || typeof selector !== "string") return validationError(res, "selector is required (e.g. 'img[src=...]' or 'a[href=...]')", "selector");
1081
+ await ensureExtension();
1082
+ const sessionId = resolveTab(body.tabId);
1083
+ const result = await sendToExtension("Runtime.evaluate", {
1084
+ expression: `(function() { var el = document.querySelector(${JSON.stringify(selector)}); if (!el) return { found: false }; var url = el.src || el.href || ''; var tag = el.tagName; return { found: true, url, tag }; })()`,
1085
+ returnByValue: true,
1086
+ }, sessionId);
1087
+ const data = result?.result?.value || { found: false };
1088
+ if (!data.found) return jsonResponse(res, 200, elementNotFound(selector));
1089
+ jsonResponse(res, 200, { ok: true, ...data });
1090
+ }
1091
+
1092
+ async function handleDownloadStart(req, res) {
1093
+ const body = await readBody(req);
1094
+ const url = typeof body.url === "string" ? body.url.trim() : "";
1095
+ if (!url) return validationError(res, "url is required", "url");
1096
+ await ensureExtension();
1097
+
1098
+ const options = { url };
1099
+ if (typeof body.filename === "string" && body.filename.trim()) options.filename = body.filename;
1100
+ if (body.saveAs === true) options.saveAs = true;
1101
+ if (typeof body.conflictAction === "string" && body.conflictAction.trim()) options.conflictAction = body.conflictAction;
1102
+
1103
+ const result = await sendToExtension("BrowserRelay.download", options);
1104
+ jsonResponse(res, 200, { ok: true, downloadId: result?.id, ...result });
1105
+ }
1106
+
1107
+ function downloadEventId(entry) {
1108
+ return entry.downloadId ?? entry.item?.id ?? entry.delta?.id ?? entry.id;
1109
+ }
1110
+
1111
+ async function handleDownloads(req, res) {
1112
+ await ensureExtension();
1113
+ const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
1114
+ const limit = boundedNumber(url.searchParams.get("limit"), 100, 1, 1000);
1115
+ const query = { limit };
1116
+ for (const name of ["id", "state", "url", "filename", "query"]) {
1117
+ const value = url.searchParams.get(name);
1118
+ if (value) query[name] = value;
1119
+ }
1120
+
1121
+ const result = await sendToExtension("BrowserRelay.searchDownloads", query);
1122
+ let events = downloadEntries.slice(-limit);
1123
+ if (query.id) {
1124
+ const id = Number(query.id);
1125
+ events = events.filter((entry) => Number(downloadEventId(entry)) === id);
1126
+ }
1127
+
1128
+ jsonResponse(res, 200, { ok: true, downloads: result?.downloads || [], events });
1129
+ }
1130
+
1131
+ async function handleDownloadsClear(_req, res) {
1132
+ const cleared = downloadEntries.length;
1133
+ downloadEntries = [];
1134
+ jsonResponse(res, 200, { ok: true, cleared });
1135
+ }
1136
+
1137
+ // ---------------------------------------------------------------------------
1138
+ // HTTP Server
1139
+ // ---------------------------------------------------------------------------
1140
+ const server = createServer(async (req, res) => {
1141
+ const startTime = Date.now();
1142
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
1143
+ const path = url.pathname;
1144
+
1145
+ if (path !== "/extension/status") {
1146
+ LOG.info("http.request", { method: req.method, path, remote: req.socket.remoteAddress || "unknown" });
1147
+ }
1148
+
1149
+ // Health probe
1150
+ if (req.method === "HEAD" && path === "/") { res.writeHead(200); res.end(); return; }
1151
+ if (req.method === "GET" && path === "/") { res.writeHead(200, { "Content-Type": "text/plain" }); res.end("OK"); return; }
1152
+
1153
+ // CORS for chrome-extension:// origins
1154
+ const origin = req.headers.origin;
1155
+ if (typeof origin === "string" && origin.startsWith("chrome-extension://")) {
1156
+ res.setHeader("Access-Control-Allow-Origin", origin);
1157
+ res.setHeader("Vary", "Origin");
1158
+ }
1159
+ if (req.method === "OPTIONS") {
1160
+ res.writeHead(204, { "Access-Control-Allow-Origin": origin || "*", "Access-Control-Allow-Methods": "GET, POST, HEAD, OPTIONS", "Access-Control-Allow-Headers": "content-type", "Access-Control-Max-Age": "86400" });
1161
+ res.end(); return;
1162
+ }
1163
+
1164
+ // /extension/status
1165
+ if (path === "/extension/status") { jsonResponse(res, 200, { connected: extensionConnected() }); return; }
1166
+
1167
+ // /json/version and /json/list
1168
+ if (path === "/json/version" || path === "/json/version/") {
1169
+ const payload = { Browser: "BrowserRelay/custom", "Protocol-Version": "1.3" };
1170
+ if (extensionConnected() || connectedTargets.size > 0) {
1171
+ payload.webSocketDebuggerUrl = `ws://${RELAY_HOST}:${RELAY_PORT}/cdp`;
1172
+ }
1173
+ return jsonResponse(res, 200, payload);
1174
+ }
1175
+ if (path === "/json" || path === "/json/" || path === "/json/list" || path === "/json/list/") {
1176
+ const list = [];
1177
+ for (const t of connectedTargets.values()) {
1178
+ list.push({ id: t.targetId, type: t.targetInfo?.type || "page", title: t.targetInfo?.title || "", url: t.targetInfo?.url || "" });
1179
+ }
1180
+ return jsonResponse(res, 200, list);
1181
+ }
1182
+
1183
+ // All /api/* routes
1184
+ if (path.startsWith("/api/")) {
1185
+ if (isAutomationPath(path)) {
1186
+ try {
1187
+ await ensureExtension();
1188
+ if (executorInfo?.protocolVersion !== PROTOCOL_VERSION)
1189
+ throw new ApiError(409,'protocol_mismatch','The running Chrome extension does not advertise protocol 2. Reload the matching Browser Relay extension; restarting the daemon alone is insufficient.',{details:{requiredProtocol:PROTOCOL_VERSION,executor:executorInfo}});
1190
+ const body = req.method === "POST" ? await readBody(req) : {};
1191
+ const taskRequest = isTaskRequest(req.method, path);
1192
+ const taskId = taskRequest ? (req.method === "GET" ? url.searchParams.get("taskId") : body.taskId) || `job_${randomUUID()}` : null;
1193
+ const sessionId = req.method === "GET" ? url.searchParams.get("sessionId") : body.sessionId;
1194
+ if (taskId) {
1195
+ if (req.method === "GET") url.searchParams.set("taskId", taskId);
1196
+ else body.taskId = taskId;
1197
+ }
1198
+ if (req.aborted || res.destroyed) return;
1199
+ const cancel = () => {
1200
+ if (taskId && !res.writableFinished)
1201
+ void sendToExtension("BrowserRelay.automation", { method: "POST", path: `/api/tasks/${encodeURIComponent(taskId)}/cancel`, body: { sessionId } }).catch(() => {});
1202
+ };
1203
+ res.once("close", cancel);
1204
+ try {
1205
+ const result = await sendToExtension("BrowserRelay.automation", {method:req.method,path:url.pathname + url.search,body});
1206
+ if (!res.destroyed) return jsonResponse(res, result.ok === false ? result.status || 400 : 200, result);
1207
+ } catch (error) {
1208
+ cancel();
1209
+ if (taskId) { error.retryable = false; error.details = { ...error.details, taskId, sessionId }; }
1210
+ throw error;
1211
+ } finally { res.removeListener("close", cancel); }
1212
+ } catch(error) { return writeError(res,error); }
1213
+ }
1214
+ if (req.method === "GET" && path === "/api/debug") {
1215
+ const tabCount = connectedTargets.size;
1216
+ const uptimeSeconds = Math.floor((Date.now() - serverStartTime) / 1000);
1217
+ return jsonResponse(res, 200, { ok: true, version: RELAY_VERSION, host: RELAY_HOST, port: RELAY_PORT, connected: extensionConnected(), tabCount, uptimeSeconds, commandsSent, executor:executorInfo });
1218
+ }
1219
+
1220
+ const routeMap = {
1221
+ "GET /api/tabs": handleTabs,
1222
+ "GET /api/console": handleConsole,
1223
+ "POST /api/console/clear": handleConsoleClear,
1224
+ "GET /api/network": handleNetwork,
1225
+ "POST /api/network/clear": handleNetworkClear,
1226
+ "POST /api/navigate": handleNavigate,
1227
+ "POST /api/eval": handleEval,
1228
+ "POST /api/wait": handleWait,
1229
+ "GET /api/snapshot": handleSnapshot,
1230
+ "POST /api/click": handleClick,
1231
+ "POST /api/type": handleType,
1232
+ "POST /api/key": handleKey,
1233
+ "GET /api/screenshot": handleScreenshot,
1234
+ "POST /api/screenshot": handleScreenshot,
1235
+ "POST /api/scroll": handleScroll,
1236
+ "POST /api/download": handleDownload,
1237
+ "POST /api/download/start": handleDownloadStart,
1238
+ "GET /api/downloads": handleDownloads,
1239
+ "POST /api/downloads/clear": handleDownloadsClear,
1240
+ };
1241
+
1242
+ const routeKey = `${req.method} ${path}`;
1243
+ let handler = routeMap[routeKey];
1244
+ if (!handler) {
1245
+ handler = routeMap[`${req.method} ${path.replace(/\/$/, "")}`];
1246
+ }
1247
+
1248
+ if (handler) {
1249
+ try { await handler(req, res); }
1250
+ catch (err) {
1251
+ const message = err instanceof Error ? err.message : String(err);
1252
+ const status = err instanceof ApiError ? err.status : 500;
1253
+ LOG.error("api.error", { path, status, error: message });
1254
+ writeError(res, err);
1255
+ }
1256
+ return;
1257
+ }
1258
+
1259
+ return errorResponse(res, 404, `Unknown API endpoint: ${path}`);
1260
+ }
1261
+
1262
+ errorResponse(res, 404, "Not found");
1263
+ });
1264
+
1265
+ // ---------------------------------------------------------------------------
1266
+ // WebSocket upgrade
1267
+ // ---------------------------------------------------------------------------
1268
+ const wss = new WebSocketServer({ noServer: true });
1269
+ const cdpWss = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 * 1024 });
1270
+ cdpWss.on("connection", (ws) => cdpBridge.connect(ws));
1271
+
1272
+ server.on("upgrade", (req, socket, head) => {
1273
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
1274
+ const pathname = url.pathname;
1275
+
1276
+ if (pathname === "/cdp") {
1277
+ // Browser pages cannot open an unrestricted debugger WebSocket via Origin.
1278
+ if (req.headers.origin) { socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); socket.destroy(); return; }
1279
+ cdpWss.handleUpgrade(req,socket,head,(ws)=>cdpWss.emit("connection",ws,req));
1280
+ return;
1281
+ }
1282
+
1283
+ if (pathname !== "/extension") {
1284
+ socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
1285
+ socket.destroy(); return;
1286
+ }
1287
+
1288
+ // Reject if extension already connected
1289
+ if (extensionWs && extensionWs.readyState === WebSocket.OPEN) {
1290
+ socket.write("HTTP/1.1 409 Conflict\r\n\r\nExtension already connected");
1291
+ socket.destroy(); return;
1292
+ }
1293
+
1294
+ if (extensionWs && extensionWs.readyState !== WebSocket.OPEN) {
1295
+ try { extensionWs.terminate(); } catch { /* ignore */ }
1296
+ extensionWs = null;
1297
+ executorInfo = null;
1298
+ cdpBridge.disconnect();
1299
+ }
1300
+
1301
+ wss.handleUpgrade(req, socket, head, (ws) => { wss.emit("connection", ws, req); });
1302
+ });
1303
+
1304
+ wss.on("connection", (ws, req) => {
1305
+ const remote = req?.socket?.remoteAddress || "unknown";
1306
+ extensionRemoteAddress = remote;
1307
+ extensionConnectedSince = new Date().toISOString();
1308
+ LOG.info("extension.connect", { remote, since: extensionConnectedSince });
1309
+ extensionWs = ws;
1310
+ extensionProtocolError = null;
1311
+ executorInfo = null;
1312
+ connectedTargets.clear();
1313
+ clearGraceTimer();
1314
+ flushReconnectWaiters(true);
1315
+
1316
+ if (pingTimer) clearInterval(pingTimer);
1317
+ pingTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ method: "ping" })); }, PING_INTERVAL_MS);
1318
+
1319
+ ws.on("message", (data) => { if (extensionWs !== ws) return; onExtensionMessage(data); });
1320
+
1321
+ ws.on("close", (code, reason) => {
1322
+ LOG.info("extension.disconnect", { code, reason: reason ? String(reason) : "none" });
1323
+ extensionConnectedSince = null; extensionRemoteAddress = null;
1324
+ if (pingTimer) { clearInterval(pingTimer); pingTimer = null; }
1325
+ if (extensionWs !== ws) return;
1326
+ extensionWs = null;
1327
+ executorInfo = null;
1328
+ cdpBridge.disconnect();
1329
+ for (const [id, pending] of pendingCommands) {
1330
+ clearTimeout(pending.timer);
1331
+ pending.reject(new ApiError(503, "extension_disconnected", "Extension disconnected", { retryable: true }));
1332
+ pendingCommands.delete(id);
1333
+ }
1334
+ scheduleGraceCleanup();
1335
+ });
1336
+
1337
+ ws.on("error", () => {});
1338
+ });
1339
+
1340
+ // ---------------------------------------------------------------------------
1341
+ // Start
1342
+ // ---------------------------------------------------------------------------
1343
+ server.listen(RELAY_PORT, RELAY_HOST, () => {
1344
+ LOG.info("server.start", { version: RELAY_VERSION, host: RELAY_HOST, port: RELAY_PORT, wsPath: "/extension", httpApiPath: "/api/", healthPath: "/" });
1345
+ console.log(`\n========================================`);
1346
+ console.log(`Browser Relay v${RELAY_VERSION}`);
1347
+ console.log(`========================================`);
1348
+ console.log(`Listening: ${RELAY_HOST}:${RELAY_PORT}`);
1349
+ console.log(`Extension WS: ws://${RELAY_HOST}:${RELAY_PORT}/extension`);
1350
+ console.log(`HTTP API: http://${RELAY_HOST}:${RELAY_PORT}/api/`);
1351
+ console.log(`Health probe: http://${RELAY_HOST}:${RELAY_PORT}/ (HEAD or GET)`);
1352
+ console.log(`========================================`);
1353
+ console.log(`\nInstall the Browser Relay extension and point it at:`);
1354
+ console.log(` ws://127.0.0.1:${RELAY_PORT}/extension`);
1355
+ console.log(`\nWaiting for extension connection...\n`);
1356
+ });