@ricsam/r5d-worker 0.0.68 → 0.0.70

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/dist/cjs/main.cjs CHANGED
@@ -62,6 +62,7 @@ var import_cli_update = require("./cli-update.cjs");
62
62
  var import_git_identity = require("./git-identity.cjs");
63
63
  var import_heartbeat = require("./heartbeat.cjs");
64
64
  var import_process_tree = require("./process-tree.cjs");
65
+ var import_pty_output_coalescer = require("./pty-output-coalescer.cjs");
65
66
  var import_port_forward_client = require("./port-forward-client.cjs");
66
67
  var import_workspace_incident_state = require("./workspace-incident-state.cjs");
67
68
  var import_workspace_command_sync_policy = require("./workspace-command-sync-policy.cjs");
@@ -2308,6 +2309,15 @@ async function openPty(input) {
2308
2309
  }) : {};
2309
2310
  const targetProcessEnv = targetIdentityEnv(input.resolvedTarget.target);
2310
2311
  const shell = resolveHostShell(input.message.command);
2312
+ const outputCoalescer = (0, import_pty_output_coalescer.createPtyOutputCoalescer)({
2313
+ send: (data) => {
2314
+ sendWorkerMessage(input.ws, {
2315
+ type: "pty_output",
2316
+ ptyId: input.message.ptyId,
2317
+ data
2318
+ });
2319
+ }
2320
+ });
2311
2321
  const ptyProcess = createNodePtyBridge({
2312
2322
  file: shell.file,
2313
2323
  args: shell.args,
@@ -2332,13 +2342,10 @@ async function openPty(input) {
2332
2342
  });
2333
2343
  },
2334
2344
  onOutput: (data) => {
2335
- sendWorkerMessage(input.ws, {
2336
- type: "pty_output",
2337
- ptyId: input.message.ptyId,
2338
- data
2339
- });
2345
+ outputCoalescer.push(data);
2340
2346
  },
2341
2347
  onExit: (event) => {
2348
+ outputCoalescer.flush();
2342
2349
  input.releaseWorkspaceMutation?.();
2343
2350
  activePtys.delete(input.message.ptyId);
2344
2351
  input.onTerminal?.();
@@ -2350,6 +2357,7 @@ async function openPty(input) {
2350
2357
  });
2351
2358
  },
2352
2359
  onError: (error) => {
2360
+ outputCoalescer.flush();
2353
2361
  input.releaseWorkspaceMutation?.();
2354
2362
  activePtys.delete(input.message.ptyId);
2355
2363
  input.onTerminal?.();
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.68",
3
+ "version": "0.0.70",
4
4
  "type": "commonjs"
5
5
  }
@@ -36,6 +36,7 @@ var import_ws = __toESM(require("ws"), 1);
36
36
  const RELAY_FRAME_BYTES = 64 * 1024;
37
37
  const RELAY_PAUSE_BYTES = 1024 * 1024;
38
38
  const RELAY_RESUME_BYTES = 256 * 1024;
