@ricsam/r5d-browser 0.0.47 → 0.0.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/cjs/main.cjs +44 -5
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/port-forward-manager.cjs +233 -0
- package/dist/mjs/main.mjs +44 -5
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/port-forward-manager.mjs +199 -0
- package/dist/types/port-forward-manager.d.ts +23 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,3 +8,7 @@ r5d-browser start
|
|
|
8
8
|
```
|
|
9
9
|
|
|
10
10
|
The profile is stored under `~/.r5d/browser/profile`. Completed downloads are retained under `~/.r5d/browser/downloads`; agents can list them and copy a selected file into the active session's artifacts. That artifact is then synchronized to every connected worker. Browser screenshots are session artifacts in r5d.dev and are not retained by the browser process.
|
|
11
|
+
|
|
12
|
+
Agents can also create loopback-only TCP port forwards from this Mac to the worker bound to a chat. For example, a forward can map Chromium's `localhost:4323` to `127.0.0.1:3232` on that worker. The relay preserves raw TCP traffic, including HTTP, HTTPS, WebSockets, HMR, and SSE; it never exposes a public listener or permits an arbitrary target host.
|
|
13
|
+
|
|
14
|
+
Port forwards remain active across agent turns and r5d.dev control-connection reconnects. They are owned by the running `r5d-browser` process, so restarting that process ends them. Agents can list and stop forwards, and the user can inspect or stop them from Settings.
|
package/dist/cjs/main.cjs
CHANGED
|
@@ -29,6 +29,7 @@ var import_node_child_process = require("node:child_process");
|
|
|
29
29
|
var import_promises = require("node:timers/promises");
|
|
30
30
|
var import_ws = __toESM(require("ws"), 1);
|
|
31
31
|
var import_playwright_install = require("./playwright-install.cjs");
|
|
32
|
+
var import_port_forward_manager = require("./port-forward-manager.cjs");
|
|
32
33
|
const DEFAULT_BASE_URL = "https://r5d.dev";
|
|
33
34
|
const PACKAGE_NAME = "@ricsam/r5d-browser";
|
|
34
35
|
const RECONNECT_DELAY_MS = 2e3;
|
|
@@ -445,9 +446,11 @@ async function connectLoop(params) {
|
|
|
445
446
|
arch: process.arch,
|
|
446
447
|
version: findVersion(),
|
|
447
448
|
chromiumVersion: params.chromiumVersion,
|
|
448
|
-
profilePath: params.profilePath
|
|
449
|
+
profilePath: params.profilePath,
|
|
450
|
+
capabilities: { portForwarding: true }
|
|
449
451
|
},
|
|
450
|
-
tabs: await params.runtime.listTabs()
|
|
452
|
+
tabs: await params.runtime.listTabs(),
|
|
453
|
+
portForwards: params.portForwards.list()
|
|
451
454
|
})
|
|
452
455
|
);
|
|
453
456
|
let lastServerMessageAt = Date.now();
|
|
@@ -463,6 +466,38 @@ async function connectLoop(params) {
|
|
|
463
466
|
socket?.send(JSON.stringify({ type: "pong" }));
|
|
464
467
|
return;
|
|
465
468
|
}
|
|
469
|
+
if (message.type === "port_forward_start") {
|
|
470
|
+
try {
|
|
471
|
+
await params.portForwards.start(message.forward);
|
|
472
|
+
socket?.send(
|
|
473
|
+
JSON.stringify({ type: "port_forward_result", requestId: message.requestId, forward: message.forward })
|
|
474
|
+
);
|
|
475
|
+
} catch (error) {
|
|
476
|
+
socket?.send(
|
|
477
|
+
JSON.stringify({
|
|
478
|
+
type: "port_forward_result",
|
|
479
|
+
requestId: message.requestId,
|
|
480
|
+
error: error instanceof Error ? error.message : String(error)
|
|
481
|
+
})
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (message.type === "port_forward_stop") {
|
|
487
|
+
try {
|
|
488
|
+
await params.portForwards.stop(message.forwardId);
|
|
489
|
+
socket?.send(JSON.stringify({ type: "port_forward_result", requestId: message.requestId }));
|
|
490
|
+
} catch (error) {
|
|
491
|
+
socket?.send(
|
|
492
|
+
JSON.stringify({
|
|
493
|
+
type: "port_forward_result",
|
|
494
|
+
requestId: message.requestId,
|
|
495
|
+
error: error instanceof Error ? error.message : String(error)
|
|
496
|
+
})
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
466
501
|
if (message.type !== "operation") return;
|
|
467
502
|
try {
|
|
468
503
|
const beforeTabs = await params.runtime.listTabs();
|
|
@@ -521,19 +556,23 @@ async function start(options) {
|
|
|
521
556
|
});
|
|
522
557
|
if (context.pages().length === 0) await context.newPage();
|
|
523
558
|
const runtime = new BrowserRuntime(context, downloadsPath);
|
|
559
|
+
const instanceId = loadInstanceId();
|
|
560
|
+
const portForwards = new import_port_forward_manager.BrowserPortForwardManager(baseUrl, token, instanceId);
|
|
524
561
|
process.stdout.write(`[r5d-browser] profile: ${profilePath}
|
|
525
562
|
`);
|
|
526
563
|
process.stdout.write(`[r5d-browser] downloads: ${downloadsPath}
|
|
527
564
|
`);
|
|
528
565
|
process.stdout.write(`[r5d-browser] agents use synthetic Chromium input; your system pointer and keyboard stay untouched.
|
|
529
566
|
`);
|
|
530
|
-
|
|
531
|
-
process.on("
|
|
567
|
+
const shutdown = () => void Promise.all([portForwards.close(), context.close()]).finally(() => process.exit(0));
|
|
568
|
+
process.on("SIGINT", shutdown);
|
|
569
|
+
process.on("SIGTERM", shutdown);
|
|
532
570
|
return connectLoop({
|
|
533
571
|
runtime,
|
|
572
|
+
portForwards,
|
|
534
573
|
baseUrl,
|
|
535
574
|
token,
|
|
536
|
-
instanceId
|
|
575
|
+
instanceId,
|
|
537
576
|
profilePath,
|
|
538
577
|
chromiumVersion: context.browser()?.version() ?? "unknown"
|
|
539
578
|
});
|
package/dist/cjs/package.json
CHANGED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
var port_forward_manager_exports = {};
|
|
30
|
+
__export(port_forward_manager_exports, {
|
|
31
|
+
BrowserPortForwardManager: () => BrowserPortForwardManager
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(port_forward_manager_exports);
|
|
34
|
+
var import_node_net = __toESM(require("node:net"), 1);
|
|
35
|
+
var import_ws = __toESM(require("ws"), 1);
|
|
36
|
+
const RELAY_FRAME_BYTES = 64 * 1024;
|
|
37
|
+
const RELAY_PAUSE_BYTES = 1024 * 1024;
|
|
38
|
+
const RELAY_RESUME_BYTES = 256 * 1024;
|
|
39
|
+
function relayUrl(baseUrl, instanceId, forwardId, relayConnectionId) {
|
|
40
|
+
const url = new URL("/browser/port-forward/ws", baseUrl);
|
|
41
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
42
|
+
url.searchParams.set("instance", instanceId);
|
|
43
|
+
url.searchParams.set("forward", forwardId);
|
|
44
|
+
url.searchParams.set("connection", relayConnectionId);
|
|
45
|
+
return url.toString();
|
|
46
|
+
}
|
|
47
|
+
async function listen(server, port, host, ipv6Only = false) {
|
|
48
|
+
await new Promise((resolve, reject) => {
|
|
49
|
+
const onError = (error) => {
|
|
50
|
+
server.off("listening", onListening);
|
|
51
|
+
reject(error);
|
|
52
|
+
};
|
|
53
|
+
const onListening = () => {
|
|
54
|
+
server.off("error", onError);
|
|
55
|
+
resolve();
|
|
56
|
+
};
|
|
57
|
+
server.once("error", onError);
|
|
58
|
+
server.once("listening", onListening);
|
|
59
|
+
server.listen({ port, host, ipv6Only, exclusive: true });
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
async function closeServer(server) {
|
|
63
|
+
if (!server.listening) return;
|
|
64
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
65
|
+
}
|
|
66
|
+
function rawDataBuffer(data) {
|
|
67
|
+
if (Buffer.isBuffer(data)) return data;
|
|
68
|
+
if (Array.isArray(data)) return Buffer.concat(data);
|
|
69
|
+
return Buffer.from(data);
|
|
70
|
+
}
|
|
71
|
+
class BrowserPortForwardManager {
|
|
72
|
+
constructor(baseUrl, token, instanceId) {
|
|
73
|
+
this.baseUrl = baseUrl;
|
|
74
|
+
this.token = token;
|
|
75
|
+
this.instanceId = instanceId;
|
|
76
|
+
}
|
|
77
|
+
baseUrl;
|
|
78
|
+
token;
|
|
79
|
+
instanceId;
|
|
80
|
+
forwards = /* @__PURE__ */ new Map();
|
|
81
|
+
forwardByBrowserPort = /* @__PURE__ */ new Map();
|
|
82
|
+
operationQueue = Promise.resolve();
|
|
83
|
+
list() {
|
|
84
|
+
return [...this.forwards.values()].map(({ definition }) => ({ ...definition }));
|
|
85
|
+
}
|
|
86
|
+
start(definition) {
|
|
87
|
+
return this.enqueue(() => this.startNow(definition));
|
|
88
|
+
}
|
|
89
|
+
stop(forwardId) {
|
|
90
|
+
return this.enqueue(() => this.stopNow(forwardId));
|
|
91
|
+
}
|
|
92
|
+
close() {
|
|
93
|
+
return this.enqueue(async () => {
|
|
94
|
+
for (const forwardId of [...this.forwards.keys()]) await this.stopNow(forwardId);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
enqueue(operation) {
|
|
98
|
+
const result = this.operationQueue.then(operation, operation);
|
|
99
|
+
this.operationQueue = result.then(
|
|
100
|
+
() => void 0,
|
|
101
|
+
() => void 0
|
|
102
|
+
);
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
async startNow(definition) {
|
|
106
|
+
const existing = this.forwards.get(definition.forwardId);
|
|
107
|
+
if (existing) {
|
|
108
|
+
if (JSON.stringify(existing.definition) === JSON.stringify(definition)) return;
|
|
109
|
+
throw new Error(`Port forward ${definition.forwardId} already exists with different settings.`);
|
|
110
|
+
}
|
|
111
|
+
const occupyingId = this.forwardByBrowserPort.get(definition.browserPort);
|
|
112
|
+
if (occupyingId) throw new Error(`Browser port ${definition.browserPort} is already used by forward ${occupyingId}.`);
|
|
113
|
+
const active = { definition: { ...definition }, servers: [], relays: /* @__PURE__ */ new Set() };
|
|
114
|
+
const accept = (socket) => this.acceptConnection(active, socket);
|
|
115
|
+
const ipv4 = import_node_net.default.createServer({ allowHalfOpen: true }, accept);
|
|
116
|
+
const ipv6 = import_node_net.default.createServer({ allowHalfOpen: true }, accept);
|
|
117
|
+
const servers = [ipv4, ipv6];
|
|
118
|
+
active.servers = servers;
|
|
119
|
+
try {
|
|
120
|
+
await listen(ipv4, definition.browserPort, "127.0.0.1");
|
|
121
|
+
await listen(ipv6, definition.browserPort, "::1", true);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
await Promise.all(servers.map(closeServer));
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Could not bind localhost:${definition.browserPort}: ${error instanceof Error ? error.message : String(error)}`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
this.forwards.set(definition.forwardId, active);
|
|
129
|
+
this.forwardByBrowserPort.set(definition.browserPort, definition.forwardId);
|
|
130
|
+
}
|
|
131
|
+
async stopNow(forwardId) {
|
|
132
|
+
const active = this.forwards.get(forwardId);
|
|
133
|
+
if (!active) return;
|
|
134
|
+
this.forwards.delete(forwardId);
|
|
135
|
+
this.forwardByBrowserPort.delete(active.definition.browserPort);
|
|
136
|
+
for (const connection of active.relays) {
|
|
137
|
+
connection.socket.destroy();
|
|
138
|
+
connection.relay.close(1e3, "Port forward stopped");
|
|
139
|
+
}
|
|
140
|
+
active.relays.clear();
|
|
141
|
+
await Promise.all(active.servers.map(closeServer));
|
|
142
|
+
}
|
|
143
|
+
acceptConnection(active, socket) {
|
|
144
|
+
socket.pause();
|
|
145
|
+
socket.setNoDelay(true);
|
|
146
|
+
const relayConnectionId = crypto.randomUUID();
|
|
147
|
+
const relay = new import_ws.default(relayUrl(this.baseUrl, this.instanceId, active.definition.forwardId, relayConnectionId), {
|
|
148
|
+
headers: { Authorization: `Bearer ${this.token}` },
|
|
149
|
+
perMessageDeflate: false
|
|
150
|
+
});
|
|
151
|
+
const connection = { socket, relay };
|
|
152
|
+
active.relays.add(connection);
|
|
153
|
+
let ready = false;
|
|
154
|
+
const pendingChunks = [];
|
|
155
|
+
let pendingBytes = 0;
|
|
156
|
+
let resumeTimer;
|
|
157
|
+
let closing = false;
|
|
158
|
+
const cleanup = () => {
|
|
159
|
+
if (closing) return;
|
|
160
|
+
closing = true;
|
|
161
|
+
if (resumeTimer) clearInterval(resumeTimer);
|
|
162
|
+
active.relays.delete(connection);
|
|
163
|
+
socket.destroy();
|
|
164
|
+
if (relay.readyState === import_ws.default.OPEN || relay.readyState === import_ws.default.CONNECTING) relay.close();
|
|
165
|
+
};
|
|
166
|
+
const waitForRelayDrain = () => {
|
|
167
|
+
if (resumeTimer || relay.bufferedAmount <= RELAY_PAUSE_BYTES) return;
|
|
168
|
+
socket.pause();
|
|
169
|
+
resumeTimer = setInterval(() => {
|
|
170
|
+
if (relay.readyState !== import_ws.default.OPEN) return cleanup();
|
|
171
|
+
if (relay.bufferedAmount > RELAY_RESUME_BYTES) return;
|
|
172
|
+
clearInterval(resumeTimer);
|
|
173
|
+
resumeTimer = void 0;
|
|
174
|
+
if (ready) socket.resume();
|
|
175
|
+
}, 10);
|
|
176
|
+
};
|
|
177
|
+
const sendChunk = (chunk) => {
|
|
178
|
+
for (let offset = 0; offset < chunk.byteLength; offset += RELAY_FRAME_BYTES) {
|
|
179
|
+
relay.send(chunk.subarray(offset, Math.min(chunk.byteLength, offset + RELAY_FRAME_BYTES)), { binary: true });
|
|
180
|
+
}
|
|
181
|
+
waitForRelayDrain();
|
|
182
|
+
};
|
|
183
|
+
relay.on("message", (data, isBinary) => {
|
|
184
|
+
if (!isBinary) {
|
|
185
|
+
let control;
|
|
186
|
+
try {
|
|
187
|
+
control = JSON.parse(rawDataBuffer(data).toString("utf8"));
|
|
188
|
+
} catch {
|
|
189
|
+
return cleanup();
|
|
190
|
+
}
|
|
191
|
+
if (control.type === "ready") {
|
|
192
|
+
ready = true;
|
|
193
|
+
for (const chunk of pendingChunks.splice(0)) sendChunk(chunk);
|
|
194
|
+
pendingBytes = 0;
|
|
195
|
+
socket.resume();
|
|
196
|
+
} else if (control.type === "end") {
|
|
197
|
+
socket.end();
|
|
198
|
+
} else if (control.type === "error") {
|
|
199
|
+
socket.destroy(new Error(control.error || "Port-forward relay failed."));
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!socket.write(rawDataBuffer(data))) {
|
|
204
|
+
relay.pause();
|
|
205
|
+
socket.once("drain", () => relay.resume());
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
relay.once("error", cleanup);
|
|
209
|
+
relay.once("close", cleanup);
|
|
210
|
+
socket.on("data", (chunk) => {
|
|
211
|
+
if (!ready || relay.readyState !== import_ws.default.OPEN) {
|
|
212
|
+
pendingBytes += chunk.byteLength;
|
|
213
|
+
if (pendingBytes > RELAY_PAUSE_BYTES) return cleanup();
|
|
214
|
+
pendingChunks.push(Buffer.from(chunk));
|
|
215
|
+
socket.pause();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
sendChunk(chunk);
|
|
219
|
+
});
|
|
220
|
+
socket.on("end", () => {
|
|
221
|
+
if (relay.readyState === import_ws.default.OPEN) relay.send(JSON.stringify({ type: "end" }));
|
|
222
|
+
});
|
|
223
|
+
socket.on("error", (error) => {
|
|
224
|
+
if (relay.readyState === import_ws.default.OPEN) relay.send(JSON.stringify({ type: "error", error: error.message }));
|
|
225
|
+
});
|
|
226
|
+
socket.on("close", cleanup);
|
|
227
|
+
socket.pause();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
231
|
+
0 && (module.exports = {
|
|
232
|
+
BrowserPortForwardManager
|
|
233
|
+
});
|
package/dist/mjs/main.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { spawnSync } from "node:child_process";
|
|
|
6
6
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
7
7
|
import WebSocket from "ws";
|
|
8
8
|
import { resolvePlaywrightCliPath } from "./playwright-install.mjs";
|
|
9
|
+
import { BrowserPortForwardManager } from "./port-forward-manager.mjs";
|
|
9
10
|
const DEFAULT_BASE_URL = "https://r5d.dev";
|
|
10
11
|
const PACKAGE_NAME = "@ricsam/r5d-browser";
|
|
11
12
|
const RECONNECT_DELAY_MS = 2e3;
|
|
@@ -422,9 +423,11 @@ async function connectLoop(params) {
|
|
|
422
423
|
arch: process.arch,
|
|
423
424
|
version: findVersion(),
|
|
424
425
|
chromiumVersion: params.chromiumVersion,
|
|
425
|
-
profilePath: params.profilePath
|
|
426
|
+
profilePath: params.profilePath,
|
|
427
|
+
capabilities: { portForwarding: true }
|
|
426
428
|
},
|
|
427
|
-
tabs: await params.runtime.listTabs()
|
|
429
|
+
tabs: await params.runtime.listTabs(),
|
|
430
|
+
portForwards: params.portForwards.list()
|
|
428
431
|
})
|
|
429
432
|
);
|
|
430
433
|
let lastServerMessageAt = Date.now();
|
|
@@ -440,6 +443,38 @@ async function connectLoop(params) {
|
|
|
440
443
|
socket?.send(JSON.stringify({ type: "pong" }));
|
|
441
444
|
return;
|
|
442
445
|
}
|
|
446
|
+
if (message.type === "port_forward_start") {
|
|
447
|
+
try {
|
|
448
|
+
await params.portForwards.start(message.forward);
|
|
449
|
+
socket?.send(
|
|
450
|
+
JSON.stringify({ type: "port_forward_result", requestId: message.requestId, forward: message.forward })
|
|
451
|
+
);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
socket?.send(
|
|
454
|
+
JSON.stringify({
|
|
455
|
+
type: "port_forward_result",
|
|
456
|
+
requestId: message.requestId,
|
|
457
|
+
error: error instanceof Error ? error.message : String(error)
|
|
458
|
+
})
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (message.type === "port_forward_stop") {
|
|
464
|
+
try {
|
|
465
|
+
await params.portForwards.stop(message.forwardId);
|
|
466
|
+
socket?.send(JSON.stringify({ type: "port_forward_result", requestId: message.requestId }));
|
|
467
|
+
} catch (error) {
|
|
468
|
+
socket?.send(
|
|
469
|
+
JSON.stringify({
|
|
470
|
+
type: "port_forward_result",
|
|
471
|
+
requestId: message.requestId,
|
|
472
|
+
error: error instanceof Error ? error.message : String(error)
|
|
473
|
+
})
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
443
478
|
if (message.type !== "operation") return;
|
|
444
479
|
try {
|
|
445
480
|
const beforeTabs = await params.runtime.listTabs();
|
|
@@ -498,19 +533,23 @@ async function start(options) {
|
|
|
498
533
|
});
|
|
499
534
|
if (context.pages().length === 0) await context.newPage();
|
|
500
535
|
const runtime = new BrowserRuntime(context, downloadsPath);
|
|
536
|
+
const instanceId = loadInstanceId();
|
|
537
|
+
const portForwards = new BrowserPortForwardManager(baseUrl, token, instanceId);
|
|
501
538
|
process.stdout.write(`[r5d-browser] profile: ${profilePath}
|
|
502
539
|
`);
|
|
503
540
|
process.stdout.write(`[r5d-browser] downloads: ${downloadsPath}
|
|
504
541
|
`);
|
|
505
542
|
process.stdout.write(`[r5d-browser] agents use synthetic Chromium input; your system pointer and keyboard stay untouched.
|
|
506
543
|
`);
|
|
507
|
-
|
|
508
|
-
process.on("
|
|
544
|
+
const shutdown = () => void Promise.all([portForwards.close(), context.close()]).finally(() => process.exit(0));
|
|
545
|
+
process.on("SIGINT", shutdown);
|
|
546
|
+
process.on("SIGTERM", shutdown);
|
|
509
547
|
return connectLoop({
|
|
510
548
|
runtime,
|
|
549
|
+
portForwards,
|
|
511
550
|
baseUrl,
|
|
512
551
|
token,
|
|
513
|
-
instanceId
|
|
552
|
+
instanceId,
|
|
514
553
|
profilePath,
|
|
515
554
|
chromiumVersion: context.browser()?.version() ?? "unknown"
|
|
516
555
|
});
|
package/dist/mjs/package.json
CHANGED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import net, {} from "node:net";
|
|
2
|
+
import WebSocket, {} from "ws";
|
|
3
|
+
const RELAY_FRAME_BYTES = 64 * 1024;
|
|
4
|
+
const RELAY_PAUSE_BYTES = 1024 * 1024;
|
|
5
|
+
const RELAY_RESUME_BYTES = 256 * 1024;
|
|
6
|
+
function relayUrl(baseUrl, instanceId, forwardId, relayConnectionId) {
|
|
7
|
+
const url = new URL("/browser/port-forward/ws", baseUrl);
|
|
8
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
9
|
+
url.searchParams.set("instance", instanceId);
|
|
10
|
+
url.searchParams.set("forward", forwardId);
|
|
11
|
+
url.searchParams.set("connection", relayConnectionId);
|
|
12
|
+
return url.toString();
|
|
13
|
+
}
|
|
14
|
+
async function listen(server, port, host, ipv6Only = false) {
|
|
15
|
+
await new Promise((resolve, reject) => {
|
|
16
|
+
const onError = (error) => {
|
|
17
|
+
server.off("listening", onListening);
|
|
18
|
+
reject(error);
|
|
19
|
+
};
|
|
20
|
+
const onListening = () => {
|
|
21
|
+
server.off("error", onError);
|
|
22
|
+
resolve();
|
|
23
|
+
};
|
|
24
|
+
server.once("error", onError);
|
|
25
|
+
server.once("listening", onListening);
|
|
26
|
+
server.listen({ port, host, ipv6Only, exclusive: true });
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
async function closeServer(server) {
|
|
30
|
+
if (!server.listening) return;
|
|
31
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
32
|
+
}
|
|
33
|
+
function rawDataBuffer(data) {
|
|
34
|
+
if (Buffer.isBuffer(data)) return data;
|
|
35
|
+
if (Array.isArray(data)) return Buffer.concat(data);
|
|
36
|
+
return Buffer.from(data);
|
|
37
|
+
}
|
|
38
|
+
class BrowserPortForwardManager {
|
|
39
|
+
constructor(baseUrl, token, instanceId) {
|
|
40
|
+
this.baseUrl = baseUrl;
|
|
41
|
+
this.token = token;
|
|
42
|
+
this.instanceId = instanceId;
|
|
43
|
+
}
|
|
44
|
+
baseUrl;
|
|
45
|
+
token;
|
|
46
|
+
instanceId;
|
|
47
|
+
forwards = /* @__PURE__ */ new Map();
|
|
48
|
+
forwardByBrowserPort = /* @__PURE__ */ new Map();
|
|
49
|
+
operationQueue = Promise.resolve();
|
|
50
|
+
list() {
|
|
51
|
+
return [...this.forwards.values()].map(({ definition }) => ({ ...definition }));
|
|
52
|
+
}
|
|
53
|
+
start(definition) {
|
|
54
|
+
return this.enqueue(() => this.startNow(definition));
|
|
55
|
+
}
|
|
56
|
+
stop(forwardId) {
|
|
57
|
+
return this.enqueue(() => this.stopNow(forwardId));
|
|
58
|
+
}
|
|
59
|
+
close() {
|
|
60
|
+
return this.enqueue(async () => {
|
|
61
|
+
for (const forwardId of [...this.forwards.keys()]) await this.stopNow(forwardId);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
enqueue(operation) {
|
|
65
|
+
const result = this.operationQueue.then(operation, operation);
|
|
66
|
+
this.operationQueue = result.then(
|
|
67
|
+
() => void 0,
|
|
68
|
+
() => void 0
|
|
69
|
+
);
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
async startNow(definition) {
|
|
73
|
+
const existing = this.forwards.get(definition.forwardId);
|
|
74
|
+
if (existing) {
|
|
75
|
+
if (JSON.stringify(existing.definition) === JSON.stringify(definition)) return;
|
|
76
|
+
throw new Error(`Port forward ${definition.forwardId} already exists with different settings.`);
|
|
77
|
+
}
|
|
78
|
+
const occupyingId = this.forwardByBrowserPort.get(definition.browserPort);
|
|
79
|
+
if (occupyingId) throw new Error(`Browser port ${definition.browserPort} is already used by forward ${occupyingId}.`);
|
|
80
|
+
const active = { definition: { ...definition }, servers: [], relays: /* @__PURE__ */ new Set() };
|
|
81
|
+
const accept = (socket) => this.acceptConnection(active, socket);
|
|
82
|
+
const ipv4 = net.createServer({ allowHalfOpen: true }, accept);
|
|
83
|
+
const ipv6 = net.createServer({ allowHalfOpen: true }, accept);
|
|
84
|
+
const servers = [ipv4, ipv6];
|
|
85
|
+
active.servers = servers;
|
|
86
|
+
try {
|
|
87
|
+
await listen(ipv4, definition.browserPort, "127.0.0.1");
|
|
88
|
+
await listen(ipv6, definition.browserPort, "::1", true);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
await Promise.all(servers.map(closeServer));
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Could not bind localhost:${definition.browserPort}: ${error instanceof Error ? error.message : String(error)}`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
this.forwards.set(definition.forwardId, active);
|
|
96
|
+
this.forwardByBrowserPort.set(definition.browserPort, definition.forwardId);
|
|
97
|
+
}
|
|
98
|
+
async stopNow(forwardId) {
|
|
99
|
+
const active = this.forwards.get(forwardId);
|
|
100
|
+
if (!active) return;
|
|
101
|
+
this.forwards.delete(forwardId);
|
|
102
|
+
this.forwardByBrowserPort.delete(active.definition.browserPort);
|
|
103
|
+
for (const connection of active.relays) {
|
|
104
|
+
connection.socket.destroy();
|
|
105
|
+
connection.relay.close(1e3, "Port forward stopped");
|
|
106
|
+
}
|
|
107
|
+
active.relays.clear();
|
|
108
|
+
await Promise.all(active.servers.map(closeServer));
|
|
109
|
+
}
|
|
110
|
+
acceptConnection(active, socket) {
|
|
111
|
+
socket.pause();
|
|
112
|
+
socket.setNoDelay(true);
|
|
113
|
+
const relayConnectionId = crypto.randomUUID();
|
|
114
|
+
const relay = new WebSocket(relayUrl(this.baseUrl, this.instanceId, active.definition.forwardId, relayConnectionId), {
|
|
115
|
+
headers: { Authorization: `Bearer ${this.token}` },
|
|
116
|
+
perMessageDeflate: false
|
|
117
|
+
});
|
|
118
|
+
const connection = { socket, relay };
|
|
119
|
+
active.relays.add(connection);
|
|
120
|
+
let ready = false;
|
|
121
|
+
const pendingChunks = [];
|
|
122
|
+
let pendingBytes = 0;
|
|
123
|
+
let resumeTimer;
|
|
124
|
+
let closing = false;
|
|
125
|
+
const cleanup = () => {
|
|
126
|
+
if (closing) return;
|
|
127
|
+
closing = true;
|
|
128
|
+
if (resumeTimer) clearInterval(resumeTimer);
|
|
129
|
+
active.relays.delete(connection);
|
|
130
|
+
socket.destroy();
|
|
131
|
+
if (relay.readyState === WebSocket.OPEN || relay.readyState === WebSocket.CONNECTING) relay.close();
|
|
132
|
+
};
|
|
133
|
+
const waitForRelayDrain = () => {
|
|
134
|
+
if (resumeTimer || relay.bufferedAmount <= RELAY_PAUSE_BYTES) return;
|
|
135
|
+
socket.pause();
|
|
136
|
+
resumeTimer = setInterval(() => {
|
|
137
|
+
if (relay.readyState !== WebSocket.OPEN) return cleanup();
|
|
138
|
+
if (relay.bufferedAmount > RELAY_RESUME_BYTES) return;
|
|
139
|
+
clearInterval(resumeTimer);
|
|
140
|
+
resumeTimer = void 0;
|
|
141
|
+
if (ready) socket.resume();
|
|
142
|
+
}, 10);
|
|
143
|
+
};
|
|
144
|
+
const sendChunk = (chunk) => {
|
|
145
|
+
for (let offset = 0; offset < chunk.byteLength; offset += RELAY_FRAME_BYTES) {
|
|
146
|
+
relay.send(chunk.subarray(offset, Math.min(chunk.byteLength, offset + RELAY_FRAME_BYTES)), { binary: true });
|
|
147
|
+
}
|
|
148
|
+
waitForRelayDrain();
|
|
149
|
+
};
|
|
150
|
+
relay.on("message", (data, isBinary) => {
|
|
151
|
+
if (!isBinary) {
|
|
152
|
+
let control;
|
|
153
|
+
try {
|
|
154
|
+
control = JSON.parse(rawDataBuffer(data).toString("utf8"));
|
|
155
|
+
} catch {
|
|
156
|
+
return cleanup();
|
|
157
|
+
}
|
|
158
|
+
if (control.type === "ready") {
|
|
159
|
+
ready = true;
|
|
160
|
+
for (const chunk of pendingChunks.splice(0)) sendChunk(chunk);
|
|
161
|
+
pendingBytes = 0;
|
|
162
|
+
socket.resume();
|
|
163
|
+
} else if (control.type === "end") {
|
|
164
|
+
socket.end();
|
|
165
|
+
} else if (control.type === "error") {
|
|
166
|
+
socket.destroy(new Error(control.error || "Port-forward relay failed."));
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (!socket.write(rawDataBuffer(data))) {
|
|
171
|
+
relay.pause();
|
|
172
|
+
socket.once("drain", () => relay.resume());
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
relay.once("error", cleanup);
|
|
176
|
+
relay.once("close", cleanup);
|
|
177
|
+
socket.on("data", (chunk) => {
|
|
178
|
+
if (!ready || relay.readyState !== WebSocket.OPEN) {
|
|
179
|
+
pendingBytes += chunk.byteLength;
|
|
180
|
+
if (pendingBytes > RELAY_PAUSE_BYTES) return cleanup();
|
|
181
|
+
pendingChunks.push(Buffer.from(chunk));
|
|
182
|
+
socket.pause();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
sendChunk(chunk);
|
|
186
|
+
});
|
|
187
|
+
socket.on("end", () => {
|
|
188
|
+
if (relay.readyState === WebSocket.OPEN) relay.send(JSON.stringify({ type: "end" }));
|
|
189
|
+
});
|
|
190
|
+
socket.on("error", (error) => {
|
|
191
|
+
if (relay.readyState === WebSocket.OPEN) relay.send(JSON.stringify({ type: "error", error: error.message }));
|
|
192
|
+
});
|
|
193
|
+
socket.on("close", cleanup);
|
|
194
|
+
socket.pause();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
export {
|
|
198
|
+
BrowserPortForwardManager
|
|
199
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type BrowserPortForwardDefinition = {
|
|
2
|
+
forwardId: string;
|
|
3
|
+
workerLabel: string;
|
|
4
|
+
workerPort: number;
|
|
5
|
+
browserPort: number;
|
|
6
|
+
};
|
|
7
|
+
export declare class BrowserPortForwardManager {
|
|
8
|
+
private readonly baseUrl;
|
|
9
|
+
private readonly token;
|
|
10
|
+
private readonly instanceId;
|
|
11
|
+
private readonly forwards;
|
|
12
|
+
private readonly forwardByBrowserPort;
|
|
13
|
+
private operationQueue;
|
|
14
|
+
constructor(baseUrl: string, token: string, instanceId: string);
|
|
15
|
+
list(): BrowserPortForwardDefinition[];
|
|
16
|
+
start(definition: BrowserPortForwardDefinition): Promise<void>;
|
|
17
|
+
stop(forwardId: string): Promise<void>;
|
|
18
|
+
close(): Promise<void>;
|
|
19
|
+
private enqueue;
|
|
20
|
+
private startNow;
|
|
21
|
+
private stopNow;
|
|
22
|
+
private acceptConnection;
|
|
23
|
+
}
|