@inkbox/sdk 0.4.7 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tunnels/client/_runtime.d.ts +45 -10
- package/dist/tunnels/client/_runtime.d.ts.map +1 -1
- package/dist/tunnels/client/_runtime.js +423 -183
- package/dist/tunnels/client/_runtime.js.map +1 -1
- package/dist/tunnels/client/_ws.d.ts +12 -0
- package/dist/tunnels/client/_ws.d.ts.map +1 -1
- package/dist/tunnels/client/_ws.js +26 -1
- package/dist/tunnels/client/_ws.js.map +1 -1
- package/dist/tunnels/client/_ws_url_edge_bridge.d.ts +1 -1
- package/dist/tunnels/client/_ws_url_edge_bridge.d.ts.map +1 -1
- package/dist/tunnels/client/_ws_url_edge_bridge.js +17 -2
- package/dist/tunnels/client/_ws_url_edge_bridge.js.map +1 -1
- package/dist/tunnels/client/index.d.ts +1 -1
- package/dist/tunnels/client/index.d.ts.map +1 -1
- package/dist/tunnels/client/index.js +1 -1
- package/dist/tunnels/client/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -25,7 +25,7 @@ import { ControlHeaders, ControlPaths, HOP_BY_HOP_RESPONSE, INKBOX_FORWARDED_HEA
|
|
|
25
25
|
import { validateEnvelopePath } from "./_validation.js";
|
|
26
26
|
import { createUndiciAgentCache, forwardEnvelopeToUrl, } from "./_url_forward.js";
|
|
27
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";
|
|
28
|
+
import { dispatchWsUpgradeInProcess, WsServerDraining, } from "./_ws.js";
|
|
29
29
|
const HTTP2_HEADER_METHOD = http2.constants.HTTP2_HEADER_METHOD;
|
|
30
30
|
const HTTP2_HEADER_PATH = http2.constants.HTTP2_HEADER_PATH;
|
|
31
31
|
const HTTP2_HEADER_SCHEME = http2.constants.HTTP2_HEADER_SCHEME;
|
|
@@ -40,6 +40,21 @@ export const PING_INTERVAL_MS = 20_000;
|
|
|
40
40
|
export const PING_ACK_TIMEOUT_MS = 10_000;
|
|
41
41
|
export const BACKOFF_CAP_SEC = 30.0;
|
|
42
42
|
export const BACKOFF_JITTER = 0.25;
|
|
43
|
+
// On drain, keep a post-GOAWAY connection alive for its in-flight bridges
|
|
44
|
+
// up to this long, then force it closed. Bounds the handoff tail.
|
|
45
|
+
export const DRAINING_CONNECTION_CLOSE_TIMEOUT_MS = 90_000;
|
|
46
|
+
// Budget for re-dialing the replacement connection during a handoff (the
|
|
47
|
+
// server may bounce the first hello while it drains). Must stay below the
|
|
48
|
+
// close timeout so a stuck handoff still resolves.
|
|
49
|
+
export const HANDOFF_REDIAL_BUDGET_MS = 30_000;
|
|
50
|
+
// Minimum spacing between handoffs. A real drain rollout spaces GOAWAYs
|
|
51
|
+
// seconds apart per task; this rate-limit keeps a stray/rapid GOAWAY from
|
|
52
|
+
// chaining handoffs in a tight loop (e.g. a fresh conn signalled at once).
|
|
53
|
+
export const HANDOFF_SETTLE_MS = 2_000;
|
|
54
|
+
// How long an HTTP reply waits for an in-flight handoff to publish the new
|
|
55
|
+
// active connection before giving up (the server response deadline + the
|
|
56
|
+
// third-party retry recover a dropped reply).
|
|
57
|
+
export const POST_ACTIVE_WAIT_MS = 5_000;
|
|
43
58
|
export const DEFAULT_INBOUND_BODY_BYTES = 32 * 1024 * 1024;
|
|
44
59
|
export const DEFAULT_OUTBOUND_BODY_BYTES = 32 * 1024 * 1024;
|
|
45
60
|
export class TunnelAuthError extends Error {
|
|
@@ -80,6 +95,34 @@ function isSessionTerminalError(err) {
|
|
|
80
95
|
code === "ERR_HTTP2_STREAM_CANCEL" ||
|
|
81
96
|
code === "ERR_HTTP2_SESSION_ERROR");
|
|
82
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* One persistent h2 connection's state. The runtime holds a single
|
|
100
|
+
* `active` Connection (the pool that parks new intakes) plus zero-or-more
|
|
101
|
+
* `draining` ones during a make-before-break handoff. State is
|
|
102
|
+
* per-connection because two live h2 sessions each allocate stream ids
|
|
103
|
+
* 1,3,5… — a shared streams map would collide across them.
|
|
104
|
+
*/
|
|
105
|
+
class Connection {
|
|
106
|
+
id;
|
|
107
|
+
session = null;
|
|
108
|
+
ownerToken = null;
|
|
109
|
+
serverPoolSize = null;
|
|
110
|
+
intakeIdleSeconds = null;
|
|
111
|
+
responseDeadlineSeconds = null;
|
|
112
|
+
// Stop parking new intakes once this conn has received GOAWAY.
|
|
113
|
+
draining = false;
|
|
114
|
+
streams = new Map();
|
|
115
|
+
bridgeStreamIds = new Set();
|
|
116
|
+
pingHandle = null;
|
|
117
|
+
pingAbort = null;
|
|
118
|
+
constructor(id) {
|
|
119
|
+
this.id = id;
|
|
120
|
+
}
|
|
121
|
+
/** Live WS/TCP bridge count — drives the drain-quiescent check. */
|
|
122
|
+
get liveBridges() {
|
|
123
|
+
return this.bridgeStreamIds.size;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
83
126
|
/**
|
|
84
127
|
* The data-plane runtime. Construct with the bootstrap-derived
|
|
85
128
|
* tunnelId/secret/zone/publicHost; call `serveForever()` to drive it,
|
|
@@ -100,14 +143,23 @@ export class TunnelRuntime {
|
|
|
100
143
|
onStatus;
|
|
101
144
|
rng;
|
|
102
145
|
http2Connect;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
146
|
+
// The pool that parks new intakes. Swapped atomically on handoff.
|
|
147
|
+
active = null;
|
|
148
|
+
// Post-GOAWAY connections finishing in-flight work before close.
|
|
149
|
+
draining = new Set();
|
|
150
|
+
nextConnId = 1;
|
|
151
|
+
// True while a make-before-break handoff is dialing the replacement.
|
|
152
|
+
handoffInFlight = false;
|
|
153
|
+
// When the last handoff began (rate-limit, see HANDOFF_SETTLE_MS).
|
|
154
|
+
lastHandoffAt = 0;
|
|
155
|
+
// The in-flight handoff, so the supervisor can await it instead of
|
|
156
|
+
// hot-spinning if the old session closes mid-dial.
|
|
157
|
+
handoffPromise = null;
|
|
158
|
+
// Resolves the supervisor's wait when the active conn is swapped.
|
|
159
|
+
wakeSupervisor = null;
|
|
108
160
|
stop = false;
|
|
109
|
-
|
|
110
|
-
|
|
161
|
+
// Dispatch tasks are runtime-scoped (not per-connection): a handoff must
|
|
162
|
+
// let an in-flight handler finish and post its reply on the NEW conn.
|
|
111
163
|
tasks = new Set();
|
|
112
164
|
// Lazy: built on first passthrough TCP stream; closed in aclose().
|
|
113
165
|
passthroughDispatch = null;
|
|
@@ -116,8 +168,6 @@ export class TunnelRuntime {
|
|
|
116
168
|
// fresh Agent per request, which would leak sockets/timers. Closed
|
|
117
169
|
// in aclose().
|
|
118
170
|
undiciAgentCache = createUndiciAgentCache();
|
|
119
|
-
pingHandle = null;
|
|
120
|
-
pingAbort = null;
|
|
121
171
|
shutdownAbort = new AbortController();
|
|
122
172
|
constructor(opts) {
|
|
123
173
|
this.tunnelId = opts.tunnelId;
|
|
@@ -185,11 +235,10 @@ export class TunnelRuntime {
|
|
|
185
235
|
}
|
|
186
236
|
this.notifyStatus("closed");
|
|
187
237
|
}
|
|
188
|
-
/** Graceful shutdown. Signals all loops to exit; closes
|
|
238
|
+
/** Graceful shutdown. Signals all loops to exit; closes every conn. */
|
|
189
239
|
async aclose() {
|
|
190
240
|
this.stop = true;
|
|
191
241
|
this.shutdownAbort.abort();
|
|
192
|
-
this.pingAbort?.abort();
|
|
193
242
|
if (this.passthroughDispatch !== null) {
|
|
194
243
|
try {
|
|
195
244
|
await this.passthroughDispatch.aclose();
|
|
@@ -205,95 +254,230 @@ export class TunnelRuntime {
|
|
|
205
254
|
catch {
|
|
206
255
|
/* swallow */
|
|
207
256
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
257
|
+
// Close active + every draining conn; stop each one's ping loop so no
|
|
258
|
+
// ping loop leaks across the handoff set.
|
|
259
|
+
const conns = [this.active, ...this.draining].filter((c) => c !== null);
|
|
260
|
+
for (const conn of conns) {
|
|
261
|
+
this.stopPingLoop(conn);
|
|
262
|
+
await this.closeConnection(conn);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Emit GOAWAY then close/destroy a connection's session, bounded by a
|
|
267
|
+
* short grace. The intake pool parks streams indefinitely, so a plain
|
|
268
|
+
* `close()` would never resolve; we GOAWAY then destroy after 250ms.
|
|
269
|
+
*/
|
|
270
|
+
async closeConnection(conn) {
|
|
271
|
+
const session = conn.session;
|
|
272
|
+
conn.session = null;
|
|
273
|
+
if (session === null || session.closed)
|
|
274
|
+
return;
|
|
275
|
+
try {
|
|
276
|
+
session.goaway();
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
/* swallow */
|
|
280
|
+
}
|
|
281
|
+
await new Promise((resolve) => {
|
|
282
|
+
const t = setTimeout(() => {
|
|
229
283
|
try {
|
|
230
|
-
|
|
231
|
-
// session-level alone; ignore — destroy below tears down all
|
|
232
|
-
// streams atomically.
|
|
233
|
-
void sid;
|
|
284
|
+
session.destroy();
|
|
234
285
|
}
|
|
235
286
|
catch {
|
|
236
287
|
/* swallow */
|
|
237
288
|
}
|
|
289
|
+
resolve();
|
|
290
|
+
}, 250);
|
|
291
|
+
session.once("close", () => {
|
|
292
|
+
clearTimeout(t);
|
|
293
|
+
resolve();
|
|
294
|
+
});
|
|
295
|
+
try {
|
|
296
|
+
session.close();
|
|
238
297
|
}
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
});
|
|
298
|
+
catch {
|
|
299
|
+
clearTimeout(t);
|
|
254
300
|
try {
|
|
255
|
-
session.
|
|
301
|
+
session.destroy();
|
|
256
302
|
}
|
|
257
303
|
catch {
|
|
258
|
-
|
|
259
|
-
try {
|
|
260
|
-
session.destroy();
|
|
261
|
-
}
|
|
262
|
-
catch {
|
|
263
|
-
/* swallow */
|
|
264
|
-
}
|
|
265
|
-
resolve();
|
|
304
|
+
/* swallow */
|
|
266
305
|
}
|
|
267
|
-
|
|
268
|
-
|
|
306
|
+
resolve();
|
|
307
|
+
}
|
|
308
|
+
});
|
|
269
309
|
}
|
|
270
310
|
// --- per-connection lifecycle -----------------------------------------
|
|
271
311
|
async runOnce() {
|
|
272
|
-
|
|
312
|
+
const first = new Connection(this.nextConnId++);
|
|
313
|
+
let conn = first;
|
|
314
|
+
this.active = first;
|
|
273
315
|
try {
|
|
274
|
-
await this.
|
|
316
|
+
await this.openConnection(first);
|
|
317
|
+
await this.sendHello(first);
|
|
275
318
|
this.notifyStatus("connected");
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
this.
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
319
|
+
this.startServing(first);
|
|
320
|
+
// Supervise the active connection. A GOAWAY handoff swaps in a new
|
|
321
|
+
// active conn out-of-band; follow it without going through the
|
|
322
|
+
// backoff loop. Only a cold death (active conn closed with no
|
|
323
|
+
// successor) returns, so serveForever reconnects with backoff.
|
|
324
|
+
while (!this.stop) {
|
|
325
|
+
await this.waitCloseOrHandoff(conn);
|
|
326
|
+
if (this.stop)
|
|
327
|
+
break;
|
|
328
|
+
// If a handoff is dialing the replacement, wait for it to resolve
|
|
329
|
+
// before deciding — yields to the event loop so the dial's IO
|
|
330
|
+
// isn't starved by re-racing an already-closed old session.
|
|
331
|
+
if (this.handoffInFlight && this.handoffPromise !== null) {
|
|
332
|
+
await this.handoffPromise;
|
|
333
|
+
}
|
|
334
|
+
const next = this.active;
|
|
335
|
+
if (next !== null && next !== conn && !next.draining) {
|
|
336
|
+
conn = next;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const session = conn.session;
|
|
340
|
+
if (session === null || session.closed || session.destroyed)
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
288
343
|
}
|
|
289
344
|
finally {
|
|
290
|
-
this.stopPingLoop();
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
345
|
+
this.stopPingLoop(conn);
|
|
346
|
+
conn.streams.clear();
|
|
347
|
+
conn.bridgeStreamIds.clear();
|
|
348
|
+
conn.session = null;
|
|
349
|
+
if (this.active === conn)
|
|
350
|
+
this.active = null;
|
|
294
351
|
}
|
|
295
352
|
}
|
|
296
|
-
|
|
353
|
+
/** Spawn a connection's intake pool + ping loop (cold open or handoff). */
|
|
354
|
+
startServing(conn) {
|
|
355
|
+
const effectivePool = conn.serverPoolSize ?? this.poolSize ?? 1;
|
|
356
|
+
for (let slot = 0; slot < effectivePool; slot++) {
|
|
357
|
+
// Fire-and-forget: each slot self-terminates when the conn closes
|
|
358
|
+
// or starts draining.
|
|
359
|
+
void this.intakeLoop(conn, slot).catch(() => undefined);
|
|
360
|
+
}
|
|
361
|
+
this.startPingLoop(conn);
|
|
362
|
+
}
|
|
363
|
+
/** Resolve once the supervised conn closes OR a handoff swaps active. */
|
|
364
|
+
waitCloseOrHandoff(conn) {
|
|
365
|
+
const closed = new Promise((resolve) => {
|
|
366
|
+
const session = conn.session;
|
|
367
|
+
if (session === null || session.closed) {
|
|
368
|
+
resolve();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
session.once("close", () => resolve());
|
|
372
|
+
});
|
|
373
|
+
const woken = new Promise((resolve) => {
|
|
374
|
+
this.wakeSupervisor = resolve;
|
|
375
|
+
});
|
|
376
|
+
return Promise.race([closed, woken]).finally(() => {
|
|
377
|
+
this.wakeSupervisor = null;
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
signalSupervisor() {
|
|
381
|
+
const w = this.wakeSupervisor;
|
|
382
|
+
this.wakeSupervisor = null;
|
|
383
|
+
if (w !== null)
|
|
384
|
+
w();
|
|
385
|
+
}
|
|
386
|
+
// --- make-before-break handoff ----------------------------------------
|
|
387
|
+
/**
|
|
388
|
+
* On a NO_ERROR GOAWAY, mark the old conn draining and stand up a fresh
|
|
389
|
+
* connection before closing it. In-band: never trips the backoff loop.
|
|
390
|
+
*/
|
|
391
|
+
beginHandoff(oldConn) {
|
|
392
|
+
if (this.stop ||
|
|
393
|
+
oldConn.draining ||
|
|
394
|
+
this.active !== oldConn ||
|
|
395
|
+
this.handoffInFlight ||
|
|
396
|
+
Date.now() - this.lastHandoffAt < HANDOFF_SETTLE_MS) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
this.handoffInFlight = true;
|
|
400
|
+
this.lastHandoffAt = Date.now();
|
|
401
|
+
oldConn.draining = true;
|
|
402
|
+
this.draining.add(oldConn);
|
|
403
|
+
// Stop the draining conn's ping loop: it's expected to close, and we
|
|
404
|
+
// don't want two ping loops racing across the handoff set.
|
|
405
|
+
this.stopPingLoop(oldConn);
|
|
406
|
+
this.handoffPromise = this.runHandoff(oldConn);
|
|
407
|
+
}
|
|
408
|
+
async runHandoff(oldConn) {
|
|
409
|
+
try {
|
|
410
|
+
const newConn = await this.makeReplacementConnection();
|
|
411
|
+
this.active = newConn;
|
|
412
|
+
// Supervisor was watching oldConn; wake it to follow newConn.
|
|
413
|
+
this.signalSupervisor();
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
// Redial budget exhausted (or auth failure): give up on
|
|
417
|
+
// make-before-break and fall to the cold reconnect path by forcing
|
|
418
|
+
// the old session closed so the supervisor returns.
|
|
419
|
+
// eslint-disable-next-line no-console
|
|
420
|
+
console.warn("tunnel runtime: handoff failed; reconnecting cold", err);
|
|
421
|
+
try {
|
|
422
|
+
oldConn.session?.destroy();
|
|
423
|
+
}
|
|
424
|
+
catch { /* swallow */ }
|
|
425
|
+
this.signalSupervisor();
|
|
426
|
+
}
|
|
427
|
+
finally {
|
|
428
|
+
this.handoffInFlight = false;
|
|
429
|
+
this.handoffPromise = null;
|
|
430
|
+
// Close the old conn once its bridges finish (or the deadline hits).
|
|
431
|
+
void this.drainOldConnection(oldConn);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/** Dial + hello + park a replacement, retrying transient hello failures. */
|
|
435
|
+
async makeReplacementConnection() {
|
|
436
|
+
let backoff = 0.1;
|
|
437
|
+
const start = Date.now();
|
|
438
|
+
while (!this.stop) {
|
|
439
|
+
const conn = new Connection(this.nextConnId++);
|
|
440
|
+
try {
|
|
441
|
+
await this.openConnection(conn);
|
|
442
|
+
await this.sendHello(conn);
|
|
443
|
+
this.startServing(conn);
|
|
444
|
+
return conn;
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
try {
|
|
448
|
+
await this.closeConnection(conn);
|
|
449
|
+
}
|
|
450
|
+
catch { /* swallow */ }
|
|
451
|
+
if (err instanceof TunnelAuthError)
|
|
452
|
+
throw err;
|
|
453
|
+
if (Date.now() - start > HANDOFF_REDIAL_BUDGET_MS) {
|
|
454
|
+
throw new Error("handoff redial budget exhausted");
|
|
455
|
+
}
|
|
456
|
+
// A drain 503 on the new hello means the NLB landed us back on the
|
|
457
|
+
// draining task; back off (jittered) so it re-routes us elsewhere.
|
|
458
|
+
const jitter = backoff * BACKOFF_JITTER * (2 * this.rng() - 1);
|
|
459
|
+
await setTimeoutPromise(Math.max(50, (backoff + jitter) * 1000), undefined, {
|
|
460
|
+
signal: this.shutdownAbort.signal,
|
|
461
|
+
}).catch(() => undefined);
|
|
462
|
+
backoff = Math.min(backoff * 2, 5.0);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
throw new Error("runtime stopped during handoff");
|
|
466
|
+
}
|
|
467
|
+
/** Wait for a draining conn's bridges to finish, then close it. */
|
|
468
|
+
async drainOldConnection(oldConn) {
|
|
469
|
+
const deadline = Date.now() + DRAINING_CONNECTION_CLOSE_TIMEOUT_MS;
|
|
470
|
+
// Node keeps existing streams running after a NO_ERROR GOAWAY, so let
|
|
471
|
+
// live WS/TCP bridges finish until they drain or the deadline hits.
|
|
472
|
+
while (oldConn.liveBridges > 0 && Date.now() < deadline && !this.stop) {
|
|
473
|
+
await setTimeoutPromise(250).catch(() => undefined);
|
|
474
|
+
}
|
|
475
|
+
await this.closeConnection(oldConn);
|
|
476
|
+
oldConn.streams.clear();
|
|
477
|
+
oldConn.bridgeStreamIds.clear();
|
|
478
|
+
this.draining.delete(oldConn);
|
|
479
|
+
}
|
|
480
|
+
async openConnection(conn) {
|
|
297
481
|
const authority = `https://${this.zone}`;
|
|
298
482
|
const session = this.http2Connect(authority, {
|
|
299
483
|
ALPNProtocols: ["h2"],
|
|
@@ -303,13 +487,13 @@ export class TunnelRuntime {
|
|
|
303
487
|
// workaround. Node http2 either accepts `:protocol` or doesn't
|
|
304
488
|
// (Spike 1) — the setting line doesn't translate.
|
|
305
489
|
});
|
|
306
|
-
|
|
490
|
+
conn.session = session;
|
|
307
491
|
session.on("close", () => {
|
|
308
492
|
// eslint-disable-next-line no-console
|
|
309
493
|
console.info("tunnel runtime: h2 session closed");
|
|
310
494
|
// Drain all open streams with a synthetic reset event so any
|
|
311
495
|
// awaiters wake up.
|
|
312
|
-
for (const [, bus] of
|
|
496
|
+
for (const [, bus] of conn.streams) {
|
|
313
497
|
if (!bus.ended) {
|
|
314
498
|
bus.events.push({ kind: "reset", code: 0 });
|
|
315
499
|
bus.ended = true;
|
|
@@ -327,6 +511,12 @@ export class TunnelRuntime {
|
|
|
327
511
|
session.on("goaway", (errorCode, lastStreamId) => {
|
|
328
512
|
// eslint-disable-next-line no-console
|
|
329
513
|
console.info(`tunnel runtime: GOAWAY received error_code=${errorCode} last_stream_id=${lastStreamId}`);
|
|
514
|
+
// NO_ERROR GOAWAY is the server's drain signal: stand up a fresh
|
|
515
|
+
// connection make-before-break. A non-zero code is a real fault —
|
|
516
|
+
// let the session close and reconnect cold.
|
|
517
|
+
if (errorCode === 0) {
|
|
518
|
+
this.beginHandoff(conn);
|
|
519
|
+
}
|
|
330
520
|
});
|
|
331
521
|
// Watch the underlying TCP/TLS socket directly. Node's h2 client
|
|
332
522
|
// sometimes loses the connection without emitting ``error`` or
|
|
@@ -387,8 +577,8 @@ export class TunnelRuntime {
|
|
|
387
577
|
/* swallow */
|
|
388
578
|
}
|
|
389
579
|
}
|
|
390
|
-
waitForSessionClose() {
|
|
391
|
-
const session =
|
|
580
|
+
waitForSessionClose(conn) {
|
|
581
|
+
const session = conn.session;
|
|
392
582
|
if (session === null)
|
|
393
583
|
return Promise.resolve();
|
|
394
584
|
return new Promise((resolve) => {
|
|
@@ -400,11 +590,11 @@ export class TunnelRuntime {
|
|
|
400
590
|
});
|
|
401
591
|
}
|
|
402
592
|
// --- handshake ---------------------------------------------------------
|
|
403
|
-
async sendHello() {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
593
|
+
async sendHello(conn) {
|
|
594
|
+
conn.ownerToken = null;
|
|
595
|
+
conn.serverPoolSize = null;
|
|
596
|
+
conn.intakeIdleSeconds = null;
|
|
597
|
+
conn.responseDeadlineSeconds = null;
|
|
408
598
|
const helloHeaders = {
|
|
409
599
|
[HTTP2_HEADER_METHOD]: "POST",
|
|
410
600
|
[HTTP2_HEADER_SCHEME]: "https",
|
|
@@ -417,8 +607,8 @@ export class TunnelRuntime {
|
|
|
417
607
|
if (this.poolSize !== null) {
|
|
418
608
|
helloHeaders[ControlHeaders.POOL_SIZE] = String(this.poolSize);
|
|
419
609
|
}
|
|
420
|
-
const stream = this.openStream(helloHeaders, { endStream: true });
|
|
421
|
-
const { status, body } = await this.awaitResponse(stream.streamId);
|
|
610
|
+
const stream = this.openStream(conn, helloHeaders, { endStream: true });
|
|
611
|
+
const { status, body } = await this.awaitResponse(conn, stream.streamId);
|
|
422
612
|
if (status === 401 || status === 403) {
|
|
423
613
|
throw new TunnelAuthError(`${ControlPaths.HELLO} returned ${status}; the API key was rejected (check the key matches the tunnel's identity scope, or use an admin-scoped key in the tunnel's org)`);
|
|
424
614
|
}
|
|
@@ -438,20 +628,20 @@ export class TunnelRuntime {
|
|
|
438
628
|
if (typeof ownerToken !== "string" || ownerToken === "") {
|
|
439
629
|
throw new Error(`${ControlPaths.HELLO} response missing owner_token; cannot park intake`);
|
|
440
630
|
}
|
|
441
|
-
|
|
631
|
+
conn.ownerToken = ownerToken;
|
|
442
632
|
if (typeof payload["default_pool_size"] === "number") {
|
|
443
|
-
|
|
633
|
+
conn.serverPoolSize = payload["default_pool_size"];
|
|
444
634
|
}
|
|
445
635
|
if (typeof payload["intake_idle_seconds"] === "number") {
|
|
446
|
-
|
|
636
|
+
conn.intakeIdleSeconds = payload["intake_idle_seconds"];
|
|
447
637
|
}
|
|
448
638
|
if (typeof payload["response_deadline_seconds"] === "number") {
|
|
449
|
-
|
|
639
|
+
conn.responseDeadlineSeconds = payload["response_deadline_seconds"];
|
|
450
640
|
}
|
|
451
641
|
}
|
|
452
642
|
// --- stream helpers ----------------------------------------------------
|
|
453
|
-
openStream(headers, opts) {
|
|
454
|
-
const session =
|
|
643
|
+
openStream(conn, headers, opts) {
|
|
644
|
+
const session = conn.session;
|
|
455
645
|
if (session === null)
|
|
456
646
|
throw new Error("h2 connection not open");
|
|
457
647
|
const stream = session.request(headers, { endStream: opts.endStream });
|
|
@@ -468,7 +658,7 @@ export class TunnelRuntime {
|
|
|
468
658
|
// In practice Node assigns the id synchronously; this is a guard.
|
|
469
659
|
throw new Error("h2 stream id not assigned synchronously");
|
|
470
660
|
}
|
|
471
|
-
|
|
661
|
+
conn.streams.set(streamId, bus);
|
|
472
662
|
stream.on("response", (responseHeaders) => {
|
|
473
663
|
const flat = [];
|
|
474
664
|
for (const k of Object.keys(responseHeaders)) {
|
|
@@ -513,8 +703,8 @@ export class TunnelRuntime {
|
|
|
513
703
|
w();
|
|
514
704
|
}
|
|
515
705
|
}
|
|
516
|
-
async nextEvent(streamId) {
|
|
517
|
-
const bus =
|
|
706
|
+
async nextEvent(conn, streamId) {
|
|
707
|
+
const bus = conn.streams.get(streamId);
|
|
518
708
|
if (bus === undefined)
|
|
519
709
|
return null;
|
|
520
710
|
while (true) {
|
|
@@ -530,14 +720,14 @@ export class TunnelRuntime {
|
|
|
530
720
|
});
|
|
531
721
|
}
|
|
532
722
|
}
|
|
533
|
-
async awaitResponse(streamId) {
|
|
723
|
+
async awaitResponse(conn, streamId) {
|
|
534
724
|
const chunks = [];
|
|
535
725
|
let status = 0;
|
|
536
726
|
let gotHeaders = false;
|
|
537
727
|
while (true) {
|
|
538
|
-
const ev = await this.nextEvent(streamId);
|
|
728
|
+
const ev = await this.nextEvent(conn, streamId);
|
|
539
729
|
if (ev === null) {
|
|
540
|
-
|
|
730
|
+
conn.streams.delete(streamId);
|
|
541
731
|
return { status, body: Buffer.concat(chunks) };
|
|
542
732
|
}
|
|
543
733
|
if (ev.kind === "headers" && !gotHeaders) {
|
|
@@ -549,27 +739,30 @@ export class TunnelRuntime {
|
|
|
549
739
|
chunks.push(ev.data);
|
|
550
740
|
}
|
|
551
741
|
else if (ev.kind === "end" || ev.kind === "reset") {
|
|
552
|
-
|
|
742
|
+
conn.streams.delete(streamId);
|
|
553
743
|
return { status, body: Buffer.concat(chunks) };
|
|
554
744
|
}
|
|
555
745
|
}
|
|
556
746
|
}
|
|
557
747
|
// --- intake pool -------------------------------------------------------
|
|
558
|
-
async intakeLoop(slot) {
|
|
559
|
-
while (!this.stop &&
|
|
748
|
+
async intakeLoop(conn, slot) {
|
|
749
|
+
while (!this.stop &&
|
|
750
|
+
!conn.draining &&
|
|
751
|
+
conn.session !== null &&
|
|
752
|
+
!conn.session.closed) {
|
|
560
753
|
let envelope;
|
|
561
754
|
try {
|
|
562
|
-
envelope = await this.parkOneIntake(slot);
|
|
755
|
+
envelope = await this.parkOneIntake(conn, slot);
|
|
563
756
|
}
|
|
564
757
|
catch (err) {
|
|
565
758
|
if (err instanceof OwnerTokenInvalidError) {
|
|
566
759
|
// eslint-disable-next-line no-console
|
|
567
760
|
console.warn(`intake slot ${slot}: owner_token rejected; ` +
|
|
568
761
|
`forcing session.destroy() and reconnecting`);
|
|
569
|
-
|
|
762
|
+
conn.session?.destroy();
|
|
570
763
|
return;
|
|
571
764
|
}
|
|
572
|
-
if (isSessionTerminalError(err) ||
|
|
765
|
+
if (isSessionTerminalError(err) || conn.session?.destroyed) {
|
|
573
766
|
// The h2 session is gone — every subsequent openStream will
|
|
574
767
|
// throw the same error. Don't retry-storm; exit the slot so
|
|
575
768
|
// ``runOnce`` observes ``waitForSessionClose`` resolve and
|
|
@@ -582,7 +775,7 @@ export class TunnelRuntime {
|
|
|
582
775
|
`${err?.code ?? "no code"}); ` +
|
|
583
776
|
`exiting slot`, err);
|
|
584
777
|
try {
|
|
585
|
-
|
|
778
|
+
conn.session?.destroy();
|
|
586
779
|
}
|
|
587
780
|
catch { /* swallow */ }
|
|
588
781
|
return;
|
|
@@ -594,8 +787,10 @@ export class TunnelRuntime {
|
|
|
594
787
|
}
|
|
595
788
|
if (envelope === null)
|
|
596
789
|
continue;
|
|
597
|
-
// Fire-and-forget dispatch; tracked
|
|
598
|
-
|
|
790
|
+
// Fire-and-forget dispatch; tracked on the runtime (not the conn) so
|
|
791
|
+
// an in-flight handler survives this conn draining and can post its
|
|
792
|
+
// reply on the new active conn during a handoff.
|
|
793
|
+
const task = this.dispatchEnvelope(conn, envelope).catch((err) => {
|
|
599
794
|
// eslint-disable-next-line no-console
|
|
600
795
|
console.warn(`dispatch failed request_id=${envelope.requestId}`, err);
|
|
601
796
|
});
|
|
@@ -603,8 +798,8 @@ export class TunnelRuntime {
|
|
|
603
798
|
task.finally(() => this.tasks.delete(task));
|
|
604
799
|
}
|
|
605
800
|
}
|
|
606
|
-
async parkOneIntake(slot) {
|
|
607
|
-
if (
|
|
801
|
+
async parkOneIntake(conn, slot) {
|
|
802
|
+
if (conn.ownerToken === null) {
|
|
608
803
|
throw new Error("intake parked before /_system/hello returned an owner_token");
|
|
609
804
|
}
|
|
610
805
|
const headers = {
|
|
@@ -613,17 +808,17 @@ export class TunnelRuntime {
|
|
|
613
808
|
[HTTP2_HEADER_AUTHORITY]: this.zone,
|
|
614
809
|
[HTTP2_HEADER_PATH]: ControlPaths.INTAKE,
|
|
615
810
|
[ControlHeaders.TUNNEL_ID]: this.tunnelId,
|
|
616
|
-
[ControlHeaders.OWNER_TOKEN]:
|
|
811
|
+
[ControlHeaders.OWNER_TOKEN]: conn.ownerToken,
|
|
617
812
|
[ControlHeaders.POOL_SLOT]: String(slot),
|
|
618
813
|
"content-length": "0",
|
|
619
814
|
};
|
|
620
|
-
const { streamId } = this.openStream(headers, { endStream: true });
|
|
815
|
+
const { streamId } = this.openStream(conn, headers, { endStream: true });
|
|
621
816
|
let recvHeaders = null;
|
|
622
817
|
const chunks = [];
|
|
623
818
|
while (true) {
|
|
624
|
-
const ev = await this.nextEvent(streamId);
|
|
819
|
+
const ev = await this.nextEvent(conn, streamId);
|
|
625
820
|
if (ev === null) {
|
|
626
|
-
|
|
821
|
+
conn.streams.delete(streamId);
|
|
627
822
|
return null;
|
|
628
823
|
}
|
|
629
824
|
if (ev.kind === "headers" && recvHeaders === null) {
|
|
@@ -636,11 +831,11 @@ export class TunnelRuntime {
|
|
|
636
831
|
break;
|
|
637
832
|
}
|
|
638
833
|
else if (ev.kind === "reset") {
|
|
639
|
-
|
|
834
|
+
conn.streams.delete(streamId);
|
|
640
835
|
return null;
|
|
641
836
|
}
|
|
642
837
|
}
|
|
643
|
-
|
|
838
|
+
conn.streams.delete(streamId);
|
|
644
839
|
if (recvHeaders === null)
|
|
645
840
|
return null;
|
|
646
841
|
const status = recvHeaders.find(([k]) => k === HTTP2_HEADER_STATUS)?.[1] ?? "0";
|
|
@@ -656,10 +851,10 @@ export class TunnelRuntime {
|
|
|
656
851
|
return parseEnvelope(recvHeaders, Buffer.concat(chunks));
|
|
657
852
|
}
|
|
658
853
|
// --- ping loop ---------------------------------------------------------
|
|
659
|
-
startPingLoop() {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
const session =
|
|
854
|
+
startPingLoop(conn) {
|
|
855
|
+
conn.pingAbort = new AbortController();
|
|
856
|
+
conn.pingHandle = setInterval(() => {
|
|
857
|
+
const session = conn.session;
|
|
663
858
|
if (session === null || session.closed)
|
|
664
859
|
return;
|
|
665
860
|
let ackTimer = null;
|
|
@@ -711,19 +906,19 @@ export class TunnelRuntime {
|
|
|
711
906
|
}, PING_INTERVAL_MS);
|
|
712
907
|
// Do NOT unref(): explicit cancellation in stopPingLoop().
|
|
713
908
|
}
|
|
714
|
-
stopPingLoop() {
|
|
715
|
-
if (
|
|
716
|
-
clearInterval(
|
|
717
|
-
|
|
909
|
+
stopPingLoop(conn) {
|
|
910
|
+
if (conn.pingHandle !== null) {
|
|
911
|
+
clearInterval(conn.pingHandle);
|
|
912
|
+
conn.pingHandle = null;
|
|
718
913
|
}
|
|
719
|
-
|
|
720
|
-
|
|
914
|
+
conn.pingAbort?.abort();
|
|
915
|
+
conn.pingAbort = null;
|
|
721
916
|
}
|
|
722
917
|
// --- envelope dispatch -------------------------------------------------
|
|
723
|
-
async dispatchEnvelope(envelope) {
|
|
918
|
+
async dispatchEnvelope(conn, envelope) {
|
|
724
919
|
if (envelope.routeKind === TunnelRouteKind.WS_UPGRADE) {
|
|
725
920
|
try {
|
|
726
|
-
await this.dispatchWsUpgrade(envelope);
|
|
921
|
+
await this.dispatchWsUpgrade(conn, envelope);
|
|
727
922
|
}
|
|
728
923
|
catch (err) {
|
|
729
924
|
// eslint-disable-next-line no-console
|
|
@@ -734,7 +929,7 @@ export class TunnelRuntime {
|
|
|
734
929
|
if (envelope.routeKind === TunnelRouteKind.TCP_STREAM) {
|
|
735
930
|
// Passthrough TCP bridge — defer until M4 lands here.
|
|
736
931
|
try {
|
|
737
|
-
await this.dispatchTcpStream(envelope);
|
|
932
|
+
await this.dispatchTcpStream(conn, envelope);
|
|
738
933
|
}
|
|
739
934
|
catch (err) {
|
|
740
935
|
// eslint-disable-next-line no-console
|
|
@@ -743,13 +938,13 @@ export class TunnelRuntime {
|
|
|
743
938
|
return;
|
|
744
939
|
}
|
|
745
940
|
try {
|
|
746
|
-
await this.dispatchHttp(envelope);
|
|
941
|
+
await this.dispatchHttp(conn, envelope);
|
|
747
942
|
}
|
|
748
943
|
catch (err) {
|
|
749
944
|
// eslint-disable-next-line no-console
|
|
750
945
|
console.warn(`dispatch failed request_id=${envelope.requestId}`, err);
|
|
751
946
|
try {
|
|
752
|
-
await this.
|
|
947
|
+
await this.postHttpResponse(conn, envelope.requestId, 500, [["content-type", "text/plain"]], Buffer.from("internal error"));
|
|
753
948
|
}
|
|
754
949
|
catch {
|
|
755
950
|
/* swallow */
|
|
@@ -757,10 +952,10 @@ export class TunnelRuntime {
|
|
|
757
952
|
}
|
|
758
953
|
}
|
|
759
954
|
// --- HTTP dispatch -----------------------------------------------------
|
|
760
|
-
async dispatchHttp(envelope) {
|
|
955
|
+
async dispatchHttp(conn, envelope) {
|
|
761
956
|
const reject = validateEnvelopePath(envelope.path);
|
|
762
957
|
if (reject !== null) {
|
|
763
|
-
await this.
|
|
958
|
+
await this.postHttpResponse(conn, envelope.requestId, 400, [
|
|
764
959
|
["content-type", "text/plain"],
|
|
765
960
|
[TunnelMetaHeader.REASON, reject],
|
|
766
961
|
], Buffer.from("invalid path"));
|
|
@@ -774,13 +969,13 @@ export class TunnelRuntime {
|
|
|
774
969
|
catch (err) {
|
|
775
970
|
const reason = err instanceof BodyTooLargeError ? "request-body-too-large" : "body-fetch-failed";
|
|
776
971
|
const status = err instanceof BodyTooLargeError ? 413 : 502;
|
|
777
|
-
await this.
|
|
972
|
+
await this.postHttpResponse(conn, envelope.requestId, status, [
|
|
778
973
|
["content-type", "text/plain"],
|
|
779
974
|
[TunnelMetaHeader.REASON, reason],
|
|
780
975
|
], Buffer.from(reason));
|
|
781
976
|
return;
|
|
782
977
|
}
|
|
783
|
-
const deadlineMs = (
|
|
978
|
+
const deadlineMs = (conn.responseDeadlineSeconds ?? 0) * 1000;
|
|
784
979
|
const ctrl = new AbortController();
|
|
785
980
|
let deadlineHandle = null;
|
|
786
981
|
// Sentinel resolved by the deadline timer — used as the loser side
|
|
@@ -838,7 +1033,7 @@ export class TunnelRuntime {
|
|
|
838
1033
|
// the server-side deadline, and a late ``postResponse`` would
|
|
839
1034
|
// target a request the tunnel server has already 504'd.
|
|
840
1035
|
dispatchPromise.catch(() => undefined);
|
|
841
|
-
await this.
|
|
1036
|
+
await this.postHttpResponse(conn, envelope.requestId, 504, [
|
|
842
1037
|
["content-type", "text/plain"],
|
|
843
1038
|
[TunnelMetaHeader.REASON, "response-deadline-exceeded"],
|
|
844
1039
|
], Buffer.from("local handler too slow"));
|
|
@@ -847,10 +1042,10 @@ export class TunnelRuntime {
|
|
|
847
1042
|
if (outcome.kind === "in-process") {
|
|
848
1043
|
const inProcess = outcome.result;
|
|
849
1044
|
if (inProcess.kind === "ok") {
|
|
850
|
-
await this.
|
|
1045
|
+
await this.postHttpResponse(conn, envelope.requestId, inProcess.status, filterResponseHeaders(inProcess.headers), inProcess.body);
|
|
851
1046
|
}
|
|
852
1047
|
else {
|
|
853
|
-
await this.
|
|
1048
|
+
await this.postHttpResponse(conn, envelope.requestId, inProcess.status, [
|
|
854
1049
|
["content-type", "text/plain"],
|
|
855
1050
|
[TunnelMetaHeader.REASON, inProcess.inkboxReason],
|
|
856
1051
|
], Buffer.from(inProcess.inkboxReason));
|
|
@@ -860,10 +1055,10 @@ export class TunnelRuntime {
|
|
|
860
1055
|
if (outcome.kind === "url-forward") {
|
|
861
1056
|
const result = outcome.result;
|
|
862
1057
|
if (result.kind === "ok") {
|
|
863
|
-
await this.
|
|
1058
|
+
await this.postHttpResponse(conn, envelope.requestId, result.status, filterResponseHeaders(result.headers), result.body);
|
|
864
1059
|
}
|
|
865
1060
|
else {
|
|
866
|
-
await this.
|
|
1061
|
+
await this.postHttpResponse(conn, envelope.requestId, result.status, [
|
|
867
1062
|
["content-type", "text/plain"],
|
|
868
1063
|
[TunnelMetaHeader.REASON, result.inkboxReason],
|
|
869
1064
|
], Buffer.from(result.inkboxReason));
|
|
@@ -872,7 +1067,7 @@ export class TunnelRuntime {
|
|
|
872
1067
|
}
|
|
873
1068
|
// No HTTP path configured — should be impossible if connect()
|
|
874
1069
|
// validation is correct, but defend.
|
|
875
|
-
await this.
|
|
1070
|
+
await this.postHttpResponse(conn, envelope.requestId, 501, [
|
|
876
1071
|
["content-type", "text/plain"],
|
|
877
1072
|
[TunnelMetaHeader.REASON, "no-http-handler"],
|
|
878
1073
|
], Buffer.from("no http handler"));
|
|
@@ -911,9 +1106,9 @@ export class TunnelRuntime {
|
|
|
911
1106
|
return { ...envelope, body: Buffer.concat(chunks, total) };
|
|
912
1107
|
}
|
|
913
1108
|
// --- WS dispatch -------------------------------------------------------
|
|
914
|
-
async dispatchWsUpgrade(envelope) {
|
|
1109
|
+
async dispatchWsUpgrade(conn, envelope) {
|
|
915
1110
|
if (envelope.wsId === null) {
|
|
916
|
-
await this.postResponse(envelope.requestId, 400, [
|
|
1111
|
+
await this.postResponse(conn, envelope.requestId, 400, [
|
|
917
1112
|
["content-type", "text/plain"],
|
|
918
1113
|
[TunnelMetaHeader.REASON, "missing-ws-id"],
|
|
919
1114
|
], Buffer.from("missing ws_id"));
|
|
@@ -923,7 +1118,7 @@ export class TunnelRuntime {
|
|
|
923
1118
|
// validateEnvelopePath check, so apply it here too.
|
|
924
1119
|
const reject = validateEnvelopePath(envelope.path);
|
|
925
1120
|
if (reject !== null) {
|
|
926
|
-
await this.postResponse(envelope.requestId, 400, [
|
|
1121
|
+
await this.postResponse(conn, envelope.requestId, 400, [
|
|
927
1122
|
["content-type", "text/plain"],
|
|
928
1123
|
[TunnelMetaHeader.REASON, reject],
|
|
929
1124
|
], Buffer.from("invalid path"));
|
|
@@ -932,19 +1127,19 @@ export class TunnelRuntime {
|
|
|
932
1127
|
// URL forward — bridge to the upstream WS via h1 Upgrade.
|
|
933
1128
|
if (this.dispatch.wsHandler === undefined &&
|
|
934
1129
|
this.dispatch.forwardTo !== undefined) {
|
|
935
|
-
await this.dispatchWsUpgradeToUrl(envelope, this.dispatch.forwardTo);
|
|
1130
|
+
await this.dispatchWsUpgradeToUrl(conn, envelope, this.dispatch.forwardTo);
|
|
936
1131
|
return;
|
|
937
1132
|
}
|
|
938
1133
|
if (this.dispatch.wsHandler === undefined) {
|
|
939
1134
|
// No URL upstream and no in-process WS handler — reject 501.
|
|
940
|
-
await this.postResponse(envelope.requestId, 501, [
|
|
1135
|
+
await this.postResponse(conn, envelope.requestId, 501, [
|
|
941
1136
|
["content-type", "text/plain"],
|
|
942
1137
|
[TunnelMetaHeader.REASON, "ws-not-supported"],
|
|
943
1138
|
], Buffer.from("ws upgrade not supported"));
|
|
944
1139
|
return;
|
|
945
1140
|
}
|
|
946
|
-
const acceptDeadlineMs = (
|
|
947
|
-
const bridge = await this.openWsBridge(envelope);
|
|
1141
|
+
const acceptDeadlineMs = (conn.responseDeadlineSeconds ?? 30) * 1000;
|
|
1142
|
+
const bridge = await this.openWsBridge(conn, envelope);
|
|
948
1143
|
try {
|
|
949
1144
|
await dispatchWsUpgradeInProcess({
|
|
950
1145
|
envelope,
|
|
@@ -958,7 +1153,7 @@ export class TunnelRuntime {
|
|
|
958
1153
|
bridge.cleanup();
|
|
959
1154
|
}
|
|
960
1155
|
}
|
|
961
|
-
async dispatchWsUpgradeToUrl(envelope, forwardTo) {
|
|
1156
|
+
async dispatchWsUpgradeToUrl(conn, envelope, forwardTo) {
|
|
962
1157
|
// Open the upstream WS hop. On failure, surface the upstream-style
|
|
963
1158
|
// status back to the third party so the client sees a clean
|
|
964
1159
|
// non-101 instead of hanging.
|
|
@@ -979,8 +1174,8 @@ export class TunnelRuntime {
|
|
|
979
1174
|
// the server already 504'd would just be wasted work.
|
|
980
1175
|
// Floor at 1ms (not 1s) — sub-second response deadlines are valid
|
|
981
1176
|
// and must be honored. Earlier shape clamped 0.1s up to 1s.
|
|
982
|
-
const handshakeTimeoutMs =
|
|
983
|
-
? Math.max(1,
|
|
1177
|
+
const handshakeTimeoutMs = conn.responseDeadlineSeconds !== null
|
|
1178
|
+
? Math.max(1, conn.responseDeadlineSeconds * 1000)
|
|
984
1179
|
: undefined;
|
|
985
1180
|
try {
|
|
986
1181
|
upstream = await openWsUpstream({
|
|
@@ -997,7 +1192,7 @@ export class TunnelRuntime {
|
|
|
997
1192
|
}
|
|
998
1193
|
catch (e) {
|
|
999
1194
|
const status = e instanceof WsUpstreamError ? e.status : 502;
|
|
1000
|
-
await this.postResponse(envelope.requestId, status, [
|
|
1195
|
+
await this.postResponse(conn, envelope.requestId, status, [
|
|
1001
1196
|
["content-type", "text/plain"],
|
|
1002
1197
|
[TunnelMetaHeader.REASON, "ws-upstream-failed"],
|
|
1003
1198
|
], Buffer.from("upstream ws upgrade failed"));
|
|
@@ -1030,7 +1225,7 @@ export class TunnelRuntime {
|
|
|
1030
1225
|
continue;
|
|
1031
1226
|
upgradeReplyHeaders.push([hk, hv]);
|
|
1032
1227
|
}
|
|
1033
|
-
const bridge = await this.openWsBridge(envelope);
|
|
1228
|
+
const bridge = await this.openWsBridge(conn, envelope);
|
|
1034
1229
|
try {
|
|
1035
1230
|
// postUpgradeReply both posts the 200 AND opens the inkbox bridge
|
|
1036
1231
|
// CONNECT stream — skipping it (an earlier draft did) leaves
|
|
@@ -1078,7 +1273,7 @@ export class TunnelRuntime {
|
|
|
1078
1273
|
bridge.cleanup();
|
|
1079
1274
|
}
|
|
1080
1275
|
}
|
|
1081
|
-
async openWsBridge(envelope) {
|
|
1276
|
+
async openWsBridge(conn, envelope) {
|
|
1082
1277
|
const wsId = envelope.wsId;
|
|
1083
1278
|
const requestId = envelope.requestId;
|
|
1084
1279
|
let connectStreamId = null;
|
|
@@ -1099,7 +1294,7 @@ export class TunnelRuntime {
|
|
|
1099
1294
|
}
|
|
1100
1295
|
};
|
|
1101
1296
|
const postUpgradeReply = async (headers) => {
|
|
1102
|
-
await this.postResponse(requestId, 200, headers, Buffer.alloc(0));
|
|
1297
|
+
await this.postResponse(conn, requestId, 200, headers, Buffer.alloc(0));
|
|
1103
1298
|
// Open the extended-CONNECT bridge stream after the upgrade reply.
|
|
1104
1299
|
const connectHeaders = {
|
|
1105
1300
|
[HTTP2_HEADER_METHOD]: "CONNECT",
|
|
@@ -1113,10 +1308,10 @@ export class TunnelRuntime {
|
|
|
1113
1308
|
[ControlHeaders.API_KEY]: this.apiKey,
|
|
1114
1309
|
[TunnelMetaHeader.WS_ID]: wsId,
|
|
1115
1310
|
};
|
|
1116
|
-
const opened = this.openStream(connectHeaders, { endStream: false });
|
|
1311
|
+
const opened = this.openStream(conn, connectHeaders, { endStream: false });
|
|
1117
1312
|
connectStreamId = opened.streamId;
|
|
1118
1313
|
bridgeStream = opened.stream;
|
|
1119
|
-
|
|
1314
|
+
conn.bridgeStreamIds.add(connectStreamId);
|
|
1120
1315
|
try {
|
|
1121
1316
|
// Wait for the 200 on the bridge stream — bounded so a server
|
|
1122
1317
|
// that never replies can't wedge the dispatch task after the
|
|
@@ -1124,7 +1319,7 @@ export class TunnelRuntime {
|
|
|
1124
1319
|
// _with_deadline + the TCP passthrough's
|
|
1125
1320
|
// BRIDGE_STATUS_TIMEOUT_MS race.
|
|
1126
1321
|
const ev = await Promise.race([
|
|
1127
|
-
this.nextEvent(connectStreamId),
|
|
1322
|
+
this.nextEvent(conn, connectStreamId),
|
|
1128
1323
|
setTimeoutPromise(BRIDGE_STATUS_TIMEOUT_MS).then(() => "timeout"),
|
|
1129
1324
|
]);
|
|
1130
1325
|
if (ev === "timeout") {
|
|
@@ -1147,7 +1342,7 @@ export class TunnelRuntime {
|
|
|
1147
1342
|
}
|
|
1148
1343
|
};
|
|
1149
1344
|
const rejectUpgrade = async (status, reason) => {
|
|
1150
|
-
await this.postResponse(requestId, status, [
|
|
1345
|
+
await this.postResponse(conn, requestId, status, [
|
|
1151
1346
|
["content-type", "text/plain"],
|
|
1152
1347
|
[TunnelMetaHeader.REASON, reason],
|
|
1153
1348
|
], Buffer.from(reason));
|
|
@@ -1165,18 +1360,30 @@ export class TunnelRuntime {
|
|
|
1165
1360
|
return (async function* () {
|
|
1166
1361
|
while (true) {
|
|
1167
1362
|
const id = sid();
|
|
1168
|
-
if (id === null)
|
|
1363
|
+
if (id === null) {
|
|
1364
|
+
if (conn.draining)
|
|
1365
|
+
throw new WsServerDraining();
|
|
1169
1366
|
return;
|
|
1170
|
-
|
|
1171
|
-
|
|
1367
|
+
}
|
|
1368
|
+
const ev = await self.nextEvent(conn, id);
|
|
1369
|
+
if (ev === null) {
|
|
1370
|
+
if (conn.draining)
|
|
1371
|
+
throw new WsServerDraining();
|
|
1172
1372
|
return;
|
|
1373
|
+
}
|
|
1173
1374
|
if (ev.kind === "data") {
|
|
1174
1375
|
yield ev.data;
|
|
1175
1376
|
}
|
|
1176
1377
|
else if (ev.kind === "end") {
|
|
1378
|
+
if (conn.draining)
|
|
1379
|
+
throw new WsServerDraining();
|
|
1177
1380
|
return;
|
|
1178
1381
|
}
|
|
1179
1382
|
else if (ev.kind === "reset") {
|
|
1383
|
+
// A reset while the conn is draining is the redeploy drain, not
|
|
1384
|
+
// a peer error — surface it typed so the handler can reconnect.
|
|
1385
|
+
if (conn.draining)
|
|
1386
|
+
throw new WsServerDraining();
|
|
1180
1387
|
throw new Error(`bridge stream reset code=${ev.code}`);
|
|
1181
1388
|
}
|
|
1182
1389
|
}
|
|
@@ -1218,8 +1425,8 @@ export class TunnelRuntime {
|
|
|
1218
1425
|
}
|
|
1219
1426
|
}
|
|
1220
1427
|
if (connectStreamId !== null) {
|
|
1221
|
-
|
|
1222
|
-
|
|
1428
|
+
conn.bridgeStreamIds.delete(connectStreamId);
|
|
1429
|
+
conn.streams.delete(connectStreamId);
|
|
1223
1430
|
}
|
|
1224
1431
|
};
|
|
1225
1432
|
return {
|
|
@@ -1228,7 +1435,7 @@ export class TunnelRuntime {
|
|
|
1228
1435
|
};
|
|
1229
1436
|
}
|
|
1230
1437
|
// --- TCP-stream bridge (passthrough) ----------------------------------
|
|
1231
|
-
async dispatchTcpStream(envelope) {
|
|
1438
|
+
async dispatchTcpStream(conn, envelope) {
|
|
1232
1439
|
if (this.tlsTerminator === null ||
|
|
1233
1440
|
(this.dispatch.forwardTo === undefined &&
|
|
1234
1441
|
this.dispatch.httpHandler === undefined) ||
|
|
@@ -1253,15 +1460,15 @@ export class TunnelRuntime {
|
|
|
1253
1460
|
[ControlHeaders.API_KEY]: this.apiKey,
|
|
1254
1461
|
[TunnelMetaHeader.TCP_ID]: tcpId,
|
|
1255
1462
|
};
|
|
1256
|
-
const { stream, streamId } = this.openStream(connectHeaders, {
|
|
1463
|
+
const { stream, streamId } = this.openStream(conn, connectHeaders, {
|
|
1257
1464
|
endStream: false,
|
|
1258
1465
|
});
|
|
1259
|
-
|
|
1466
|
+
conn.bridgeStreamIds.add(streamId);
|
|
1260
1467
|
// 2) Wait for status 200 with timeout.
|
|
1261
1468
|
let openOk = false;
|
|
1262
1469
|
try {
|
|
1263
1470
|
const ev = await Promise.race([
|
|
1264
|
-
this.nextEvent(streamId),
|
|
1471
|
+
this.nextEvent(conn, streamId),
|
|
1265
1472
|
setTimeoutPromise(BRIDGE_STATUS_TIMEOUT_MS).then(() => "timeout"),
|
|
1266
1473
|
]);
|
|
1267
1474
|
if (ev !== null && ev !== "timeout" && typeof ev !== "string" && ev.kind === "headers") {
|
|
@@ -1281,8 +1488,8 @@ export class TunnelRuntime {
|
|
|
1281
1488
|
catch {
|
|
1282
1489
|
/* swallow */
|
|
1283
1490
|
}
|
|
1284
|
-
|
|
1285
|
-
|
|
1491
|
+
conn.bridgeStreamIds.delete(streamId);
|
|
1492
|
+
conn.streams.delete(streamId);
|
|
1286
1493
|
return;
|
|
1287
1494
|
}
|
|
1288
1495
|
// 3) Build (or reuse) the passthrough dispatcher for this runtime.
|
|
@@ -1380,7 +1587,7 @@ export class TunnelRuntime {
|
|
|
1380
1587
|
let pendingFrags = null;
|
|
1381
1588
|
try {
|
|
1382
1589
|
while (true) {
|
|
1383
|
-
const ev = await this.nextEvent(streamId);
|
|
1590
|
+
const ev = await this.nextEvent(conn, streamId);
|
|
1384
1591
|
if (ev === null)
|
|
1385
1592
|
return;
|
|
1386
1593
|
if (ev.kind === "end")
|
|
@@ -1560,8 +1767,8 @@ export class TunnelRuntime {
|
|
|
1560
1767
|
/* swallow */
|
|
1561
1768
|
}
|
|
1562
1769
|
}
|
|
1563
|
-
|
|
1564
|
-
|
|
1770
|
+
conn.bridgeStreamIds.delete(streamId);
|
|
1771
|
+
conn.streams.delete(streamId);
|
|
1565
1772
|
}
|
|
1566
1773
|
}
|
|
1567
1774
|
writeBridgeFrame(stream, frame, endStream = false) {
|
|
@@ -1584,7 +1791,40 @@ export class TunnelRuntime {
|
|
|
1584
1791
|
});
|
|
1585
1792
|
}
|
|
1586
1793
|
// --- response posting --------------------------------------------------
|
|
1587
|
-
|
|
1794
|
+
/**
|
|
1795
|
+
* Post an HTTP webhook reply on the CURRENT active connection (not the
|
|
1796
|
+
* connection the envelope arrived on). After a GOAWAY the old connection
|
|
1797
|
+
* refuses new streams, so an in-flight reply must ride the new one, which
|
|
1798
|
+
* lands on the new task. `origin` is the fallback if no handoff is active.
|
|
1799
|
+
* If a handoff is mid-dial, wait (bounded) for it to publish the new
|
|
1800
|
+
* active; if none is healthy in time, drop the reply (the server deadline
|
|
1801
|
+
* + third-party retry recover it).
|
|
1802
|
+
*/
|
|
1803
|
+
async postHttpResponse(origin, requestId, status, userHeaders, body) {
|
|
1804
|
+
if (this.handoffInFlight && this.handoffPromise !== null) {
|
|
1805
|
+
await Promise.race([
|
|
1806
|
+
this.handoffPromise,
|
|
1807
|
+
setTimeoutPromise(POST_ACTIVE_WAIT_MS),
|
|
1808
|
+
]);
|
|
1809
|
+
}
|
|
1810
|
+
const target = this.pickReplyConnection(origin);
|
|
1811
|
+
if (target === null) {
|
|
1812
|
+
// eslint-disable-next-line no-console
|
|
1813
|
+
console.warn(`no live connection to post reply request_id=${requestId}; dropping`);
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
await this.postResponse(target, requestId, status, userHeaders, body);
|
|
1817
|
+
}
|
|
1818
|
+
/** The active conn if it can take new streams, else the origin if it can. */
|
|
1819
|
+
pickReplyConnection(origin) {
|
|
1820
|
+
const usable = (c) => c !== null && !c.draining && c.session !== null && !c.session.closed;
|
|
1821
|
+
if (usable(this.active))
|
|
1822
|
+
return this.active;
|
|
1823
|
+
if (usable(origin))
|
|
1824
|
+
return origin;
|
|
1825
|
+
return null;
|
|
1826
|
+
}
|
|
1827
|
+
async postResponse(conn, requestId, status, userHeaders, body) {
|
|
1588
1828
|
const reqHeaders = {
|
|
1589
1829
|
[HTTP2_HEADER_METHOD]: "POST",
|
|
1590
1830
|
[HTTP2_HEADER_SCHEME]: "https",
|
|
@@ -1624,7 +1864,7 @@ export class TunnelRuntime {
|
|
|
1624
1864
|
reqHeaders[TunnelMetaHeader.REASON] = v;
|
|
1625
1865
|
}
|
|
1626
1866
|
}
|
|
1627
|
-
const { streamId, stream } = this.openStream(reqHeaders, {
|
|
1867
|
+
const { streamId, stream } = this.openStream(conn, reqHeaders, {
|
|
1628
1868
|
endStream: body.length === 0,
|
|
1629
1869
|
});
|
|
1630
1870
|
if (body.length > 0) {
|
|
@@ -1639,12 +1879,12 @@ export class TunnelRuntime {
|
|
|
1639
1879
|
// block forever; cleanup either way.
|
|
1640
1880
|
try {
|
|
1641
1881
|
await Promise.race([
|
|
1642
|
-
this.awaitResponse(streamId),
|
|
1882
|
+
this.awaitResponse(conn, streamId),
|
|
1643
1883
|
setTimeoutPromise(30_000),
|
|
1644
1884
|
]);
|
|
1645
1885
|
}
|
|
1646
1886
|
finally {
|
|
1647
|
-
|
|
1887
|
+
conn.streams.delete(streamId);
|
|
1648
1888
|
}
|
|
1649
1889
|
}
|
|
1650
1890
|
// --- utilities ---------------------------------------------------------
|