39
+ const LOOPBACK_HOSTS = ["127.0.0.1", "::1"];
39
40
  function relayUrl(baseUrl, label, forwardId, relayConnectionId) {
40
41
  const url = new URL("/worker/port-forward/ws", baseUrl);
41
42
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
@@ -44,42 +45,59 @@ function relayUrl(baseUrl, label, forwardId, relayConnectionId) {
44
45
  url.searchParams.set("connection", relayConnectionId);
45
46
  return url.toString();
46
47
  }
48
+ function loopbackAddress(host, port) {
49
+ return host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`;
50
+ }
47
51
  function rawDataBuffer(data) {
48
52
  if (Buffer.isBuffer(data)) return data;
49
53
  if (Array.isArray(data)) return Buffer.concat(data);
50
54
  return Buffer.from(data);
51
55
  }
52
56
  function openWorkerPortForwardRelay(options) {
53
- const socket = import_node_net.default.createConnection({ host: "127.0.0.1", port: options.workerPort, allowHalfOpen: true });
54
- socket.pause();
55
- socket.setNoDelay(true);
56
57
  const relay = new import_ws.default(relayUrl(options.baseUrl, options.label, options.forwardId, options.relayConnectionId), {
57
58
  headers: { Authorization: `Bearer ${options.token}` },
58
59
  perMessageDeflate: false
59
60
  });
61
+ let socket;
60
62
  let relayReady = false;
61
63
  let targetReady = false;
64
+ let targetEndRequested = false;
65
+ let targetFailure;
62
66
  const pendingChunks = [];
63
67
  let pendingBytes = 0;
68
+ const pendingWrites = [];
69
+ let pendingWriteBytes = 0;
64
70
  let closing = false;
65
71
  let resumeTimer;
66
72
  const maybeResume = () => {
67
- if (relayReady && targetReady && !resumeTimer) socket.resume();
73
+ if (relayReady && targetReady && !resumeTimer) socket?.resume();
68
74
  };
69
75
  const cleanup = () => {
70
76
  if (closing) return;
71
77
  closing = true;
72
78
  if (resumeTimer) clearInterval(resumeTimer);
73
- socket.destroy();
79
+ socket?.destroy();
74
80
  if (relay.readyState === import_ws.default.OPEN || relay.readyState === import_ws.default.CONNECTING) relay.close();
75
81
  };
76
82
  const sendError = (message) => {
77
- if (relay.readyState === import_ws.default.OPEN) relay.send(JSON.stringify({ type: "error", error: message }));
83
+ if (closing) return;
84
+ const payload = JSON.stringify({ type: "error", error: message });
85
+ if (relay.readyState === import_ws.default.CONNECTING) {
86
+ closing = true;
87
+ if (resumeTimer) clearInterval(resumeTimer);
88
+ socket?.destroy();
89
+ relay.once("open", () => {
90
+ relay.send(payload);
91
+ relay.close();
92
+ });
93
+ return;
94
+ }
95
+ if (relay.readyState === import_ws.default.OPEN) relay.send(payload);
78
96
  cleanup();
79
97
  };
80
98
  const waitForRelayDrain = () => {
81
99
  if (resumeTimer || relay.bufferedAmount <= RELAY_PAUSE_BYTES) return;
82
- socket.pause();
100
+ socket?.pause();
83
101
  resumeTimer = setInterval(() => {
84
102
  if (relay.readyState !== import_ws.default.OPEN) return cleanup();
85
103
  if (relay.bufferedAmount > RELAY_RESUME_BYTES) return;
@@ -94,25 +112,53 @@ function openWorkerPortForwardRelay(options) {
94
112
  }
95
113
  waitForRelayDrain();
96
114
  };
97
- socket.once("connect", () => {
115
+ const adoptTarget = (target, host) => {
116
+ if (closing) return target.destroy();
117
+ socket = target;
118
+ target.on("data", (chunk) => {
119
+ if (!relayReady || relay.readyState !== import_ws.default.OPEN) {
120
+ pendingBytes += chunk.byteLength;
121
+ if (pendingBytes > RELAY_PAUSE_BYTES) return cleanup();
122
+ pendingChunks.push(Buffer.from(chunk));
123
+ target.pause();
124
+ return;
125
+ }
126
+ sendChunk(chunk);
127
+ });
128
+ target.on("end", () => {
129
+ if (relay.readyState === import_ws.default.OPEN) relay.send(JSON.stringify({ type: "end" }));
130
+ });
131
+ target.on("error", (error) => sendError(`Connection to ${loopbackAddress(host, options.workerPort)} failed: ${error.message}`));
132
+ target.on("close", cleanup);
133
+ target.pause();
98
134
  targetReady = true;
135
+ for (const chunk of pendingWrites.splice(0)) target.write(chunk);
136
+ pendingWriteBytes = 0;
137
+ if (targetFailure) return void target.destroy(targetFailure);
138
+ if (targetEndRequested) target.end();
99
139
  maybeResume();
100
- });
101
- socket.on("data", (chunk) => {
102
- if (!relayReady || relay.readyState !== import_ws.default.OPEN) {
103
- pendingBytes += chunk.byteLength;
104
- if (pendingBytes > RELAY_PAUSE_BYTES) return cleanup();
105
- pendingChunks.push(Buffer.from(chunk));
106
- socket.pause();
107
- return;
108
- }
109
- sendChunk(chunk);
110
- });
111
- socket.on("end", () => {
112
- if (relay.readyState === import_ws.default.OPEN) relay.send(JSON.stringify({ type: "end" }));
113
- });
114
- socket.on("error", (error) => sendError(`Could not connect to 127.0.0.1:${options.workerPort}: ${error.message}`));
115
- socket.on("close", cleanup);
140
+ };
141
+ const dialFailures = [];
142
+ const dialTarget = (host, fallbackHosts2) => {
143
+ if (closing) return;
144
+ const candidate = import_node_net.default.createConnection({ host, port: options.workerPort, allowHalfOpen: true });
145
+ candidate.pause();
146
+ candidate.setNoDelay(true);
147
+ const onDialError = (error) => {
148
+ candidate.destroy();
149
+ dialFailures.push(`${loopbackAddress(host, options.workerPort)} (${error.code ?? error.message})`);
150
+ const [nextHost, ...rest] = fallbackHosts2;
151
+ if (nextHost) return dialTarget(nextHost, rest);
152
+ sendError(
153
+ `Could not connect to ${dialFailures.join(" or ")} on the worker. Nothing is listening on either loopback address for port ${options.workerPort} \u2014 make sure the server is running and bound to a loopback or wildcard host.`
154
+ );
155
+ };
156
+ candidate.once("error", onDialError);
157
+ candidate.once("connect", () => {
158
+ candidate.off("error", onDialError);
159
+ adoptTarget(candidate, host);
160
+ });
161
+ };
116
162
  relay.on("message", (data, isBinary) => {
117
163
  if (!isBinary) {
118
164
  let control;
@@ -123,24 +169,36 @@ function openWorkerPortForwardRelay(options) {
123
169
  }
124
170
  if (control.type === "ready") {
125
171
  relayReady = true;
126
- for (const chunk of pendingChunks.splice(0)) sendChunk(chunk);
172
+ for (const chunk2 of pendingChunks.splice(0)) sendChunk(chunk2);
127
173
  pendingBytes = 0;
128
174
  maybeResume();
129
175
  } else if (control.type === "end") {
130
- socket.end();
176
+ if (socket) socket.end();
177
+ else targetEndRequested = true;
131
178
  } else if (control.type === "error") {
132
- socket.destroy(new Error(control.error || "Port-forward relay failed."));
179
+ const failure = new Error(control.error || "Port-forward relay failed.");
180
+ if (socket) socket.destroy(failure);
181
+ else targetFailure = failure;
133
182
  }
134
183
  return;
135
184
  }
136
- if (!socket.write(rawDataBuffer(data))) {
185
+ const chunk = rawDataBuffer(data);
186
+ if (!socket) {
187
+ pendingWriteBytes += chunk.byteLength;
188
+ if (pendingWriteBytes > RELAY_PAUSE_BYTES) return cleanup();
189
+ pendingWrites.push(Buffer.from(chunk));
190
+ return;
191
+ }
192
+ const target = socket;
193
+ if (!target.write(chunk)) {
137
194
  relay.pause();
138
- socket.once("drain", () => relay.resume());
195
+ target.once("drain", () => relay.resume());
139
196
  }
140
197
  });
141
198
  relay.on("error", cleanup);
142
199
  relay.once("close", cleanup);
143
- socket.pause();
200
+ const [primaryHost, ...fallbackHosts] = LOOPBACK_HOSTS;
201
+ dialTarget(primaryHost, fallbackHosts);
144
202
  }
145
203
  // Annotate the CommonJS export names for ESM import in node:
146
204
  0 && (module.exports = {
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var pty_output_coalescer_exports = {};
20
+ __export(pty_output_coalescer_exports, {
21
+ DEFAULT_PTY_OUTPUT_FLUSH_INTERVAL_MS: () => DEFAULT_PTY_OUTPUT_FLUSH_INTERVAL_MS,
22
+ DEFAULT_PTY_OUTPUT_MAX_BUFFER_BYTES: () => DEFAULT_PTY_OUTPUT_MAX_BUFFER_BYTES,
23
+ createPtyOutputCoalescer: () => createPtyOutputCoalescer
24
+ });
25
+ module.exports = __toCommonJS(pty_output_coalescer_exports);
26
+ const DEFAULT_PTY_OUTPUT_FLUSH_INTERVAL_MS = 8;
27
+ const DEFAULT_PTY_OUTPUT_MAX_BUFFER_BYTES = 64 * 1024;
28
+ function defaultSchedule(callback, delayMs) {
29
+ const timer = setTimeout(callback, delayMs);
30
+ timer.unref();
31
+ return () => clearTimeout(timer);
32
+ }
33
+ function createPtyOutputCoalescer(options) {
34
+ const flushIntervalMs = options.flushIntervalMs ?? DEFAULT_PTY_OUTPUT_FLUSH_INTERVAL_MS;
35
+ const maxBufferBytes = options.maxBufferBytes ?? DEFAULT_PTY_OUTPUT_MAX_BUFFER_BYTES;
36
+ const now = options.now ?? Date.now;
37
+ const schedule = options.schedule ?? defaultSchedule;
38
+ let buffer = "";
39
+ let bufferedBytes = 0;
40
+ let cancelScheduledFlush = null;
41
+ let lastSendAt = Number.NEGATIVE_INFINITY;
42
+ const sendNow = (data) => {
43
+ lastSendAt = now();
44
+ options.send(data);
45
+ };
46
+ const flush = () => {
47
+ if (cancelScheduledFlush) {
48
+ cancelScheduledFlush();
49
+ cancelScheduledFlush = null;
50
+ }
51
+ if (bufferedBytes === 0) {
52
+ return;
53
+ }
54
+ const data = buffer;
55
+ buffer = "";
56
+ bufferedBytes = 0;
57
+ sendNow(data);
58
+ };
59
+ const push = (data) => {
60
+ if (data.length === 0) {
61
+ return;
62
+ }
63
+ if (bufferedBytes === 0 && now() - lastSendAt >= flushIntervalMs) {
64
+ sendNow(data);
65
+ return;
66
+ }
67
+ buffer += data;
68
+ bufferedBytes += Buffer.byteLength(data, "utf8");
69
+ if (bufferedBytes >= maxBufferBytes) {
70
+ flush();
71
+ return;
72
+ }
73
+ if (!cancelScheduledFlush) {
74
+ cancelScheduledFlush = schedule(() => {
75
+ cancelScheduledFlush = null;
76
+ flush();
77
+ }, flushIntervalMs);
78
+ }
79
+ };
80
+ return { push, flush };
81
+ }
82
+ // Annotate the CommonJS export names for ESM import in node:
83
+ 0 && (module.exports = {
84
+ DEFAULT_PTY_OUTPUT_FLUSH_INTERVAL_MS,
85
+ DEFAULT_PTY_OUTPUT_MAX_BUFFER_BYTES,
86
+ createPtyOutputCoalescer
87
+ });
package/dist/mjs/main.mjs CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  WORKER_HEARTBEAT_INTERVAL_MS
13
13
  } from "./heartbeat.mjs";
14
14
  import { terminateProcessTree } from "./process-tree.mjs";
15
+ import { createPtyOutputCoalescer } from "./pty-output-coalescer.mjs";
15
16
  import { openWorkerPortForwardRelay } from "./port-forward-client.mjs";
16
17
  import { applyWorkspaceIncidentUpdate, releasePendingWorkspaceHead, WorkspaceIncidentOrderingFence } from "./workspace-incident-state.mjs";
17
18
  import { acquireWorkspaceCommandMutation, runWorkspaceCommand } from "./workspace-command-sync-policy.mjs";
@@ -2279,6 +2280,15 @@ async function openPty(input) {
2279
2280
  }) : {};
2280
2281
  const targetProcessEnv = targetIdentityEnv(input.resolvedTarget.target);
2281
2282
  const shell = resolveHostShell(input.message.command);
2283
+ const outputCoalescer = createPtyOutputCoalescer({
2284
+ send: (data) => {
2285
+ sendWorkerMessage(input.ws, {
2286
+ type: "pty_output",
2287
+ ptyId: input.message.ptyId,
2288
+ data
2289
+ });
2290
+ }
2291
+ });
2282
2292
  const ptyProcess = createNodePtyBridge({
2283
2293
  file: shell.file,
2284
2294
  args: shell.args,
@@ -2303,13 +2313,10 @@ async function openPty(input) {
2303
2313
  });
2304
2314
  },
2305
2315
  onOutput: (data) => {
2306
- sendWorkerMessage(input.ws, {
2307
- type: "pty_output",
2308
- ptyId: input.message.ptyId,
2309
- data
2310
- });
2316
+ outputCoalescer.push(data);
2311
2317
  },
2312
2318
  onExit: (event) => {
2319
+ outputCoalescer.flush();
2313
2320
  input.releaseWorkspaceMutation?.();
2314
2321
  activePtys.delete(input.message.ptyId);
2315
2322
  input.onTerminal?.();
@@ -2321,6 +2328,7 @@ async function openPty(input) {
2321
2328
  });
2322
2329
  },
2323
2330
  onError: (error) => {
2331
+ outputCoalescer.flush();
2324
2332
  input.releaseWorkspaceMutation?.();
2325
2333
  activePtys.delete(input.message.ptyId);
2326
2334
  input.onTerminal?.();
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.68",
3
+ "version": "0.0.70",
4
4
  "type": "module"
5
5
  }
@@ -3,6 +3,7 @@ import WebSocket, {} from "ws";
3
3
  const RELAY_FRAME_BYTES = 64 * 1024;
4
4
  const RELAY_PAUSE_BYTES = 1024 * 1024;
5
5
  const RELAY_RESUME_BYTES = 256 * 1024;
6
+ const LOOPBACK_HOSTS = ["127.0.0.1", "::1"];
6
7
  function relayUrl(baseUrl, label, forwardId, relayConnectionId) {
7
8
  const url = new URL("/worker/port-forward/ws", baseUrl);
8
9
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
@@ -11,42 +12,59 @@ function relayUrl(baseUrl, label, forwardId, relayConnectionId) {
11
12
  url.searchParams.set("connection", relayConnectionId);
12
13
  return url.toString();
13
14
  }
15
+ function loopbackAddress(host, port) {
16
+ return host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`;
17
+ }
14
18
  function rawDataBuffer(data) {
15
19
  if (Buffer.isBuffer(data)) return data;
16
20
  if (Array.isArray(data)) return Buffer.concat(data);
17
21
  return Buffer.from(data);
18
22
  }
19
23
  function openWorkerPortForwardRelay(options) {
20
- const socket = net.createConnection({ host: "127.0.0.1", port: options.workerPort, allowHalfOpen: true });
21
- socket.pause();
22
- socket.setNoDelay(true);
23
24
  const relay = new WebSocket(relayUrl(options.baseUrl, options.label, options.forwardId, options.relayConnectionId), {
24
25
  headers: { Authorization: `Bearer ${options.token}` },
25
26
  perMessageDeflate: false
26
27
  });
28
+ let socket;
27
29
  let relayReady = false;
28
30
  let targetReady = false;
31
+ let targetEndRequested = false;
32
+ let targetFailure;
29
33
  const pendingChunks = [];
30
34
  let pendingBytes = 0;
35
+ const pendingWrites = [];
36
+ let pendingWriteBytes = 0;
31
37
  let closing = false;
32
38
  let resumeTimer;
33
39
  const maybeResume = () => {
34
- if (relayReady && targetReady && !resumeTimer) socket.resume();
40
+ if (relayReady && targetReady && !resumeTimer) socket?.resume();
35
41
  };
36
42
  const cleanup = () => {
37
43
  if (closing) return;
38
44
  closing = true;
39
45
  if (resumeTimer) clearInterval(resumeTimer);
40
- socket.destroy();
46
+ socket?.destroy();
41
47
  if (relay.readyState === WebSocket.OPEN || relay.readyState === WebSocket.CONNECTING) relay.close();
42
48
  };
43
49
  const sendError = (message) => {
44
- if (relay.readyState === WebSocket.OPEN) relay.send(JSON.stringify({ type: "error", error: message }));
50
+ if (closing) return;
51
+ const payload = JSON.stringify({ type: "error", error: message });
52
+ if (relay.readyState === WebSocket.CONNECTING) {
53
+ closing = true;
54
+ if (resumeTimer) clearInterval(resumeTimer);
55
+ socket?.destroy();
56
+ relay.once("open", () => {
57
+ relay.send(payload);
58
+ relay.close();
59
+ });
60
+ return;
61
+ }
62
+ if (relay.readyState === WebSocket.OPEN) relay.send(payload);
45
63
  cleanup();
46
64
  };
47
65
  const waitForRelayDrain = () => {
48
66
  if (resumeTimer || relay.bufferedAmount <= RELAY_PAUSE_BYTES) return;
49
- socket.pause();
67
+ socket?.pause();
50
68
  resumeTimer = setInterval(() => {
51
69
  if (relay.readyState !== WebSocket.OPEN) return cleanup();
52
70
  if (relay.bufferedAmount > RELAY_RESUME_BYTES) return;
@@ -61,25 +79,53 @@ function openWorkerPortForwardRelay(options) {
61
79
  }
62
80
  waitForRelayDrain();
63
81
  };
64
- socket.once("connect", () => {
82
+ const adoptTarget = (target, host) => {
83
+ if (closing) return target.destroy();
84
+ socket = target;
85
+ target.on("data", (chunk) => {
86
+ if (!relayReady || relay.readyState !== WebSocket.OPEN) {
87
+ pendingBytes += chunk.byteLength;
88
+ if (pendingBytes > RELAY_PAUSE_BYTES) return cleanup();
89
+ pendingChunks.push(Buffer.from(chunk));
90
+ target.pause();
91
+ return;
92
+ }
93
+ sendChunk(chunk);
94
+ });
95
+ target.on("end", () => {
96
+ if (relay.readyState === WebSocket.OPEN) relay.send(JSON.stringify({ type: "end" }));
97
+ });
98
+ target.on("error", (error) => sendError(`Connection to ${loopbackAddress(host, options.workerPort)} failed: ${error.message}`));
99
+ target.on("close", cleanup);
100
+ target.pause();
65
101
  targetReady = true;
102
+ for (const chunk of pendingWrites.splice(0)) target.write(chunk);
103
+ pendingWriteBytes = 0;
104
+ if (targetFailure) return void target.destroy(targetFailure);
105
+ if (targetEndRequested) target.end();
66
106
  maybeResume();
67
- });
68
- socket.on("data", (chunk) => {
69
- if (!relayReady || relay.readyState !== WebSocket.OPEN) {
70
- pendingBytes += chunk.byteLength;
71
- if (pendingBytes > RELAY_PAUSE_BYTES) return cleanup();
72
- pendingChunks.push(Buffer.from(chunk));
73
- socket.pause();
74
- return;
75
- }
76
- sendChunk(chunk);
77
- });
78
- socket.on("end", () => {
79
- if (relay.readyState === WebSocket.OPEN) relay.send(JSON.stringify({ type: "end" }));
80
- });
81
- socket.on("error", (error) => sendError(`Could not connect to 127.0.0.1:${options.workerPort}: ${error.message}`));
82
- socket.on("close", cleanup);
107
+ };
108
+ const dialFailures = [];
109
+ const dialTarget = (host, fallbackHosts2) => {
110
+ if (closing) return;
111
+ const candidate = net.createConnection({ host, port: options.workerPort, allowHalfOpen: true });
112
+ candidate.pause();
113
+ candidate.setNoDelay(true);
114
+ const onDialError = (error) => {
115
+ candidate.destroy();
116
+ dialFailures.push(`${loopbackAddress(host, options.workerPort)} (${error.code ?? error.message})`);
117
+ const [nextHost, ...rest] = fallbackHosts2;
118
+ if (nextHost) return dialTarget(nextHost, rest);
119
+ sendError(
120
+ `Could not connect to ${dialFailures.join(" or ")} on the worker. Nothing is listening on either loopback address for port ${options.workerPort} \u2014 make sure the server is running and bound to a loopback or wildcard host.`
121
+ );
122
+ };
123
+ candidate.once("error", onDialError);
124
+ candidate.once("connect", () => {
125
+ candidate.off("error", onDialError);
126
+ adoptTarget(candidate, host);
127
+ });
128
+ };
83
129
  relay.on("message", (data, isBinary) => {
84
130
  if (!isBinary) {
85
131
  let control;
@@ -90,24 +136,36 @@ function openWorkerPortForwardRelay(options) {
90
136
  }
91
137
  if (control.type === "ready") {
92
138
  relayReady = true;
93
- for (const chunk of pendingChunks.splice(0)) sendChunk(chunk);
139
+ for (const chunk2 of pendingChunks.splice(0)) sendChunk(chunk2);
94
140
  pendingBytes = 0;
95
141
  maybeResume();
96
142
  } else if (control.type === "end") {
97
- socket.end();
143
+ if (socket) socket.end();
144
+ else targetEndRequested = true;
98
145
  } else if (control.type === "error") {
99
- socket.destroy(new Error(control.error || "Port-forward relay failed."));
146
+ const failure = new Error(control.error || "Port-forward relay failed.");
147
+ if (socket) socket.destroy(failure);
148
+ else targetFailure = failure;
100
149
  }
101
150
  return;
102
151
  }
103
- if (!socket.write(rawDataBuffer(data))) {
152
+ const chunk = rawDataBuffer(data);
153
+ if (!socket) {
154
+ pendingWriteBytes += chunk.byteLength;
155
+ if (pendingWriteBytes > RELAY_PAUSE_BYTES) return cleanup();
156
+ pendingWrites.push(Buffer.from(chunk));
157
+ return;
158
+ }
159
+ const target = socket;
160
+ if (!target.write(chunk)) {
104
161
  relay.pause();
105
- socket.once("drain", () => relay.resume());
162
+ target.once("drain", () => relay.resume());
106
163
  }
107
164
  });
108
165
  relay.on("error", cleanup);
109
166
  relay.once("close", cleanup);
110
- socket.pause();
167
+ const [primaryHost, ...fallbackHosts] = LOOPBACK_HOSTS;
168
+ dialTarget(primaryHost, fallbackHosts);
111
169
  }
112
170
  export {
113
171
  openWorkerPortForwardRelay
@@ -0,0 +1,61 @@
1
+ const DEFAULT_PTY_OUTPUT_FLUSH_INTERVAL_MS = 8;
2
+ const DEFAULT_PTY_OUTPUT_MAX_BUFFER_BYTES = 64 * 1024;
3
+ function defaultSchedule(callback, delayMs) {
4
+ const timer = setTimeout(callback, delayMs);
5
+ timer.unref();
6
+ return () => clearTimeout(timer);
7
+ }
8
+ function createPtyOutputCoalescer(options) {
9
+ const flushIntervalMs = options.flushIntervalMs ?? DEFAULT_PTY_OUTPUT_FLUSH_INTERVAL_MS;
10
+ const maxBufferBytes = options.maxBufferBytes ?? DEFAULT_PTY_OUTPUT_MAX_BUFFER_BYTES;
11
+ const now = options.now ?? Date.now;
12
+ const schedule = options.schedule ?? defaultSchedule;
13
+ let buffer = "";
14
+ let bufferedBytes = 0;
15
+ let cancelScheduledFlush = null;
16
+ let lastSendAt = Number.NEGATIVE_INFINITY;
17
+ const sendNow = (data) => {
18
+ lastSendAt = now();
19
+ options.send(data);
20
+ };
21
+ const flush = () => {
22
+ if (cancelScheduledFlush) {
23
+ cancelScheduledFlush();
24
+ cancelScheduledFlush = null;
25
+ }
26
+ if (bufferedBytes === 0) {
27
+ return;
28
+ }
29
+ const data = buffer;
30
+ buffer = "";
31
+ bufferedBytes = 0;
32
+ sendNow(data);
33
+ };
34
+ const push = (data) => {
35
+ if (data.length === 0) {
36
+ return;
37
+ }
38
+ if (bufferedBytes === 0 && now() - lastSendAt >= flushIntervalMs) {
39
+ sendNow(data);
40
+ return;
41
+ }
42
+ buffer += data;
43
+ bufferedBytes += Buffer.byteLength(data, "utf8");
44
+ if (bufferedBytes >= maxBufferBytes) {
45
+ flush();
46
+ return;
47
+ }
48
+ if (!cancelScheduledFlush) {
49
+ cancelScheduledFlush = schedule(() => {
50
+ cancelScheduledFlush = null;
51
+ flush();
52
+ }, flushIntervalMs);
53
+ }
54
+ };
55
+ return { push, flush };
56
+ }
57
+ export {
58
+ DEFAULT_PTY_OUTPUT_FLUSH_INTERVAL_MS,
59
+ DEFAULT_PTY_OUTPUT_MAX_BUFFER_BYTES,
60
+ createPtyOutputCoalescer
61
+ };
@@ -0,0 +1,20 @@
1
+ export interface PtyOutputCoalescerOptions {
2
+ send: (data: string) => void;
3
+ flushIntervalMs?: number;
4
+ maxBufferBytes?: number;
5
+ now?: () => number;
6
+ schedule?: (callback: () => void, delayMs: number) => () => void;
7
+ }
8
+ export interface PtyOutputCoalescer {
9
+ push(data: string): void;
10
+ flush(): void;
11
+ }
12
+ export declare const DEFAULT_PTY_OUTPUT_FLUSH_INTERVAL_MS = 8;
13
+ export declare const DEFAULT_PTY_OUTPUT_MAX_BUFFER_BYTES: number;
14
+ /**
15
+ * Batches PTY output into fewer, larger relay messages. An isolated chunk
16
+ * (an interactive keystroke echo) is sent immediately so coalescing adds no
17
+ * input latency; only sustained bursts (build logs, `cat` of a large file)
18
+ * are buffered, bounded by the flush interval and the buffer byte cap.
19
+ */
20
+ export declare function createPtyOutputCoalescer(options: PtyOutputCoalescerOptions): PtyOutputCoalescer;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.68",
3
+ "version": "0.0.70",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",