@inkbox/sdk 0.2.16 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/dist/_http.d.ts +24 -5
- package/dist/_http.d.ts.map +1 -1
- package/dist/_http.js +21 -11
- package/dist/_http.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/inkbox.d.ts +4 -0
- package/dist/inkbox.d.ts.map +1 -1
- package/dist/inkbox.js +5 -0
- package/dist/inkbox.js.map +1 -1
- package/dist/tunnels/_validation.d.ts +7 -0
- package/dist/tunnels/_validation.d.ts.map +1 -0
- package/dist/tunnels/_validation.js +27 -0
- package/dist/tunnels/_validation.js.map +1 -0
- package/dist/tunnels/client/_bridge.d.ts +35 -0
- package/dist/tunnels/client/_bridge.d.ts.map +1 -0
- package/dist/tunnels/client/_bridge.js +52 -0
- package/dist/tunnels/client/_bridge.js.map +1 -0
- package/dist/tunnels/client/_callable_streaming.d.ts +25 -0
- package/dist/tunnels/client/_callable_streaming.d.ts.map +1 -0
- package/dist/tunnels/client/_callable_streaming.js +158 -0
- package/dist/tunnels/client/_callable_streaming.js.map +1 -0
- package/dist/tunnels/client/_cert.d.ts +45 -0
- package/dist/tunnels/client/_cert.d.ts.map +1 -0
- package/dist/tunnels/client/_cert.js +193 -0
- package/dist/tunnels/client/_cert.js.map +1 -0
- package/dist/tunnels/client/_dispatch.d.ts +109 -0
- package/dist/tunnels/client/_dispatch.d.ts.map +1 -0
- package/dist/tunnels/client/_dispatch.js +314 -0
- package/dist/tunnels/client/_dispatch.js.map +1 -0
- package/dist/tunnels/client/_envelope.d.ts +55 -0
- package/dist/tunnels/client/_envelope.d.ts.map +1 -0
- package/dist/tunnels/client/_envelope.js +97 -0
- package/dist/tunnels/client/_envelope.js.map +1 -0
- package/dist/tunnels/client/_h1_server.d.ts +37 -0
- package/dist/tunnels/client/_h1_server.d.ts.map +1 -0
- package/dist/tunnels/client/_h1_server.js +433 -0
- package/dist/tunnels/client/_h1_server.js.map +1 -0
- package/dist/tunnels/client/_h2_transcode.d.ts +43 -0
- package/dist/tunnels/client/_h2_transcode.d.ts.map +1 -0
- package/dist/tunnels/client/_h2_transcode.js +488 -0
- package/dist/tunnels/client/_h2_transcode.js.map +1 -0
- package/dist/tunnels/client/_handler.d.ts +62 -0
- package/dist/tunnels/client/_handler.d.ts.map +1 -0
- package/dist/tunnels/client/_handler.js +121 -0
- package/dist/tunnels/client/_handler.js.map +1 -0
- package/dist/tunnels/client/_listener.d.ts +64 -0
- package/dist/tunnels/client/_listener.d.ts.map +1 -0
- package/dist/tunnels/client/_listener.js +113 -0
- package/dist/tunnels/client/_listener.js.map +1 -0
- package/dist/tunnels/client/_protocol.d.ts +67 -0
- package/dist/tunnels/client/_protocol.d.ts.map +1 -0
- package/dist/tunnels/client/_protocol.js +86 -0
- package/dist/tunnels/client/_protocol.js.map +1 -0
- package/dist/tunnels/client/_runtime.d.ts +143 -0
- package/dist/tunnels/client/_runtime.d.ts.map +1 -0
- package/dist/tunnels/client/_runtime.js +1679 -0
- package/dist/tunnels/client/_runtime.js.map +1 -0
- package/dist/tunnels/client/_state.d.ts +45 -0
- package/dist/tunnels/client/_state.d.ts.map +1 -0
- package/dist/tunnels/client/_state.js +165 -0
- package/dist/tunnels/client/_state.js.map +1 -0
- package/dist/tunnels/client/_tls.d.ts +50 -0
- package/dist/tunnels/client/_tls.d.ts.map +1 -0
- package/dist/tunnels/client/_tls.js +139 -0
- package/dist/tunnels/client/_tls.js.map +1 -0
- package/dist/tunnels/client/_upstream_tls.d.ts +25 -0
- package/dist/tunnels/client/_upstream_tls.d.ts.map +1 -0
- package/dist/tunnels/client/_upstream_tls.js +24 -0
- package/dist/tunnels/client/_upstream_tls.js.map +1 -0
- package/dist/tunnels/client/_url_forward.d.ts +92 -0
- package/dist/tunnels/client/_url_forward.d.ts.map +1 -0
- package/dist/tunnels/client/_url_forward.js +255 -0
- package/dist/tunnels/client/_url_forward.js.map +1 -0
- package/dist/tunnels/client/_validation.d.ts +27 -0
- package/dist/tunnels/client/_validation.d.ts.map +1 -0
- package/dist/tunnels/client/_validation.js +96 -0
- package/dist/tunnels/client/_validation.js.map +1 -0
- package/dist/tunnels/client/_ws.d.ts +149 -0
- package/dist/tunnels/client/_ws.d.ts.map +1 -0
- package/dist/tunnels/client/_ws.js +351 -0
- package/dist/tunnels/client/_ws.js.map +1 -0
- package/dist/tunnels/client/_ws_passthrough.d.ts +129 -0
- package/dist/tunnels/client/_ws_passthrough.d.ts.map +1 -0
- package/dist/tunnels/client/_ws_passthrough.js +432 -0
- package/dist/tunnels/client/_ws_passthrough.js.map +1 -0
- package/dist/tunnels/client/_ws_url_bridge.d.ts +71 -0
- package/dist/tunnels/client/_ws_url_bridge.d.ts.map +1 -0
- package/dist/tunnels/client/_ws_url_bridge.js +474 -0
- package/dist/tunnels/client/_ws_url_bridge.js.map +1 -0
- package/dist/tunnels/client/_ws_url_edge_bridge.d.ts +26 -0
- package/dist/tunnels/client/_ws_url_edge_bridge.d.ts.map +1 -0
- package/dist/tunnels/client/_ws_url_edge_bridge.js +256 -0
- package/dist/tunnels/client/_ws_url_edge_bridge.js.map +1 -0
- package/dist/tunnels/client/_wsframe.d.ts +142 -0
- package/dist/tunnels/client/_wsframe.d.ts.map +1 -0
- package/dist/tunnels/client/_wsframe.js +282 -0
- package/dist/tunnels/client/_wsframe.js.map +1 -0
- package/dist/tunnels/client/index.d.ts +101 -0
- package/dist/tunnels/client/index.d.ts.map +1 -0
- package/dist/tunnels/client/index.js +242 -0
- package/dist/tunnels/client/index.js.map +1 -0
- package/dist/tunnels/exceptions.d.ts +31 -0
- package/dist/tunnels/exceptions.d.ts.map +1 -0
- package/dist/tunnels/exceptions.js +68 -0
- package/dist/tunnels/exceptions.js.map +1 -0
- package/dist/tunnels/resources/tunnels.d.ts +73 -0
- package/dist/tunnels/resources/tunnels.d.ts.map +1 -0
- package/dist/tunnels/resources/tunnels.js +173 -0
- package/dist/tunnels/resources/tunnels.js.map +1 -0
- package/dist/tunnels/types.d.ts +99 -0
- package/dist/tunnels/types.d.ts.map +1 -0
- package/dist/tunnels/types.js +76 -0
- package/dist/tunnels/types.js.map +1 -0
- package/package.json +14 -5
- 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
|