socketduct 0.1.16 → 0.1.18

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.
@@ -6,6 +6,7 @@ import {availableCodecs, negotiateCodec, zstdAvailable} from "../protocol/compre
6
6
  import {decodeV3Data, encodeV3Data, AdaptiveCompressor} from "../protocol/v3-compression.js"
7
7
  import {decodeV3Control, encodeV3Control, encodeV3Frame, V3FrameDecoder, V3FrameType} from "../protocol/v3-frame.js"
8
8
  import {
9
+ asError,
9
10
  DEFAULT_FRAME_BYTES,
10
11
  DEFAULT_HANDSHAKE_MS,
11
12
  DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS,
@@ -17,10 +18,15 @@ import {
17
18
  parseTarget,
18
19
  positiveInteger,
19
20
  protocolError,
21
+ ROUTE_CLASS,
20
22
  STABLE_IDENTIFIER,
21
23
  writeSocket
22
24
  } from "./common.js"
23
25
 
26
+ const DEFAULT_OUTPUT_BUFFER_BYTES = 32 * 1024 * 1024
27
+ const DEFAULT_RESUME_MS = 60_000
28
+ const MANIFEST_PAGE_STREAMS = 64
29
+
24
30
  /**
25
31
  * @typedef {{userId: string, username: string}} Principal
26
32
  * @typedef {{host: string, port: number}} Target
@@ -31,8 +37,11 @@ import {
31
37
  * @property {{certFile: string, keyFile: string, clientCaFile?: string}} tls - Mandatory outer TLS server configuration.
32
38
  * @property {(input: {principal: Principal, gatewayId: string}) => boolean} authorizeGateway - Stable gateway identity binding.
33
39
  * @property {(input: {principal: Principal, gatewayId: string}) => boolean} authorizeClientGateway - Client-to-gateway selection policy.
40
+ * @property {(input: {principal: Principal, routeClass: string}) => boolean} [authorizeClientRouteClass] - Client-to-route-class selection policy.
34
41
  * @property {(input: {principal: Principal, gatewayId: string, target: Target}) => boolean} authorizeOpen - Hub-side route policy.
42
+ * @property {(input: {principal: Principal, routeClass: string, target: Target}) => string[]} [resolveOpenGateways] - Eligible exact gateways for a route-class OPEN.
35
43
  * @property {string[]} [knownGatewayIds] - Configured IDs that may be temporarily unavailable.
44
+ * @property {string[]} [knownRouteClasses] - Configured route classes available to authorized clients.
36
45
  * @property {number} [initialWindowBytes] - Per-direction flow-control window.
37
46
  * @property {number} [maxFrameBytes] - Maximum uncompressed DATA frame.
38
47
  * @property {number} [maxStreams] - Process-wide bridge limit.
@@ -40,6 +49,8 @@ import {
40
49
  * @property {number} [maxConnectionIdentities] - Retained stale-generation identity bound.
41
50
  * @property {number} [handshakeTimeoutMs] - Authentication deadline.
42
51
  * @property {number} [maxInputBytesPerConnection] - Pre-authentication byte bound.
52
+ * @property {number} [maxOutputBytesPerConnection] - Per-physical queued hub output bound.
53
+ * @property {number} [resumeWindowMs] - Detached reverse-stream retention window.
43
54
  * @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - zstd safety bounds.
44
55
  * @typedef {object} HubPhysical
45
56
  * @property {import('node:tls').TLSSocket} socket
@@ -49,27 +60,50 @@ import {
49
60
  * @property {number} generation
50
61
  * @property {string | undefined} codec
51
62
  * @property {string | undefined} gatewayId
63
+ * @property {string | undefined} routeClass
52
64
  * @property {Map<number, Bridge>} streams
53
65
  * @property {number} lastClientStreamId
54
66
  * @property {number} nextGatewayStreamId
55
67
  * @property {boolean} authenticated
56
68
  * @property {boolean} closing
69
+ * @property {boolean} retainOnDetach
70
+ * @property {string | undefined} detachCode
71
+ * @property {boolean} resumeRequired
72
+ * @property {number} resumeNextPage
73
+ * @property {Array<Record<string, unknown>>} resumeItems
74
+ * @property {boolean} resumeFinal
75
+ * @property {Array<{streamId: number, bridge: Bridge | undefined, code?: string}>} resumeEntries
76
+ * @property {ReturnType<typeof setTimeout> | undefined} resumeTimer
57
77
  * @property {(() => void) | undefined} clearHandshake
78
+ * @property {HubOutputItem[]} outputQueue
79
+ * @property {number} outputBytes
80
+ * @property {boolean} outputWriting
81
+ * @typedef {object} HubOutputItem
82
+ * @property {Buffer} bytes
83
+ * @property {(delivered: boolean) => void} resolve
58
84
  * @typedef {object} GatewayOwner
59
85
  * @property {string} gatewayId
60
86
  * @property {string} connectionId
61
87
  * @property {string} userId
62
88
  * @property {number} generation
63
89
  * @property {HubPhysical | undefined} physical
90
+ * @property {Map<number, Bridge>} streams
91
+ * @property {ReturnType<typeof setTimeout> | undefined} expiryTimer
64
92
  * @typedef {object} ClientOwner
65
93
  * @property {string} connectionId
66
94
  * @property {string} userId
67
95
  * @property {number} generation
68
96
  * @property {HubPhysical | undefined} physical
97
+ * @property {Map<number, Bridge>} streams
98
+ * @property {string | undefined} gatewayId
99
+ * @property {string | undefined} routeClass
100
+ * @property {ReturnType<typeof setTimeout> | undefined} expiryTimer
69
101
  * @typedef {object} Bridge
70
102
  * @property {HubPhysical} client
103
+ * @property {ClientOwner} clientOwner
71
104
  * @property {number} clientStreamId
72
105
  * @property {HubPhysical} gateway
106
+ * @property {GatewayOwner} gatewayOwner
73
107
  * @property {number} gatewayStreamId
74
108
  * @property {string} identifier
75
109
  * @property {Target} target
@@ -82,6 +116,9 @@ import {
82
116
  * @property {boolean} gatewayEnded
83
117
  * @property {AdaptiveCompressor} clientToGatewayCompressor
84
118
  * @property {AdaptiveCompressor} gatewayToClientCompressor
119
+ * @property {{generation: number, clientToGatewaySequence: number, clientToGatewayAck: number, gatewayToClientSequence: number, gatewayToClientAck: number, localEnded: boolean, remoteEnded: boolean} | undefined} clientResume
120
+ * @property {{generation: number, clientToGatewaySequence: number, clientToGatewayAck: number, gatewayToClientSequence: number, gatewayToClientAck: number, localEnded: boolean, remoteEnded: boolean} | undefined} gatewayResume
121
+ * @property {string | undefined} reconciledResume
85
122
  */
86
123
 
87
124
  /** Starts the Server3 reverse hub. */
@@ -92,6 +129,8 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
92
129
  const maxStreams = positiveInteger(options.maxStreams, "maxStreams", DEFAULT_MAX_STREAMS)
93
130
  const maxIdentities = positiveInteger(options.maxConnectionIdentities, "maxConnectionIdentities", 4096)
94
131
  const maxPhysicalConnections = positiveInteger(options.maxPhysicalConnections, "maxPhysicalConnections", 8192)
132
+ const maxOutputBytes = positiveInteger(options.maxOutputBytesPerConnection, "maxOutputBytesPerConnection", DEFAULT_OUTPUT_BUFFER_BYTES)
133
+ const resumeWindowMs = positiveInteger(options.resumeWindowMs, "resumeWindowMs", DEFAULT_RESUME_MS)
95
134
  /** @type {Map<string, GatewayOwner>} */
96
135
  const gateways = new Map()
97
136
  /** @type {Map<string, ClientOwner>} */
@@ -153,12 +192,24 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
153
192
  generation: 0,
154
193
  codec: undefined,
155
194
  gatewayId: undefined,
195
+ routeClass: undefined,
156
196
  streams: new Map(),
157
197
  lastClientStreamId: 0,
158
198
  nextGatewayStreamId: 2,
159
199
  authenticated: false,
160
200
  closing: false,
161
- clearHandshake: undefined
201
+ retainOnDetach: true,
202
+ detachCode: undefined,
203
+ resumeRequired: false,
204
+ resumeNextPage: 0,
205
+ resumeItems: [],
206
+ resumeFinal: false,
207
+ resumeEntries: [],
208
+ resumeTimer: undefined,
209
+ clearHandshake: undefined,
210
+ outputQueue: [],
211
+ outputBytes: 0,
212
+ outputWriting: false
162
213
  }
