@agentproto/acp 0.1.0-alpha.2 → 0.2.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;
@@ -51,13 +58,21 @@ function encodeData(bytes) {
51
58
  function decodeData(data) {
52
59
  return Buffer.from(data, "base64");
53
60
  }
61
+ var DEFAULT_WS_DIAL_TIMEOUT_MS = 1e4;
62
+ var DEFAULT_HTTP_FORWARD_TIMEOUT_MS = 3e4;
54
63
  function createTunnelServer(opts) {
55
64
  const children = /* @__PURE__ */ new Map();
65
+ const ptyProcs = /* @__PURE__ */ new Map();
66
+ const upstreamWs = /* @__PURE__ */ new Map();
56
67
  let closed = false;
57
68
  opts.sink.send({
58
69
  t: "hello",
59
70
  version: TUNNEL_VERSION,
60
- capabilities: { pty: opts.pty === true },
71
+ capabilities: {
72
+ pty: opts.pty === true,
73
+ wsForward: opts.dialUpstreamWs !== void 0 && !!opts.httpUpstream,
74
+ ...opts.tools && opts.tools.length ? { tools: opts.tools } : {}
75
+ },
61
76
  label: opts.label,
62
77
  daemon: {
63
78
  name: "agentproto",
@@ -76,6 +91,15 @@ function createTunnelServer(opts) {
76
91
  closed = true;
77
92
  for (const [, child] of children) child.kill("SIGTERM");
78
93
  children.clear();
94
+ for (const [, pty] of ptyProcs) pty.kill();
95
+ ptyProcs.clear();
96
+ for (const [, ws] of upstreamWs) {
97
+ try {
98
+ ws.close(1001, "tunnel_closed");
99
+ } catch {
100
+ }
101
+ }
102
+ upstreamWs.clear();
79
103
  offFrame();
80
104
  offClose();
81
105
  });
@@ -85,6 +109,11 @@ function createTunnelServer(opts) {
85
109
  await handleSpawn(frame);
86
110
  return;
87
111
  case "stdin": {
112
+ const pty = ptyProcs.get(frame.execId);
113
+ if (pty) {
114
+ pty.write(Buffer.from(frame.data, "base64").toString("utf8"));
115
+ return;
116
+ }
88
117
  const child = children.get(frame.execId);
89
118
  if (!child || !child.stdin) {
90
119
  opts.sink.send({
@@ -99,6 +128,11 @@ function createTunnelServer(opts) {
99
128
  return;
100
129
  }
101
130
  case "kill": {
131
+ const pty = ptyProcs.get(frame.execId);
132
+ if (pty) {
133
+ pty.kill(frame.signal ?? "SIGTERM");
134
+ return;
135
+ }
102
136
  const child = children.get(frame.execId);
103
137
  if (!child) {
104
138
  opts.sink.send({
@@ -112,14 +146,60 @@ function createTunnelServer(opts) {
112
146
  child.kill(frame.signal ?? "SIGTERM");
113
147
  return;
114
148
  }
115
- case "resize":
149
+ case "resize": {
150
+ ptyProcs.get(frame.execId)?.resize(frame.cols, frame.rows);
116
151
  return;
152
+ }
117
153
  case "http_request":
118
154
  await handleHttpRequest(frame);
119
155
  return;
156
+ case "ws_open":
157
+ await handleWsOpen(frame);
158
+ return;
159
+ case "ws_message": {
160
+ const ws = upstreamWs.get(frame.reqId);
161
+ if (!ws) {
162
+ return;
163
+ }
164
+ try {
165
+ ws.send(decodeData(frame.data), { binary: frame.binary === true });
166
+ } catch (err) {
167
+ const message = err instanceof Error ? err.message : String(err);
168
+ opts.sink.send({
169
+ t: "ws_close",
170
+ reqId: frame.reqId,
171
+ code: 1011,
172
+ reason: `send_failed: ${message}`
173
+ });
174
+ try {
175
+ ws.close(1011, "send_failed");
176
+ } catch {
177
+ }
178
+ upstreamWs.delete(frame.reqId);
179
+ }
180
+ return;
181
+ }
182
+ case "ws_close": {
183
+ const ws = upstreamWs.get(frame.reqId);
184
+ if (!ws) return;
185
+ upstreamWs.delete(frame.reqId);
186
+ try {
187
+ ws.close(frame.code, frame.reason);
188
+ } catch {
189
+ }
190
+ return;
191
+ }
120
192
  case "ping":
121
193
  opts.sink.send({ t: "pong", nonce: frame.nonce });
122
194
  return;
195
+ case "reconnect_soon":
196
+ try {
197
+ opts.onReconnectSoon?.({
198
+ ...frame.reasonMs !== void 0 ? { reasonMs: frame.reasonMs } : {}
199
+ });
200
+ } catch {
201
+ }
202
+ return;
123
203
  case "pong":
124
204
  case "error":
125
205
  return;
@@ -155,6 +235,48 @@ function createTunnelServer(opts) {
155
235
  });
156
236
  return;
157
237
  }
238
+ if (approved.pty && opts.spawnPty) {
239
+ let pty;
240
+ try {
241
+ pty = opts.spawnPty({
242
+ command: approved.command,
243
+ args: [...approved.args],
244
+ cwd: approved.cwd,
245
+ env: approved.env ? { ...approved.env } : void 0,
246
+ cols: approved.cols ?? 80,
247
+ rows: approved.rows ?? 24
248
+ });
249
+ } catch (err) {
250
+ const message = err instanceof Error ? err.message : String(err);
251
+ opts.sink.send({ t: "error", execId: req.execId, code: "spawn_failed", message });
252
+ return;
253
+ }
254
+ ptyProcs.set(req.execId, pty);
255
+ opts.sink.send({ t: "spawned", execId: req.execId, pid: pty.pid });
256
+ pty.onData((data) => {
257
+ opts.sink.send({ t: "stdout", execId: req.execId, data: encodeData(data) });
258
+ });
259
+ pty.onExit(({ exitCode, signal }) => {
260
+ opts.sink.send({
261
+ t: "exit",
262
+ execId: req.execId,
263
+ code: exitCode,
264
+ signal: signal != null ? String(signal) : null
265
+ });
266
+ ptyProcs.delete(req.execId);
267
+ });
268
+ if (opts.onChildSpawned) {
269
+ try {
270
+ opts.onChildSpawned({
271
+ execId: req.execId,
272
+ child: { pid: pty.pid },
273
+ request: approved
274
+ });
275
+ } catch {
276
+ }
277
+ }
278
+ return;
279
+ }
158
280
  let child;
159
281
  try {
160
282
  child = spawn(approved.command, [...approved.args], {
@@ -223,6 +345,162 @@ function createTunnelServer(opts) {
223
345
  children.delete(req.execId);
224
346
  });
225
347
  }
348
+ async function handleWsOpen(req) {
349
+ if (closed) return;
350
+ const reject = (status, code, message) => {
351
+ opts.sink.send({
352
+ t: "ws_open_ack",
353
+ reqId: req.reqId,
354
+ status,
355
+ error: { code, message }
356
+ });
357
+ };
358
+ if (!opts.dialUpstreamWs || !opts.httpUpstream) {
359
+ reject(
360
+ 502,
361
+ "ws_upstream_not_configured",
362
+ "Daemon does not support WS forwarding \u2014 start with `--upstream ws://\u2026` (or upgrade)."
363
+ );
364
+ return;
365
+ }
366
+ if (!req.path.startsWith("/") || req.path.startsWith("//") || req.path.includes("..")) {
367
+ reject(
368
+ 400,
369
+ "invalid_path",
370
+ `Daemon WS forward requires a safe relative path; got '${req.path}'.`
371
+ );
372
+ return;
373
+ }
374
+ const HOP_BY_HOP = /* @__PURE__ */ new Set([
375
+ "connection",
376
+ "upgrade",
377
+ "sec-websocket-version",
378
+ "sec-websocket-key",
379
+ "sec-websocket-extensions",
380
+ "sec-websocket-protocol",
381
+ "host",
382
+ "content-length"
383
+ ]);
384
+ const headers = {};
385
+ for (const [k, v] of Object.entries(req.headers ?? {})) {
386
+ if (HOP_BY_HOP.has(k.toLowerCase())) continue;
387
+ headers[k] = v;
388
+ }
389
+ let httpBase = opts.httpUpstream;
390
+ if (req.upstream) {
391
+ const resolved = await opts.resolveWsUpstream?.(req.upstream);
392
+ if (!resolved) {
393
+ reject(
394
+ 502,
395
+ "unknown_ws_upstream",
396
+ `No WS upstream registered for '${req.upstream}'.`
397
+ );
398
+ return;
399
+ }
400
+ httpBase = resolved;
401
+ }
402
+ const base = httpBase.replace(/^http(s?):\/\//, "ws$1://");
403
+ const url = `${base.replace(/\/$/, "")}${req.path}`;
404
+ const dialTimeoutMs = opts.wsDialTimeoutMs ?? DEFAULT_WS_DIAL_TIMEOUT_MS;
405
+ const dialAbort = new AbortController();
406
+ let dialTimedOut = false;
407
+ let dialTimer = null;
408
+ const dialPromise = opts.dialUpstreamWs({
409
+ url,
410
+ ...req.protocols && req.protocols.length > 0 ? { protocols: req.protocols } : {},
411
+ headers,
412
+ signal: dialAbort.signal
413
+ });
414
+ const timeoutPromise = new Promise((_resolve, rej) => {
415
+ dialTimer = setTimeout(() => {
416
+ dialTimedOut = true;
417
+ dialAbort.abort(new Error("ws_dial_timeout"));
418
+ rej(new Error(`upstream WS dial timed out after ${dialTimeoutMs}ms`));
419
+ }, dialTimeoutMs);
420
+ if (typeof dialTimer !== "number") dialTimer.unref();
421
+ });
422
+ let upstream;
423
+ try {
424
+ upstream = await Promise.race([dialPromise, timeoutPromise]);
425
+ } catch (err) {
426
+ if (dialTimer) clearTimeout(dialTimer);
427
+ if (dialTimedOut) {
428
+ dialPromise.then(
429
+ (sock) => {
430
+ try {
431
+ sock.close(1001, "dial_timeout");
432
+ } catch {
433
+ }
434
+ },
435
+ () => {
436
+ }
437
+ );
438
+ reject(
439
+ 504,
440
+ "upstream_ws_timeout",
441
+ `Upstream did not complete the WS upgrade within ${dialTimeoutMs}ms.`
442
+ );
443
+ return;
444
+ }
445
+ const message = err instanceof Error ? err.message : String(err);
446
+ const m = message.match(/Unexpected server response: (\d+)/);
447
+ const status = m ? Number.parseInt(m[1], 10) : 502;
448
+ reject(status, "upstream_ws_failed", message);
449
+ return;
450
+ }
451
+ if (dialTimer) clearTimeout(dialTimer);
452
+ if (closed) {
453
+ try {
454
+ upstream.close(1001, "tunnel_closed");
455
+ } catch {
456
+ }
457
+ return;
458
+ }
459
+ upstreamWs.set(req.reqId, upstream);
460
+ opts.sink.send({
461
+ t: "ws_open_ack",
462
+ reqId: req.reqId,
463
+ status: 101,
464
+ ...upstream.protocol ? { protocol: upstream.protocol } : {}
465
+ });
466
+ upstream.onMessage((data, isBinary) => {
467
+ if (!opts.sink.isOpen) return;
468
+ opts.sink.send({
469
+ t: "ws_message",
470
+ reqId: req.reqId,
471
+ data: encodeData(data),
472
+ binary: isBinary
473
+ });
474
+ });
475
+ upstream.onClose((code, reason) => {
476
+ if (!upstreamWs.has(req.reqId)) return;
477
+ upstreamWs.delete(req.reqId);
478
+ if (opts.sink.isOpen) {
479
+ opts.sink.send({
480
+ t: "ws_close",
481
+ reqId: req.reqId,
482
+ code,
483
+ ...reason ? { reason } : {}
484
+ });
485
+ }
486
+ });
487
+ upstream.onError((err) => {
488
+ if (!upstreamWs.has(req.reqId)) return;
489
+ upstreamWs.delete(req.reqId);
490
+ if (opts.sink.isOpen) {
491
+ opts.sink.send({
492
+ t: "ws_close",
493
+ reqId: req.reqId,
494
+ code: 1011,
495
+ reason: `upstream_error: ${err.message}`
496
+ });
497
+ }
498
+ try {
499
+ upstream.close(1011, "upstream_error");
500
+ } catch {
501
+ }
502
+ });
503
+ }
226
504
  async function handleHttpRequest(req) {
227
505
  if (closed) return;
228
506
  const respond = (partial) => {
@@ -273,8 +551,17 @@ function createTunnelServer(opts) {
273
551
  }
274
552
  const url = `${opts.httpUpstream.replace(/\/$/, "")}${req.path}`;
275
553
  const controller = new AbortController();
276
- const timeoutMs = req.timeoutMs ?? 3e4;
277
- const timer = setTimeout(() => controller.abort(), timeoutMs);
554
+ const timeoutMs = req.timeoutMs ?? opts.httpForwardTimeoutMs ?? DEFAULT_HTTP_FORWARD_TIMEOUT_MS;
555
+ let timer = setTimeout(
556
+ () => controller.abort(),
557
+ timeoutMs
558
+ );
559
+ const clearTimer = () => {
560
+ if (timer) {
561
+ clearTimeout(timer);
562
+ timer = null;
563
+ }
564
+ };
278
565
  try {
279
566
  const upstreamRes = await fetch(url, {
280
567
  method: req.method,
@@ -284,12 +571,92 @@ function createTunnelServer(opts) {
284
571
  // node fetch follows redirects by default — fine for MCP, the
285
572
  // upstream gateway doesn't redirect anyway.
286
573
  });
287
- const buf = Buffer.from(await upstreamRes.arrayBuffer());
288
574
  const outHeaders = {};
289
575
  upstreamRes.headers.forEach((value, key) => {
290
576
  if (HOP_BY_HOP.has(key.toLowerCase())) return;
291
577
  outHeaders[key] = value;
292
578
  });
579
+ const contentType = (outHeaders["content-type"] ?? "").toLowerCase();
580
+ const isStream = contentType.startsWith("text/event-stream") || contentType.includes("application/x-ndjson");
581
+ if (isStream && upstreamRes.body) {
582
+ clearTimer();
583
+ opts.sink.send({
584
+ t: "http_response_head",
585
+ reqId: req.reqId,
586
+ status: upstreamRes.status,
587
+ headers: outHeaders
588
+ });
589
+ const reader = upstreamRes.body.getReader();
590
+ const idleMs = opts.httpStreamIdleTimeoutMs;
591
+ const IDLE = /* @__PURE__ */ Symbol("idle");
592
+ const readChunk = async () => {
593
+ if (!idleMs) return reader.read();
594
+ let idleTimer = null;
595
+ const idle = new Promise((resolve) => {
596
+ idleTimer = setTimeout(() => resolve(IDLE), idleMs);
597
+ if (typeof idleTimer !== "number") idleTimer.unref();
598
+ });
599
+ try {
600
+ return await Promise.race([reader.read(), idle]);
601
+ } finally {
602
+ if (idleTimer) clearTimeout(idleTimer);
603
+ }
604
+ };
605
+ try {
606
+ while (true) {
607
+ const chunk = await readChunk();
608
+ if (chunk === IDLE) {
609
+ try {
610
+ await reader.cancel();
611
+ } catch {
612
+ }
613
+ opts.sink.send({
614
+ t: "http_response_chunk",
615
+ reqId: req.reqId,
616
+ end: true,
617
+ error: {
618
+ code: "upstream_stream_idle_timeout",
619
+ message: `No data from upstream for ${idleMs}ms.`
620
+ }
621
+ });
622
+ return;
623
+ }
624
+ const { value, done } = chunk;
625
+ if (done) {
626
+ opts.sink.send({
627
+ t: "http_response_chunk",
628
+ reqId: req.reqId,
629
+ end: true
630
+ });
631
+ return;
632
+ }
633
+ if (value && value.byteLength > 0) {
634
+ opts.sink.send({
635
+ t: "http_response_chunk",
636
+ reqId: req.reqId,
637
+ data: encodeData(Buffer.from(value))
638
+ });
639
+ }
640
+ if (!opts.sink.isOpen) {
641
+ try {
642
+ await reader.cancel();
643
+ } catch {
644
+ }
645
+ return;
646
+ }
647
+ }
648
+ } catch (err) {
649
+ const message = err instanceof Error ? err.message : String(err);
650
+ opts.sink.send({
651
+ t: "http_response_chunk",
652
+ reqId: req.reqId,
653
+ end: true,
654
+ error: { code: "upstream_stream_failed", message }
655
+ });
656
+ }
657
+ return;
658
+ }
659
+ const buf = Buffer.from(await upstreamRes.arrayBuffer());
293
660
  respond({
294
661
  status: upstreamRes.status,
295
662
  headers: outHeaders,
@@ -306,7 +673,7 @@ function createTunnelServer(opts) {
306
673
  }
307
674
  });
308
675
  } finally {
309
- clearTimeout(timer);
676
+ clearTimer();
310
677
  }
311
678
  }
312
679
  return {
@@ -327,6 +694,8 @@ function createTunnelClient(opts) {
327
694
  const childByExec = /* @__PURE__ */ new Map();
328
695
  const spawnPending = /* @__PURE__ */ new Map();
329
696
  const httpPending = /* @__PURE__ */ new Map();
697
+ const wsOpenPending = /* @__PURE__ */ new Map();
698
+ const wsByReq = /* @__PURE__ */ new Map();
330
699
  const offFrame = opts.sink.onFrame((frame) => routeIncoming(frame));
331
700
  const offClose = opts.sink.onClose(() => {
332
701
  for (const [, duck] of childByExec) duck.__handleSinkClosed();
@@ -337,9 +706,29 @@ function createTunnelClient(opts) {
337
706
  spawnPending.clear();
338
707
  for (const [, p] of httpPending) {
339
708
  clearTimeout(p.timer);
340
- p.reject(new Error("Tunnel closed before HTTP response arrived."));
709
+ if (p.mode === "buffered") {
710
+ p.reject(new Error("Tunnel closed before HTTP response arrived."));
711
+ } else {
712
+ if (!p.headEmitted) {
713
+ p.reject(new Error("Tunnel closed before HTTP response head arrived."));
714
+ } else if (p.controller && !p.ended) {
715
+ try {
716
+ p.controller.error(new Error("Tunnel closed mid-stream."));
717
+ } catch {
718
+ }
719
+ }
720
+ }
341
721
  }
342
722
  httpPending.clear();
723
+ for (const [, p] of wsOpenPending) {
724
+ clearTimeout(p.timer);
725
+ p.reject(new Error("Tunnel closed before WS upgrade completed."));
726
+ }
727
+ wsOpenPending.clear();
728
+ for (const [, duck] of wsByReq) {
729
+ duck.__handleSinkClosed();
730
+ }
731
+ wsByReq.clear();
343
732
  offFrame();
344
733
  offClose();
345
734
  });
@@ -396,12 +785,171 @@ function createTunnelClient(opts) {
396
785
  if (!pending) return;
397
786
  httpPending.delete(frame.reqId);
398
787
  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
- });
788
+ const body = frame.body ? decodeData(frame.body) : Buffer.alloc(0);
789
+ if (pending.mode === "buffered") {
790
+ pending.resolve({
791
+ status: frame.status,
792
+ headers: frame.headers ?? {},
793
+ body,
794
+ ...frame.error ? { error: frame.error } : {}
795
+ });
796
+ } else {
797
+ const stream = new ReadableStream({
798
+ start(controller) {
799
+ if (body.length > 0) controller.enqueue(new Uint8Array(body));
800
+ controller.close();
801
+ }
802
+ });
803
+ pending.resolveHead({
804
+ status: frame.status,
805
+ headers: frame.headers ?? {},
806
+ body: stream
807
+ });
808
+ }
809
+ return;
810
+ }
811
+ case "http_response_head": {
812
+ const pending = httpPending.get(frame.reqId);
813
+ if (!pending) return;
814
+ const headers = frame.headers ?? {};
815
+ if (pending.mode === "stream") {
816
+ const stream = new ReadableStream({
817
+ start(controller) {
818
+ pending.controller = controller;
819
+ for (const chunk of pending.pendingChunks) {
820
+ if (chunk.length > 0) controller.enqueue(new Uint8Array(chunk));
821
+ }
822
+ pending.pendingChunks = [];
823
+ },
824
+ cancel: () => {
825
+ if (httpPending.get(frame.reqId) === pending) {
826
+ clearTimeout(pending.timer);
827
+ httpPending.delete(frame.reqId);
828
+ }
829
+ }
830
+ });
831
+ pending.headEmitted = true;
832
+ clearTimeout(pending.timer);
833
+ pending.resolveHead({
834
+ status: frame.status,
835
+ headers,
836
+ body: stream
837
+ });
838
+ } else {
839
+ pending.streamHead = { status: frame.status, headers };
840
+ }
841
+ return;
842
+ }
843
+ case "http_response_chunk": {
844
+ const pending = httpPending.get(frame.reqId);
845
+ if (!pending) return;
846
+ const data = frame.data ? decodeData(frame.data) : Buffer.alloc(0);
847
+ if (pending.mode === "stream") {
848
+ if (frame.error) {
849
+ try {
850
+ pending.controller?.error(
851
+ new Error(`${frame.error.code}: ${frame.error.message}`)
852
+ );
853
+ } catch {
854
+ }
855
+ pending.ended = true;
856
+ clearTimeout(pending.timer);
857
+ httpPending.delete(frame.reqId);
858
+ return;
859
+ }
860
+ if (pending.controller) {
861
+ if (data.length > 0) {
862
+ try {
863
+ pending.controller.enqueue(new Uint8Array(data));
864
+ } catch {
865
+ }
866
+ }
867
+ if (frame.end) {
868
+ try {
869
+ pending.controller.close();
870
+ } catch {
871
+ }
872
+ pending.ended = true;
873
+ clearTimeout(pending.timer);
874
+ httpPending.delete(frame.reqId);
875
+ }
876
+ } else {
877
+ if (data.length > 0) pending.pendingChunks.push(data);
878
+ if (frame.end) {
879
+ pending.ended = true;
880
+ }
881
+ }
882
+ } else {
883
+ if (data.length > 0) pending.streamChunks.push(data);
884
+ if (frame.error) {
885
+ clearTimeout(pending.timer);
886
+ httpPending.delete(frame.reqId);
887
+ const concatenated = Buffer.concat(pending.streamChunks);
888
+ pending.resolve({
889
+ status: pending.streamHead?.status ?? 502,
890
+ headers: pending.streamHead?.headers ?? {},
891
+ body: concatenated,
892
+ error: frame.error
893
+ });
894
+ return;
895
+ }
896
+ if (frame.end) {
897
+ clearTimeout(pending.timer);
898
+ httpPending.delete(frame.reqId);
899
+ const concatenated = Buffer.concat(pending.streamChunks);
900
+ pending.resolve({
901
+ status: pending.streamHead?.status ?? 200,
902
+ headers: pending.streamHead?.headers ?? {},
903
+ body: concatenated
904
+ });
905
+ }
906
+ }
907
+ return;
908
+ }
909
+ case "ws_open_ack": {
910
+ const pending = wsOpenPending.get(frame.reqId);
911
+ if (!pending) return;
912
+ wsOpenPending.delete(frame.reqId);
913
+ clearTimeout(pending.timer);
914
+ if (frame.error || frame.status !== 101) {
915
+ const code = frame.error?.code ?? `http_${frame.status}`;
916
+ const msg = frame.error?.message ?? `Upstream WS upgrade returned ${frame.status}`;
917
+ pending.reject(new Error(`${code}: ${msg}`));
918
+ return;
919
+ }
920
+ const duck = new TunnelWebSocketDuck(
921
+ frame.reqId,
922
+ frame.protocol ?? null,
923
+ opts.sink,
924
+ () => wsByReq.delete(frame.reqId)
925
+ );
926
+ wsByReq.set(frame.reqId, duck);
927
+ pending.resolve(duck);
928
+ return;
929
+ }
930
+ case "ws_message": {
931
+ const duck = wsByReq.get(frame.reqId);
932
+ if (!duck) return;
933
+ duck.__handleMessage(decodeData(frame.data), frame.binary === true);
934
+ return;
935
+ }
936
+ case "ws_close": {
937
+ const pending = wsOpenPending.get(frame.reqId);
938
+ if (pending) {
939
+ wsOpenPending.delete(frame.reqId);
940
+ clearTimeout(pending.timer);
941
+ pending.reject(
942
+ new Error(
943
+ `WS closed before upgrade (code ${frame.code}${frame.reason ? `: ${frame.reason}` : ""})`
944
+ )
945
+ );
946
+ return;
947
+ }
948
+ const duck = wsByReq.get(frame.reqId);
949
+ if (duck) {
950
+ wsByReq.delete(frame.reqId);
951
+ duck.__handleClose(frame.code, frame.reason ?? "");
952
+ }
405
953
  return;
406
954
  }
407
955
  case "ping":
@@ -413,6 +961,7 @@ function createTunnelClient(opts) {
413
961
  case "kill":
414
962
  case "resize":
415
963
  case "http_request":
964
+ case "ws_open":
416
965
  return;
417
966
  }
418
967
  }
@@ -461,7 +1010,8 @@ function createTunnelClient(opts) {
461
1010
  args,
462
1011
  cwd: spawnOpts?.cwd,
463
1012
  env: spawnOpts?.env,
464
- pty: spawnOpts?.pty
1013
+ pty: spawnOpts?.pty,
1014
+ ...spawnOpts?.pty ? { cols: spawnOpts.cols ?? 80, rows: spawnOpts.rows ?? 24 } : {}
465
1015
  };
466
1016
  const spawnedFrame = await new Promise(
467
1017
  (resolve, reject) => {
@@ -498,7 +1048,86 @@ function createTunnelClient(opts) {
498
1048
  )
499
1049
  );
500
1050
  }, timeoutMs + 5e3);
501
- httpPending.set(reqId, { resolve, reject, timer });
1051
+ httpPending.set(reqId, {
1052
+ mode: "buffered",
1053
+ resolve,
1054
+ reject,
1055
+ timer,
1056
+ streamHead: null,
1057
+ streamChunks: []
1058
+ });
1059
+ opts.sink.send(frame);
1060
+ });
1061
+ },
1062
+ async forwardHttpStream(req) {
1063
+ if (!hello) await this.ready();
1064
+ const reqId = randomUUID();
1065
+ const headTimeoutMs = req.timeoutMs ?? 3e4;
1066
+ const body = req.body === void 0 ? void 0 : encodeData(
1067
+ typeof req.body === "string" ? req.body : Buffer.from(req.body)
1068
+ );
1069
+ const frame = {
1070
+ t: "http_request",
1071
+ reqId,
1072
+ method: req.method,
1073
+ path: req.path,
1074
+ ...req.headers ? { headers: req.headers } : {},
1075
+ ...body !== void 0 ? { body } : {},
1076
+ timeoutMs: 24 * 60 * 60 * 1e3
1077
+ // 24h — effectively "forever"
1078
+ };
1079
+ return new Promise((resolveHead, reject) => {
1080
+ const timer = setTimeout(() => {
1081
+ if (httpPending.has(reqId)) {
1082
+ httpPending.delete(reqId);
1083
+ reject(
1084
+ new Error(
1085
+ `Tunnel forwardHttpStream(${req.method} ${req.path}) head did not arrive within ${headTimeoutMs}ms.`
1086
+ )
1087
+ );
1088
+ }
1089
+ }, headTimeoutMs);
1090
+ httpPending.set(reqId, {
1091
+ mode: "stream",
1092
+ resolveHead,
1093
+ reject,
1094
+ timer,
1095
+ controller: null,
1096
+ pendingChunks: [],
1097
+ headEmitted: false,
1098
+ ended: false
1099
+ });
1100
+ opts.sink.send(frame);
1101
+ });
1102
+ },
1103
+ async forwardWebSocket(req) {
1104
+ const h = await this.ready();
1105
+ if (h.capabilities.wsForward !== true) {
1106
+ throw new Error(
1107
+ `Tunnel daemon '${h.label ?? "(unnamed)"}' does not advertise wsForward capability. Update the daemon to forward browser WS upgrades.`
1108
+ );
1109
+ }
1110
+ const reqId = randomUUID();
1111
+ const timeoutMs = req.openTimeoutMs ?? 3e4;
1112
+ const frame = {
1113
+ t: "ws_open",
1114
+ reqId,
1115
+ path: req.path,
1116
+ ...req.upstream ? { upstream: req.upstream } : {},
1117
+ ...req.headers ? { headers: req.headers } : {},
1118
+ ...req.protocols && req.protocols.length > 0 ? { protocols: req.protocols } : {}
1119
+ };
1120
+ return new Promise((resolve, reject) => {
1121
+ const timer = setTimeout(() => {
1122
+ if (!wsOpenPending.has(reqId)) return;
1123
+ wsOpenPending.delete(reqId);
1124
+ reject(
1125
+ new Error(
1126
+ `Tunnel forwardWebSocket(${req.path}) did not receive ws_open_ack within ${timeoutMs}ms.`
1127
+ )
1128
+ );
1129
+ }, timeoutMs);
1130
+ wsOpenPending.set(reqId, { resolve, reject, timer });
502
1131
  opts.sink.send(frame);
503
1132
  });
504
1133
  },
@@ -513,11 +1142,13 @@ var TunnelChildDuck = class extends EventEmitter {
513
1142
  stdin;
514
1143
  stdout;
515
1144
  stderr;
1145
+ sink;
516
1146
  exited = false;
517
1147
  constructor(execId, pid, sink) {
518
1148
  super();
519
1149
  this.execId = execId;
520
1150
  this.pid = pid > 0 ? pid : null;
1151
+ this.sink = sink;
521
1152
  this.stdout = new Readable({ read() {
522
1153
  } });
523
1154
  this.stderr = new Readable({ read() {
@@ -540,10 +1171,6 @@ var TunnelChildDuck = class extends EventEmitter {
540
1171
  cb();
541
1172
  }
542
1173
  });
543
- Object.defineProperty(this, "__sink", {
544
- value: sink,
545
- enumerable: false
546
- });
547
1174
  }
548
1175
  __pushStdout(buf) {
549
1176
  this.stdout.push(buf);
@@ -575,14 +1202,104 @@ var TunnelChildDuck = class extends EventEmitter {
575
1202
  }
576
1203
  kill(signal = "SIGTERM") {
577
1204
  if (this.exited) return false;
578
- const sink = this.__sink;
579
- sink.send({
1205
+ this.sink.send({
580
1206
  t: "kill",
581
1207
  execId: this.execId,
582
1208
  signal: typeof signal === "string" ? signal : `SIG${signal}`
583
1209
  });
584
1210
  return true;
585
1211
  }
1212
+ resize(cols, rows) {
1213
+ if (this.exited) return;
1214
+ this.sink.send({ t: "resize", execId: this.execId, cols, rows });
1215
+ }
1216
+ };
1217
+ var TunnelWebSocketDuck = class {
1218
+ protocol;
1219
+ _readyState = "open";
1220
+ reqId;
1221
+ sink;
1222
+ onCleanup;
1223
+ messageListeners = /* @__PURE__ */ new Set();
1224
+ closeListeners = /* @__PURE__ */ new Set();
1225
+ errorListeners = /* @__PURE__ */ new Set();
1226
+ constructor(reqId, protocol, sink, onCleanup) {
1227
+ this.reqId = reqId;
1228
+ this.protocol = protocol;
1229
+ this.sink = sink;
1230
+ this.onCleanup = onCleanup;
1231
+ }
1232
+ get readyState() {
1233
+ return this._readyState;
1234
+ }
1235
+ send(data) {
1236
+ if (this._readyState !== "open") return;
1237
+ const bytes = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
1238
+ this.sink.send({
1239
+ t: "ws_message",
1240
+ reqId: this.reqId,
1241
+ data: encodeData(bytes),
1242
+ binary: typeof data !== "string"
1243
+ });
1244
+ }
1245
+ close(code = 1e3, reason) {
1246
+ if (this._readyState === "closed") return;
1247
+ if (this._readyState === "open") {
1248
+ this._readyState = "closing";
1249
+ this.sink.send({
1250
+ t: "ws_close",
1251
+ reqId: this.reqId,
1252
+ code,
1253
+ ...reason ? { reason } : {}
1254
+ });
1255
+ }
1256
+ this.__handleClose(code, reason ?? "");
1257
+ }
1258
+ onMessage(listener) {
1259
+ this.messageListeners.add(listener);
1260
+ return () => this.messageListeners.delete(listener);
1261
+ }
1262
+ onClose(listener) {
1263
+ this.closeListeners.add(listener);
1264
+ return () => this.closeListeners.delete(listener);
1265
+ }
1266
+ onError(listener) {
1267
+ this.errorListeners.add(listener);
1268
+ return () => this.errorListeners.delete(listener);
1269
+ }
1270
+ __handleMessage(data, isBinary) {
1271
+ if (this._readyState !== "open") return;
1272
+ for (const l of this.messageListeners) {
1273
+ try {
1274
+ l(data, isBinary);
1275
+ } catch {
1276
+ }
1277
+ }
1278
+ }
1279
+ __handleClose(code, reason) {
1280
+ if (this._readyState === "closed") return;
1281
+ this._readyState = "closed";
1282
+ this.onCleanup();
1283
+ for (const l of this.closeListeners) {
1284
+ try {
1285
+ l(code, reason);
1286
+ } catch {
1287
+ }
1288
+ }
1289
+ this.messageListeners.clear();
1290
+ this.closeListeners.clear();
1291
+ this.errorListeners.clear();
1292
+ }
1293
+ __handleSinkClosed() {
1294
+ if (this._readyState === "closed") return;
1295
+ for (const l of this.errorListeners) {
1296
+ try {
1297
+ l(new Error("Tunnel closed mid-WS."));
1298
+ } catch {
1299
+ }
1300
+ }
1301
+ this.__handleClose(1006, "tunnel_closed");
1302
+ }
586
1303
  };
587
1304
 
588
1305
  // src/tunnel/ws-adapter.ts
@@ -646,6 +1363,6 @@ function wrapWebSocket(ws) {
646
1363
  };
647
1364
  }
648
1365
 
649
- export { TUNNEL_VERSION, createTunnelClient, createTunnelServer, decodeData, encodeData, encodeFrame, parseFrame, wrapWebSocket };
1366
+ export { DEFAULT_HTTP_FORWARD_TIMEOUT_MS, DEFAULT_WS_DIAL_TIMEOUT_MS, TUNNEL_VERSION, createTunnelClient, createTunnelServer, decodeData, encodeData, encodeFrame, parseFrame, wrapWebSocket };
650
1367
  //# sourceMappingURL=index.mjs.map
651
1368
  //# sourceMappingURL=index.mjs.map