@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,13 @@
1
+ {
2
+ "name": "browser-relay-hub",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Browser Relay public relay — Cloudflare Worker + Durable Object",
7
+ "scripts": {
8
+ "deploy": "wrangler deploy"
9
+ },
10
+ "devDependencies": {
11
+ "wrangler": "^4"
12
+ }
13
+ }
package/hub/src/rpc.js ADDED
@@ -0,0 +1,41 @@
1
+ import { isTaskRequest } from '../../extension/protocol.js';
2
+
3
+ // Shared by the Node hub and the Worker. No browser task may outlive a lost
4
+ // synchronous RPC merely because its HTTP caller did not use our SDK.
5
+ export function sendRpc(socket, pending, request, {signal, timeoutMs=30000}={}) {
6
+ const path=new URL(request.path,'http://relay.local');
7
+ const tracked=isTaskRequest(request.method,path.pathname);
8
+ const taskId=tracked ? (request.method==='GET'?path.searchParams.get('taskId'):request.body?.taskId) || `job_${crypto.randomUUID()}` : undefined;
9
+ const sessionId=request.method==='GET'?path.searchParams.get('sessionId'):request.body?.sessionId;
10
+ const body=tracked && request.method!=='GET'?{...request.body,taskId}:request.body;
11
+ if(tracked && request.method==='GET')path.searchParams.set('taskId',taskId);
12
+ const id=request.id || `rpc_${crypto.randomUUID()}`;
13
+ const frame={type:'rpc.request',id,method:request.method,path:path.pathname+path.search,headers:request.headers||{},body:body??null};
14
+ return new Promise((resolve,reject)=>{
15
+ let timer, sent=false, settled=false;
16
+ const cleanup=()=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);pending.delete(id);};
17
+ const finish=(fn,value)=>{if(settled)return;settled=true;cleanup();fn(value);};
18
+ const fail=(code,message,status)=>{
19
+ if(settled)return;
20
+ const payload={ok:false,code,message,error:message,status,retryable:!tracked && code!=='request_cancelled'};
21
+ if(tracked) {
22
+ Object.assign(payload,{taskId,sessionId,cancellationRequested:false});
23
+ if(sent) {
24
+ try {
25
+ socket.send(JSON.stringify({type:'rpc.request',id:`cancel_${crypto.randomUUID()}`,method:'POST',path:`/api/tasks/${encodeURIComponent(taskId)}/cancel`,headers:{},body:{sessionId}}));
26
+ payload.cancellationRequested=true;
27
+ } catch {payload.cancellationError='remote_send_failed';}
28
+ }
29
+ }
30
+ finish(reject,payload);
31
+ };
32
+ const abort=()=>fail('request_cancelled','Remote request cancelled; completed actions were not undone',409);
33
+ if(pending.has(id)) {reject({ok:false,code:'duplicate_rpc',message:'RPC id is already pending',status:409,retryable:false});return;}
34
+ if(signal?.aborted) {abort();return;}
35
+ pending.set(id,{resolve:value=>finish(resolve,value),reject:value=>finish(reject,tracked?{...value,taskId,sessionId,retryable:false}:value)});
36
+ signal?.addEventListener('abort',abort,{once:true});
37
+ timer=setTimeout(()=>fail('remote_request_timeout','Remote device did not respond before timeout',504),timeoutMs);
38
+ try {socket.send(JSON.stringify(frame));sent=true;}
39
+ catch(error) {fail('remote_send_failed',error.message || String(error),502);}
40
+ });
41
+ }
@@ -0,0 +1,322 @@
1
+ import { sendRpc } from "./rpc.js";
2
+ const MAX_BODY_SIZE = 128 * 1024;
3
+ const RPC_TIMEOUT_MS = 30_000;
4
+ const DEVICE_AUTH_TIMEOUT_MS = 5_000;
5
+
6
+ function jsonResponse(body, status = 200, headers = {}) {
7
+ return new Response(JSON.stringify(body), {
8
+ status,
9
+ headers: {
10
+ "Content-Type": "application/json",
11
+ "Cache-Control": "no-store",
12
+ ...headers,
13
+ },
14
+ });
15
+ }
16
+
17
+ function errorPayload(code, message, { status = 500, retryable = false, details } = {}) {
18
+ const payload = { ok: false, code, error: message, message, status, retryable };
19
+ if (details !== undefined) payload.details = details;
20
+ return payload;
21
+ }
22
+
23
+ function bearerToken(headers) {
24
+ const auth = headers.get("Authorization") || "";
25
+ const match = /^Bearer\s+(.+)$/i.exec(auth);
26
+ return match ? match[1].trim() : "";
27
+ }
28
+
29
+ async function readJson(request) {
30
+ const text = await request.text();
31
+ if (text.length > MAX_BODY_SIZE) {
32
+ throw Object.assign(new Error("Request body too large"), { status: 413, code: "request_body_too_large" });
33
+ }
34
+ if (!text.trim()) return {};
35
+ try { return JSON.parse(text); }
36
+ catch { throw Object.assign(new Error("Invalid JSON in request body"), { status: 400, code: "invalid_json" }); }
37
+ }
38
+
39
+ async function sha256Hex(value) {
40
+ const data = new TextEncoder().encode(String(value));
41
+ const hash = await crypto.subtle.digest("SHA-256", data);
42
+ return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join("");
43
+ }
44
+
45
+ async function deriveRouteId(secret) {
46
+ const data = new TextEncoder().encode(String(secret));
47
+ const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", data));
48
+ let binary = "";
49
+ for (const byte of hash) binary += String.fromCharCode(byte);
50
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "").slice(0, 16);
51
+ }
52
+
53
+ function safeEqualString(a, b) {
54
+ const left = String(a || "");
55
+ const right = String(b || "");
56
+ let diff = left.length ^ right.length;
57
+ const max = Math.max(left.length, right.length);
58
+ for (let i = 0; i < max; i++) {
59
+ diff |= (left.charCodeAt(i) || 0) ^ (right.charCodeAt(i) || 0);
60
+ }
61
+ return diff === 0;
62
+ }
63
+
64
+ function isRouteId(value) {
65
+ return /^[A-Za-z0-9_-]{10,}$/.test(String(value || ""));
66
+ }
67
+
68
+ function isSecret(value) {
69
+ return /^[A-Za-z0-9_-]{16,}$/.test(String(value || ""));
70
+ }
71
+
72
+ function websocketPair() {
73
+ const pair = new WebSocketPair();
74
+ const values = Object.values(pair);
75
+ return { client: values[0], server: values[1] };
76
+ }
77
+
78
+ export class BrowserRelayDevice {
79
+ constructor(state, env) {
80
+ this.state = state;
81
+ this.env = env;
82
+ this.deviceSocket = null;
83
+ this.connectedAt = null;
84
+ this.lastSeen = null;
85
+ this.hello = null;
86
+ this.pending = new Map();
87
+ // In-memory only — nothing is persisted. A route exists only while its device
88
+ // is connected (the DO stays resident on the open WS); once it drops and the DO
89
+ // evicts, all state is gone. No storage writes → no zombie routes to clean up.
90
+ this.secretHash = null;
91
+ }
92
+
93
+ async fetch(request) {
94
+ const url = new URL(request.url);
95
+ try {
96
+ if (url.pathname === "/v1/device/connect") return this.handleDeviceConnect(request, url);
97
+ if (url.pathname === "/v1/rpc") return this.handleRpc(request);
98
+ if (url.pathname === "/v1/status") return this.handleStatus(request);
99
+ return jsonResponse(errorPayload("endpoint_not_found", `Unknown device endpoint: ${url.pathname}`, { status: 404 }), 404);
100
+ } catch (err) {
101
+ const status = err.status || 500;
102
+ const code = err.code || "internal_error";
103
+ return jsonResponse(errorPayload(code, err instanceof Error ? err.message : String(err), { status }), status);
104
+ }
105
+ }
106
+
107
+ // secretHash lives only in memory. The device's connect claims it (claim: true);
108
+ // CLI rpc/status must match a live claim — no device connected means no claim,
109
+ // which reads as "offline" rather than silently registering a credential.
110
+ async authorize(secret, { claim = false, routeId = "" } = {}) {
111
+ if (!isSecret(secret)) {
112
+ return { ok: false, status: 401, code: "invalid_remote_device", message: "Invalid or missing remote device credentials" };
113
+ }
114
+ if (!isRouteId(routeId) || !safeEqualString(routeId, await deriveRouteId(secret))) {
115
+ return { ok: false, status: 401, code: "invalid_remote_device", message: "Invalid or revoked remote device id" };
116
+ }
117
+ const incomingHash = await sha256Hex(secret);
118
+ if (!this.secretHash) {
119
+ if (claim) { this.secretHash = incomingHash; return { ok: true, claimed: true }; }
120
+ return { ok: false, status: 409, code: "remote_device_offline", message: "Remote Browser Relay device is offline", retryable: true };
121
+ }
122
+ if (!safeEqualString(this.secretHash, incomingHash)) {
123
+ return { ok: false, status: 401, code: "invalid_remote_device", message: "Invalid or revoked remote device id" };
124
+ }
125
+ return { ok: true, claimed: false };
126
+ }
127
+
128
+ async handleDeviceConnect(request, url) {
129
+ if (request.headers.get("Upgrade") !== "websocket") {
130
+ return jsonResponse(errorPayload("upgrade_required", "WebSocket upgrade required", { status: 426 }), 426);
131
+ }
132
+ const { client, server } = websocketPair();
133
+ server.accept();
134
+ let authenticated = false;
135
+ const authTimer = setTimeout(() => {
136
+ if (!authenticated) server.close(4008, "authentication timeout");
137
+ }, DEVICE_AUTH_TIMEOUT_MS);
138
+
139
+ const authenticate = async (secret) => {
140
+ if (authenticated) return true;
141
+ const auth = await this.authorize(secret, { claim: true, routeId: url.searchParams.get("routeId") || "" });
142
+ if (!auth.ok) {
143
+ clearTimeout(authTimer);
144
+ server.close(4003, "authentication failed");
145
+ return false;
146
+ }
147
+ if (this.deviceSocket && this.deviceSocket.readyState === WebSocket.OPEN && this.deviceSocket !== server) {
148
+ try { this.deviceSocket.close(4001, "superseded"); } catch {}
149
+ }
150
+ this.deviceSocket = server;
151
+ this.connectedAt = new Date().toISOString();
152
+ this.lastSeen = this.connectedAt;
153
+ authenticated = true;
154
+ clearTimeout(authTimer);
155
+ server.send(JSON.stringify({ type: "device.authenticated" }));
156
+ return true;
157
+ };
158
+
159
+ const legacySecret = url.searchParams.get("token") || bearerToken(request.headers);
160
+ if (legacySecret && !(await authenticate(legacySecret))) {
161
+ return new Response(null, { status: 101, webSocket: client });
162
+ }
163
+
164
+ server.addEventListener("message", (event) => {
165
+ void (async () => {
166
+ let msg;
167
+ try { msg = JSON.parse(String(event.data)); } catch { return; }
168
+ if (!authenticated) {
169
+ if (msg.type === "device.auth" && typeof msg.secret === "string") await authenticate(msg.secret);
170
+ else {
171
+ clearTimeout(authTimer);
172
+ server.close(4003, "authenticate first");
173
+ }
174
+ return;
175
+ }
176
+ if (msg.type === "device.auth") return;
177
+ this.handleDeviceMessage(server, event.data);
178
+ })();
179
+ });
180
+ server.addEventListener("close", () => {
181
+ clearTimeout(authTimer);
182
+ if (authenticated) this.handleDeviceClose(server);
183
+ });
184
+ server.addEventListener("error", () => {
185
+ clearTimeout(authTimer);
186
+ if (authenticated) this.handleDeviceClose(server);
187
+ });
188
+
189
+ return new Response(null, { status: 101, webSocket: client });
190
+ }
191
+
192
+ handleDeviceMessage(socket, raw) {
193
+ let msg;
194
+ try { msg = JSON.parse(String(raw)); }
195
+ catch { return; }
196
+ if (this.deviceSocket !== socket) return;
197
+ this.lastSeen = new Date().toISOString();
198
+
199
+ if (msg.type === "device.hello") {
200
+ this.hello = msg;
201
+ return;
202
+ }
203
+
204
+ if (msg.type === "rpc.response" && msg.id) {
205
+ const pending = this.pending.get(msg.id);
206
+ if (!pending) return;
207
+ clearTimeout(pending.timer);
208
+ this.pending.delete(msg.id);
209
+ pending.resolve(msg);
210
+ }
211
+ }
212
+
213
+ handleDeviceClose(socket) {
214
+ if (this.deviceSocket !== socket) return;
215
+ this.deviceSocket = null;
216
+ this.lastSeen = new Date().toISOString();
217
+ for (const [id, pending] of this.pending) {
218
+ clearTimeout(pending.timer);
219
+ pending.reject(errorPayload("remote_device_offline", "Remote Browser Relay device disconnected", { status: 409, retryable: true }));
220
+ this.pending.delete(id);
221
+ }
222
+ }
223
+
224
+ async handleRpc(request) {
225
+ const body = await readJson(request);
226
+ const secret = bearerToken(request.headers);
227
+ const auth = await this.authorize(secret, { routeId: String(body.routeId || "") });
228
+ if (!auth.ok) return jsonResponse(errorPayload(auth.code, auth.message, { status: auth.status }), auth.status);
229
+ if (!body.method || !body.path) {
230
+ return jsonResponse(errorPayload("invalid_request", "method and path are required", { status: 400 }), 400);
231
+ }
232
+
233
+ try {
234
+ const response = await this.sendRpcToDevice(body, request.signal);
235
+ const status = Number(response.status) || 200;
236
+ if (typeof response.body === "string") {
237
+ return new Response(response.body, {
238
+ status,
239
+ headers: {
240
+ "Content-Type": response.headers?.["content-type"] || response.headers?.["Content-Type"] || "text/plain",
241
+ "Cache-Control": "no-store",
242
+ },
243
+ });
244
+ }
245
+ return jsonResponse(response.body ?? null, status);
246
+ } catch (err) {
247
+ const payload = err && typeof err === "object" && "code" in err
248
+ ? err
249
+ : errorPayload("remote_rpc_failed", err instanceof Error ? err.message : String(err), { status: 502, retryable: true });
250
+ return jsonResponse(payload, payload.status || 502);
251
+ }
252
+ }
253
+
254
+ sendRpcToDevice(requestBody, signal) {
255
+ if (!this.deviceSocket || this.deviceSocket.readyState !== WebSocket.OPEN) {
256
+ throw errorPayload("remote_device_offline", "Remote Browser Relay device is offline", { status: 409, retryable: true });
257
+ }
258
+ return sendRpc(this.deviceSocket,this.pending,requestBody,{signal,timeoutMs:RPC_TIMEOUT_MS});
259
+ }
260
+
261
+ async handleStatus(request) {
262
+ const routeId = new URL(request.url).searchParams.get("routeId") || "";
263
+ const secret = bearerToken(request.headers);
264
+ const auth = await this.authorize(secret, { routeId });
265
+ if (!auth.ok) return jsonResponse(errorPayload(auth.code, auth.message, { status: auth.status }), auth.status);
266
+ return jsonResponse({
267
+ ok: true,
268
+ routeId,
269
+ connected: !!this.deviceSocket && this.deviceSocket.readyState === WebSocket.OPEN,
270
+ connectedAt: this.connectedAt,
271
+ lastSeen: this.lastSeen,
272
+ hello: this.hello,
273
+ });
274
+ }
275
+ }
276
+
277
+ export default {
278
+ async fetch(request, env) {
279
+ const url = new URL(request.url);
280
+ try {
281
+ if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/v1/health")) {
282
+ return jsonResponse({ ok: true, service: "browser-relay-hub", host: url.host });
283
+ }
284
+
285
+ if (request.method === "GET" && url.pathname === "/v1/device/connect") {
286
+ const routeId = url.searchParams.get("routeId") || "";
287
+ if (!isRouteId(routeId)) return jsonResponse(errorPayload("invalid_remote_device", "Invalid route id", { status: 401 }), 401);
288
+ const id = env.DEVICES.idFromName(routeId);
289
+ return env.DEVICES.get(id).fetch(request);
290
+ }
291
+
292
+ if (request.method === "POST" && url.pathname === "/v1/rpc") {
293
+ const body = await readJson(request.clone());
294
+ const routeId = String(body.routeId || "");
295
+ if (!isRouteId(routeId)) return jsonResponse(errorPayload("invalid_remote_device", "Invalid route id", { status: 401 }), 401);
296
+ const id = env.DEVICES.idFromName(routeId);
297
+ return env.DEVICES.get(id).fetch(new Request("https://device.local/v1/rpc", {
298
+ method: "POST",
299
+ headers: request.headers,
300
+ body: JSON.stringify(body),
301
+ signal: request.signal,
302
+ }));
303
+ }
304
+
305
+ if (request.method === "GET" && url.pathname.startsWith("/v1/status/")) {
306
+ const routeId = decodeURIComponent(url.pathname.slice("/v1/status/".length));
307
+ if (!isRouteId(routeId)) return jsonResponse(errorPayload("invalid_remote_device", "Invalid route id", { status: 401 }), 401);
308
+ const id = env.DEVICES.idFromName(routeId);
309
+ return env.DEVICES.get(id).fetch(new Request(`https://device.local/v1/status?routeId=${encodeURIComponent(routeId)}`, {
310
+ method: "GET",
311
+ headers: request.headers,
312
+ }));
313
+ }
314
+
315
+ return jsonResponse(errorPayload("endpoint_not_found", `Unknown hub endpoint: ${url.pathname}`, { status: 404 }), 404);
316
+ } catch (err) {
317
+ const status = err.status || 500;
318
+ const code = err.code || "internal_error";
319
+ return jsonResponse(errorPayload(code, err instanceof Error ? err.message : String(err), { status }), status);
320
+ }
321
+ },
322
+ };
@@ -0,0 +1,19 @@
1
+ name = "browser-relay-hub"
2
+ main = "src/worker.js"
3
+ compatibility_date = "2025-12-01"
4
+ compatibility_flags = ["enable_request_signal"]
5
+ workers_dev = true
6
+
7
+ # Self-hosters: `wrangler deploy` publishes this to
8
+ # https://browser-relay-hub.<your-subdomain>.workers.dev — use that URL as the
9
+ # extension's "Public relay". To put it on your own domain, add a route, e.g.:
10
+ # routes = [{ pattern = "relay.example.com", custom_domain = true }]
11
+
12
+ [durable_objects]
13
+ bindings = [
14
+ { name = "DEVICES", class_name = "BrowserRelayDevice" }
15
+ ]
16
+
17
+ [[migrations]]
18
+ tag = "v1"
19
+ new_sqlite_classes = ["BrowserRelayDevice"]
package/package.json ADDED
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "@xnng/browser-relay",
3
+ "version": "1.6.0",
4
+ "type": "module",
5
+ "engines": {
6
+ "node": ">=18"
7
+ },
8
+ "description": "Agent-native browser control through a Skill + CLI: share your existing, logged-in Chrome locally or across multiple machines.",
9
+ "homepage": "https://www.npmjs.com/package/@xnng/browser-relay",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/xnng/makemoney.git",
13
+ "directory": "tools/browser-relay"
14
+ },
15
+ "main": "server/relay-server.js",
16
+ "bin": {
17
+ "browser-relay": "server/cli.js",
18
+ "browser-relay-mcp": "server/mcp-server.js",
19
+ "browser-relay-hub": "server/hub-server.js"
20
+ },
21
+ "files": [
22
+ "server/",
23
+ "extension/",
24
+ "hub/",
25
+ "docs/remote-control-hub.md",
26
+ "docs/demo/intranet.html",
27
+ "skills/",
28
+ "README.md",
29
+ "docs/README.zh-CN.md",
30
+ "docs/browser-use-comparison.md",
31
+ "docs/browser-use-gap-audit.md",
32
+ "docs/browser-use-parity.md",
33
+ "docs/benchmarks/",
34
+ "docs/releases/"
35
+ ],
36
+ "scripts": {
37
+ "start": "node server/relay-server.js",
38
+ "hub": "node server/hub-server.js",
39
+ "hub:dry-run": "wrangler deploy --config hub/wrangler.toml --dry-run",
40
+ "hub:deploy": "wrangler deploy --config hub/wrangler.toml",
41
+ "mcp": "node server/mcp-server.js",
42
+ "test": "node --test",
43
+ "pack:dry-run": "npm pack --dry-run",
44
+ "sync-version": "node scripts/sync-version.mjs",
45
+ "prepublishOnly": "node scripts/check-publish.mjs && node scripts/sync-version.mjs",
46
+ "postinstall": "node server/install.js",
47
+ "postuninstall": "node server/uninstall.js",
48
+ "test:browser": "node scripts/run-browser-tests.mjs",
49
+ "bench:browser": "node scripts/benchmark-browser.mjs",
50
+ "bench:serve": "node scripts/serve-browser-benchmark.mjs"
51
+ },
52
+ "keywords": [
53
+ "agent-skill",
54
+ "agent-browser",
55
+ "browser-agent",
56
+ "browser-control",
57
+ "browser-cli",
58
+ "browser-skill",
59
+ "browser",
60
+ "automation",
61
+ "cli",
62
+ "cdp",
63
+ "ai-agent",
64
+ "chrome",
65
+ "logged-in-browser",
66
+ "remote-browser"
67
+ ],
68
+ "license": "MIT",
69
+ "publishConfig": {
70
+ "access": "public",
71
+ "tag": "latest"
72
+ },
73
+ "dependencies": {
74
+ "ws": "^8.21.1"
75
+ },
76
+ "devDependencies": {
77
+ "playwright": "^1.63.0"
78
+ },
79
+ "exports": {
80
+ ".": "./server/relay-server.js",
81
+ "./sdk": "./server/sdk.js"
82
+ }
83
+ }
@@ -0,0 +1,200 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ // Chrome owns the real debugger attachments. CDP clients get independent virtual
4
+ // sessions; disconnecting Playwright must never close Chrome or detach a user tab.
5
+ export function createCdpBridge({ targets, send, ensure }) {
6
+ const peers = new Set();
7
+ const targetInfo = (target) => ({
8
+ ...target.targetInfo,
9
+ attached: true,
10
+ browserContextId:
11
+ target.targetInfo.browserContextId || "browser-relay-default",
12
+ });
13
+ function emit(peer, method, params, sessionId) {
14
+ if (peer.ws.readyState === 1)
15
+ peer.ws.send(
16
+ JSON.stringify({ method, params, ...(sessionId ? { sessionId } : {}) }),
17
+ );
18
+ }
19
+ function attach(peer, target, parent) {
20
+ let entry = [...peer.sessions.values()].find(
21
+ (s) => s.real === target.sessionId,
22
+ );
23
+ if (entry) return entry.id;
24
+ const id = `relay_${randomUUID()}`;
25
+ entry = { id, real: target.sessionId, targetId: target.targetId };
26
+ peer.sessions.set(id, entry);
27
+ emit(
28
+ peer,
29
+ "Target.attachedToTarget",
30
+ {
31
+ sessionId: id,
32
+ targetInfo: targetInfo(target),
33
+ waitingForDebugger: false,
34
+ },
35
+ parent,
36
+ );
37
+ return id;
38
+ }
39
+ async function command(peer, msg) {
40
+ const { method, params = {}, sessionId } = msg;
41
+ await ensure();
42
+ const session = sessionId ? peer.sessions.get(sessionId) : undefined;
43
+ if (sessionId && !session) throw new Error("Unknown CDP session");
44
+ if (session && method !== "Target.detachFromTarget")
45
+ // Shared Chrome must not leave future iframe targets paused after the
46
+ // client disconnects. Playwright accepts already-running targets.
47
+ return send(method, method === 'Target.setAutoAttach' ? {...params,waitForDebuggerOnStart:false} : params, session.real);
48
+ if (method === "Browser.getVersion") {
49
+ return send("Browser.getVersion", {});
50
+ }
51
+ if (method === "Browser.close") return {};
52
+ if (method === "Browser.setDownloadBehavior") return {}; // Downloads use the user's Chrome manager.
53
+ if (method === "Target.getBrowserContexts")
54
+ return { browserContextIds: [] };
55
+ if (method === "Target.getTargets")
56
+ return { targetInfos: [...targets().values()].map(targetInfo) };
57
+ if (method === "Target.setDiscoverTargets") {
58
+ peer.discover = !!params.discover;
59
+ if (peer.discover)
60
+ for (const target of targets().values())
61
+ emit(peer, "Target.targetCreated", {
62
+ targetInfo: targetInfo(target),
63
+ });
64
+ return {};
65
+ }
66
+ if (method === "Target.setAutoAttach") {
67
+ peer.autoAttach = !!params.autoAttach;
68
+ if (peer.autoAttach)
69
+ for (const target of targets().values()) attach(peer, target);
70
+ return {};
71
+ }
72
+ if (method === "Target.attachToTarget") {
73
+ const target = [...targets().values()].find(
74
+ (t) => t.targetId === params.targetId,
75
+ );
76
+ if (!target) throw new Error("Target not found");
77
+ return { sessionId: attach(peer, target) };
78
+ }
79
+ if (method === "Target.detachFromTarget") {
80
+ peer.sessions.delete(params.sessionId);
81
+ return {};
82
+ }
83
+ if (method === "Target.getTargetInfo") {
84
+ if (!params.targetId)
85
+ return {
86
+ targetInfo: {
87
+ targetId: "browser-relay-browser",
88
+ type: "browser",
89
+ title: "Browser Relay",
90
+ url: "",
91
+ attached: true,
92
+ browserContextId: "browser-relay-default",
93
+ },
94
+ };
95
+ const target = [...targets().values()].find(
96
+ (t) => t.targetId === params.targetId,
97
+ );
98
+ if (!target) throw new Error("Target not found");
99
+ return { targetInfo: targetInfo(target) };
100
+ }
101
+ if (method === "Browser.getWindowForTarget")
102
+ return {
103
+ windowId: 1,
104
+ bounds: {
105
+ left: 0,
106
+ top: 0,
107
+ width: 1280,
108
+ height: 720,
109
+ windowState: "normal",
110
+ },
111
+ };
112
+ if (method === "Browser.setWindowBounds")
113
+ throw new Error("Window resizing is not supported on shared Chrome");
114
+ if (
115
+ method === "Target.createBrowserContext" ||
116
+ method === "Target.disposeBrowserContext"
117
+ )
118
+ throw new Error(
119
+ "Browser contexts are owned by the user; reuse the default context",
120
+ );
121
+ return send(method, params);
122
+ }
123
+ function connect(ws) {
124
+ const peer = {
125
+ ws,
126
+ sessions: new Map(),
127
+ autoAttach: false,
128
+ discover: false,
129
+ };
130
+ peers.add(peer);
131
+ ws.on("close", () => peers.delete(peer));
132
+ ws.on("error", () => {});
133
+ ws.on("message", async (raw) => {
134
+ let msg;
135
+ try {
136
+ msg = JSON.parse(raw.toString());
137
+ } catch {
138
+ return;
139
+ }
140
+ if (!Number.isInteger(msg.id) || typeof msg.method !== "string") return;
141
+ try {
142
+ const result = await command(peer, msg);
143
+ if (ws.readyState === 1)
144
+ ws.send(
145
+ JSON.stringify({
146
+ id: msg.id,
147
+ result,
148
+ ...(msg.sessionId ? { sessionId: msg.sessionId } : {}),
149
+ }),
150
+ );
151
+ } catch (error) {
152
+ if (ws.readyState === 1)
153
+ ws.send(
154
+ JSON.stringify({
155
+ id: msg.id,
156
+ error: { code: -32000, message: error.message },
157
+ ...(msg.sessionId ? { sessionId: msg.sessionId } : {}),
158
+ }),
159
+ );
160
+ }
161
+ });
162
+ }
163
+ function event(method, params = {}, realSession) {
164
+ for (const peer of peers) {
165
+ const owner = [...peer.sessions.values()].find(
166
+ (s) => s.real === realSession,
167
+ );
168
+ if (method === "Target.attachedToTarget") {
169
+ const target = {
170
+ sessionId: params.sessionId,
171
+ targetId: params.targetInfo.targetId,
172
+ targetInfo: params.targetInfo,
173
+ };
174
+ if (owner) attach(peer, target, owner.id);
175
+ else if (params.targetInfo.type === "page") {
176
+ if (peer.discover)
177
+ emit(peer, "Target.targetCreated", {
178
+ targetInfo: targetInfo(target),
179
+ });
180
+ if (peer.autoAttach) attach(peer, target);
181
+ }
182
+ } else if (method === "Target.detachedFromTarget") {
183
+ const child = [...peer.sessions.values()].find(
184
+ (s) => s.real === params.sessionId,
185
+ );
186
+ if (child) {
187
+ emit(peer, method, { ...params, sessionId: child.id }, owner?.id);
188
+ peer.sessions.delete(child.id);
189
+ }
190
+ } else if (owner) emit(peer, method, params, owner.id);
191
+ else if (method.startsWith("Target.") && peer.discover)
192
+ emit(peer, method, params);
193
+ }
194
+ }
195
+ function disconnect() {
196
+ for (const peer of peers) peer.ws.close(1012, "Extension disconnected");
197
+ peers.clear();
198
+ }
199
+ return { connect, event, disconnect };
200
+ }