163
214
  physicals.add(physical)
164
215
  const decoder = new V3FrameDecoder({maxFrameBytes: Math.max(MAX_CONTROL_BYTES, maxFrameBytes + 4), maxBufferedBytes: Math.max(MAX_CONTROL_BYTES, maxFrameBytes + 4) * 2})
@@ -174,10 +225,9 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
174
225
  if (!physical.authenticated && unauthenticatedBytes > (options.maxInputBytesPerConnection ?? DEFAULT_MAX_INPUT_BYTES)) throw protocolError("INPUT_LIMIT_EXCEEDED")
175
226
  const frames = decoder.push(chunk)
176
227
  for (const frame of frames) await handleFrame(physical, frame)
177
- }).catch(async (error) => {
228
+ }).catch((error) => {
178
229
  const code = typeof /** @type {{code?: unknown}} */ (error).code === "string" ? /** @type {{code: string}} */ (error).code : "PROTOCOL_VIOLATION"
179
- await sendControl(physical, V3FrameType.GOAWAY, 0, 0, {code}).catch(() => {})
180
- socket.destroy()
230
+ terminate(physical, code)
181
231
  }).finally(() => {
182
232
  if (!socket.destroyed && !physical.closing) socket.resume()
183
233
  })
@@ -185,7 +235,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
185
235
  socket.on("error", () => {})
186
236
  socket.once("close", () => {
187
237
  clearTimeout(handshakeTimer)
188
- void queue.finally(() => detachPhysical(physical, "TRANSPORT_CLOSED"))
238
+ void queue.finally(() => detachPhysical(physical, physical.detachCode ?? "TRANSPORT_CLOSED"))
189
239
  })
190
240
  physical.clearHandshake = () => clearTimeout(handshakeTimer)
191
241
  }
@@ -227,6 +277,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
227
277
  }
228
278
  if (frame.type === V3FrameType.PONG) return
229
279
  if (frame.type === V3FrameType.GOAWAY) {
280
+ physical.retainOnDetach = false
230
281
  physical.socket.end()
231
282
  return
232
283
  }
@@ -237,12 +288,17 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
237
288
  await registerGateway(physical, frame)
238
289
  return
239
290
  }
240
- if (physical.role === "client" && physical.gatewayId === undefined) {
291
+ if (physical.role === "client" && physical.gatewayId === undefined && physical.routeClass === undefined) {
241
292
  if (frame.type !== V3FrameType.GATEWAY_SELECT) throw protocolError("GATEWAY_SELECTION_REQUIRED")
242
- await selectGateway(physical, frame)
293
+ await selectClientRoute(physical, frame)
294
+ return
295
+ }
296
+ if (physical.resumeRequired && frame.type !== V3FrameType.RESUME) throw protocolError("RESUME_REQUIRED")
297
+ if (frame.type === V3FrameType.RESUME) {
298
+ await receiveResumeManifest(physical, frame)
243
299
  return
244
300
  }
245
- if (frame.type === V3FrameType.GATEWAY_REGISTER || frame.type === V3FrameType.GATEWAY_REGISTERED || frame.type === V3FrameType.GATEWAY_SELECT || frame.type === V3FrameType.GATEWAY_SELECTED || frame.type === V3FrameType.AUTH || frame.type === V3FrameType.AUTH_OK || frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.RESUME || frame.type === V3FrameType.RESUMED) throw protocolError("UNSUPPORTED_FRAME")
301
+ if (frame.type === V3FrameType.GATEWAY_REGISTER || frame.type === V3FrameType.GATEWAY_REGISTERED || frame.type === V3FrameType.GATEWAY_SELECT || frame.type === V3FrameType.GATEWAY_SELECTED || frame.type === V3FrameType.AUTH || frame.type === V3FrameType.AUTH_OK || frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.RESUMED) throw protocolError("UNSUPPORTED_FRAME")
246
302
  if (physical.role === "client" && frame.type === V3FrameType.OPEN) {
247
303
  await openBridge(physical, frame)
248
304
  return
@@ -260,13 +316,30 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
260
316
  if (existing === undefined && clients.size >= limit) evictDisconnectedClientIdentity()
261
317
  if (existing === undefined && clients.size >= limit) throw protocolError("IDENTITY_LIMIT")
262
318
  if (existing?.physical !== undefined) terminate(existing.physical, "CONNECTION_REPLACED")
263
- clients.set(id, {connectionId: id, userId: principal.userId, generation: physical.generation, physical})
319
+ if (existing?.expiryTimer !== undefined) clearTimeout(existing.expiryTimer)
320
+ const owner = existing ?? {
321
+ connectionId: id,
322
+ userId: principal.userId,
323
+ generation: physical.generation,
324
+ physical: undefined,
325
+ streams: new Map(),
326
+ gatewayId: undefined,
327
+ routeClass: undefined,
328
+ expiryTimer: undefined
329
+ }
330
+ owner.generation = physical.generation
331
+ owner.physical = physical
332
+ owner.expiryTimer = undefined
333
+ physical.streams = owner.streams
334
+ physical.lastClientStreamId = Math.max(0, ...owner.streams.keys())
335
+ physical.resumeRequired = existing !== undefined && owner.streams.size > 0
336
+ clients.set(id, owner)
264
337
  }
265
338
 
266
339
  /** Evicts the oldest disconnected client identity while active owners remain fenced. */
267
340
  function evictDisconnectedClientIdentity() {
268
341
  for (const [id, owner] of clients) {
269
- if (owner.physical !== undefined) continue
342
+ if (owner.physical !== undefined || owner.streams.size > 0) continue
270
343
  clients.delete(id)
271
344
  return
272
345
  }
@@ -296,30 +369,220 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
296
369
  }
297
370
  if (existing === undefined && gateways.size >= maxIdentities) throw protocolError("IDENTITY_LIMIT")
298
371
  if (existing?.physical !== undefined) terminate(existing.physical, "GATEWAY_REPLACED")
372
+ if (existing?.expiryTimer !== undefined) clearTimeout(existing.expiryTimer)
299
373
  physical.gatewayId = request.gatewayId
300
- gateways.set(request.gatewayId, {
374
+ const owner = existing ?? {
301
375
  gatewayId: request.gatewayId,
302
376
  connectionId,
303
377
  userId: physical.principal.userId,
304
378
  generation: physical.generation,
305
- physical
306
- })
379
+ physical: undefined,
380
+ streams: new Map(),
381
+ expiryTimer: undefined
382
+ }
383
+ owner.generation = physical.generation
384
+ owner.physical = physical
385
+ owner.expiryTimer = undefined
386
+ physical.streams = owner.streams
387
+ physical.nextGatewayStreamId = Math.max(0, ...owner.streams.keys()) + 2
388
+ physical.resumeRequired = existing !== undefined && owner.streams.size > 0
389
+ gateways.set(request.gatewayId, owner)
307
390
  gatewayRegistrations += 1
308
- await sendControl(physical, V3FrameType.GATEWAY_REGISTERED, 0, physical.generation, {gatewayId: request.gatewayId, generation: physical.generation})
391
+ await sendControl(physical, V3FrameType.GATEWAY_REGISTERED, 0, physical.generation, {gatewayId: request.gatewayId, generation: physical.generation, resumed: physical.resumeRequired})
392
+ armResumeTimeout(physical)
309
393
  }
