@agentproto/acp 0.1.0-alpha.2 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tunnel/index.d.ts +307 -6
- package/dist/tunnel/index.mjs +655 -21
- package/dist/tunnel/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/tunnel/index.mjs
CHANGED
|
@@ -18,6 +18,12 @@ var KNOWN_TYPES = /* @__PURE__ */ new Set([
|
|
|
18
18
|
"resize",
|
|
19
19
|
"http_request",
|
|
20
20
|
"http_response",
|
|
21
|
+
"http_response_head",
|
|
22
|
+
"http_response_chunk",
|
|
23
|
+
"ws_open",
|
|
24
|
+
"ws_open_ack",
|
|
25
|
+
"ws_message",
|
|
26
|
+
"ws_close",
|
|
21
27
|
"ping",
|
|
22
28
|
"pong",
|
|
23
29
|
"error",
|
|
@@ -25,7 +31,8 @@ var KNOWN_TYPES = /* @__PURE__ */ new Set([
|
|
|
25
31
|
"spawned",
|
|
26
32
|
"stdout",
|
|
27
33
|
"stderr",
|
|
28
|
-
"exit"
|
|
34
|
+
"exit",
|
|
35
|
+
"reconnect_soon"
|
|
29
36
|
]);
|
|
30
37
|
function parseFrame(raw) {
|
|
31
38
|
let parsed;
|
|
@@ -53,11 +60,16 @@ function decodeData(data) {
|
|
|
53
60
|
}
|
|
54
61
|
function createTunnelServer(opts) {
|
|
55
62
|
const children = /* @__PURE__ */ new Map();
|
|
63
|
+
const ptyProcs = /* @__PURE__ */ new Map();
|
|
64
|
+
const upstreamWs = /* @__PURE__ */ new Map();
|
|
56
65
|
let closed = false;
|
|
57
66
|
opts.sink.send({
|
|
58
67
|
t: "hello",
|
|
59
68
|
version: TUNNEL_VERSION,
|
|
60
|
-
capabilities: {
|
|
69
|
+
capabilities: {
|
|
70
|
+
pty: opts.pty === true,
|
|
71
|
+
wsForward: opts.dialUpstreamWs !== void 0 && !!opts.httpUpstream
|
|
72
|
+
},
|
|
61
73
|
label: opts.label,
|
|
62
74
|
daemon: {
|
|
63
75
|
name: "agentproto",
|
|
@@ -76,6 +88,15 @@ function createTunnelServer(opts) {
|
|
|
76
88
|
closed = true;
|
|
77
89
|
for (const [, child] of children) child.kill("SIGTERM");
|
|
78
90
|
children.clear();
|
|
91
|
+
for (const [, pty] of ptyProcs) pty.kill();
|
|
92
|
+
ptyProcs.clear();
|
|
93
|
+
for (const [, ws] of upstreamWs) {
|
|
94
|
+
try {
|
|
95
|
+
ws.close(1001, "tunnel_closed");
|
|
96
|
+
} catch {
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
upstreamWs.clear();
|
|
79
100
|
offFrame();
|
|
80
101
|
offClose();
|
|
81
102
|
});
|
|
@@ -85,6 +106,11 @@ function createTunnelServer(opts) {
|
|
|
85
106
|
await handleSpawn(frame);
|
|
86
107
|
return;
|
|
87
108
|
case "stdin": {
|
|
109
|
+
const pty = ptyProcs.get(frame.execId);
|
|
110
|
+
if (pty) {
|
|
111
|
+
pty.write(Buffer.from(frame.data, "base64").toString("utf8"));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
88
114
|
const child = children.get(frame.execId);
|
|
89
115
|
if (!child || !child.stdin) {
|
|
90
116
|
opts.sink.send({
|
|
@@ -99,6 +125,11 @@ function createTunnelServer(opts) {
|
|
|
99
125
|
return;
|
|
100
126
|
}
|
|
101
127
|
case "kill": {
|
|
128
|
+
const pty = ptyProcs.get(frame.execId);
|
|
129
|
+
if (pty) {
|
|
130
|
+
pty.kill(frame.signal ?? "SIGTERM");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
102
133
|
const child = children.get(frame.execId);
|
|
103
134
|
if (!child) {
|
|
104
135
|
opts.sink.send({
|
|
@@ -112,14 +143,60 @@ function createTunnelServer(opts) {
|
|
|
112
143
|
child.kill(frame.signal ?? "SIGTERM");
|
|
113
144
|
return;
|
|
114
145
|
}
|
|
115
|
-
case "resize":
|
|
146
|
+
case "resize": {
|
|
147
|
+
ptyProcs.get(frame.execId)?.resize(frame.cols, frame.rows);
|
|
116
148
|
return;
|
|
149
|
+
}
|
|
117
150
|
case "http_request":
|
|
118
151
|
await handleHttpRequest(frame);
|
|
119
152
|
return;
|
|
153
|
+
case "ws_open":
|
|
154
|
+
await handleWsOpen(frame);
|
|
155
|
+
return;
|
|
156
|
+
case "ws_message": {
|
|
157
|
+
const ws = upstreamWs.get(frame.reqId);
|
|
158
|
+
if (!ws) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
ws.send(decodeData(frame.data), { binary: frame.binary === true });
|
|
163
|
+
} catch (err) {
|
|
164
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
165
|
+
opts.sink.send({
|
|
166
|
+
t: "ws_close",
|
|
167
|
+
reqId: frame.reqId,
|
|
168
|
+
code: 1011,
|
|
169
|
+
reason: `send_failed: ${message}`
|
|
170
|
+
});
|
|
171
|
+
try {
|
|
172
|
+
ws.close(1011, "send_failed");
|
|
173
|
+
} catch {
|
|
174
|
+
}
|
|
175
|
+
upstreamWs.delete(frame.reqId);
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
case "ws_close": {
|
|
180
|
+
const ws = upstreamWs.get(frame.reqId);
|
|
181
|
+
if (!ws) return;
|
|
182
|
+
upstreamWs.delete(frame.reqId);
|
|
183
|
+
try {
|
|
184
|
+
ws.close(frame.code, frame.reason);
|
|
185
|
+
} catch {
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
120
189
|
case "ping":
|
|
121
190
|
opts.sink.send({ t: "pong", nonce: frame.nonce });
|
|
122
191
|
return;
|
|
192
|
+
case "reconnect_soon":
|
|
193
|
+
try {
|
|
194
|
+
opts.onReconnectSoon?.({
|
|
195
|
+
...frame.reasonMs !== void 0 ? { reasonMs: frame.reasonMs } : {}
|
|
196
|
+
});
|
|
197
|
+
} catch {
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
123
200
|
case "pong":
|
|
124
201
|
case "error":
|
|
125
202
|
return;
|
|
@@ -155,6 +232,48 @@ function createTunnelServer(opts) {
|
|
|
155
232
|
});
|
|
156
233
|
return;
|
|
157
234
|
}
|
|
235
|
+
if (approved.pty && opts.spawnPty) {
|
|
236
|
+
let pty;
|
|
237
|
+
try {
|
|
238
|
+
pty = opts.spawnPty({
|
|
239
|
+
command: approved.command,
|
|
240
|
+
args: [...approved.args],
|
|
241
|
+
cwd: approved.cwd,
|
|
242
|
+
env: approved.env ? { ...approved.env } : void 0,
|
|
243
|
+
cols: approved.cols ?? 80,
|
|
244
|
+
rows: approved.rows ?? 24
|
|
245
|
+
});
|
|
246
|
+
} catch (err) {
|
|
247
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
248
|
+
opts.sink.send({ t: "error", execId: req.execId, code: "spawn_failed", message });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
ptyProcs.set(req.execId, pty);
|
|
252
|
+
opts.sink.send({ t: "spawned", execId: req.execId, pid: pty.pid });
|
|
253
|
+
pty.onData((data) => {
|
|
254
|
+
opts.sink.send({ t: "stdout", execId: req.execId, data: encodeData(data) });
|
|
255
|
+
});
|
|
256
|
+
pty.onExit(({ exitCode, signal }) => {
|
|
257
|
+
opts.sink.send({
|
|
258
|
+
t: "exit",
|
|
259
|
+
execId: req.execId,
|
|
260
|
+
code: exitCode,
|
|
261
|
+
signal: signal != null ? String(signal) : null
|
|
262
|
+
});
|
|
263
|
+
ptyProcs.delete(req.execId);
|
|
264
|
+
});
|
|
265
|
+
if (opts.onChildSpawned) {
|
|
266
|
+
try {
|
|
267
|
+
opts.onChildSpawned({
|
|
268
|
+
execId: req.execId,
|
|
269
|
+
child: { pid: pty.pid },
|
|
270
|
+
request: approved
|
|
271
|
+
});
|
|
272
|
+
} catch {
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
158
277
|
let child;
|
|
159
278
|
try {
|
|
160
279
|
child = spawn(approved.command, [...approved.args], {
|
|
@@ -223,6 +342,115 @@ function createTunnelServer(opts) {
|
|
|
223
342
|
children.delete(req.execId);
|
|
224
343
|
});
|
|
225
344
|
}
|
|
345
|
+
async function handleWsOpen(req) {
|
|
346
|
+
if (closed) return;
|
|
347
|
+
const reject = (status, code, message) => {
|
|
348
|
+
opts.sink.send({
|
|
349
|
+
t: "ws_open_ack",
|
|
350
|
+
reqId: req.reqId,
|
|
351
|
+
status,
|
|
352
|
+
error: { code, message }
|
|
353
|
+
});
|
|
354
|
+
};
|
|
355
|
+
if (!opts.dialUpstreamWs || !opts.httpUpstream) {
|
|
356
|
+
reject(
|
|
357
|
+
502,
|
|
358
|
+
"ws_upstream_not_configured",
|
|
359
|
+
"Daemon does not support WS forwarding \u2014 start with `--upstream ws://\u2026` (or upgrade)."
|
|
360
|
+
);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (!req.path.startsWith("/") || req.path.startsWith("//") || req.path.includes("..")) {
|
|
364
|
+
reject(
|
|
365
|
+
400,
|
|
366
|
+
"invalid_path",
|
|
367
|
+
`Daemon WS forward requires a safe relative path; got '${req.path}'.`
|
|
368
|
+
);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
372
|
+
"connection",
|
|
373
|
+
"upgrade",
|
|
374
|
+
"sec-websocket-version",
|
|
375
|
+
"sec-websocket-key",
|
|
376
|
+
"sec-websocket-extensions",
|
|
377
|
+
"sec-websocket-protocol",
|
|
378
|
+
"host",
|
|
379
|
+
"content-length"
|
|
380
|
+
]);
|
|
381
|
+
const headers = {};
|
|
382
|
+
for (const [k, v] of Object.entries(req.headers ?? {})) {
|
|
383
|
+
if (HOP_BY_HOP.has(k.toLowerCase())) continue;
|
|
384
|
+
headers[k] = v;
|
|
385
|
+
}
|
|
386
|
+
const base = opts.httpUpstream.replace(/^http(s?):\/\//, "ws$1://");
|
|
387
|
+
const url = `${base.replace(/\/$/, "")}${req.path}`;
|
|
388
|
+
let upstream;
|
|
389
|
+
try {
|
|
390
|
+
upstream = await opts.dialUpstreamWs({
|
|
391
|
+
url,
|
|
392
|
+
...req.protocols && req.protocols.length > 0 ? { protocols: req.protocols } : {},
|
|
393
|
+
headers
|
|
394
|
+
});
|
|
395
|
+
} catch (err) {
|
|
396
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
397
|
+
const m = message.match(/Unexpected server response: (\d+)/);
|
|
398
|
+
const status = m ? Number.parseInt(m[1], 10) : 502;
|
|
399
|
+
reject(status, "upstream_ws_failed", message);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (closed) {
|
|
403
|
+
try {
|
|
404
|
+
upstream.close(1001, "tunnel_closed");
|
|
405
|
+
} catch {
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
upstreamWs.set(req.reqId, upstream);
|
|
410
|
+
opts.sink.send({
|
|
411
|
+
t: "ws_open_ack",
|
|
412
|
+
reqId: req.reqId,
|
|
413
|
+
status: 101,
|
|
414
|
+
...upstream.protocol ? { protocol: upstream.protocol } : {}
|
|
415
|
+
});
|
|
416
|
+
upstream.onMessage((data, isBinary) => {
|
|
417
|
+
if (!opts.sink.isOpen) return;
|
|
418
|
+
opts.sink.send({
|
|
419
|
+
t: "ws_message",
|
|
420
|
+
reqId: req.reqId,
|
|
421
|
+
data: encodeData(data),
|
|
422
|
+
binary: isBinary
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
upstream.onClose((code, reason) => {
|
|
426
|
+
if (!upstreamWs.has(req.reqId)) return;
|
|
427
|
+
upstreamWs.delete(req.reqId);
|
|
428
|
+
if (opts.sink.isOpen) {
|
|
429
|
+
opts.sink.send({
|
|
430
|
+
t: "ws_close",
|
|
431
|
+
reqId: req.reqId,
|
|
432
|
+
code,
|
|
433
|
+
...reason ? { reason } : {}
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
upstream.onError((err) => {
|
|
438
|
+
if (!upstreamWs.has(req.reqId)) return;
|
|
439
|
+
upstreamWs.delete(req.reqId);
|
|
440
|
+
if (opts.sink.isOpen) {
|
|
441
|
+
opts.sink.send({
|
|
442
|
+
t: "ws_close",
|
|
443
|
+
reqId: req.reqId,
|
|
444
|
+
code: 1011,
|
|
445
|
+
reason: `upstream_error: ${err.message}`
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
upstream.close(1011, "upstream_error");
|
|
450
|
+
} catch {
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
}
|
|
226
454
|
async function handleHttpRequest(req) {
|
|
227
455
|
if (closed) return;
|
|
228
456
|
const respond = (partial) => {
|
|
@@ -274,7 +502,16 @@ function createTunnelServer(opts) {
|
|
|
274
502
|
const url = `${opts.httpUpstream.replace(/\/$/, "")}${req.path}`;
|
|
275
503
|
const controller = new AbortController();
|
|
276
504
|
const timeoutMs = req.timeoutMs ?? 3e4;
|
|
277
|
-
|
|
505
|
+
let timer = setTimeout(
|
|
506
|
+
() => controller.abort(),
|
|
507
|
+
timeoutMs
|
|
508
|
+
);
|
|
509
|
+
const clearTimer = () => {
|
|
510
|
+
if (timer) {
|
|
511
|
+
clearTimeout(timer);
|
|
512
|
+
timer = null;
|
|
513
|
+
}
|
|
514
|
+
};
|
|
278
515
|
try {
|
|
279
516
|
const upstreamRes = await fetch(url, {
|
|
280
517
|
method: req.method,
|
|
@@ -284,12 +521,60 @@ function createTunnelServer(opts) {
|
|
|
284
521
|
// node fetch follows redirects by default — fine for MCP, the
|
|
285
522
|
// upstream gateway doesn't redirect anyway.
|
|
286
523
|
});
|
|
287
|
-
const buf = Buffer.from(await upstreamRes.arrayBuffer());
|
|
288
524
|
const outHeaders = {};
|
|
289
525
|
upstreamRes.headers.forEach((value, key) => {
|
|
290
526
|
if (HOP_BY_HOP.has(key.toLowerCase())) return;
|
|
291
527
|
outHeaders[key] = value;
|
|
292
528
|
});
|
|
529
|
+
const contentType = (outHeaders["content-type"] ?? "").toLowerCase();
|
|
530
|
+
const isStream = contentType.startsWith("text/event-stream") || contentType.includes("application/x-ndjson");
|
|
531
|
+
if (isStream && upstreamRes.body) {
|
|
532
|
+
clearTimer();
|
|
533
|
+
opts.sink.send({
|
|
534
|
+
t: "http_response_head",
|
|
535
|
+
reqId: req.reqId,
|
|
536
|
+
status: upstreamRes.status,
|
|
537
|
+
headers: outHeaders
|
|
538
|
+
});
|
|
539
|
+
const reader = upstreamRes.body.getReader();
|
|
540
|
+
try {
|
|
541
|
+
while (true) {
|
|
542
|
+
const { value, done } = await reader.read();
|
|
543
|
+
if (done) {
|
|
544
|
+
opts.sink.send({
|
|
545
|
+
t: "http_response_chunk",
|
|
546
|
+
reqId: req.reqId,
|
|
547
|
+
end: true
|
|
548
|
+
});
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (value && value.byteLength > 0) {
|
|
552
|
+
opts.sink.send({
|
|
553
|
+
t: "http_response_chunk",
|
|
554
|
+
reqId: req.reqId,
|
|
555
|
+
data: encodeData(Buffer.from(value))
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
if (!opts.sink.isOpen) {
|
|
559
|
+
try {
|
|
560
|
+
await reader.cancel();
|
|
561
|
+
} catch {
|
|
562
|
+
}
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
} catch (err) {
|
|
567
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
568
|
+
opts.sink.send({
|
|
569
|
+
t: "http_response_chunk",
|
|
570
|
+
reqId: req.reqId,
|
|
571
|
+
end: true,
|
|
572
|
+
error: { code: "upstream_stream_failed", message }
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
const buf = Buffer.from(await upstreamRes.arrayBuffer());
|
|
293
578
|
respond({
|
|
294
579
|
status: upstreamRes.status,
|
|
295
580
|
headers: outHeaders,
|
|
@@ -306,7 +591,7 @@ function createTunnelServer(opts) {
|
|
|
306
591
|
}
|
|
307
592
|
});
|
|
308
593
|
} finally {
|
|
309
|
-
|
|
594
|
+
clearTimer();
|
|
310
595
|
}
|
|
311
596
|
}
|
|
312
597
|
return {
|
|
@@ -327,6 +612,8 @@ function createTunnelClient(opts) {
|
|
|
327
612
|
const childByExec = /* @__PURE__ */ new Map();
|
|
328
613
|
const spawnPending = /* @__PURE__ */ new Map();
|
|
329
614
|
const httpPending = /* @__PURE__ */ new Map();
|
|
615
|
+
const wsOpenPending = /* @__PURE__ */ new Map();
|
|
616
|
+
const wsByReq = /* @__PURE__ */ new Map();
|
|
330
617
|
const offFrame = opts.sink.onFrame((frame) => routeIncoming(frame));
|
|
331
618
|
const offClose = opts.sink.onClose(() => {
|
|
332
619
|
for (const [, duck] of childByExec) duck.__handleSinkClosed();
|
|
@@ -337,9 +624,29 @@ function createTunnelClient(opts) {
|
|
|
337
624
|
spawnPending.clear();
|
|
338
625
|
for (const [, p] of httpPending) {
|
|
339
626
|
clearTimeout(p.timer);
|
|
340
|
-
p.
|
|
627
|
+
if (p.mode === "buffered") {
|
|
628
|
+
p.reject(new Error("Tunnel closed before HTTP response arrived."));
|
|
629
|
+
} else {
|
|
630
|
+
if (!p.headEmitted) {
|
|
631
|
+
p.reject(new Error("Tunnel closed before HTTP response head arrived."));
|
|
632
|
+
} else if (p.controller && !p.ended) {
|
|
633
|
+
try {
|
|
634
|
+
p.controller.error(new Error("Tunnel closed mid-stream."));
|
|
635
|
+
} catch {
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
341
639
|
}
|
|
342
640
|
httpPending.clear();
|
|
641
|
+
for (const [, p] of wsOpenPending) {
|
|
642
|
+
clearTimeout(p.timer);
|
|
643
|
+
p.reject(new Error("Tunnel closed before WS upgrade completed."));
|
|
644
|
+
}
|
|
645
|
+
wsOpenPending.clear();
|
|
646
|
+
for (const [, duck] of wsByReq) {
|
|
647
|
+
duck.__handleSinkClosed();
|
|
648
|
+
}
|
|
649
|
+
wsByReq.clear();
|
|
343
650
|
offFrame();
|
|
344
651
|
offClose();
|
|
345
652
|
});
|
|
@@ -396,12 +703,171 @@ function createTunnelClient(opts) {
|
|
|
396
703
|
if (!pending) return;
|
|
397
704
|
httpPending.delete(frame.reqId);
|
|
398
705
|
clearTimeout(pending.timer);
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
706
|
+
const body = frame.body ? decodeData(frame.body) : Buffer.alloc(0);
|
|
707
|
+
if (pending.mode === "buffered") {
|
|
708
|
+
pending.resolve({
|
|
709
|
+
status: frame.status,
|
|
710
|
+
headers: frame.headers ?? {},
|
|
711
|
+
body,
|
|
712
|
+
...frame.error ? { error: frame.error } : {}
|
|
713
|
+
});
|
|
714
|
+
} else {
|
|
715
|
+
const stream = new ReadableStream({
|
|
716
|
+
start(controller) {
|
|
717
|
+
if (body.length > 0) controller.enqueue(new Uint8Array(body));
|
|
718
|
+
controller.close();
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
pending.resolveHead({
|
|
722
|
+
status: frame.status,
|
|
723
|
+
headers: frame.headers ?? {},
|
|
724
|
+
body: stream
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
case "http_response_head": {
|
|
730
|
+
const pending = httpPending.get(frame.reqId);
|
|
731
|
+
if (!pending) return;
|
|
732
|
+
const headers = frame.headers ?? {};
|
|
733
|
+
if (pending.mode === "stream") {
|
|
734
|
+
const stream = new ReadableStream({
|
|
735
|
+
start(controller) {
|
|
736
|
+
pending.controller = controller;
|
|
737
|
+
for (const chunk of pending.pendingChunks) {
|
|
738
|
+
if (chunk.length > 0) controller.enqueue(new Uint8Array(chunk));
|
|
739
|
+
}
|
|
740
|
+
pending.pendingChunks = [];
|
|
741
|
+
},
|
|
742
|
+
cancel: () => {
|
|
743
|
+
if (httpPending.get(frame.reqId) === pending) {
|
|
744
|
+
clearTimeout(pending.timer);
|
|
745
|
+
httpPending.delete(frame.reqId);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
pending.headEmitted = true;
|
|
750
|
+
clearTimeout(pending.timer);
|
|
751
|
+
pending.resolveHead({
|
|
752
|
+
status: frame.status,
|
|
753
|
+
headers,
|
|
754
|
+
body: stream
|
|
755
|
+
});
|
|
756
|
+
} else {
|
|
757
|
+
pending.streamHead = { status: frame.status, headers };
|
|
758
|
+
}
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
case "http_response_chunk": {
|
|
762
|
+
const pending = httpPending.get(frame.reqId);
|
|
763
|
+
if (!pending) return;
|
|
764
|
+
const data = frame.data ? decodeData(frame.data) : Buffer.alloc(0);
|
|
765
|
+
if (pending.mode === "stream") {
|
|
766
|
+
if (frame.error) {
|
|
767
|
+
try {
|
|
768
|
+
pending.controller?.error(
|
|
769
|
+
new Error(`${frame.error.code}: ${frame.error.message}`)
|
|
770
|
+
);
|
|
771
|
+
} catch {
|
|
772
|
+
}
|
|
773
|
+
pending.ended = true;
|
|
774
|
+
clearTimeout(pending.timer);
|
|
775
|
+
httpPending.delete(frame.reqId);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
if (pending.controller) {
|
|
779
|
+
if (data.length > 0) {
|
|
780
|
+
try {
|
|
781
|
+
pending.controller.enqueue(new Uint8Array(data));
|
|
782
|
+
} catch {
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
if (frame.end) {
|
|
786
|
+
try {
|
|
787
|
+
pending.controller.close();
|
|
788
|
+
} catch {
|
|
789
|
+
}
|
|
790
|
+
pending.ended = true;
|
|
791
|
+
clearTimeout(pending.timer);
|
|
792
|
+
httpPending.delete(frame.reqId);
|
|
793
|
+
}
|
|
794
|
+
} else {
|
|
795
|
+
if (data.length > 0) pending.pendingChunks.push(data);
|
|
796
|
+
if (frame.end) {
|
|
797
|
+
pending.ended = true;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
} else {
|
|
801
|
+
if (data.length > 0) pending.streamChunks.push(data);
|
|
802
|
+
if (frame.error) {
|
|
803
|
+
clearTimeout(pending.timer);
|
|
804
|
+
httpPending.delete(frame.reqId);
|
|
805
|
+
const concatenated = Buffer.concat(pending.streamChunks);
|
|
806
|
+
pending.resolve({
|
|
807
|
+
status: pending.streamHead?.status ?? 502,
|
|
808
|
+
headers: pending.streamHead?.headers ?? {},
|
|
809
|
+
body: concatenated,
|
|
810
|
+
error: frame.error
|
|
811
|
+
});
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (frame.end) {
|
|
815
|
+
clearTimeout(pending.timer);
|
|
816
|
+
httpPending.delete(frame.reqId);
|
|
817
|
+
const concatenated = Buffer.concat(pending.streamChunks);
|
|
818
|
+
pending.resolve({
|
|
819
|
+
status: pending.streamHead?.status ?? 200,
|
|
820
|
+
headers: pending.streamHead?.headers ?? {},
|
|
821
|
+
body: concatenated
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
case "ws_open_ack": {
|
|
828
|
+
const pending = wsOpenPending.get(frame.reqId);
|
|
829
|
+
if (!pending) return;
|
|
830
|
+
wsOpenPending.delete(frame.reqId);
|
|
831
|
+
clearTimeout(pending.timer);
|
|
832
|
+
if (frame.error || frame.status !== 101) {
|
|
833
|
+
const code = frame.error?.code ?? `http_${frame.status}`;
|
|
834
|
+
const msg = frame.error?.message ?? `Upstream WS upgrade returned ${frame.status}`;
|
|
835
|
+
pending.reject(new Error(`${code}: ${msg}`));
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
const duck = new TunnelWebSocketDuck(
|
|
839
|
+
frame.reqId,
|
|
840
|
+
frame.protocol ?? null,
|
|
841
|
+
opts.sink,
|
|
842
|
+
() => wsByReq.delete(frame.reqId)
|
|
843
|
+
);
|
|
844
|
+
wsByReq.set(frame.reqId, duck);
|
|
845
|
+
pending.resolve(duck);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
case "ws_message": {
|
|
849
|
+
const duck = wsByReq.get(frame.reqId);
|
|
850
|
+
if (!duck) return;
|
|
851
|
+
duck.__handleMessage(decodeData(frame.data), frame.binary === true);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
case "ws_close": {
|
|
855
|
+
const pending = wsOpenPending.get(frame.reqId);
|
|
856
|
+
if (pending) {
|
|
857
|
+
wsOpenPending.delete(frame.reqId);
|
|
858
|
+
clearTimeout(pending.timer);
|
|
859
|
+
pending.reject(
|
|
860
|
+
new Error(
|
|
861
|
+
`WS closed before upgrade (code ${frame.code}${frame.reason ? `: ${frame.reason}` : ""})`
|
|
862
|
+
)
|
|
863
|
+
);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
const duck = wsByReq.get(frame.reqId);
|
|
867
|
+
if (duck) {
|
|
868
|
+
wsByReq.delete(frame.reqId);
|
|
869
|
+
duck.__handleClose(frame.code, frame.reason ?? "");
|
|
870
|
+
}
|
|
405
871
|
return;
|
|
406
872
|
}
|
|
407
873
|
case "ping":
|
|
@@ -413,6 +879,7 @@ function createTunnelClient(opts) {
|
|
|
413
879
|
case "kill":
|
|
414
880
|
case "resize":
|
|
415
881
|
case "http_request":
|
|
882
|
+
case "ws_open":
|
|
416
883
|
return;
|
|
417
884
|
}
|
|
418
885
|
}
|
|
@@ -461,7 +928,8 @@ function createTunnelClient(opts) {
|
|
|
461
928
|
args,
|
|
462
929
|
cwd: spawnOpts?.cwd,
|
|
463
930
|
env: spawnOpts?.env,
|
|
464
|
-
pty: spawnOpts?.pty
|
|
931
|
+
pty: spawnOpts?.pty,
|
|
932
|
+
...spawnOpts?.pty ? { cols: spawnOpts.cols ?? 80, rows: spawnOpts.rows ?? 24 } : {}
|
|
465
933
|
};
|
|
466
934
|
const spawnedFrame = await new Promise(
|
|
467
935
|
(resolve, reject) => {
|
|
@@ -498,7 +966,85 @@ function createTunnelClient(opts) {
|
|
|
498
966
|
)
|
|
499
967
|
);
|
|
500
968
|
}, timeoutMs + 5e3);
|
|
501
|
-
httpPending.set(reqId, {
|
|
969
|
+
httpPending.set(reqId, {
|
|
970
|
+
mode: "buffered",
|
|
971
|
+
resolve,
|
|
972
|
+
reject,
|
|
973
|
+
timer,
|
|
974
|
+
streamHead: null,
|
|
975
|
+
streamChunks: []
|
|
976
|
+
});
|
|
977
|
+
opts.sink.send(frame);
|
|
978
|
+
});
|
|
979
|
+
},
|
|
980
|
+
async forwardHttpStream(req) {
|
|
981
|
+
if (!hello) await this.ready();
|
|
982
|
+
const reqId = randomUUID();
|
|
983
|
+
const headTimeoutMs = req.timeoutMs ?? 3e4;
|
|
984
|
+
const body = req.body === void 0 ? void 0 : encodeData(
|
|
985
|
+
typeof req.body === "string" ? req.body : Buffer.from(req.body)
|
|
986
|
+
);
|
|
987
|
+
const frame = {
|
|
988
|
+
t: "http_request",
|
|
989
|
+
reqId,
|
|
990
|
+
method: req.method,
|
|
991
|
+
path: req.path,
|
|
992
|
+
...req.headers ? { headers: req.headers } : {},
|
|
993
|
+
...body !== void 0 ? { body } : {},
|
|
994
|
+
timeoutMs: 24 * 60 * 60 * 1e3
|
|
995
|
+
// 24h — effectively "forever"
|
|
996
|
+
};
|
|
997
|
+
return new Promise((resolveHead, reject) => {
|
|
998
|
+
const timer = setTimeout(() => {
|
|
999
|
+
if (httpPending.has(reqId)) {
|
|
1000
|
+
httpPending.delete(reqId);
|
|
1001
|
+
reject(
|
|
1002
|
+
new Error(
|
|
1003
|
+
`Tunnel forwardHttpStream(${req.method} ${req.path}) head did not arrive within ${headTimeoutMs}ms.`
|
|
1004
|
+
)
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
}, headTimeoutMs);
|
|
1008
|
+
httpPending.set(reqId, {
|
|
1009
|
+
mode: "stream",
|
|
1010
|
+
resolveHead,
|
|
1011
|
+
reject,
|
|
1012
|
+
timer,
|
|
1013
|
+
controller: null,
|
|
1014
|
+
pendingChunks: [],
|
|
1015
|
+
headEmitted: false,
|
|
1016
|
+
ended: false
|
|
1017
|
+
});
|
|
1018
|
+
opts.sink.send(frame);
|
|
1019
|
+
});
|
|
1020
|
+
},
|
|
1021
|
+
async forwardWebSocket(req) {
|
|
1022
|
+
const h = await this.ready();
|
|
1023
|
+
if (h.capabilities.wsForward !== true) {
|
|
1024
|
+
throw new Error(
|
|
1025
|
+
`Tunnel daemon '${h.label ?? "(unnamed)"}' does not advertise wsForward capability. Update the daemon to forward browser WS upgrades.`
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
const reqId = randomUUID();
|
|
1029
|
+
const timeoutMs = req.openTimeoutMs ?? 3e4;
|
|
1030
|
+
const frame = {
|
|
1031
|
+
t: "ws_open",
|
|
1032
|
+
reqId,
|
|
1033
|
+
path: req.path,
|
|
1034
|
+
...req.headers ? { headers: req.headers } : {},
|
|
1035
|
+
...req.protocols && req.protocols.length > 0 ? { protocols: req.protocols } : {}
|
|
1036
|
+
};
|
|
1037
|
+
return new Promise((resolve, reject) => {
|
|
1038
|
+
const timer = setTimeout(() => {
|
|
1039
|
+
if (!wsOpenPending.has(reqId)) return;
|
|
1040
|
+
wsOpenPending.delete(reqId);
|
|
1041
|
+
reject(
|
|
1042
|
+
new Error(
|
|
1043
|
+
`Tunnel forwardWebSocket(${req.path}) did not receive ws_open_ack within ${timeoutMs}ms.`
|
|
1044
|
+
)
|
|
1045
|
+
);
|
|
1046
|
+
}, timeoutMs);
|
|
1047
|
+
wsOpenPending.set(reqId, { resolve, reject, timer });
|
|
502
1048
|
opts.sink.send(frame);
|
|
503
1049
|
});
|
|
504
1050
|
},
|
|
@@ -513,11 +1059,13 @@ var TunnelChildDuck = class extends EventEmitter {
|
|
|
513
1059
|
stdin;
|
|
514
1060
|
stdout;
|
|
515
1061
|
stderr;
|
|
1062
|
+
sink;
|
|
516
1063
|
exited = false;
|
|
517
1064
|
constructor(execId, pid, sink) {
|
|
518
1065
|
super();
|
|
519
1066
|
this.execId = execId;
|
|
520
1067
|
this.pid = pid > 0 ? pid : null;
|
|
1068
|
+
this.sink = sink;
|
|
521
1069
|
this.stdout = new Readable({ read() {
|
|
522
1070
|
} });
|
|
523
1071
|
this.stderr = new Readable({ read() {
|
|
@@ -540,10 +1088,6 @@ var TunnelChildDuck = class extends EventEmitter {
|
|
|
540
1088
|
cb();
|
|
541
1089
|
}
|
|
542
1090
|
});
|
|
543
|
-
Object.defineProperty(this, "__sink", {
|
|
544
|
-
value: sink,
|
|
545
|
-
enumerable: false
|
|
546
|
-
});
|
|
547
1091
|
}
|
|
548
1092
|
__pushStdout(buf) {
|
|
549
1093
|
this.stdout.push(buf);
|
|
@@ -575,14 +1119,104 @@ var TunnelChildDuck = class extends EventEmitter {
|
|
|
575
1119
|
}
|
|
576
1120
|
kill(signal = "SIGTERM") {
|
|
577
1121
|
if (this.exited) return false;
|
|
578
|
-
|
|
579
|
-
sink.send({
|
|
1122
|
+
this.sink.send({
|
|
580
1123
|
t: "kill",
|
|
581
1124
|
execId: this.execId,
|
|
582
1125
|
signal: typeof signal === "string" ? signal : `SIG${signal}`
|
|
583
1126
|
});
|
|
584
1127
|
return true;
|
|
585
1128
|
}
|
|
1129
|
+
resize(cols, rows) {
|
|
1130
|
+
if (this.exited) return;
|
|
1131
|
+
this.sink.send({ t: "resize", execId: this.execId, cols, rows });
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
var TunnelWebSocketDuck = class {
|
|
1135
|
+
protocol;
|
|
1136
|
+
_readyState = "open";
|
|
1137
|
+
reqId;
|
|
1138
|
+
sink;
|
|
1139
|
+
onCleanup;
|
|
1140
|
+
messageListeners = /* @__PURE__ */ new Set();
|
|
1141
|
+
closeListeners = /* @__PURE__ */ new Set();
|
|
1142
|
+
errorListeners = /* @__PURE__ */ new Set();
|
|
1143
|
+
constructor(reqId, protocol, sink, onCleanup) {
|
|
1144
|
+
this.reqId = reqId;
|
|
1145
|
+
this.protocol = protocol;
|
|
1146
|
+
this.sink = sink;
|
|
1147
|
+
this.onCleanup = onCleanup;
|
|
1148
|
+
}
|
|
1149
|
+
get readyState() {
|
|
1150
|
+
return this._readyState;
|
|
1151
|
+
}
|
|
1152
|
+
send(data) {
|
|
1153
|
+
if (this._readyState !== "open") return;
|
|
1154
|
+
const bytes = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
|
|
1155
|
+
this.sink.send({
|
|
1156
|
+
t: "ws_message",
|
|
1157
|
+
reqId: this.reqId,
|
|
1158
|
+
data: encodeData(bytes),
|
|
1159
|
+
binary: typeof data !== "string"
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
close(code = 1e3, reason) {
|
|
1163
|
+
if (this._readyState === "closed") return;
|
|
1164
|
+
if (this._readyState === "open") {
|
|
1165
|
+
this._readyState = "closing";
|
|
1166
|
+
this.sink.send({
|
|
1167
|
+
t: "ws_close",
|
|
1168
|
+
reqId: this.reqId,
|
|
1169
|
+
code,
|
|
1170
|
+
...reason ? { reason } : {}
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
this.__handleClose(code, reason ?? "");
|
|
1174
|
+
}
|
|
1175
|
+
onMessage(listener) {
|
|
1176
|
+
this.messageListeners.add(listener);
|
|
1177
|
+
return () => this.messageListeners.delete(listener);
|
|
1178
|
+
}
|
|
1179
|
+
onClose(listener) {
|
|
1180
|
+
this.closeListeners.add(listener);
|
|
1181
|
+
return () => this.closeListeners.delete(listener);
|
|
1182
|
+
}
|
|
1183
|
+
onError(listener) {
|
|
1184
|
+
this.errorListeners.add(listener);
|
|
1185
|
+
return () => this.errorListeners.delete(listener);
|
|
1186
|
+
}
|
|
1187
|
+
__handleMessage(data, isBinary) {
|
|
1188
|
+
if (this._readyState !== "open") return;
|
|
1189
|
+
for (const l of this.messageListeners) {
|
|
1190
|
+
try {
|
|
1191
|
+
l(data, isBinary);
|
|
1192
|
+
} catch {
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
__handleClose(code, reason) {
|
|
1197
|
+
if (this._readyState === "closed") return;
|
|
1198
|
+
this._readyState = "closed";
|
|
1199
|
+
this.onCleanup();
|
|
1200
|
+
for (const l of this.closeListeners) {
|
|
1201
|
+
try {
|
|
1202
|
+
l(code, reason);
|
|
1203
|
+
} catch {
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
this.messageListeners.clear();
|
|
1207
|
+
this.closeListeners.clear();
|
|
1208
|
+
this.errorListeners.clear();
|
|
1209
|
+
}
|
|
1210
|
+
__handleSinkClosed() {
|
|
1211
|
+
if (this._readyState === "closed") return;
|
|
1212
|
+
for (const l of this.errorListeners) {
|
|
1213
|
+
try {
|
|
1214
|
+
l(new Error("Tunnel closed mid-WS."));
|
|
1215
|
+
} catch {
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
this.__handleClose(1006, "tunnel_closed");
|
|
1219
|
+
}
|
|
586
1220
|
};
|
|
587
1221
|
|
|
588
1222
|
// src/tunnel/ws-adapter.ts
|