@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.
@@ -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: { pty: opts.pty === true },
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
- const timer = setTimeout(() => controller.abort(), timeoutMs);
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
- clearTimeout(timer);
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.reject(new Error("Tunnel closed before HTTP response arrived."));
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
- pending.resolve({
400
- status: frame.status,
401
- headers: frame.headers ?? {},
402
- body: frame.body ? decodeData(frame.body) : Buffer.alloc(0),
403
- ...frame.error ? { error: frame.error } : {}
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, { resolve, reject, timer });
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
- const sink = this.__sink;
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