310
394
 
311
395
  /** @param {HubPhysical} physical - Authenticated client. @param {import('../protocol/v3-frame.js').V3Frame} frame - Selection frame. */
312
- async function selectGateway(physical, frame) {
396
+ async function selectClientRoute(physical, frame) {
313
397
  if (frame.streamId !== 0 || frame.sequence !== physical.generation || frame.flags !== 0) throw protocolError("INVALID_GATEWAY_SELECTION")
314
398
  const request = decodeV3Control(frame.payload)
315
- if (typeof request.gatewayId !== "string" || !GATEWAY_ID.test(request.gatewayId)) throw protocolError("INVALID_GATEWAY_SELECTION")
316
- if (physical.principal === undefined || options.authorizeClientGateway({principal: physical.principal, gatewayId: request.gatewayId}) !== true) throw protocolError("GATEWAY_UNAUTHORIZED")
317
- const owner = gateways.get(request.gatewayId)
318
- const known = owner !== undefined || options.knownGatewayIds?.includes(request.gatewayId) === true
319
- if (!known) throw protocolError("UNKNOWN_GATEWAY_ID")
320
- if (owner?.physical === undefined) throw protocolError("GATEWAY_UNAVAILABLE")
321
- physical.gatewayId = request.gatewayId
322
- await sendControl(physical, V3FrameType.GATEWAY_SELECTED, 0, physical.generation, {gatewayId: request.gatewayId, generation: physical.generation})
399
+ const hasGatewayId = Object.hasOwn(request, "gatewayId")
400
+ const hasRouteClass = Object.hasOwn(request, "routeClass")
401
+ if (hasGatewayId === hasRouteClass || physical.principal === undefined) throw protocolError("INVALID_GATEWAY_SELECTION")
402
+ const clientOwner = clients.get(/** @type {string} */ (physical.connectionId))
403
+ if (clientOwner === undefined || clientOwner.physical !== physical) throw protocolError("STALE_GENERATION")
404
+ if (hasGatewayId) {
405
+ if (typeof request.gatewayId !== "string" || !GATEWAY_ID.test(request.gatewayId)) throw protocolError("INVALID_GATEWAY_SELECTION")
406
+ if (options.authorizeClientGateway({principal: physical.principal, gatewayId: request.gatewayId}) !== true) throw protocolError("GATEWAY_UNAUTHORIZED")
407
+ const owner = gateways.get(request.gatewayId)
408
+ const known = owner !== undefined || options.knownGatewayIds?.includes(request.gatewayId) === true
409
+ if (!known) throw protocolError("UNKNOWN_GATEWAY_ID")
410
+ const retainedSelection = clientOwner.gatewayId === request.gatewayId && clientOwner.streams.size > 0
411
+ const selectable = owner?.physical !== undefined && !owner.physical.socket.destroyed && !owner.physical.closing && !owner.physical.resumeRequired
412
+ if (!selectable && !retainedSelection) throw protocolError("GATEWAY_UNAVAILABLE")
413
+ if (clientOwner.routeClass !== undefined || (clientOwner.gatewayId !== undefined && clientOwner.gatewayId !== request.gatewayId)) throw protocolError("GATEWAY_SELECTION_MISMATCH")
414
+ physical.gatewayId = request.gatewayId
415
+ clientOwner.gatewayId = request.gatewayId
416
+ await sendControl(physical, V3FrameType.GATEWAY_SELECTED, 0, physical.generation, {gatewayId: request.gatewayId, generation: physical.generation, resumed: physical.resumeRequired})
417
+ armResumeTimeout(physical)
418
+ return
419
+ }
420
+ if (typeof request.routeClass !== "string" || !ROUTE_CLASS.test(request.routeClass)) throw protocolError("INVALID_ROUTE_CLASS_SELECTION")
421
+ if (options.authorizeClientRouteClass?.({principal: physical.principal, routeClass: request.routeClass}) !== true) throw protocolError("ROUTE_CLASS_UNAUTHORIZED")
422
+ if (options.knownRouteClasses?.includes(request.routeClass) !== true) throw protocolError("UNKNOWN_ROUTE_CLASS")
423
+ if (clientOwner.gatewayId !== undefined || (clientOwner.routeClass !== undefined && clientOwner.routeClass !== request.routeClass)) throw protocolError("GATEWAY_SELECTION_MISMATCH")
424
+ physical.routeClass = request.routeClass
425
+ clientOwner.routeClass = request.routeClass
426
+ await sendControl(physical, V3FrameType.GATEWAY_SELECTED, 0, physical.generation, {routeClass: request.routeClass, generation: physical.generation, resumed: physical.resumeRequired})
427
+ armResumeTimeout(physical)
428
+ }
429
+
430
+ /** @param {HubPhysical} physical - Reconnecting reverse peer. @param {import('../protocol/v3-frame.js').V3Frame} frame - Resume manifest page. */
431
+ async function receiveResumeManifest(physical, frame) {
432
+ if (!physical.resumeRequired || physical.resumeFinal || frame.streamId !== 0 || frame.sequence !== physical.generation || frame.flags !== 0) throw protocolError("INVALID_RESUME_MANIFEST")
433
+ const manifest = decodeV3Control(frame.payload)
434
+ if (manifest.page !== physical.resumeNextPage || !Array.isArray(manifest.streams) || manifest.streams.length > MANIFEST_PAGE_STREAMS || physical.resumeItems.length + manifest.streams.length > maxStreams) throw protocolError("INVALID_RESUME_MANIFEST")
435
+ physical.resumeNextPage += 1
436
+ physical.resumeItems.push(...manifest.streams.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)))
437
+ if (manifest.final !== true) return
438
+ const presented = new Set()
439
+ physical.resumeEntries = []
440
+ for (const value of physical.resumeItems) {
441
+ const item = parseResumeItem(value)
442
+ if (presented.has(item.streamId)) throw protocolError("DUPLICATE_RESUME_STREAM")
443
+ presented.add(item.streamId)
444
+ const bridge = physical.streams.get(item.streamId)
445
+ if (bridge === undefined) {
446
+ physical.resumeEntries.push({streamId: item.streamId, bridge: undefined, code: "RESUME_SESSION_LOST"})
447
+ continue
448
+ }
449
+ if (bridge.identifier !== item.identifier) {
450
+ closeBridge(bridge, "RESUME_IDENTITY_MISMATCH", physical)
451
+ physical.resumeEntries.push({streamId: item.streamId, bridge: undefined, code: "RESUME_IDENTITY_MISMATCH"})
452
+ continue
453
+ }
454
+ if (physical.role === "client") {
455
+ if (bridge.clientOwner.physical !== physical) throw protocolError("STALE_GENERATION")
456
+ bridge.client = physical
457
+ bridge.clientResume = {generation: physical.generation, ...item}
458
+ } else {
459
+ if (bridge.gatewayOwner.physical !== physical) throw protocolError("STALE_GENERATION")
460
+ bridge.gateway = physical
461
+ bridge.gatewayResume = {generation: physical.generation, ...item}
462
+ }
463
+ physical.resumeEntries.push({streamId: item.streamId, bridge})
464
+ }
465
+ for (const bridge of [...physical.streams.values()]) {
466
+ const streamId = physical.role === "client" ? bridge.clientStreamId : bridge.gatewayStreamId
467
+ if (!presented.has(streamId)) closeBridge(bridge, "RESUME_STREAM_OMITTED", physical)
468
+ }
469
+ physical.resumeItems = []
470
+ physical.resumeFinal = true
471
+ await tryCompleteResumes()
472
+ }
473
+
474
+ /** Completes physical resume responses only after both logical endpoints have declared durable positions. */
475
+ async function tryCompleteResumes() {
476
+ while (true) {
477
+ const candidates = [...physicals].filter((physical) => physical.resumeRequired && physical.resumeFinal && !physical.socket.destroyed && !physical.resumeEntries.some(({bridge}) => bridge !== undefined && bridgeStillOwned(bridge) && (bridge.clientResume === undefined || bridge.gatewayResume === undefined)))
478
+ const readySet = new Set(candidates)
479
+ let pruned
480
+ do {
481
+ pruned = false
482
+ for (const physical of [...readySet]) {
483
+ const hasUnreadyPeer = physical.resumeEntries.some(({bridge}) => bridge !== undefined && bridgeStillOwned(bridge) && (!readySet.has(bridge.client) || !readySet.has(bridge.gateway)))
484
+ if (!hasUnreadyPeer) continue
485
+ readySet.delete(physical)
486
+ pruned = true
487
+ }
488
+ } while (pruned)
489
+ const ready = [...readySet]
490
+ if (ready.length === 0) return
491
+ for (const physical of ready) {
492
+ clearResumeTimeout(physical)
493
+ physical.resumeFinal = false
494
+ physical.resumeRequired = false
495
+ }
496
+ /** @type {Promise<boolean>[]} */
497
+ const deliveries = []
498
+ for (const physical of ready) {
499
+ /** @type {Array<Record<string, unknown>>} */
500
+ const results = []
501
+ for (const entry of physical.resumeEntries) {
502
+ const bridge = entry.bridge
503
+ if (bridge === undefined || !bridgeStillOwned(bridge)) {
504
+ results.push({streamId: entry.streamId, status: "reset", code: entry.code ?? "RESUME_SESSION_LOST"})
505
+ continue
506
+ }
507
+ try {
508
+ reconcileBridge(bridge)
509
+ } catch {
510
+ closeBridge(bridge, "RESUME_STATE_MISMATCH")
511
+ results.push({streamId: entry.streamId, status: "reset", code: "RESUME_STATE_MISMATCH"})
512
+ continue
513
+ }
514
+ results.push({
515
+ streamId: entry.streamId,
516
+ status: "resumed",
517
+ clientToGatewaySequence: bridge.clientToGatewaySequence,
518
+ clientToGatewayAck: bridge.clientToGatewayAck,
519
+ gatewayToClientSequence: bridge.gatewayToClientSequence,
520
+ gatewayToClientAck: bridge.gatewayToClientAck,
521
+ clientEnded: bridge.clientEnded,
522
+ gatewayEnded: bridge.gatewayEnded,
523
+ opened: bridge.state !== "opening"
524
+ })
525
+ }
526
+ const pageCount = Math.max(1, Math.ceil(results.length / MANIFEST_PAGE_STREAMS))
527
+ for (let page = 0; page < pageCount; page += 1) {
528
+ deliveries.push(sendControl(physical, V3FrameType.RESUMED, 0, physical.generation, {
529
+ page,
530
+ final: page === pageCount - 1,
531
+ streams: results.slice(page * MANIFEST_PAGE_STREAMS, (page + 1) * MANIFEST_PAGE_STREAMS)
532
+ }))
533
+ }
534
+ physical.resumeEntries = []
535
+ physical.resumeNextPage = 0
536
+ }
537
+ await Promise.all(deliveries)
538
+ }
539
+ }
540
+
541
+ /** @param {Bridge} bridge - Retained bridge. @returns {boolean} Whether both owners still retain it. */
542
+ function bridgeStillOwned(bridge) {
543
+ return bridge.clientOwner.streams.get(bridge.clientStreamId) === bridge && bridge.gatewayOwner.streams.get(bridge.gatewayStreamId) === bridge
544
+ }
545
+
546
+ /** Bounds an authenticated peer that reconnects but never completes its resume manifest. */
547
+ function armResumeTimeout(/** @type {HubPhysical} */ physical) {
548
+ clearResumeTimeout(physical)
549
+ if (!physical.resumeRequired || physical.socket.destroyed) return
550
+ physical.resumeTimer = setTimeout(() => terminate(physical, "RESUME_WINDOW_EXPIRED"), resumeWindowMs)
551
+ physical.resumeTimer.unref?.()
552
+ }
553
+
554
+ /** Clears one physical resume deadline. */
555
+ function clearResumeTimeout(/** @type {HubPhysical} */ physical) {
556
+ if (physical.resumeTimer !== undefined) clearTimeout(physical.resumeTimer)
557
+ physical.resumeTimer = undefined
558
+ }
559
+
560
+ /** Reconciles one bridge to receiver-committed byte positions for a fresh physical generation. */
561
+ function reconcileBridge(/** @type {Bridge} */ bridge) {
562
+ const client = bridge.clientResume
563
+ const gateway = bridge.gatewayResume
564
+ if (client === undefined || gateway === undefined) throw protocolError("RESUME_STATE_MISMATCH")
565
+ const key = `${client.generation}:${gateway.generation}`
566
+ if (bridge.reconciledResume === key) return
567
+ if (gateway.clientToGatewaySequence > client.clientToGatewaySequence || client.clientToGatewayAck > gateway.clientToGatewayAck || client.gatewayToClientSequence > gateway.gatewayToClientSequence || gateway.gatewayToClientAck > client.gatewayToClientAck) throw protocolError("RESUME_STATE_MISMATCH")
568
+ if ((gateway.remoteEnded && !client.localEnded) || (client.remoteEnded && !gateway.localEnded)) throw protocolError("RESUME_STATE_MISMATCH")
569
+ bridge.clientToGatewaySequence = gateway.clientToGatewaySequence
570
+ bridge.clientToGatewayAck = gateway.clientToGatewayAck
571
+ bridge.gatewayToClientSequence = client.gatewayToClientSequence
572
+ bridge.gatewayToClientAck = client.gatewayToClientAck
573
+ bridge.clientEnded = gateway.remoteEnded
574
+ bridge.gatewayEnded = client.remoteEnded
575
+ bridge.clientToGatewayCompressor = new AdaptiveCompressor()
576
+ bridge.gatewayToClientCompressor = new AdaptiveCompressor()
577
+ bridge.reconciledResume = key
578
+ }
579
+
580
+ /** @param {Record<string, unknown>} value - Resume manifest entry. */
581
+ function parseResumeItem(value) {
582
+ const item = /** @type {{streamId?: unknown, identifier?: unknown, clientToGatewaySequence?: unknown, clientToGatewayAck?: unknown, gatewayToClientSequence?: unknown, gatewayToClientAck?: unknown, localEnded?: unknown, remoteEnded?: unknown}} */ (value)
583
+ const sequences = [item.clientToGatewaySequence, item.clientToGatewayAck, item.gatewayToClientSequence, item.gatewayToClientAck]
584
+ if (!Number.isInteger(item.streamId) || /** @type {number} */ (item.streamId) <= 0 || typeof item.identifier !== "string" || !STABLE_IDENTIFIER.test(item.identifier) || !sequences.every((sequence) => Number.isSafeInteger(sequence) && /** @type {number} */ (sequence) >= 0) || /** @type {number} */ (item.clientToGatewayAck) > /** @type {number} */ (item.clientToGatewaySequence) || /** @type {number} */ (item.gatewayToClientAck) > /** @type {number} */ (item.gatewayToClientSequence) || typeof item.localEnded !== "boolean" || typeof item.remoteEnded !== "boolean") throw protocolError("INVALID_RESUME_MANIFEST")
585
+ return /** @type {{streamId: number, identifier: string, clientToGatewaySequence: number, clientToGatewayAck: number, gatewayToClientSequence: number, gatewayToClientAck: number, localEnded: boolean, remoteEnded: boolean}} */ (item)
323
586
  }
