@inkbox/sdk 0.2.16 → 0.3.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.
Files changed (119) hide show
  1. package/README.md +10 -1
  2. package/dist/_http.d.ts +24 -5
  3. package/dist/_http.d.ts.map +1 -1
  4. package/dist/_http.js +21 -11
  5. package/dist/_http.js.map +1 -1
  6. package/dist/index.d.ts +4 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +3 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/inkbox.d.ts +4 -0
  11. package/dist/inkbox.d.ts.map +1 -1
  12. package/dist/inkbox.js +5 -0
  13. package/dist/inkbox.js.map +1 -1
  14. package/dist/tunnels/_validation.d.ts +7 -0
  15. package/dist/tunnels/_validation.d.ts.map +1 -0
  16. package/dist/tunnels/_validation.js +27 -0
  17. package/dist/tunnels/_validation.js.map +1 -0
  18. package/dist/tunnels/client/_bridge.d.ts +35 -0
  19. package/dist/tunnels/client/_bridge.d.ts.map +1 -0
  20. package/dist/tunnels/client/_bridge.js +52 -0
  21. package/dist/tunnels/client/_bridge.js.map +1 -0
  22. package/dist/tunnels/client/_callable_streaming.d.ts +25 -0
  23. package/dist/tunnels/client/_callable_streaming.d.ts.map +1 -0
  24. package/dist/tunnels/client/_callable_streaming.js +158 -0
  25. package/dist/tunnels/client/_callable_streaming.js.map +1 -0
  26. package/dist/tunnels/client/_cert.d.ts +45 -0
  27. package/dist/tunnels/client/_cert.d.ts.map +1 -0
  28. package/dist/tunnels/client/_cert.js +193 -0
  29. package/dist/tunnels/client/_cert.js.map +1 -0
  30. package/dist/tunnels/client/_dispatch.d.ts +109 -0
  31. package/dist/tunnels/client/_dispatch.d.ts.map +1 -0
  32. package/dist/tunnels/client/_dispatch.js +314 -0
  33. package/dist/tunnels/client/_dispatch.js.map +1 -0
  34. package/dist/tunnels/client/_envelope.d.ts +55 -0
  35. package/dist/tunnels/client/_envelope.d.ts.map +1 -0
  36. package/dist/tunnels/client/_envelope.js +97 -0
  37. package/dist/tunnels/client/_envelope.js.map +1 -0
  38. package/dist/tunnels/client/_h1_server.d.ts +37 -0
  39. package/dist/tunnels/client/_h1_server.d.ts.map +1 -0
  40. package/dist/tunnels/client/_h1_server.js +433 -0
  41. package/dist/tunnels/client/_h1_server.js.map +1 -0
  42. package/dist/tunnels/client/_h2_transcode.d.ts +43 -0
  43. package/dist/tunnels/client/_h2_transcode.d.ts.map +1 -0
  44. package/dist/tunnels/client/_h2_transcode.js +488 -0
  45. package/dist/tunnels/client/_h2_transcode.js.map +1 -0
  46. package/dist/tunnels/client/_handler.d.ts +62 -0
  47. package/dist/tunnels/client/_handler.d.ts.map +1 -0
  48. package/dist/tunnels/client/_handler.js +121 -0
  49. package/dist/tunnels/client/_handler.js.map +1 -0
  50. package/dist/tunnels/client/_listener.d.ts +64 -0
  51. package/dist/tunnels/client/_listener.d.ts.map +1 -0
  52. package/dist/tunnels/client/_listener.js +113 -0
  53. package/dist/tunnels/client/_listener.js.map +1 -0
  54. package/dist/tunnels/client/_protocol.d.ts +67 -0
  55. package/dist/tunnels/client/_protocol.d.ts.map +1 -0
  56. package/dist/tunnels/client/_protocol.js +86 -0
  57. package/dist/tunnels/client/_protocol.js.map +1 -0
  58. package/dist/tunnels/client/_runtime.d.ts +143 -0
  59. package/dist/tunnels/client/_runtime.d.ts.map +1 -0
  60. package/dist/tunnels/client/_runtime.js +1679 -0
  61. package/dist/tunnels/client/_runtime.js.map +1 -0
  62. package/dist/tunnels/client/_state.d.ts +45 -0
  63. package/dist/tunnels/client/_state.d.ts.map +1 -0
  64. package/dist/tunnels/client/_state.js +165 -0
  65. package/dist/tunnels/client/_state.js.map +1 -0
  66. package/dist/tunnels/client/_tls.d.ts +50 -0
  67. package/dist/tunnels/client/_tls.d.ts.map +1 -0
  68. package/dist/tunnels/client/_tls.js +139 -0
  69. package/dist/tunnels/client/_tls.js.map +1 -0
  70. package/dist/tunnels/client/_upstream_tls.d.ts +25 -0
  71. package/dist/tunnels/client/_upstream_tls.d.ts.map +1 -0
  72. package/dist/tunnels/client/_upstream_tls.js +24 -0
  73. package/dist/tunnels/client/_upstream_tls.js.map +1 -0
  74. package/dist/tunnels/client/_url_forward.d.ts +92 -0
  75. package/dist/tunnels/client/_url_forward.d.ts.map +1 -0
  76. package/dist/tunnels/client/_url_forward.js +255 -0
  77. package/dist/tunnels/client/_url_forward.js.map +1 -0
  78. package/dist/tunnels/client/_validation.d.ts +27 -0
  79. package/dist/tunnels/client/_validation.d.ts.map +1 -0
  80. package/dist/tunnels/client/_validation.js +96 -0
  81. package/dist/tunnels/client/_validation.js.map +1 -0
  82. package/dist/tunnels/client/_ws.d.ts +149 -0
  83. package/dist/tunnels/client/_ws.d.ts.map +1 -0
  84. package/dist/tunnels/client/_ws.js +351 -0
  85. package/dist/tunnels/client/_ws.js.map +1 -0
  86. package/dist/tunnels/client/_ws_passthrough.d.ts +129 -0
  87. package/dist/tunnels/client/_ws_passthrough.d.ts.map +1 -0
  88. package/dist/tunnels/client/_ws_passthrough.js +432 -0
  89. package/dist/tunnels/client/_ws_passthrough.js.map +1 -0
  90. package/dist/tunnels/client/_ws_url_bridge.d.ts +71 -0
  91. package/dist/tunnels/client/_ws_url_bridge.d.ts.map +1 -0
  92. package/dist/tunnels/client/_ws_url_bridge.js +474 -0
  93. package/dist/tunnels/client/_ws_url_bridge.js.map +1 -0
  94. package/dist/tunnels/client/_ws_url_edge_bridge.d.ts +26 -0
  95. package/dist/tunnels/client/_ws_url_edge_bridge.d.ts.map +1 -0
  96. package/dist/tunnels/client/_ws_url_edge_bridge.js +256 -0
  97. package/dist/tunnels/client/_ws_url_edge_bridge.js.map +1 -0
  98. package/dist/tunnels/client/_wsframe.d.ts +142 -0
  99. package/dist/tunnels/client/_wsframe.d.ts.map +1 -0
  100. package/dist/tunnels/client/_wsframe.js +282 -0
  101. package/dist/tunnels/client/_wsframe.js.map +1 -0
  102. package/dist/tunnels/client/index.d.ts +101 -0
  103. package/dist/tunnels/client/index.d.ts.map +1 -0
  104. package/dist/tunnels/client/index.js +242 -0
  105. package/dist/tunnels/client/index.js.map +1 -0
  106. package/dist/tunnels/exceptions.d.ts +31 -0
  107. package/dist/tunnels/exceptions.d.ts.map +1 -0
  108. package/dist/tunnels/exceptions.js +68 -0
  109. package/dist/tunnels/exceptions.js.map +1 -0
  110. package/dist/tunnels/resources/tunnels.d.ts +73 -0
  111. package/dist/tunnels/resources/tunnels.d.ts.map +1 -0
  112. package/dist/tunnels/resources/tunnels.js +173 -0
  113. package/dist/tunnels/resources/tunnels.js.map +1 -0
  114. package/dist/tunnels/types.d.ts +99 -0
  115. package/dist/tunnels/types.d.ts.map +1 -0
  116. package/dist/tunnels/types.js +76 -0
  117. package/dist/tunnels/types.js.map +1 -0
  118. package/package.json +14 -5
  119. package/protocol/tunnel_protocol_constants.json +65 -0
