@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 +13 -5
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/port-forward-client.cjs +88 -30
- package/dist/cjs/pty-output-coalescer.cjs +87 -0
- package/dist/mjs/main.mjs +13 -5
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/port-forward-client.mjs +88 -30
- package/dist/mjs/pty-output-coalescer.mjs +61 -0
- package/dist/types/pty-output-coalescer.d.ts +20 -0
- package/package.json +1 -1
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
|
-
|
|
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?.();
|
package/dist/cjs/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
+
target.once("drain", () => relay.resume());
|
|
139
196
|
}
|
|
140
197
|
});
|
|
141
198
|
relay.on("error", cleanup);
|
|
142
199
|
relay.once("close", cleanup);
|
|
143
|
-
|
|
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
|
-
|
|
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?.();
|
package/dist/mjs/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
+
target.once("drain", () => relay.resume());
|
|
106
163
|
}
|
|
107
164
|
});
|
|
108
165
|
relay.on("error", cleanup);
|
|
109
166
|
relay.once("close", cleanup);
|
|
110
|
-
|
|
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;
|