324
587
 
325
588
  /** @param {HubPhysical} client - Client physical. @param {import('../protocol/v3-frame.js').V3Frame} frame - OPEN. */
@@ -329,20 +592,51 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
329
592
  client.lastClientStreamId = frame.streamId
330
593
  const request = decodeV3Control(frame.payload)
331
594
  const target = parseTarget(request.target)
332
- if (target === undefined || typeof request.gatewayId !== "string" || request.gatewayId !== client.gatewayId || !GATEWAY_ID.test(request.gatewayId) || typeof request.identifier !== "string" || !STABLE_IDENTIFIER.test(request.identifier) || typeof request.name !== "string" || request.name.length === 0 || request.name.length > 256 || client.principal === undefined) {
595
+ if (target === undefined || typeof request.identifier !== "string" || !STABLE_IDENTIFIER.test(request.identifier) || typeof request.name !== "string" || request.name.length === 0 || request.name.length > 256 || client.principal === undefined) {
333
596
  await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "INVALID_STREAM_OPEN"})
334
597
  return
335
598
  }
336
- if (options.authorizeOpen({principal: client.principal, gatewayId: request.gatewayId, target}) !== true) {
599
+ let gatewayId
600
+ if (client.routeClass !== undefined) {
601
+ if (request.routeClass !== client.routeClass || Object.hasOwn(request, "gatewayId")) {
602
+ await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "ROUTE_CLASS_MISMATCH"})
603
+ return
604
+ }
605
+ const candidates = options.resolveOpenGateways?.({principal: client.principal, routeClass: client.routeClass, target})
606
+ if (!Array.isArray(candidates) || candidates.some((candidate) => typeof candidate !== "string" || !GATEWAY_ID.test(candidate))) {
607
+ rejectedOpens += 1
608
+ await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "INVALID_ROUTE_RESOLUTION"})
609
+ return
610
+ }
611
+ if (candidates.length === 0) {
612
+ rejectedOpens += 1
613
+ await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "TARGET_UNAUTHORIZED"})
614
+ return
615
+ }
616
+ if (candidates.length !== 1 || new Set(candidates).size !== 1) {
617
+ rejectedOpens += 1
618
+ await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "AMBIGUOUS_ROUTE"})
619
+ return
620
+ }
621
+ gatewayId = /** @type {string} */ (candidates[0])
622
+ } else {
623
+ if (typeof request.gatewayId !== "string" || request.gatewayId !== client.gatewayId || !GATEWAY_ID.test(request.gatewayId) || Object.hasOwn(request, "routeClass")) {
624
+ await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "INVALID_STREAM_OPEN"})
625
+ return
626
+ }
627
+ gatewayId = request.gatewayId
628
+ }
629
+ if (options.authorizeOpen({principal: client.principal, gatewayId, target}) !== true) {
337
630
  rejectedOpens += 1
338
631
  await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "TARGET_UNAUTHORIZED"})