@@ -0,0 +1,1679 @@
1
+ /**
2
+ * inkbox-tunnels/client/_runtime.ts
3
+ *
4
+ * The h2 data-plane runtime (Node-only). Maintains one persistent
5
+ * HTTP/2 connection to `https://{zone}/_system/connect`, parks N
6
+ * intake streams, dispatches envelopes (HTTP / WS upgrade / passthrough
7
+ * TCP-stream), and manages flow control + reconnect.
8
+ *
9
+ * Mirrors Python `_runtime.py` at the wire level. The TS-side API
10
+ * shape diverges where Python exposed ASGI 3.0 — we expose Web
11
+ * standards (Fetch API, `InkboxWebSocket`) instead. The on-wire
12
+ * behavior is identical.
13
+ *
14
+ * Flow-control caveat: Node's high-level h2 server auto-credits, so
15
+ * the `awaitWindow` / `markWindowBlocked` / per-stream send-window
16
+ * gate paths are unit-tested only and not exercised through a real h2
17
+ * stack against a real flow-control sequence.
18
+ */
19
+ import * as http2 from "node:http2";
20
+ import { setTimeout as setTimeoutPromise } from "node:timers/promises";
21
+ import { BRIDGE_CLEANUP_SEND_TIMEOUT_MS, BRIDGE_CLOSE_CODE, BRIDGE_HALF_CLOSE_GRACE_MS, BRIDGE_STATUS_TIMEOUT_MS, BridgeProtocolError, BridgeStreamReset, makeBridgeStats, } from "./_bridge.js";
22
+ import { filterResponseHeaders, parseEnvelope, } from "./_envelope.js";
23
+ import { dispatchHttpInProcess, } from "./_handler.js";
24
+ import { ControlHeaders, ControlPaths, HOP_BY_HOP_RESPONSE, INKBOX_FORWARDED_HEADER_PREFIX, TunnelMetaHeader, TunnelRouteKind, TunnelSubprotocol, } from "./_protocol.js";
25
+ import { validateEnvelopePath } from "./_validation.js";
26
+ import { createUndiciAgentCache, forwardEnvelopeToUrl, } from "./_url_forward.js";
27
+ import { WS_OPCODE_BINARY, WS_OPCODE_CLOSE, WS_OPCODE_CONTINUATION, WS_OPCODE_PING, WS_OPCODE_PONG, WS_OPCODE_TEXT, WsFrameDecoder, encodeWsEnvelope, encodeWsFrame, } from "./_wsframe.js";
28
+ import { dispatchWsUpgradeInProcess, } from "./_ws.js";
29
+ const HTTP2_HEADER_METHOD = http2.constants.HTTP2_HEADER_METHOD;
30
+ const HTTP2_HEADER_PATH = http2.constants.HTTP2_HEADER_PATH;
31
+ const HTTP2_HEADER_SCHEME = http2.constants.HTTP2_HEADER_SCHEME;
32
+ const HTTP2_HEADER_AUTHORITY = http2.constants.HTTP2_HEADER_AUTHORITY;
33
+ const HTTP2_HEADER_STATUS = http2.constants.HTTP2_HEADER_STATUS;
34
+ export const PING_INTERVAL_MS = 20_000;
35
+ /**
36
+ * Force-reconnect window if a PING goes unacked. Long enough to absorb
37
+ * a slow path's RTT (multi-hop NLB, congested link), short enough that
38
+ * a dead TCP doesn't strand the runtime past the next intake.
39
+ */
40
+ export const PING_ACK_TIMEOUT_MS = 10_000;
41
+ export const BACKOFF_CAP_SEC = 30.0;
42
+ export const BACKOFF_JITTER = 0.25;
43
+ export const DEFAULT_INBOUND_BODY_BYTES = 32 * 1024 * 1024;
44
+ export const DEFAULT_OUTBOUND_BODY_BYTES = 32 * 1024 * 1024;
45
+ export class TunnelAuthError extends Error {
46
+ constructor(message) {
47
+ super(message);
48
+ this.name = "TunnelAuthError";
49
+ }
50
+ }
51
+ class OwnerTokenInvalidError extends Error {
52
+ constructor(message) {
53
+ super(message);
54
+ this.name = "OwnerTokenInvalidError";
55
+ }
56
+ }
57
+ /**
58
+ * True iff the error indicates the h2 session is terminally gone —
59
+ * any further ``openStream`` against this session will throw the
60
+ * same error. Caller's responsibility is to STOP retrying and let
61
+ * ``serveForever`` reconnect, not to spin on the corpse.
62
+ *
63
+ * Recognized codes:
64
+ * * ``ERR_HTTP2_INVALID_SESSION`` — Node throws this when
65
+ * ``ClientHttp2Session.request`` is called after the session has
66
+ * been destroyed.
67
+ * * ``ERR_HTTP2_GOAWAY_SESSION`` — Node throws this when a stream
68
+ * is opened against a session that has received GOAWAY.
69
+ * * ``ERR_HTTP2_STREAM_CANCEL`` (less common at session level) —
70
+ * surfaces in some teardown races.
71
+ */
72
+ function isSessionTerminalError(err) {
73
+ if (typeof err !== "object" || err === null)
74
+ return false;
75
+ const code = err.code;
76
+ if (typeof code !== "string")
77
+ return false;
78
+ return (code === "ERR_HTTP2_INVALID_SESSION" ||
79
+ code === "ERR_HTTP2_GOAWAY_SESSION" ||
80
+ code === "ERR_HTTP2_STREAM_CANCEL" ||
81
+ code === "ERR_HTTP2_SESSION_ERROR");
82
+ }
83
+ /**
84
+ * The data-plane runtime. Construct with the bootstrap-derived
85
+ * tunnelId/secret/zone/publicHost; call `serveForever()` to drive it,
86
+ * `aclose()` to shut down.
87
+ */
88
+ export class TunnelRuntime {
89
+ tunnelId;
90
+ secret;
91
+ zone;
92
+ publicHost;
93
+ poolSize;
94
+ dispatch;
95
+ maxInbound;
96
+ maxOutbound;
97
+ tlsTerminator;
98
+ forwardToVerifyTls;
99
+ forwardToCaBundle;
100
+ onStatus;
101
+ rng;
102
+ http2Connect;
103
+ session = null;
104
+ ownerToken = null;
105
+ serverPoolSize = null;
106
+ intakeIdleSeconds = null;
107
+ responseDeadlineSeconds = null;
108
+ stop = false;
109
+ streams = new Map();
110
+ bridgeStreamIds = new Set();
111
+ tasks = new Set();
112
+ // Lazy: built on first passthrough TCP stream; closed in aclose().
113
+ passthroughDispatch = null;
114
+ // Cache of undici Agent instances for HTTPS URL-forward with TLS
115
+ // overrides (verifyTls=false or caBundle set). Avoids constructing a
116
+ // fresh Agent per request, which would leak sockets/timers. Closed
117
+ // in aclose().
118
+ undiciAgentCache = createUndiciAgentCache();
119
+ pingHandle = null;
120
+ pingAbort = null;
121
+ shutdownAbort = new AbortController();
122
+ constructor(opts) {
123
+ this.tunnelId = opts.tunnelId;
124
+ this.secret = opts.secret;
125
+ this.zone = opts.zone;
126
+ this.publicHost = opts.publicHost;
127
+ this.poolSize = opts.poolSize;
128
+ this.dispatch = opts.dispatch;
129
+ this.maxInbound = opts.maxInboundBodyBytes ?? DEFAULT_INBOUND_BODY_BYTES;
130
+ this.maxOutbound = opts.maxResponseBytes ?? DEFAULT_OUTBOUND_BODY_BYTES;
131
+ this.tlsTerminator = opts.tlsTerminator ?? null;
132
+ this.forwardToVerifyTls = opts.forwardToVerifyTls ?? true;
133
+ this.forwardToCaBundle = opts.forwardToCaBundle ?? null;
134
+ this.onStatus = opts.onStatus;
135
+ this.rng = opts.rng ?? Math.random;
136
+ this.http2Connect = opts.http2Connect ?? http2.connect.bind(http2);
137
+ }
138
+ // --- public lifecycle ---------------------------------------------------
139
+ /**
140
+ * Drive the runtime forever. Reconnects with jittered exponential
141
+ * backoff; rejects only on permanent auth failure (rotate the
142
+ * secret) or after `aclose()`.
143
+ */
144
+ async serveForever() {
145
+ let backoff = 1.0;
146
+ let consecutiveFailures = 0;
147
+ this.notifyStatus("connecting");
148
+ while (!this.stop) {
149
+ try {
150
+ await this.runOnce();
151
+ backoff = 1.0;
152
+ consecutiveFailures = 0;
153
+ }
154
+ catch (err) {
155
+ if (err instanceof TunnelAuthError) {
156
+ this.notifyStatus("closed");
157
+ throw err;
158
+ }
159
+ consecutiveFailures += 1;
160
+ // eslint-disable-next-line no-console
161
+ console.warn(`tunnel runtime: connection error (#${consecutiveFailures}); reconnecting`, err);
162
+ this.notifyStatus("reconnecting");
163
+ }
164
+ if (this.stop) {
165
+ this.notifyStatus("closed");
166
+ return;
167
+ }
168
+ // Exact Python parity (verified _runtime.py:240):
169
+ // jitter = backoff * 0.25 * (2*rng() - 1)
170
+ // sleep = max(0.1, backoff + jitter)
171
+ // backoff = min(backoff * 2, 30.0)
172
+ const jitter = backoff * BACKOFF_JITTER * (2 * this.rng() - 1);
173
+ const sleepFor = Math.max(0.1, backoff + jitter);
174
+ try {
175
+ await setTimeoutPromise(sleepFor * 1000, undefined, {
176
+ signal: this.shutdownAbort.signal,
177
+ });
178
+ }
179
+ catch {
180
+ // aborted by aclose()
181
+ this.notifyStatus("closed");
182
+ return;
183
+ }
184
+ backoff = Math.min(backoff * 2, BACKOFF_CAP_SEC);
185
+ }
186
+ this.notifyStatus("closed");
187
+ }
188
+ /** Graceful shutdown. Signals all loops to exit; closes the h2 session. */
189
+ async aclose() {
190
+ this.stop = true;
191
+ this.shutdownAbort.abort();
192
+ this.pingAbort?.abort();
193
+ if (this.passthroughDispatch !== null) {
194
+ try {
195
+ await this.passthroughDispatch.aclose();
196
+ }
197
+ catch {
198
+ /* swallow */
199
+ }
200
+ this.passthroughDispatch = null;
201
+ }
202
+ try {
203
+ await this.undiciAgentCache.close();
204
+ }
205
+ catch {
206
+ /* swallow */
207
+ }
208
+ if (this.pingHandle !== null) {
209
+ clearInterval(this.pingHandle);
210
+ this.pingHandle = null;
211
+ }
212
+ const session = this.session;
213
+ if (session !== null && !session.closed) {
214
+ // Tier 1 of the GOAWAY fallback ladder: high-level close().
215
+ // Node's `Http2Session.close()` waits for in-flight streams to
216
+ // drain before emitting `close`. The intake pool parks streams
217
+ // indefinitely, so we explicitly emit GOAWAY and then destroy
218
+ // after a short grace — this is Tier 4 of the ladder
219
+ // (bounded drain timeout) and is a known divergence from
220
+ // Python's no-timeout aclose().
221
+ try {
222
+ session.goaway();
223
+ }
224
+ catch {
225
+ /* swallow */
226
+ }
227
+ // Cancel parked intake streams: best-effort.
228
+ for (const sid of this.streams.keys()) {
229
+ try {
230
+ // node:http2 does not surface a per-stream cancel from the
231
+ // session-level alone; ignore — destroy below tears down all
232
+ // streams atomically.
233
+ void sid;
234
+ }
235
+ catch {
236
+ /* swallow */
237
+ }
238
+ }
239
+ // Bounded drain: 250ms. Then destroy.
240
+ await new Promise((resolve) => {
241
+ const t = setTimeout(() => {
242
+ try {
243
+ session.destroy();
244
+ }
245
+ catch {
246
+ /* swallow */
247
+ }
248
+ resolve();
249
+ }, 250);
250
+ session.once("close", () => {
251
+ clearTimeout(t);
252
+ resolve();
253
+ });
254
+ try {
255
+ session.close();
256
+ }
257
+ catch {
258
+ clearTimeout(t);
259
+ try {
260
+ session.destroy();
261
+ }
262
+ catch {
263
+ /* swallow */
264
+ }
265
+ resolve();
266
+ }
267
+ });
268
+ }
269
+ }
270
+ // --- per-connection lifecycle -----------------------------------------
271
+ async runOnce() {
272
+ await this.openConnection();
273
+ try {
274
+ await this.sendHello();
275
+ this.notifyStatus("connected");
276
+ const effectivePool = this.serverPoolSize ?? this.poolSize ?? 1;
277
+ const intakes = [];
278
+ for (let slot = 0; slot < effectivePool; slot++) {
279
+ intakes.push(this.intakeLoop(slot));
280
+ }
281
+ this.startPingLoop();
282
+ // Wait for the session to close (read pump implicitly drives via
283
+ // `session.on('close', ...)`).
284
+ await this.waitForSessionClose();
285
+ // Cancel intake loops; they exit when stop is set or session
286
+ // closes (read pump drains queue events).
287
+ await Promise.allSettled(intakes);
288
+ }
289
+ finally {
290
+ this.stopPingLoop();
291
+ this.streams.clear();
292
+ this.bridgeStreamIds.clear();
293
+ this.session = null;
294
+ }
295
+ }
296
+ async openConnection() {
297
+ const authority = `https://${this.zone}`;
298
+ const session = this.http2Connect(authority, {
299
+ ALPNProtocols: ["h2"],
300
+ // Note: we deliberately do NOT set ENABLE_CONNECT_PROTOCOL on
301
+ // local settings. Per RFC 8441 §3 that setting is server-to-
302
+ // client; Python sets it as a hyper-h2 library validator
303
+ // workaround. Node http2 either accepts `:protocol` or doesn't
304
+ // (Spike 1) — the setting line doesn't translate.
305
+ });
306
+ this.session = session;
307
+ session.on("close", () => {
308
+ // eslint-disable-next-line no-console
309
+ console.info("tunnel runtime: h2 session closed");
310
+ // Drain all open streams with a synthetic reset event so any
311
+ // awaiters wake up.
312
+ for (const [, bus] of this.streams) {
313
+ if (!bus.ended) {
314
+ bus.events.push({ kind: "reset", code: 0 });
315
+ bus.ended = true;
316
+ this.wake(bus);
317
+ }
318
+ }
319
+ });
320
+ session.on("error", (err) => {
321
+ // Visibility into session-fatal errors. Stream-level errors
322
+ // surface separately via stream events; this is genuinely
323
+ // session-terminal.
324
+ // eslint-disable-next-line no-console
325
+ console.warn("tunnel runtime: h2 session error", err);
326
+ });
327
+ session.on("goaway", (errorCode, lastStreamId) => {
328
+ // eslint-disable-next-line no-console
329
+ console.info(`tunnel runtime: GOAWAY received error_code=${errorCode} last_stream_id=${lastStreamId}`);
330
+ });
331
+ // Watch the underlying TCP/TLS socket directly. Node's h2 client
332
+ // sometimes loses the connection without emitting ``error`` or
333
+ // ``close`` on the session itself — the underlying socket reliably
334
+ // emits them. Force-destroy the session on either so
335
+ // ``waitForSessionClose`` resolves promptly and ``serveForever``
336
+ // reconnects without waiting for the ``PING_ACK_TIMEOUT_MS`` window.
337
+ try {
338
+ const sock = session.socket;
339
+ const onSocketDeath = (label, err) => {
340
+ // eslint-disable-next-line no-console
341
+ console.info(`tunnel runtime: underlying socket ${label}` +
342
+ (err !== undefined ? ` err=${err.message}` : ""));
343
+ if (!session.closed && !session.destroyed) {
344
+ // Forensic — log the stack so we can see which path
345
+ // triggered this destroy in production.
346
+ // eslint-disable-next-line no-console
347
+ console.warn("tunnel runtime: forcing session.destroy() from socket-death", new Error("trace").stack);
348
+ try {
349
+ session.destroy();
350
+ }
351
+ catch { /* swallow */ }
352
+ }
353
+ };
354
+ sock?.once?.("close", (hadError) => {
355
+ onSocketDeath(`closed hadError=${hadError}`);
356
+ });
357
+ sock?.once?.("error", (err) => {
358
+ onSocketDeath("error", err);
359
+ });
360
+ }
361
+ catch {
362
+ /* swallow */
363
+ }
364
+ await new Promise((resolve, reject) => {
365
+ const onConnect = () => {
366
+ session.off("error", onError);
367
+ resolve();
368
+ };
369
+ const onError = (err) => {
370
+ session.off("connect", onConnect);
371
+ reject(err);
372
+ };
373
+ session.once("connect", onConnect);
374
+ session.once("error", onError);
375
+ });
376
+ // OS-level keepalive on the underlying TCP socket so a silently-
377
+ // dropped connection (NAT timeout, NLB idle eviction, peer power-
378
+ // off, etc.) eventually surfaces as a socket error even if no
379
+ // application traffic is flowing. Application-level PING ack
380
+ // tracking (see startPingLoop) is the load-bearing detector;
381
+ // this is defense-in-depth.
382
+ try {
383
+ const sock = session.socket;
384
+ sock?.setKeepAlive?.(true, 30_000);
385
+ }
386
+ catch {
387
+ /* swallow */
388
+ }
389
+ }
390
+ waitForSessionClose() {
391
+ const session = this.session;
392
+ if (session === null)
393
+ return Promise.resolve();
394
+ return new Promise((resolve) => {
395
+ if (session.closed) {
396
+ resolve();
397
+ return;
398
+ }
399
+ session.once("close", () => resolve());
400
+ });
401
+ }
402
+ // --- handshake ---------------------------------------------------------
403
+ async sendHello() {
404
+ this.ownerToken = null;
405
+ this.serverPoolSize = null;
406
+ this.intakeIdleSeconds = null;
407
+ this.responseDeadlineSeconds = null;
408
+ const helloHeaders = {
409
+ [HTTP2_HEADER_METHOD]: "POST",
410
+ [HTTP2_HEADER_SCHEME]: "https",
411
+ [HTTP2_HEADER_AUTHORITY]: this.zone,
412
+ [HTTP2_HEADER_PATH]: ControlPaths.HELLO,
413
+ [ControlHeaders.TUNNEL_ID]: this.tunnelId,
414
+ [ControlHeaders.TUNNEL_SECRET]: this.secret,
415
+ "content-length": "0",
416
+ };
417
+ if (this.poolSize !== null) {
418
+ helloHeaders[ControlHeaders.POOL_SIZE] = String(this.poolSize);
419
+ }
420
+ const stream = this.openStream(helloHeaders, { endStream: true });
421
+ const { status, body } = await this.awaitResponse(stream.streamId);
422
+ if (status === 401 || status === 403) {
423
+ throw new TunnelAuthError(`${ControlPaths.HELLO} returned ${status}; connect secret is invalid`);
424
+ }
425
+ if (status !== 200) {
426
+ throw new Error(`${ControlPaths.HELLO} returned ${status}; transient — will retry`);
427
+ }
428
+ let payload = {};
429
+ if (body.length > 0) {
430
+ try {
431
+ payload = JSON.parse(body.toString("utf-8"));
432
+ }
433
+ catch {
434
+ throw new Error(`${ControlPaths.HELLO} returned 200 but body was not JSON`);
435
+ }
436
+ }
437
+ const ownerToken = payload["owner_token"];
438
+ if (typeof ownerToken !== "string" || ownerToken === "") {
439
+ throw new Error(`${ControlPaths.HELLO} response missing owner_token; cannot park intake`);
440
+ }
441
+ this.ownerToken = ownerToken;
442
+ if (typeof payload["default_pool_size"] === "number") {
443
+ this.serverPoolSize = payload["default_pool_size"];
444
+ }
445
+ if (typeof payload["intake_idle_seconds"] === "number") {
446
+ this.intakeIdleSeconds = payload["intake_idle_seconds"];
447
+ }
448
+ if (typeof payload["response_deadline_seconds"] === "number") {
449
+ this.responseDeadlineSeconds = payload["response_deadline_seconds"];
450
+ }
451
+ }
452
+ // --- stream helpers ----------------------------------------------------
453
+ openStream(headers, opts) {
454
+ const session = this.session;
455
+ if (session === null)
456
+ throw new Error("h2 connection not open");
457
+ const stream = session.request(headers, { endStream: opts.endStream });
458
+ const bus = {
459
+ events: [],
460
+ waiter: null,
461
+ ended: false,
462
+ rstCode: null,
463
+ };
464
+ // Stream id is allocated synchronously after `request()`.
465
+ const streamId = stream.id ?? -1;
466
+ if (streamId === -1) {
467
+ // Fall back to listening for `ready`.
468
+ // In practice Node assigns the id synchronously; this is a guard.
469
+ throw new Error("h2 stream id not assigned synchronously");
470
+ }
471
+ this.streams.set(streamId, bus);
472
+ stream.on("response", (responseHeaders) => {
473
+ const flat = [];
474
+ for (const k of Object.keys(responseHeaders)) {
475
+ const v = responseHeaders[k];
476
+ if (Array.isArray(v)) {
477
+ for (const item of v)
478
+ flat.push([k, item]);
479
+ }
480
+ else if (v !== undefined) {
481
+ flat.push([k, String(v)]);
482
+ }
483
+ }
484
+ bus.events.push({ kind: "headers", headers: flat });
485
+ this.wake(bus);
486
+ });
487
+ stream.on("data", (chunk) => {
488
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
489
+ bus.events.push({ kind: "data", data: buf });
490
+ this.wake(bus);
491
+ });
492
+ stream.on("end", () => {
493
+ bus.events.push({ kind: "end" });
494
+ bus.ended = true;
495
+ this.wake(bus);
496
+ });
497
+ stream.on("close", () => {
498
+ if (!bus.ended) {
499
+ bus.events.push({ kind: "reset", code: stream.rstCode ?? 0 });
500
+ bus.ended = true;
501
+ this.wake(bus);
502
+ }
503
+ });
504
+ stream.on("error", () => {
505
+ // Surfaces via close().
506
+ });
507
+ return { stream, streamId };
508
+ }
509
+ wake(bus) {
510
+ const w = bus.waiter;
511
+ if (w !== null) {
512
+ bus.waiter = null;
513
+ w();
514
+ }
515
+ }
516
+ async nextEvent(streamId) {
517
+ const bus = this.streams.get(streamId);
518
+ if (bus === undefined)
519
+ return null;
520
+ while (true) {
521
+ const ev = bus.events.shift();
522
+ if (ev !== undefined)
523
+ return ev;
524
+ if (bus.ended && bus.events.length === 0) {
525
+ // Already drained; signal end.
526
+ return null;
527
+ }
528
+ await new Promise((resolve) => {
529
+ bus.waiter = resolve;
530
+ });
531
+ }
532
+ }
533
+ async awaitResponse(streamId) {
534
+ const chunks = [];
535
+ let status = 0;
536
+ let gotHeaders = false;
537
+ while (true) {
538
+ const ev = await this.nextEvent(streamId);
539
+ if (ev === null) {
540
+ this.streams.delete(streamId);
541
+ return { status, body: Buffer.concat(chunks) };
542
+ }
543
+ if (ev.kind === "headers" && !gotHeaders) {
544
+ gotHeaders = true;
545
+ const statusStr = ev.headers.find(([k]) => k === HTTP2_HEADER_STATUS)?.[1] ?? "0";
546
+ status = parseInt(statusStr, 10) || 0;
547
+ }
548
+ else if (ev.kind === "data") {
549
+ chunks.push(ev.data);
550
+ }
551
+ else if (ev.kind === "end" || ev.kind === "reset") {
552
+ this.streams.delete(streamId);
553
+ return { status, body: Buffer.concat(chunks) };
554
+ }
555
+ }
556
+ }
557
+ // --- intake pool -------------------------------------------------------
558
+ async intakeLoop(slot) {
559
+ while (!this.stop && this.session !== null && !this.session.closed) {
560
+ let envelope;
561
+ try {
562
+ envelope = await this.parkOneIntake(slot);
563
+ }
564
+ catch (err) {
565
+ if (err instanceof OwnerTokenInvalidError) {
566
+ // eslint-disable-next-line no-console
567
+ console.warn(`intake slot ${slot}: owner_token rejected; ` +
568
+ `forcing session.destroy() and reconnecting`);
569
+ this.session?.destroy();
570
+ return;
571
+ }
572
+ if (isSessionTerminalError(err) || this.session?.destroyed) {
573
+ // The h2 session is gone — every subsequent openStream will
574
+ // throw the same error. Don't retry-storm; exit the slot so
575
+ // ``runOnce`` observes ``waitForSessionClose`` resolve and
576
+ // ``serveForever`` reconnects. Same shape as Python's
577
+ // ``_OwnerTokenInvalidError`` retry-storm fix in
578
+ // ``_intake_loop``: distinguish terminal session errors
579
+ // before the generic retry handler.
580
+ // eslint-disable-next-line no-console
581
+ console.warn(`intake slot ${slot}: h2 session terminal (` +
582
+ `${err?.code ?? "no code"}); ` +
583
+ `exiting slot`, err);
584
+ try {
585
+ this.session?.destroy();
586
+ }
587
+ catch { /* swallow */ }
588
+ return;
589
+ }
590
+ // eslint-disable-next-line no-console
591
+ console.warn(`intake slot ${slot} transient error; retrying`, err);
592
+ await setTimeoutPromise(250).catch(() => undefined);
593
+ continue;
594
+ }
595
+ if (envelope === null)
596
+ continue;
597
+ // Fire-and-forget dispatch; tracked so we can join on shutdown.
598
+ const task = this.dispatchEnvelope(envelope).catch((err) => {
599
+ // eslint-disable-next-line no-console
600
+ console.warn(`dispatch failed request_id=${envelope.requestId}`, err);
601
+ });
602
+ this.tasks.add(task);
603
+ task.finally(() => this.tasks.delete(task));
604
+ }
605
+ }
606
+ async parkOneIntake(slot) {
607
+ if (this.ownerToken === null) {
608
+ throw new Error("intake parked before /_system/hello returned an owner_token");
609
+ }
610
+ const headers = {
611
+ [HTTP2_HEADER_METHOD]: "POST",
612
+ [HTTP2_HEADER_SCHEME]: "https",
613
+ [HTTP2_HEADER_AUTHORITY]: this.zone,
614
+ [HTTP2_HEADER_PATH]: ControlPaths.INTAKE,
615
+ [ControlHeaders.TUNNEL_ID]: this.tunnelId,
616
+ [ControlHeaders.OWNER_TOKEN]: this.ownerToken,
617
+ [ControlHeaders.POOL_SLOT]: String(slot),
618
+ "content-length": "0",
619
+ };
620
+ const { streamId } = this.openStream(headers, { endStream: true });
621
+ let recvHeaders = null;
622
+ const chunks = [];
623
+ while (true) {
624
+ const ev = await this.nextEvent(streamId);
625
+ if (ev === null) {
626
+ this.streams.delete(streamId);
627
+ return null;
628
+ }
629
+ if (ev.kind === "headers" && recvHeaders === null) {
630
+ recvHeaders = ev.headers;
631
+ }
632
+ else if (ev.kind === "data") {
633
+ chunks.push(ev.data);
634
+ }
635
+ else if (ev.kind === "end") {
636
+ break;
637
+ }
638
+ else if (ev.kind === "reset") {
639
+ this.streams.delete(streamId);
640
+ return null;
641
+ }
642
+ }
643
+ this.streams.delete(streamId);
644
+ if (recvHeaders === null)
645
+ return null;
646
+ const status = recvHeaders.find(([k]) => k === HTTP2_HEADER_STATUS)?.[1] ?? "0";
647
+ if (status !== "200") {
648
+ const reason = recvHeaders.find(([k]) => k === TunnelMetaHeader.REASON)?.[1] ?? "";
649
+ // eslint-disable-next-line no-console
650
+ console.warn(`${ControlPaths.INTAKE} slot=${slot} -> status=${status} reason=${reason}`);
651
+ if (status === "401") {
652
+ throw new OwnerTokenInvalidError(`slot=${slot} status=401 reason=${reason}`);
653
+ }
654
+ return null;
655
+ }
656
+ return parseEnvelope(recvHeaders, Buffer.concat(chunks));
657
+ }
658
+ // --- ping loop ---------------------------------------------------------
659
+ startPingLoop() {
660
+ this.pingAbort = new AbortController();
661
+ this.pingHandle = setInterval(() => {
662
+ const session = this.session;
663
+ if (session === null || session.closed)
664
+ return;
665
+ let ackTimer = null;
666
+ let acked = false;
667
+ try {
668
+ session.ping((err) => {
669
+ acked = true;
670
+ if (ackTimer !== null) {
671
+ clearTimeout(ackTimer);
672
+ ackTimer = null;
673
+ }
674
+ if (err !== null && err !== undefined) {
675
+ // eslint-disable-next-line no-console
676
+ console.warn("tunnel runtime: PING errored; forcing session.destroy()", err);
677
+ try {
678
+ session.destroy();
679
+ }
680
+ catch { /* swallow */ }
681
+ }
682
+ });
683
+ }
684
+ catch (err) {
685
+ // eslint-disable-next-line no-console
686
+ console.warn("tunnel runtime: session.ping() threw synchronously; forcing destroy", err);
687
+ try {
688
+ session.destroy();
689
+ }
690
+ catch { /* swallow */ }
691
+ return;
692
+ }
693
+ // Application-level liveness check: if the ack doesn't come
694
+ // back within PING_ACK_TIMEOUT_MS, the underlying TCP is gone
695
+ // (kernel send buffer absorbing writes silently is the typical
696
+ // failure mode — Node's high-level h2 session won't notice
697
+ // without our help). Force-destroy the session; serveForever
698
+ // observes the close and reconnects.
699
+ ackTimer = setTimeout(() => {
700
+ if (acked)
701
+ return;
702
+ // eslint-disable-next-line no-console
703
+ console.warn(`tunnel runtime: PING ack not received within ` +
704
+ `${PING_ACK_TIMEOUT_MS}ms; assuming dead connection, ` +
705
+ `forcing reconnect`);
706
+ try {
707
+ session.destroy();
708
+ }
709
+ catch { /* swallow */ }
710
+ }, PING_ACK_TIMEOUT_MS);
711
+ }, PING_INTERVAL_MS);
712
+ // Do NOT unref(): explicit cancellation in stopPingLoop().
713
+ }
714
+ stopPingLoop() {
715
+ if (this.pingHandle !== null) {
716
+ clearInterval(this.pingHandle);
717
+ this.pingHandle = null;
718
+ }
719
+ this.pingAbort?.abort();
720
+ this.pingAbort = null;
721
+ }
722
+ // --- envelope dispatch -------------------------------------------------
723
+ async dispatchEnvelope(envelope) {
724
+ if (envelope.routeKind === TunnelRouteKind.WS_UPGRADE) {
725
+ try {
726
+ await this.dispatchWsUpgrade(envelope);
727
+ }
728
+ catch (err) {
729
+ // eslint-disable-next-line no-console
730
+ console.warn(`ws dispatch failed request_id=${envelope.requestId}`, err);
731
+ }
732
+ return;
733
+ }
734
+ if (envelope.routeKind === TunnelRouteKind.TCP_STREAM) {
735
+ // Passthrough TCP bridge — defer until M4 lands here.
736
+ try {
737
+ await this.dispatchTcpStream(envelope);
738
+ }
739
+ catch (err) {
740
+ // eslint-disable-next-line no-console
741
+ console.warn(`tcp-stream dispatch failed tcp_id=${envelope.tcpId}`, err);
742
+ }
743
+ return;
744
+ }
745
+ try {
746
+ await this.dispatchHttp(envelope);
747
+ }
748
+ catch (err) {
749
+ // eslint-disable-next-line no-console
750
+ console.warn(`dispatch failed request_id=${envelope.requestId}`, err);
751
+ try {
752
+ await this.postResponse(envelope.requestId, 500, [["content-type", "text/plain"]], Buffer.from("internal error"));
753
+ }
754
+ catch {
755
+ /* swallow */
756
+ }
757
+ }
758
+ }
759
+ // --- HTTP dispatch -----------------------------------------------------
760
+ async dispatchHttp(envelope) {
761
+ const reject = validateEnvelopePath(envelope.path);
762
+ if (reject !== null) {
763
+ await this.postResponse(envelope.requestId, 400, [
764
+ ["content-type", "text/plain"],
765
+ [TunnelMetaHeader.REASON, reject],
766
+ ], Buffer.from("invalid path"));
767
+ return;
768
+ }
769
+ // Materialize body if offloaded.
770
+ let materialized;
771
+ try {
772
+ materialized = await this.materializeBody(envelope);
773
+ }
774
+ catch (err) {
775
+ const reason = err instanceof BodyTooLargeError ? "request-body-too-large" : "body-fetch-failed";
776
+ const status = err instanceof BodyTooLargeError ? 413 : 502;
777
+ await this.postResponse(envelope.requestId, status, [
778
+ ["content-type", "text/plain"],
779
+ [TunnelMetaHeader.REASON, reason],
780
+ ], Buffer.from(reason));
781
+ return;
782
+ }
783
+ const deadlineMs = (this.responseDeadlineSeconds ?? 0) * 1000;
784
+ const ctrl = new AbortController();
785
+ let deadlineHandle = null;
786
+ // Sentinel resolved by the deadline timer — used as the loser side
787
+ // of the Promise.race against the dispatch. We use a sentinel
788
+ // (rather than rejecting) so the race resolves with a discriminable
789
+ // outcome and the dispatch task can keep running in the background
790
+ // while we surface a 504 to the server. Mirrors Python's
791
+ // ``_with_deadline()`` semantics.
792
+ const TIMEOUT = Symbol("dispatch-deadline-exceeded");
793
+ let deadlinePromise = new Promise(() => { });
794
+ if (deadlineMs > 0) {
795
+ deadlinePromise = new Promise((resolve) => {
796
+ deadlineHandle = setTimeout(() => {
797
+ ctrl.abort();
798
+ resolve(TIMEOUT);
799
+ }, deadlineMs);
800
+ });
801
+ }
802
+ const dispatchPromise = (async () => {
803
+ if (this.dispatch.httpHandler !== undefined) {
804
+ const inProcess = await dispatchHttpInProcess({
805
+ envelope: materialized,
806
+ handler: this.dispatch.httpHandler,
807
+ publicHost: this.publicHost,
808
+ maxResponseBytes: this.maxOutbound,
809
+ signal: ctrl.signal,
810
+ });
811
+ return { kind: "in-process", result: inProcess };
812
+ }
813
+ if (this.dispatch.forwardTo !== undefined) {
814
+ const result = await forwardEnvelopeToUrl({
815
+ envelope: materialized,
816
+ forwardTo: this.dispatch.forwardTo,
817
+ publicHost: this.publicHost,
818
+ maxResponseBytes: this.maxOutbound,
819
+ signal: ctrl.signal,
820
+ verifyTls: this.forwardToVerifyTls,
821
+ caBundle: this.forwardToCaBundle,
822
+ agentCache: this.undiciAgentCache,
823
+ });
824
+ return { kind: "url-forward", result };
825
+ }
826
+ return { kind: "no-handler" };
827
+ })();
828
+ try {
829
+ const outcome = deadlineMs > 0
830
+ ? await Promise.race([dispatchPromise, deadlinePromise])
831
+ : await dispatchPromise;
832
+ if (outcome === TIMEOUT) {
833
+ // Hard deadline tripped: post 504 immediately. The dispatch
834
+ // promise keeps running in the background — its eventual
835
+ // result is discarded by the no-op handler attached below.
836
+ // Without this race, a handler that ignores ``ctx.signal`` or
837
+ // hangs on a body stream would keep the SDK task alive past
838
+ // the server-side deadline, and a late ``postResponse`` would
839
+ // target a request the tunnel server has already 504'd.
840
+ dispatchPromise.catch(() => undefined);
841
+ await this.postResponse(envelope.requestId, 504, [
842
+ ["content-type", "text/plain"],
843
+ [TunnelMetaHeader.REASON, "response-deadline-exceeded"],
844
+ ], Buffer.from("local handler too slow"));
845
+ return;
846
+ }
847
+ if (outcome.kind === "in-process") {
848
+ const inProcess = outcome.result;
849
+ if (inProcess.kind === "ok") {
850
+ await this.postResponse(envelope.requestId, inProcess.status, filterResponseHeaders(inProcess.headers), inProcess.body);
851
+ }
852
+ else {
853
+ await this.postResponse(envelope.requestId, inProcess.status, [
854
+ ["content-type", "text/plain"],
855
+ [TunnelMetaHeader.REASON, inProcess.inkboxReason],
856
+ ], Buffer.from(inProcess.inkboxReason));
857
+ }
858
+ return;
859
+ }
860
+ if (outcome.kind === "url-forward") {
861
+ const result = outcome.result;
862
+ if (result.kind === "ok") {
863
+ await this.postResponse(envelope.requestId, result.status, filterResponseHeaders(result.headers), result.body);
864
+ }
865
+ else {
866
+ await this.postResponse(envelope.requestId, result.status, [
867
+ ["content-type", "text/plain"],
868
+ [TunnelMetaHeader.REASON, result.inkboxReason],
869
+ ], Buffer.from(result.inkboxReason));
870
+ }
871
+ return;
872
+ }
873
+ // No HTTP path configured — should be impossible if connect()
874
+ // validation is correct, but defend.
875
+ await this.postResponse(envelope.requestId, 501, [
876
+ ["content-type", "text/plain"],
877
+ [TunnelMetaHeader.REASON, "no-http-handler"],
878
+ ], Buffer.from("no http handler"));
879
+ }
880
+ finally {
881
+ if (deadlineHandle !== null)
882
+ clearTimeout(deadlineHandle);
883
+ }
884
+ }
885
+ async materializeBody(envelope) {
886
+ if (envelope.body.length > this.maxInbound) {
887
+ throw new BodyTooLargeError();
888
+ }
889
+ if (envelope.bodyUri === null)
890
+ return envelope;
891
+ const resp = await fetch(envelope.bodyUri);
892
+ if (!resp.ok) {
893
+ throw new Error(`inkbox-body-uri GET returned ${resp.status}`);
894
+ }
895
+ const reader = resp.body?.getReader();
896
+ if (!reader) {
897
+ return { ...envelope, body: Buffer.alloc(0) };
898
+ }
899
+ const chunks = [];
900
+ let total = 0;
901
+ while (true) {
902
+ const { value, done } = await reader.read();
903
+ if (done)
904
+ break;
905
+ const chunk = Buffer.from(value);
906
+ total += chunk.length;
907
+ if (total > this.maxInbound)
908
+ throw new BodyTooLargeError();
909
+ chunks.push(chunk);
910
+ }
911
+ return { ...envelope, body: Buffer.concat(chunks, total) };
912
+ }
913
+ // --- WS dispatch -------------------------------------------------------
914
+ async dispatchWsUpgrade(envelope) {
915
+ if (envelope.wsId === null) {
916
+ await this.postResponse(envelope.requestId, 400, [
917
+ ["content-type", "text/plain"],
918
+ [TunnelMetaHeader.REASON, "missing-ws-id"],
919
+ ], Buffer.from("missing ws_id"));
920
+ return;
921
+ }
922
+ // Path-traversal guard. Edge WS upgrades skip dispatchHttp's
923
+ // validateEnvelopePath check, so apply it here too.
924
+ const reject = validateEnvelopePath(envelope.path);
925
+ if (reject !== null) {
926
+ await this.postResponse(envelope.requestId, 400, [
927
+ ["content-type", "text/plain"],
928
+ [TunnelMetaHeader.REASON, reject],
929
+ ], Buffer.from("invalid path"));
930
+ return;
931
+ }
932
+ // URL forward — bridge to the upstream WS via h1 Upgrade.
933
+ if (this.dispatch.wsHandler === undefined &&
934
+ this.dispatch.forwardTo !== undefined) {
935
+ await this.dispatchWsUpgradeToUrl(envelope, this.dispatch.forwardTo);
936
+ return;
937
+ }
938
+ if (this.dispatch.wsHandler === undefined) {
939
+ // No URL upstream and no in-process WS handler — reject 501.
940
+ await this.postResponse(envelope.requestId, 501, [
941
+ ["content-type", "text/plain"],
942
+ [TunnelMetaHeader.REASON, "ws-not-supported"],
943
+ ], Buffer.from("ws upgrade not supported"));
944
+ return;
945
+ }
946
+ const acceptDeadlineMs = (this.responseDeadlineSeconds ?? 30) * 1000;
947
+ const bridge = await this.openWsBridge(envelope);
948
+ try {
949
+ await dispatchWsUpgradeInProcess({
950
+ envelope,
951
+ handler: this.dispatch.wsHandler,
952
+ publicHost: this.publicHost,
953
+ acceptDeadlineMs,
954
+ bridge: bridge.io,
955
+ });
956
+ }
957
+ finally {
958
+ bridge.cleanup();
959
+ }
960
+ }
961
+ async dispatchWsUpgradeToUrl(envelope, forwardTo) {
962
+ // Open the upstream WS hop. On failure, surface the upstream-style
963
+ // status back to the third party so the client sees a clean
964
+ // non-101 instead of hanging.
965
+ const { openWsUpstream, WsUpstreamError } = await import("./_ws_url_bridge.js");
966
+ const headersList = [
967
+ ...envelope.forwardedHeaders,
968
+ ];
969
+ let subprotocol = null;
970
+ for (const [k, v] of envelope.forwardedHeaders) {
971
+ if (k.toLowerCase() === "sec-websocket-protocol") {
972
+ subprotocol = v;
973
+ }
974
+ }
975
+ let upstream;
976
+ // Bound the upstream handshake by the same clock the server uses
977
+ // for the third-party reply. If response_deadline_seconds is
978
+ // smaller than the helper default, posting a stale reject after
979
+ // the server already 504'd would just be wasted work.
980
+ // Floor at 1ms (not 1s) — sub-second response deadlines are valid
981
+ // and must be honored. Earlier shape clamped 0.1s up to 1s.
982
+ const handshakeTimeoutMs = this.responseDeadlineSeconds !== null
983
+ ? Math.max(1, this.responseDeadlineSeconds * 1000)
984
+ : undefined;
985
+ try {
986
+ upstream = await openWsUpstream({
987
+ forwardTo: new URL(forwardTo),
988
+ publicHost: this.publicHost,
989
+ verifyTls: this.forwardToVerifyTls,
990
+ caBundle: this.forwardToCaBundle,
991
+ requestPath: envelope.path,
992
+ requestHeaders: headersList,
993
+ wsSubprotocol: subprotocol,
994
+ forwardedForIp: envelope.forwardedForIp,
995
+ handshakeTimeoutMs,
996
+ });
997
+ }
998
+ catch (e) {
999
+ const status = e instanceof WsUpstreamError ? e.status : 502;
1000
+ await this.postResponse(envelope.requestId, status, [
1001
+ ["content-type", "text/plain"],
1002
+ [TunnelMetaHeader.REASON, "ws-upstream-failed"],
1003
+ ], Buffer.from("upstream ws upgrade failed"));
1004
+ return;
1005
+ }
1006
+ // Forward the upstream's 101 response headers to the third party.
1007
+ // Application-defined headers (Set-Cookie, X-Use-Inkbox-* opt-out
1008
+ // flags, custom correlation IDs) live here; customers expect them
1009
+ // to round-trip. Strip:
1010
+ // * hop-by-hop (connection, upgrade, transfer-encoding, ...)
1011
+ // * ws handshake-control headers — these are per-hop. The
1012
+ // tunnel server recomputes sec-websocket-accept against the
1013
+ // third party's key. sec-websocket-key/version are
1014
+ // request-only; sec-websocket-extensions is already gated
1015
+ // above (we 502 if upstream confirmed one).
1016
+ // * h2 pseudo-headers (defensive).
1017
+ const wsHandshakeStrip = new Set([
1018
+ "sec-websocket-accept",
1019
+ "sec-websocket-extensions",
1020
+ "sec-websocket-key",
1021
+ "sec-websocket-version",
1022
+ ]);
1023
+ const upgradeReplyHeaders = [];
1024
+ for (const [hk, hv] of upstream.headers) {
1025
+ if (hk.startsWith(":"))
1026
+ continue;
1027
+ if (HOP_BY_HOP_RESPONSE.has(hk))
1028
+ continue;
1029
+ if (wsHandshakeStrip.has(hk))
1030
+ continue;
1031
+ upgradeReplyHeaders.push([hk, hv]);
1032
+ }
1033
+ const bridge = await this.openWsBridge(envelope);
1034
+ try {
1035
+ // postUpgradeReply both posts the 200 AND opens the inkbox bridge
1036
+ // CONNECT stream — skipping it (an earlier draft did) leaves
1037
+ // connectStreamId null so recv() returns immediately and sendFrame
1038
+ // throws "bridge stream not open". Pump runs against a real bridge.
1039
+ await bridge.io.postUpgradeReply(upgradeReplyHeaders);
1040
+ }
1041
+ catch (e) {
1042
+ try {
1043
+ upstream.socket.destroy();
1044
+ }
1045
+ catch {
1046
+ /* swallow */
1047
+ }
1048
+ bridge.cleanup();
1049
+ // postUpgradeReply may have already posted 200 before failing on
1050
+ // the bridge open; no good way to retract the 200, so just log.
1051
+ // eslint-disable-next-line no-console
1052
+ console.warn(`ws bridge open failed after upstream 101 request_id=${envelope.requestId}`, e);
1053
+ return;
1054
+ }
1055
+ const { pumpWsUrlEdgeBridge } = await import("./_ws_url_edge_bridge.js");
1056
+ try {
1057
+ await pumpWsUrlEdgeBridge({
1058
+ upstream,
1059
+ bridge: bridge.io,
1060
+ });
1061
+ }
1062
+ finally {
1063
+ try {
1064
+ upstream.socket.destroy();
1065
+ }
1066
+ catch {
1067
+ /* swallow */
1068
+ }
1069
+ // End the bridge stream too so the server-side knows we're done
1070
+ // (esp. on the abrupt-upstream-close path where the pump exits
1071
+ // before either peer sent CLOSE).
1072
+ try {
1073
+ await bridge.io.closeStream();
1074
+ }
1075
+ catch {
1076
+ /* swallow */
1077
+ }
1078
+ bridge.cleanup();
1079
+ }
1080
+ }
1081
+ async openWsBridge(envelope) {
1082
+ const wsId = envelope.wsId;
1083
+ const requestId = envelope.requestId;
1084
+ let connectStreamId = null;
1085
+ let bridgeStream = null;
1086
+ // Pair every successful openStream() inside postUpgradeReply with
1087
+ // a guaranteed close on failure paths. Without it, a timeout /
1088
+ // non-200 / end-before-headers leaves the h2 stream half-open
1089
+ // server-side until the session GOAWAYs. Callers only see the
1090
+ // throw and call ``cleanup``, which is local-bookkeeping only.
1091
+ const abortBridgeStream = () => {
1092
+ if (bridgeStream !== null) {
1093
+ try {
1094
+ bridgeStream.close(http2.constants.NGHTTP2_CANCEL);
1095
+ }
1096
+ catch {
1097
+ /* swallow */
1098
+ }
1099
+ }
1100
+ };
1101
+ const postUpgradeReply = async (headers) => {
1102
+ await this.postResponse(requestId, 200, headers, Buffer.alloc(0));
1103
+ // Open the extended-CONNECT bridge stream after the upgrade reply.
1104
+ const connectHeaders = {
1105
+ [HTTP2_HEADER_METHOD]: "CONNECT",
1106
+ [HTTP2_HEADER_SCHEME]: "https",
1107
+ [HTTP2_HEADER_AUTHORITY]: this.zone,
1108
+ [HTTP2_HEADER_PATH]: `${ControlPaths.WS_PREFIX}${wsId}`,
1109
+ // RFC 8441 extended CONNECT — Spike 1 verified Node 22 emits this.
1110
+ [":protocol"]: TunnelSubprotocol.WS,
1111
+ "sec-websocket-version": "13",
1112
+ [ControlHeaders.TUNNEL_ID]: this.tunnelId,
1113
+ [ControlHeaders.TUNNEL_SECRET]: this.secret,
1114
+ [TunnelMetaHeader.WS_ID]: wsId,
1115
+ };
1116
+ const opened = this.openStream(connectHeaders, { endStream: false });
1117
+ connectStreamId = opened.streamId;
1118
+ bridgeStream = opened.stream;
1119
+ this.bridgeStreamIds.add(connectStreamId);
1120
+ try {
1121
+ // Wait for the 200 on the bridge stream — bounded so a server
1122
+ // that never replies can't wedge the dispatch task after the
1123
+ // public side has already seen success. Mirrors Python's
1124
+ // _with_deadline + the TCP passthrough's
1125
+ // BRIDGE_STATUS_TIMEOUT_MS race.
1126
+ const ev = await Promise.race([
1127
+ this.nextEvent(connectStreamId),
1128
+ setTimeoutPromise(BRIDGE_STATUS_TIMEOUT_MS).then(() => "timeout"),
1129
+ ]);
1130
+ if (ev === "timeout") {
1131
+ throw new Error("bridge stream did not return :status within deadline");
1132
+ }
1133
+ if (ev === null || ev.kind !== "headers") {
1134
+ throw new Error("bridge stream closed before headers");
1135
+ }
1136
+ const status = ev.headers.find(([k]) => k === HTTP2_HEADER_STATUS)?.[1];
1137
+ if (status !== "200") {
1138
+ throw new Error(`bridge stream returned ${status}`);
1139
+ }
1140
+ }
1141
+ catch (e) {
1142
+ // Best-effort cancel of the just-opened h2 stream so it
1143
+ // doesn't sit half-open server-side. Re-throw so the caller
1144
+ // still sees the failure and tears down the public side.
1145
+ abortBridgeStream();
1146
+ throw e;
1147
+ }
1148
+ };
1149
+ const rejectUpgrade = async (status, reason) => {
1150
+ await this.postResponse(requestId, status, [
1151
+ ["content-type", "text/plain"],
1152
+ [TunnelMetaHeader.REASON, reason],
1153
+ ], Buffer.from(reason));
1154
+ };
1155
+ const sendFrame = async (frame) => {
1156
+ if (bridgeStream === null)
1157
+ throw new Error("bridge stream not open");
1158
+ await new Promise((resolve, reject) => {
1159
+ bridgeStream.write(frame, (err) => (err ? reject(err) : resolve()));
1160
+ });
1161
+ };
1162
+ const recv = () => {
1163
+ const sid = () => connectStreamId;
1164
+ const self = this;
1165
+ return (async function* () {
1166
+ while (true) {
1167
+ const id = sid();
1168
+ if (id === null)
1169
+ return;
1170
+ const ev = await self.nextEvent(id);
1171
+ if (ev === null)
1172
+ return;
1173
+ if (ev.kind === "data") {
1174
+ yield ev.data;
1175
+ }
1176
+ else if (ev.kind === "end") {
1177
+ return;
1178
+ }
1179
+ else if (ev.kind === "reset") {
1180
+ throw new Error(`bridge stream reset code=${ev.code}`);
1181
+ }
1182
+ }
1183
+ })();
1184
+ };
1185
+ // Track whether the caller already requested a graceful close so
1186
+ // cleanup() doesn't convert it into RST_STREAM(CANCEL). h2 doesn't
1187
+ // mark ``bridgeStream.closed`` true until the remote also ends, so
1188
+ // looking at the JS-level state alone races against the server's
1189
+ // matching END_STREAM and would over-cancel every successful WSS
1190
+ // shutdown.
1191
+ let gracefullyClosed = false;
1192
+ const closeStream = async () => {
1193
+ if (bridgeStream !== null) {
1194
+ gracefullyClosed = true;
1195
+ try {
1196
+ bridgeStream.end();
1197
+ }
1198
+ catch {
1199
+ /* swallow */
1200
+ }
1201
+ }
1202
+ };
1203
+ const cleanup = () => {
1204
+ // Cancel the h2 stream only if the caller didn't already
1205
+ // initiate a graceful close. ``postUpgradeReply`` handles its
1206
+ // own open-time failures separately; this branch only fires
1207
+ // when a mid-pump exception left the stream half-open without
1208
+ // a closeStream() call.
1209
+ if (bridgeStream !== null &&
1210
+ !gracefullyClosed &&
1211
+ !bridgeStream.closed &&
1212
+ !bridgeStream.destroyed) {
1213
+ try {
1214
+ bridgeStream.close(http2.constants.NGHTTP2_CANCEL);
1215
+ }
1216
+ catch {
1217
+ /* swallow */
1218
+ }
1219
+ }
1220
+ if (connectStreamId !== null) {
1221
+ this.bridgeStreamIds.delete(connectStreamId);
1222
+ this.streams.delete(connectStreamId);
1223
+ }
1224
+ };
1225
+ return {
1226
+ io: { sendFrame, recv, closeStream, postUpgradeReply, rejectUpgrade },
1227
+ cleanup,
1228
+ };
1229
+ }
1230
+ // --- TCP-stream bridge (passthrough) ----------------------------------
1231
+ async dispatchTcpStream(envelope) {
1232
+ if (this.tlsTerminator === null ||
1233
+ (this.dispatch.forwardTo === undefined &&
1234
+ this.dispatch.httpHandler === undefined) ||
1235
+ envelope.tcpId === null) {
1236
+ // eslint-disable-next-line no-console
1237
+ console.warn(`tcp-stream envelope received but passthrough not configured ` +
1238
+ `(tcp_id=${envelope.tcpId}); dropping`);
1239
+ return;
1240
+ }
1241
+ const tcpId = envelope.tcpId;
1242
+ const sniHost = envelope.sniHost ?? "";
1243
+ // 1) Open the extended-CONNECT bridge stream.
1244
+ const connectHeaders = {
1245
+ [HTTP2_HEADER_METHOD]: "CONNECT",
1246
+ [HTTP2_HEADER_SCHEME]: "https",
1247
+ [HTTP2_HEADER_AUTHORITY]: this.zone,
1248
+ [HTTP2_HEADER_PATH]: `${ControlPaths.TCP_PREFIX}${tcpId}`,
1249
+ [":protocol"]: TunnelSubprotocol.TCP,
1250
+ "sec-websocket-version": "13",
1251
+ "sec-websocket-protocol": TunnelSubprotocol.TCP,
1252
+ [ControlHeaders.TUNNEL_ID]: this.tunnelId,
1253
+ [ControlHeaders.TUNNEL_SECRET]: this.secret,
1254
+ [TunnelMetaHeader.TCP_ID]: tcpId,
1255
+ };
1256
+ const { stream, streamId } = this.openStream(connectHeaders, {
1257
+ endStream: false,
1258
+ });
1259
+ this.bridgeStreamIds.add(streamId);
1260
+ // 2) Wait for status 200 with timeout.
1261
+ let openOk = false;
1262
+ try {
1263
+ const ev = await Promise.race([
1264
+ this.nextEvent(streamId),
1265
+ setTimeoutPromise(BRIDGE_STATUS_TIMEOUT_MS).then(() => "timeout"),
1266
+ ]);
1267
+ if (ev !== null && ev !== "timeout" && typeof ev !== "string" && ev.kind === "headers") {
1268
+ const status = ev.headers.find(([k]) => k === HTTP2_HEADER_STATUS)?.[1];
1269
+ openOk = status === "200";
1270
+ }
1271
+ }
1272
+ catch {
1273
+ openOk = false;
1274
+ }
1275
+ if (!openOk) {
1276
+ // eslint-disable-next-line no-console
1277
+ console.warn(`bridge open failed tcp_id=${tcpId}`);
1278
+ try {
1279
+ stream.close(http2.constants.NGHTTP2_CANCEL);
1280
+ }
1281
+ catch {
1282
+ /* swallow */
1283
+ }
1284
+ this.bridgeStreamIds.delete(streamId);
1285
+ this.streams.delete(streamId);
1286
+ return;
1287
+ }
1288
+ // 3) Build (or reuse) the passthrough dispatcher for this runtime.
1289
+ // UpstreamUrlDispatch owns its own undici Pool; we share it across
1290
+ // bridge streams. Closed in TunnelRuntime.aclose().
1291
+ if (this.passthroughDispatch === null) {
1292
+ const dispMod = await import("./_dispatch.js");
1293
+ if (this.dispatch.forwardTo !== undefined) {
1294
+ this.passthroughDispatch = new dispMod.UpstreamUrlDispatch({
1295
+ forwardTo: this.dispatch.forwardTo,
1296
+ publicHost: this.publicHost,
1297
+ maxOutboundBodyBytes: this.maxOutbound,
1298
+ maxInboundBodyBytes: this.maxInbound,
1299
+ verifyTls: this.forwardToVerifyTls,
1300
+ caBundle: this.forwardToCaBundle,
1301
+ });
1302
+ }
1303
+ else if (this.dispatch.httpHandler !== undefined) {
1304
+ this.passthroughDispatch = new dispMod.CallableDispatch({
1305
+ handler: this.dispatch.httpHandler,
1306
+ wsHandler: this.dispatch.wsHandler,
1307
+ publicHost: this.publicHost,
1308
+ maxOutboundBodyBytes: this.maxOutbound,
1309
+ });
1310
+ }
1311
+ else {
1312
+ // Defensive: dispatchTcpStream's early guard already requires
1313
+ // at least one of these to be set.
1314
+ // eslint-disable-next-line no-console
1315
+ console.warn("passthrough dispatch has neither forwardTo nor handler");
1316
+ return;
1317
+ }
1318
+ }
1319
+ const dispatchImpl = this.passthroughDispatch;
1320
+ // 4) Run the inbound + outbound pumps.
1321
+ const stats = makeBridgeStats(tcpId, streamId, sniHost);
1322
+ const session = this.tlsTerminator.session();
1323
+ let tlsClosed = false;
1324
+ let closeReason = "clean-eof";
1325
+ const tlsTail = async () => {
1326
+ if (tlsClosed)
1327
+ return Buffer.alloc(0);
1328
+ tlsClosed = true;
1329
+ try {
1330
+ return await session.close();
1331
+ }
1332
+ catch {
1333
+ return Buffer.alloc(0);
1334
+ }
1335
+ };
1336
+ const sendFrame = async (opcode, payload, endStream = false) => {
1337
+ await this.writeBridgeFrame(stream, encodeWsFrame(opcode, payload, { mask: true }), endStream);
1338
+ };
1339
+ const inboundDone = { value: false };
1340
+ const outboundDone = { value: false };
1341
+ const adapterHolder = { value: null };
1342
+ const adapterReady = (() => {
1343
+ let resolveFn;
1344
+ const p = new Promise((r) => (resolveFn = r));
1345
+ return { promise: p, resolve: resolveFn };
1346
+ })();
1347
+ const buildAdapter = async (alpn) => {
1348
+ if (alpn === "h2") {
1349
+ const mod = await import("./_h2_transcode.js");
1350
+ return new mod.H2TranscoderPlaintext({
1351
+ dispatch: dispatchImpl,
1352
+ maxInboundBodyBytes: this.maxInbound,
1353
+ forwardedForIp: null,
1354
+ sniHost: sniHost || null,
1355
+ });
1356
+ }
1357
+ // Default to h1 parser for "http/1.1", null/false, or anything
1358
+ // else (defensive — unknown ALPN gets the parser path).
1359
+ const mod = await import("./_h1_server.js");
1360
+ return new mod.InProcH1ParserPlaintext({
1361
+ dispatch: dispatchImpl,
1362
+ maxInboundBodyBytes: this.maxInbound,
1363
+ forwardedForIp: null,
1364
+ sniHost: sniHost || null,
1365
+ });
1366
+ };
1367
+ // Outbound: TLS-wrap the adapter's plaintext and emit as WS BINARY.
1368
+ const sendPlaintext = async (plaintext) => {
1369
+ if (plaintext.length === 0)
1370
+ return;
1371
+ const encrypted = await session.send(plaintext);
1372
+ if (encrypted.length > 0) {
1373
+ await sendFrame(WS_OPCODE_BINARY, encrypted);
1374
+ stats.outboundFrames += 1;
1375
+ stats.encryptedBytes += encrypted.length;
1376
+ }
1377
+ };
1378
+ const inbound = async () => {
1379
+ const frameDecoder = new WsFrameDecoder();
1380
+ let pendingFrags = null;
1381
+ try {
1382
+ while (true) {
1383
+ const ev = await this.nextEvent(streamId);
1384
+ if (ev === null)
1385
+ return;
1386
+ if (ev.kind === "end")
1387
+ return;
1388
+ if (ev.kind === "reset") {
1389
+ throw new BridgeStreamReset("inbound stream reset");
1390
+ }
1391
+ if (ev.kind !== "data")
1392
+ continue;
1393
+ const frames = frameDecoder.feed(ev.data);
1394
+ for (const frame of frames) {
1395
+ if (frame.opcode === WS_OPCODE_PING) {
1396
+ await sendFrame(WS_OPCODE_PONG, frame.payload);
1397
+ continue;
1398
+ }
1399
+ if (frame.opcode === WS_OPCODE_CLOSE)
1400
+ return;
1401
+ if (frame.opcode === WS_OPCODE_PONG)
1402
+ continue;
1403
+ if (frame.opcode === WS_OPCODE_TEXT) {
1404
+ throw new BridgeProtocolError("unexpected TEXT frame");
1405
+ }
1406
+ if (frame.opcode === WS_OPCODE_CONTINUATION) {
1407
+ if (pendingFrags === null) {
1408
+ throw new BridgeProtocolError("continuation without start frame");
1409
+ }
1410
+ pendingFrags = Buffer.concat([pendingFrags, frame.payload]);
1411
+ stats.continuationFrames += 1;
1412
+ }
1413
+ else if (frame.opcode === WS_OPCODE_BINARY) {
1414
+ if (pendingFrags !== null) {
1415
+ throw new BridgeProtocolError("new BINARY frame while fragmented msg open");
1416
+ }
1417
+ pendingFrags = frame.payload;
1418
+ }
1419
+ else {
1420
+ throw new BridgeProtocolError(`unexpected opcode 0x${frame.opcode.toString(16)}`);
1421
+ }
1422
+ if (!frame.fin)
1423
+ continue;
1424
+ const chunk = pendingFrags;
1425
+ pendingFrags = null;
1426
+ const { plaintext, encryptedToSend } = await session.feed(chunk);
1427
+ if (encryptedToSend.length > 0) {
1428
+ await sendFrame(WS_OPCODE_BINARY, encryptedToSend);
1429
+ stats.outboundFrames += 1;
1430
+ stats.encryptedBytes += encryptedToSend.length;
1431
+ }
1432
+ // Build the plaintext adapter on first handshake-complete OR
1433
+ // first plaintext byte. Plaintext can only flow after the
1434
+ // handshake is materially done (Node's TLS layer can't
1435
+ // decrypt application data otherwise), but the
1436
+ // ``secureConnect`` event that flips ``handshakeDone``
1437
+ // sometimes lands a tick later than ``feed()`` returns —
1438
+ // gating only on the flag drops the very first request.
1439
+ if (adapterHolder.value === null &&
1440
+ (session.handshakeDone || plaintext.length > 0)) {
1441
+ const alpn = session.tlsSocket?.alpnProtocol ?? null;
1442
+ adapterHolder.value = await buildAdapter(alpn);
1443
+ adapterReady.resolve();
1444
+ }
1445
+ if (adapterHolder.value !== null) {
1446
+ for (const pt of plaintext) {
1447
+ await adapterHolder.value.feed(pt);
1448
+ }
1449
+ }
1450
+ stats.inboundFrames += 1;
1451
+ stats.decryptedBytes += plaintext.reduce((a, b) => a + b.length, 0);
1452
+ if (!stats.tlsHandshakeDone && session.handshakeDone) {
1453
+ stats.tlsHandshakeDone = true;
1454
+ }
1455
+ }
1456
+ }
1457
+ }
1458
+ finally {
1459
+ inboundDone.value = true;
1460
+ }
1461
+ };
1462
+ const outbound = async () => {
1463
+ try {
1464
+ // Wait for the adapter to be picked (post-handshake), then run
1465
+ // its outbound pump until the adapter closes.
1466
+ await adapterReady.promise;
1467
+ if (adapterHolder.value !== null) {
1468
+ await adapterHolder.value.pumpOutbound(sendPlaintext);
1469
+ }
1470
+ const tail = await tlsTail();
1471
+ if (tail.length > 0)
1472
+ await sendFrame(WS_OPCODE_BINARY, tail);
1473
+ }
1474
+ finally {
1475
+ outboundDone.value = true;
1476
+ }
1477
+ };
1478
+ const inTask = inbound();
1479
+ const outTask = outbound();
1480
+ try {
1481
+ // Wait for either pump to complete.
1482
+ await Promise.race([
1483
+ inTask.catch((err) => {
1484
+ if (err instanceof BridgeProtocolError)
1485
+ closeReason = "protocol-error";
1486
+ else if (err instanceof BridgeStreamReset)
1487
+ closeReason = "inbound-error";
1488
+ else
1489
+ closeReason = "inbound-error";
1490
+ throw err;
1491
+ }),
1492
+ outTask.catch((err) => {
1493
+ closeReason = "outbound-error";
1494
+ throw err;
1495
+ }),
1496
+ ]).catch(() => undefined);
1497
+ // Asymmetric close grace: if outbound finished cleanly, cancel
1498
+ // inbound; otherwise let outbound drain for HALF_CLOSE_GRACE.
1499
+ if (outboundDone.value && !inboundDone.value) {
1500
+ await Promise.race([
1501
+ inTask,
1502
+ setTimeoutPromise(BRIDGE_HALF_CLOSE_GRACE_MS),
1503
+ ]).catch(() => undefined);
1504
+ }
1505
+ else if (inboundDone.value && !outboundDone.value) {
1506
+ // Inbound finished — close the adapter so the outbound pump
1507
+ // exits, then wait briefly for outbound to drain.
1508
+ if (adapterHolder.value !== null) {
1509
+ try {
1510
+ await adapterHolder.value.aclose();
1511
+ }
1512
+ catch {
1513
+ /* swallow */
1514
+ }
1515
+ }
1516
+ await Promise.race([
1517
+ outTask,
1518
+ setTimeoutPromise(BRIDGE_HALF_CLOSE_GRACE_MS).then(() => {
1519
+ closeReason = "cancelled";
1520
+ }),
1521
+ ]).catch(() => undefined);
1522
+ }
1523
+ }
1524
+ finally {
1525
+ // Cleanup: TLS tail, CLOSE frame, drain socket.
1526
+ const tail = await tlsTail();
1527
+ if (tail.length > 0) {
1528
+ try {
1529
+ await Promise.race([
1530
+ sendFrame(WS_OPCODE_BINARY, tail),
1531
+ setTimeoutPromise(BRIDGE_CLEANUP_SEND_TIMEOUT_MS),
1532
+ ]);
1533
+ }
1534
+ catch {
1535
+ /* swallow */
1536
+ }
1537
+ }
1538
+ const wsCloseCode = BRIDGE_CLOSE_CODE[closeReason] ?? 1011;
1539
+ const reasonBytes = Buffer.from(closeReason, "utf-8").subarray(0, 123);
1540
+ const closePayload = Buffer.alloc(2 + reasonBytes.length);
1541
+ closePayload.writeUInt16BE(wsCloseCode, 0);
1542
+ reasonBytes.copy(closePayload, 2);
1543
+ stats.closeReason = closeReason;
1544
+ try {
1545
+ await Promise.race([
1546
+ sendFrame(WS_OPCODE_CLOSE, closePayload, true),
1547
+ setTimeoutPromise(BRIDGE_CLEANUP_SEND_TIMEOUT_MS),
1548
+ ]);
1549
+ }
1550
+ catch {
1551
+ /* swallow */
1552
+ }
1553
+ // Close the per-bridge plaintext adapter. The shared
1554
+ // UpstreamUrlDispatch lives on the runtime and is closed in aclose().
1555
+ if (adapterHolder.value !== null) {
1556
+ try {
1557
+ await adapterHolder.value.aclose();
1558
+ }
1559
+ catch {
1560
+ /* swallow */
1561
+ }
1562
+ }
1563
+ this.bridgeStreamIds.delete(streamId);
1564
+ this.streams.delete(streamId);
1565
+ }
1566
+ }
1567
+ writeBridgeFrame(stream, frame, endStream = false) {
1568
+ return new Promise((resolve, reject) => {
1569
+ stream.write(frame, (err) => {
1570
+ if (err) {
1571
+ reject(err);
1572
+ return;
1573
+ }
1574
+ if (endStream) {
1575
+ try {
1576
+ stream.end();
1577
+ }
1578
+ catch {
1579
+ /* swallow */
1580
+ }
1581
+ }
1582
+ resolve();
1583
+ });
1584
+ });
1585
+ }
1586
+ // --- response posting --------------------------------------------------
1587
+ async postResponse(requestId, status, userHeaders, body) {
1588
+ const reqHeaders = {
1589
+ [HTTP2_HEADER_METHOD]: "POST",
1590
+ [HTTP2_HEADER_SCHEME]: "https",
1591
+ [HTTP2_HEADER_AUTHORITY]: this.zone,
1592
+ [HTTP2_HEADER_PATH]: `${ControlPaths.RESPONSE_PREFIX}${requestId}`,
1593
+ [ControlHeaders.TUNNEL_ID]: this.tunnelId,
1594
+ [ControlHeaders.TUNNEL_SECRET]: this.secret,
1595
+ [TunnelMetaHeader.STATUS]: String(status),
1596
+ [TunnelMetaHeader.REQUEST_ID]: requestId,
1597
+ "content-length": String(body.length),
1598
+ };
1599
+ for (const [k, v] of userHeaders) {
1600
+ const kl = k.toLowerCase();
1601
+ if (kl === "content-length" || kl === "transfer-encoding")
1602
+ continue;
1603
+ // The reason meta header is already top-level; pass through the
1604
+ // forwarded-h-* prefix for the rest. The spec allows multiple
1605
+ // values per name; flatten by appending under indexed keys.
1606
+ const targetKey = `${INKBOX_FORWARDED_HEADER_PREFIX}${kl}`;
1607
+ const existing = reqHeaders[targetKey];
1608
+ if (existing === undefined) {
1609
+ reqHeaders[targetKey] = v;
1610
+ }
1611
+ else if (Array.isArray(existing)) {
1612
+ existing.push(v);
1613
+ }
1614
+ else {
1615
+ reqHeaders[targetKey] = [String(existing), v];
1616
+ }
1617
+ // The TunnelMetaHeader.REASON is also forwarded above as
1618
+ // `inkbox-h-inkbox-reason` — that's OK since it's also surfaced
1619
+ // top-level via the `[STATUS, REASON]` tuple Python attaches.
1620
+ // Match Python's behavior exactly: any user header whose name
1621
+ // overlaps a reserved meta key is passed through under the h-
1622
+ // prefix.
1623
+ if (kl === TunnelMetaHeader.REASON) {
1624
+ reqHeaders[TunnelMetaHeader.REASON] = v;
1625
+ }
1626
+ }
1627
+ const { streamId, stream } = this.openStream(reqHeaders, {
1628
+ endStream: body.length === 0,
1629
+ });
1630
+ if (body.length > 0) {
1631
+ await new Promise((resolve, reject) => {
1632
+ stream.write(body, (err) => (err ? reject(err) : resolve()));
1633
+ });
1634
+ await new Promise((resolve) => {
1635
+ stream.end(() => resolve());
1636
+ });
1637
+ }
1638
+ // Best-effort: wait briefly for the response status, but don't
1639
+ // block forever; cleanup either way.
1640
+ try {
1641
+ await Promise.race([
1642
+ this.awaitResponse(streamId),
1643
+ setTimeoutPromise(30_000),
1644
+ ]);
1645
+ }
1646
+ finally {
1647
+ this.streams.delete(streamId);
1648
+ }
1649
+ }
1650
+ // --- utilities ---------------------------------------------------------
1651
+ notifyStatus(status) {
1652
+ if (this.onStatus !== undefined) {
1653
+ try {
1654
+ this.onStatus(status);
1655
+ }
1656
+ catch (err) {
1657
+ // eslint-disable-next-line no-console
1658
+ console.warn("on_status callback raised", err);
1659
+ }
1660
+ }
1661
+ }
1662
+ }
1663
+ class BodyTooLargeError extends Error {
1664
+ constructor() {
1665
+ super("body too large");
1666
+ this.name = "BodyTooLargeError";
1667
+ }
1668
+ }
1669
+ // Placeholder to keep the file aware of the WS frame opcodes — used by
1670
+ // the WS bridge IO when it calls back into the runtime's frame helpers.
1671
+ // (kept for future export wiring; explicit imports above).
1672
+ void WS_OPCODE_BINARY;
1673
+ void WS_OPCODE_CLOSE;
1674
+ void WS_OPCODE_PING;
1675
+ void WS_OPCODE_PONG;
1676
+ void WsFrameDecoder;
1677
+ void encodeWsEnvelope;
1678
+ void encodeWsFrame;
1679
+ //# sourceMappingURL=_runtime.js.map