@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 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
- process.on("SIGINT", () => void context.close().finally(() => process.exit(0)));
531
- process.on("SIGTERM", () => void context.close().finally(() => process.exit(0)));
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: loadInstanceId(),
575
+ instanceId,
537
576
  profilePath,
538
577
  chromiumVersion: context.browser()?.version() ?? "unknown"
539
578
  });
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-browser",
3
- "version": "0.0.47",
3
+ "version": "0.0.49",
4
4
  "type": "commonjs"
5
5
  }
@@ -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
- process.on("SIGINT", () => void context.close().finally(() => process.exit(0)));
508
- process.on("SIGTERM", () => void context.close().finally(() => process.exit(0)));
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: loadInstanceId(),
552
+ instanceId,
514
553
  profilePath,
515
554
  chromiumVersion: context.browser()?.version() ?? "unknown"
516
555
  });
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-browser",
3
- "version": "0.0.47",
3
+ "version": "0.0.49",
4
4
  "type": "module"
5
5
  }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-browser",
3
- "version": "0.0.47",
3
+ "version": "0.0.49",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",