339
632
  return
340
633
  }
341
- const gatewayOwner = gateways.get(request.gatewayId)
634
+ const gatewayOwner = gateways.get(gatewayId)
342
635
  const gateway = gatewayOwner?.physical
343
- if (gateway === undefined || gateway.socket.destroyed || gateway.gatewayId !== request.gatewayId) {
636
+ if (gatewayOwner === undefined || gateway === undefined || gateway.socket.destroyed || gateway.closing || gateway.resumeRequired || gateway.role !== "gateway" || gateway.gatewayId !== gatewayId || gatewayOwner.gatewayId !== gatewayId || gatewayOwner.userId !== gateway.principal?.userId) {
344
637
  rejectedOpens += 1
345
- await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: gatewayOwner === undefined ? "UNKNOWN_GATEWAY_ID" : "GATEWAY_UNAVAILABLE"})
638
+ const known = gatewayOwner !== undefined || options.knownGatewayIds?.includes(gatewayId) === true
639
+ await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: known ? "GATEWAY_UNAVAILABLE" : "UNKNOWN_GATEWAY_ID"})
346
640
  return
347
641
  }
348
642
  const gatewayStreamId = gateway.nextGatewayStreamId
@@ -351,11 +645,15 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
351
645
  return
352
646
  }
353
647
  gateway.nextGatewayStreamId += 2
648
+ const clientOwner = clients.get(/** @type {string} */ (client.connectionId))
649
+ if (clientOwner === undefined || clientOwner.physical !== client) throw protocolError("STALE_GENERATION")
354
650
  /** @type {Bridge} */
355
651
  const bridge = {
356
652
  client,
653
+ clientOwner,
357
654
  clientStreamId: frame.streamId,
358
655
  gateway,
656
+ gatewayOwner,
359
657
  gatewayStreamId,
360
658
  identifier: request.identifier,
361
659
  target,
@@ -367,12 +665,15 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
367
665
  clientEnded: false,
368
666
  gatewayEnded: false,
369
667
  clientToGatewayCompressor: new AdaptiveCompressor(),
370
- gatewayToClientCompressor: new AdaptiveCompressor()
668
+ gatewayToClientCompressor: new AdaptiveCompressor(),
669
+ clientResume: undefined,
670
+ gatewayResume: undefined,
671
+ reconciledResume: undefined
371
672
  }
372
- client.streams.set(frame.streamId, bridge)
373
- gateway.streams.set(gatewayStreamId, bridge)
673
+ clientOwner.streams.set(frame.streamId, bridge)
674
+ gatewayOwner.streams.set(gatewayStreamId, bridge)
374
675
  bridges += 1
375
- await sendControl(gateway, V3FrameType.OPEN, gatewayStreamId, 0, {identifier: request.identifier, name: request.name, target, initialWindow})
676
+ forwardControl(gateway, V3FrameType.OPEN, gatewayStreamId, 0, {identifier: request.identifier, name: request.name, target, initialWindow})
376
677
  }
377
678
 
378
679
  /** @param {HubPhysical} source - Frame source. @param {import('../protocol/v3-frame.js').V3Frame} frame - Stream frame. */
@@ -390,7 +691,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
390
691
  const detail = decodeV3Control(frame.payload)
391
692
  if (detail.identifier !== bridge.identifier) throw protocolError("OPEN_IDENTIFIER_MISMATCH")
392
693
  bridge.state = "open"
393
- await sendControl(destination, V3FrameType.OPENED, destinationStreamId, 0, {identifier: bridge.identifier, initialWindow})
694
+ forwardControl(destination, V3FrameType.OPENED, destinationStreamId, 0, {identifier: bridge.identifier, initialWindow})
394
695
  return
395
696
  }
396
697
  if (bridge.state === "opening" && frame.type !== V3FrameType.RESET) throw protocolError("STREAM_NOT_OPEN")
@@ -400,12 +701,12 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
400
701
  if (!Number.isSafeInteger(frame.sequence) || frame.sequence < previous || frame.sequence > maximum) throw protocolError("INVALID_ACK")
401
702
  if (fromClient) bridge.gatewayToClientAck = frame.sequence
402
703
  else bridge.clientToGatewayAck = frame.sequence
403
- if (bridge.state !== "closing") await sendControl(destination, V3FrameType.ACK, destinationStreamId, frame.sequence, {})
704
+ if (bridge.state !== "closing") forwardControl(destination, V3FrameType.ACK, destinationStreamId, frame.sequence, {})
404
705
  return
405
706
  }
406
707
  if (frame.type === V3FrameType.WINDOW) {
407
708
  if (frame.sequence <= 0 || frame.sequence > initialWindow) throw protocolError("INVALID_WINDOW")
408
- if (bridge.state !== "closing") await sendControl(destination, V3FrameType.WINDOW, destinationStreamId, frame.sequence, {})
709
+ if (bridge.state !== "closing") forwardControl(destination, V3FrameType.WINDOW, destinationStreamId, frame.sequence, {})
409
710
  return
410
711
  }
411
712
  if (bridge.state === "closing") {
@@ -426,8 +727,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
426
727
  const encoded = await encodeV3Data(raw, destination.codec, fromClient ? bridge.clientToGatewayCompressor : bridge.gatewayToClientCompressor, options.compressionLimits)
427
728
  rawDataBytes += encoded.rawBytes
428
729
  wireDataBytes += encoded.wireBytes
429
- const delivered = await writeSocket(destination.socket, encodeV3Frame({type: V3FrameType.DATA, streamId: destinationStreamId, sequence: frame.sequence, flags: encoded.flags}, encoded.payload))
430
- if (!delivered) closeBridge(bridge, "DESTINATION_CLOSED", source)
730
+ forwardOutput(destination, encodeV3Frame({type: V3FrameType.DATA, streamId: destinationStreamId, sequence: frame.sequence, flags: encoded.flags}, encoded.payload))
431
731
  return
432
732
  }
433
733
  if (frame.type === V3FrameType.END) {
@@ -436,14 +736,14 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
436
736
  if (fromClient) bridge.clientEnded = true
437
737
  else bridge.gatewayEnded = true
438
738
  bridge.state = "half-closed"
439
- await sendControl(destination, V3FrameType.END, destinationStreamId, frame.sequence, {})
739
+ forwardControl(destination, V3FrameType.END, destinationStreamId, frame.sequence, {})
440
740
  return
441
741
  }
442
742
  if (frame.type === V3FrameType.CLOSE || frame.type === V3FrameType.RESET) {
443
743
  const expected = fromClient ? bridge.clientToGatewaySequence : bridge.gatewayToClientSequence
444
744
  if ((frame.type === V3FrameType.CLOSE && frame.sequence !== expected) || (frame.type === V3FrameType.RESET && frame.sequence !== 0)) throw protocolError("INVALID_STREAM_CLOSE")
445
745
  const detail = frame.payload.length === 0 ? {} : decodeV3Control(frame.payload)
446
- await sendControl(destination, frame.type, destinationStreamId, frame.sequence, detail)
746
+ forwardControl(destination, frame.type, destinationStreamId, frame.sequence, detail)
447
747
  if (frame.type === V3FrameType.RESET || fromClient) removeBridge(bridge)
448
748
  else bridge.state = "closing"
449
749
  return
@@ -453,11 +753,21 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
453
753
 
454
754
  /** @param {Bridge} bridge - Stream. */
455
755
  function removeBridge(bridge) {
456
- const existed = bridge.client.streams.delete(bridge.clientStreamId)
457
- bridge.gateway.streams.delete(bridge.gatewayStreamId)
756
+ const existed = bridge.clientOwner.streams.delete(bridge.clientStreamId)
757
+ bridge.gatewayOwner.streams.delete(bridge.gatewayStreamId)
458
758
  if (existed) bridges = Math.max(0, bridges - 1)
459
759
  }
460
760
 
761
+ /** @param {HubPhysical} destination - Frame destination. @param {Buffer} bytes - Encoded frame. */
762
+ function forwardOutput(destination, bytes) {
763
+ void enqueueOutput(destination, bytes)
764
+ }
765
+
766
+ /** @param {HubPhysical} destination - Frame destination. @param {number} type - Frame type. @param {number} streamId - Destination stream. @param {number} sequence - Frame sequence. @param {Record<string, unknown>} value - Control body. */
767
+ function forwardControl(destination, type, streamId, sequence, value) {
768
+ forwardOutput(destination, encodeV3Frame({type, streamId, sequence}, encodeV3Control(value)))
769
+ }
770
+
461
771
  /** @param {Bridge} bridge - Stream. @param {string} code - Reset code. @param {HubPhysical} [excluded] - Already-dead peer. */
462
772
  function closeBridge(bridge, code, excluded) {
463
773
  if (bridge.client !== excluded && !bridge.client.socket.destroyed) void sendControl(bridge.client, V3FrameType.RESET, bridge.clientStreamId, 0, {code})
@@ -465,13 +775,15 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
465
775
  removeBridge(bridge)
466
776
  }
467
777
 
468
- /** @param {HubPhysical} physical - Connection. @param {string} code - Reason. */
469
- function terminate(physical, code) {
778
+ /** @param {HubPhysical} physical - Connection. @param {string} code - Reason. @param {boolean} [retainStreams] - Preserve logical streams for paired resume. */
779
+ function terminate(physical, code, retainStreams = false) {
470
780
  if (physical.closing) return
471
781
  physical.closing = true
782
+ physical.retainOnDetach = retainStreams
783
+ physical.detachCode = code
472
784
  const forceTimer = setTimeout(() => physical.socket.destroy(), 1_000)
473
785
  forceTimer.unref?.()
474
- void sendControl(physical, V3FrameType.GOAWAY, 0, 0, {code}).finally(() => {
786
+ void sendControl(physical, V3FrameType.GOAWAY, 0, 0, {code}, true).finally(() => {
475
787
  clearTimeout(forceTimer)
476
788
  physical.socket.destroy()
477
789
  })
@@ -481,25 +793,117 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
481
793
  function detachPhysical(physical, code) {
482
794
  if (!physicals.delete(physical)) return
483
795
  physical.closing = true
484
- for (const bridge of [...physical.streams.values()]) closeBridge(bridge, physical.role === "gateway" ? "GATEWAY_DISCONNECTED" : code, physical)
796
+ clearResumeTimeout(physical)
797
+ discardQueuedOutput(physical)
485
798
  if (physical.gatewayId !== undefined) {
486
799
  const owner = gateways.get(physical.gatewayId)
487
800
  if (physical.role === "gateway" && owner?.physical === physical) {
488
801
  owner.physical = undefined
489
- for (const client of [...physicals]) {
490
- if (client.role === "client" && client.gatewayId === physical.gatewayId) terminate(client, "GATEWAY_DISCONNECTED")
802
+ if (!physical.retainOnDetach || closing) {
803
+ for (const bridge of [...owner.streams.values()]) closeBridge(bridge, code, physical)
804
+ gateways.delete(owner.gatewayId)
805
+ } else {
806
+ scheduleOwnerExpiry(owner, "gateway")
807
+ const clientsToReconnect = new Set([...owner.streams.values()].map((bridge) => bridge.clientOwner.physical).filter((peer) => peer !== undefined))
808
+ for (const client of clientsToReconnect) terminate(/** @type {HubPhysical} */ (client), "GATEWAY_DISCONNECTED", true)
491
809
  }
492
810
  }
493
811
  }
494
812
  if (physical.role === "client" && physical.connectionId !== undefined) {
495
813
  const owner = clients.get(physical.connectionId)
496
- if (owner?.physical === physical) owner.physical = undefined
814
+ if (owner?.physical === physical) {
815
+ owner.physical = undefined
816
+ if (!physical.retainOnDetach || closing) {
817
+ for (const bridge of [...owner.streams.values()]) closeBridge(bridge, code, physical)
818
+ clients.delete(owner.connectionId)
819
+ } else {
820
+ scheduleOwnerExpiry(owner, "client")
821
+ const gatewaysToReconnect = new Set([...owner.streams.values()].map((bridge) => bridge.gatewayOwner.physical).filter((peer) => peer !== undefined))
822
+ for (const gateway of gatewaysToReconnect) terminate(/** @type {HubPhysical} */ (gateway), "CLIENT_DISCONNECTED", true)
823
+ }
824
+ }
825
+ }
826
+ }
827
+
828
+ /** @param {GatewayOwner | ClientOwner} owner - Detached identity. @param {'gateway' | 'client'} role - Owner role. */
829
+ function scheduleOwnerExpiry(owner, role) {
830
+ if (owner.expiryTimer !== undefined) clearTimeout(owner.expiryTimer)
831
+ owner.expiryTimer = setTimeout(() => {
832
+ owner.expiryTimer = undefined
833
+ if (owner.physical !== undefined) return
834
+ for (const bridge of [...owner.streams.values()]) closeBridge(bridge, "RESUME_WINDOW_EXPIRED")
835
+ if (role === "gateway") {
836
+ const gatewayOwner = /** @type {GatewayOwner} */ (owner)
837
+ if (gateways.get(gatewayOwner.gatewayId) === gatewayOwner) gateways.delete(gatewayOwner.gatewayId)
838
+ } else {
839
+ const clientOwner = /** @type {ClientOwner} */ (owner)
840
+ if (clients.get(clientOwner.connectionId) === clientOwner) clients.delete(clientOwner.connectionId)
841
+ }
842
+ void tryCompleteResumes()
843
+ }, resumeWindowMs)
844
+ owner.expiryTimer.unref?.()
845
+ }
846
+
847
+ /** @param {HubPhysical} physical - Destination. @param {Buffer} bytes - Encoded frame. @param {boolean} [allowClosing] - Allow a final GOAWAY after closing starts. @returns {Promise<boolean>} Delivery result. */
848
+ function enqueueOutput(physical, bytes, allowClosing = false) {
849
+ if (physical.socket.destroyed || (physical.closing && !allowClosing)) return Promise.resolve(false)
850
+ if (physical.outputBytes + bytes.length > maxOutputBytes) {
851
+ abortOutput(physical)
852
+ return Promise.resolve(false)
497
853
  }
854
+ physical.outputBytes += bytes.length
855
+ const queued = new Promise((resolve) => physical.outputQueue.push({bytes, resolve}))
856
+ void drainOutput(physical)
857
+ return queued
858
+ }
859
+
860
+ /** @param {HubPhysical} physical - Destination output owner. */
861
+ async function drainOutput(physical) {
862
+ if (physical.outputWriting) return
863
+ physical.outputWriting = true
864
+ try {
865
+ while (physical.outputQueue.length > 0) {
866
+ const item = /** @type {HubOutputItem} */ (physical.outputQueue.shift())
867
+ let delivered = false
868
+ try {
869
+ delivered = await writeSocket(physical.socket, item.bytes)
870
+ } catch (error) {
871
+ physical.socket.destroy(asError(error))
872
+ }
873
+ physical.outputBytes = Math.max(0, physical.outputBytes - item.bytes.length)
874
+ item.resolve(delivered)
875
+ if (!delivered) {
876
+ discardQueuedOutput(physical)
877
+ abortOutput(physical, "DESTINATION_CLOSED")
878
+ break
879
+ }
880
+ }
881
+ } finally {
882
+ physical.outputWriting = false
883
+ if (physical.outputQueue.length > 0) void drainOutput(physical)
884
+ }
885
+ }
886
+
887
+ /** @param {HubPhysical} physical - Destination whose pending output is no longer deliverable. */
888
+ function discardQueuedOutput(physical) {
889
+ for (const item of physical.outputQueue.splice(0)) {
890
+ physical.outputBytes = Math.max(0, physical.outputBytes - item.bytes.length)
891
+ item.resolve(false)
892
+ }
893
+ }
894
+
895
+ /** @param {HubPhysical} physical - Destination whose output transport failed. @param {string} [code] - Terminal code. */
896
+ function abortOutput(physical, code = "OUTPUT_BACKPRESSURE_LIMIT") {
897
+ physical.closing = true
898
+ physical.retainOnDetach = false
899
+ physical.detachCode ??= code
900
+ discardQueuedOutput(physical)
901
+ if (!physical.socket.destroyed) physical.socket.destroy(protocolError(code))
498
902
  }
499
903
 
500
- /** @param {HubPhysical} physical - Destination. @param {number} type - Frame type. @param {number} streamId - Stream ID. @param {number} sequence - Sequence. @param {Record<string, unknown>} value - Control body. */
501
- async function sendControl(physical, type, streamId, sequence, value) {
502
- return await writeSocket(physical.socket, encodeV3Frame({type, streamId, sequence}, encodeV3Control(value)))
904
+ /** @param {HubPhysical} physical - Destination. @param {number} type - Frame type. @param {number} streamId - Stream ID. @param {number} sequence - Sequence. @param {Record<string, unknown>} value - Control body. @param {boolean} [allowClosing] - Allow a final GOAWAY. @returns {Promise<boolean>} Delivery result. */
905
+ function sendControl(physical, type, streamId, sequence, value, allowClosing = false) {
906
+ return enqueueOutput(physical, encodeV3Frame({type, streamId, sequence}, encodeV3Control(value)), allowClosing)
503
907
  }
504
908
 
505
909
  await new Promise((resolve, reject) => {
@@ -517,9 +921,11 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
517
921
  physicalConnections: tcpSockets.size,
518
922
  gatewayPhysicalConnections: [...physicals].filter((physical) => physical.role === "gateway" && physical.gatewayId !== undefined).length,
519
923
  clientPhysicalConnections: [...physicals].filter((physical) => physical.role === "client" && physical.authenticated).length,
924
+ routeClassClientPhysicalConnections: [...physicals].filter((physical) => physical.role === "client" && physical.routeClass !== undefined).length,
925
+ queuedOutputBytes: [...physicals].reduce((total, physical) => total + physical.outputBytes, 0),
520
926
  retainedClientIdentities: clients.size,
521
927
  registeredGateways: gateways.size,
522
- healthyGateways: [...gateways.values()].filter((owner) => owner.physical !== undefined).length,
928
+ healthyGateways: [...gateways.values()].filter((owner) => owner.physical !== undefined && !owner.physical.socket.destroyed && !owner.physical.closing && !owner.physical.resumeRequired).length,
523
929
  gatewayRegistrations,
524
930
  rejectedRegistrations,
525
931
  rejectedOpens,
@@ -539,6 +945,8 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
539
945
  for (const {socket} of pendingTls.values()) socket.destroy()
540
946
  for (const physical of [...physicals]) terminate(physical, "HUB_SHUTDOWN")
541
947
  await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve(undefined)))
948
+ for (const owner of gateways.values()) if (owner.expiryTimer !== undefined) clearTimeout(owner.expiryTimer)
949
+ for (const owner of clients.values()) if (owner.expiryTimer !== undefined) clearTimeout(owner.expiryTimer)
542
950
  gateways.clear()
543
951
  clients.clear()
544
952
  }
@@ -557,7 +965,11 @@ function validateOptions(options) {
557
965
  if (typeof options.tls !== "object" || options.tls === null || typeof options.tls.certFile !== "string" || typeof options.tls.keyFile !== "string") throw new TypeError("Reverse gateway hub requires TLS certFile and keyFile")
558
966
  if (!zstdAvailable()) throw new Error("Reverse gateway hub requires Node.js zstd support")
559
967
  if (options.knownGatewayIds !== undefined && (!Array.isArray(options.knownGatewayIds) || options.knownGatewayIds.some((gatewayId) => typeof gatewayId !== "string" || !GATEWAY_ID.test(gatewayId)) || new Set(options.knownGatewayIds).size !== options.knownGatewayIds.length)) throw new TypeError("knownGatewayIds must contain unique valid gateway IDs")
968
+ const routeClassPolicyConfigured = options.knownRouteClasses !== undefined || options.authorizeClientRouteClass !== undefined || options.resolveOpenGateways !== undefined
969
+ if (routeClassPolicyConfigured && (!Array.isArray(options.knownRouteClasses) || options.knownRouteClasses.length === 0 || typeof options.authorizeClientRouteClass !== "function" || typeof options.resolveOpenGateways !== "function")) throw new TypeError("Route-class hub policy requires knownRouteClasses, authorizeClientRouteClass, and resolveOpenGateways")
970
+ if (options.knownRouteClasses !== undefined && (options.knownRouteClasses.some((routeClass) => typeof routeClass !== "string" || !ROUTE_CLASS.test(routeClass)) || new Set(options.knownRouteClasses).size !== options.knownRouteClasses.length)) throw new TypeError("knownRouteClasses must contain unique valid route classes")
560
971
  const frame = positiveInteger(options.maxFrameBytes, "maxFrameBytes", DEFAULT_FRAME_BYTES)
561
972
  const window = positiveInteger(options.initialWindowBytes, "initialWindowBytes", DEFAULT_WINDOW)
973
+ positiveInteger(options.resumeWindowMs, "resumeWindowMs", DEFAULT_RESUME_MS)
562
974
  if (frame > window) throw new RangeError("maxFrameBytes must not exceed initialWindowBytes")
563
975
  }