socketduct 0.1.17 → 0.1.19

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.
@@ -24,6 +24,8 @@ import {
24
24
  } from "./common.js"
25
25
 
26
26
  const DEFAULT_OUTPUT_BUFFER_BYTES = 32 * 1024 * 1024
27
+ const DEFAULT_RESUME_MS = 60_000
28
+ const MANIFEST_PAGE_STREAMS = 64
27
29
 
28
30
  /**
29
31
  * @typedef {{userId: string, username: string}} Principal
@@ -48,6 +50,7 @@ const DEFAULT_OUTPUT_BUFFER_BYTES = 32 * 1024 * 1024
48
50
  * @property {number} [handshakeTimeoutMs] - Authentication deadline.
49
51
  * @property {number} [maxInputBytesPerConnection] - Pre-authentication byte bound.
50
52
  * @property {number} [maxOutputBytesPerConnection] - Per-physical queued hub output bound.
53
+ * @property {number} [resumeWindowMs] - Detached reverse-stream retention window.
51
54
  * @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - zstd safety bounds.
52
55
  * @typedef {object} HubPhysical
53
56
  * @property {import('node:tls').TLSSocket} socket
@@ -63,6 +66,14 @@ const DEFAULT_OUTPUT_BUFFER_BYTES = 32 * 1024 * 1024
63
66
  * @property {number} nextGatewayStreamId
64
67
  * @property {boolean} authenticated
65
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
66
77
  * @property {(() => void) | undefined} clearHandshake
67
78
  * @property {HubOutputItem[]} outputQueue
68
79
  * @property {number} outputBytes
@@ -76,15 +87,23 @@ const DEFAULT_OUTPUT_BUFFER_BYTES = 32 * 1024 * 1024
76
87
  * @property {string} userId
77
88
  * @property {number} generation
78
89
  * @property {HubPhysical | undefined} physical
90
+ * @property {Map<number, Bridge>} streams
91
+ * @property {ReturnType<typeof setTimeout> | undefined} expiryTimer
79
92
  * @typedef {object} ClientOwner
80
93
  * @property {string} connectionId
81
94
  * @property {string} userId
82
95
  * @property {number} generation
83
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
84
101
  * @typedef {object} Bridge
85
102
  * @property {HubPhysical} client
103
+ * @property {ClientOwner} clientOwner
86
104
  * @property {number} clientStreamId
87
105
  * @property {HubPhysical} gateway
106
+ * @property {GatewayOwner} gatewayOwner
88
107
  * @property {number} gatewayStreamId
89
108
  * @property {string} identifier
90
109
  * @property {Target} target
@@ -97,6 +116,9 @@ const DEFAULT_OUTPUT_BUFFER_BYTES = 32 * 1024 * 1024
97
116
  * @property {boolean} gatewayEnded
98
117
  * @property {AdaptiveCompressor} clientToGatewayCompressor
99
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
100
122
  */
101
123
 
102
124
  /** Starts the Server3 reverse hub. */
@@ -108,6 +130,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
108
130
  const maxIdentities = positiveInteger(options.maxConnectionIdentities, "maxConnectionIdentities", 4096)
109
131
  const maxPhysicalConnections = positiveInteger(options.maxPhysicalConnections, "maxPhysicalConnections", 8192)
110
132
  const maxOutputBytes = positiveInteger(options.maxOutputBytesPerConnection, "maxOutputBytesPerConnection", DEFAULT_OUTPUT_BUFFER_BYTES)
133
+ const resumeWindowMs = positiveInteger(options.resumeWindowMs, "resumeWindowMs", DEFAULT_RESUME_MS)
111
134
  /** @type {Map<string, GatewayOwner>} */
112
135
  const gateways = new Map()
113
136
  /** @type {Map<string, ClientOwner>} */
@@ -175,6 +198,14 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
175
198
  nextGatewayStreamId: 2,
176
199
  authenticated: false,
177
200
  closing: false,
201
+ retainOnDetach: true,
202
+ detachCode: undefined,
203
+ resumeRequired: false,
204
+ resumeNextPage: 0,
205
+ resumeItems: [],
206
+ resumeFinal: false,
207
+ resumeEntries: [],
208
+ resumeTimer: undefined,
178
209
  clearHandshake: undefined,
179
210
  outputQueue: [],
180
211
  outputBytes: 0,
@@ -204,7 +235,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
204
235
  socket.on("error", () => {})
205
236
  socket.once("close", () => {
206
237
  clearTimeout(handshakeTimer)
207
- void queue.finally(() => detachPhysical(physical, "TRANSPORT_CLOSED"))
238
+ void queue.finally(() => detachPhysical(physical, physical.detachCode ?? "TRANSPORT_CLOSED"))
208
239
  })
209
240
  physical.clearHandshake = () => clearTimeout(handshakeTimer)
210
241
  }
@@ -246,6 +277,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
246
277
  }
247
278
  if (frame.type === V3FrameType.PONG) return
248
279
  if (frame.type === V3FrameType.GOAWAY) {
280
+ physical.retainOnDetach = false
249
281
  physical.socket.end()
250
282
  return
251
283
  }
@@ -261,7 +293,12 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
261
293
  await selectClientRoute(physical, frame)
262
294
  return
263
295
  }
264
- 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")
296
+ if (physical.resumeRequired && frame.type !== V3FrameType.RESUME) throw protocolError("RESUME_REQUIRED")
297
+ if (frame.type === V3FrameType.RESUME) {
298
+ await receiveResumeManifest(physical, frame)
299
+ return
300
+ }
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")
265
302
  if (physical.role === "client" && frame.type === V3FrameType.OPEN) {
266
303
  await openBridge(physical, frame)
267
304
  return
@@ -279,13 +316,30 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
279
316
  if (existing === undefined && clients.size >= limit) evictDisconnectedClientIdentity()
280
317
  if (existing === undefined && clients.size >= limit) throw protocolError("IDENTITY_LIMIT")
281
318
  if (existing?.physical !== undefined) terminate(existing.physical, "CONNECTION_REPLACED")
282
- 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)
283
337
  }
284
338
 
285
339
  /** Evicts the oldest disconnected client identity while active owners remain fenced. */
286
340
  function evictDisconnectedClientIdentity() {
287
341
  for (const [id, owner] of clients) {
288
- if (owner.physical !== undefined) continue
342
+ if (owner.physical !== undefined || owner.streams.size > 0) continue
289
343
  clients.delete(id)
290
344
  return
291
345
  }
@@ -315,16 +369,27 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
315
369
  }
316
370
  if (existing === undefined && gateways.size >= maxIdentities) throw protocolError("IDENTITY_LIMIT")
317
371
  if (existing?.physical !== undefined) terminate(existing.physical, "GATEWAY_REPLACED")
372
+ if (existing?.expiryTimer !== undefined) clearTimeout(existing.expiryTimer)
318
373
  physical.gatewayId = request.gatewayId
319
- gateways.set(request.gatewayId, {
374
+ const owner = existing ?? {
320
375
  gatewayId: request.gatewayId,
321
376
  connectionId,
322
377
  userId: physical.principal.userId,
323
378
  generation: physical.generation,
324
- physical
325
- })
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)
326
390
  gatewayRegistrations += 1
327
- 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)
328
393
  }
329
394
 
330
395
  /** @param {HubPhysical} physical - Authenticated client. @param {import('../protocol/v3-frame.js').V3Frame} frame - Selection frame. */
@@ -334,22 +399,190 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
334
399
  const hasGatewayId = Object.hasOwn(request, "gatewayId")
335
400
  const hasRouteClass = Object.hasOwn(request, "routeClass")
336
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")
337
404
  if (hasGatewayId) {
338
405
  if (typeof request.gatewayId !== "string" || !GATEWAY_ID.test(request.gatewayId)) throw protocolError("INVALID_GATEWAY_SELECTION")
339
406
  if (options.authorizeClientGateway({principal: physical.principal, gatewayId: request.gatewayId}) !== true) throw protocolError("GATEWAY_UNAUTHORIZED")
340
407
  const owner = gateways.get(request.gatewayId)
341
408
  const known = owner !== undefined || options.knownGatewayIds?.includes(request.gatewayId) === true
342
409
  if (!known) throw protocolError("UNKNOWN_GATEWAY_ID")
343
- if (owner?.physical === undefined) throw protocolError("GATEWAY_UNAVAILABLE")
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")
344
414
  physical.gatewayId = request.gatewayId
345
- await sendControl(physical, V3FrameType.GATEWAY_SELECTED, 0, physical.generation, {gatewayId: request.gatewayId, generation: physical.generation})
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)
346
418
  return
347
419
  }
348
420
  if (typeof request.routeClass !== "string" || !ROUTE_CLASS.test(request.routeClass)) throw protocolError("INVALID_ROUTE_CLASS_SELECTION")
349
421
  if (options.authorizeClientRouteClass?.({principal: physical.principal, routeClass: request.routeClass}) !== true) throw protocolError("ROUTE_CLASS_UNAUTHORIZED")
350
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")
351
424
  physical.routeClass = request.routeClass
352
- await sendControl(physical, V3FrameType.GATEWAY_SELECTED, 0, physical.generation, {routeClass: request.routeClass, generation: physical.generation})
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)
353
586
  }
354
587
 
355
588
  /** @param {HubPhysical} client - Client physical. @param {import('../protocol/v3-frame.js').V3Frame} frame - OPEN. */
@@ -400,7 +633,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
400
633
  }
401
634
  const gatewayOwner = gateways.get(gatewayId)
402
635
  const gateway = gatewayOwner?.physical
403
- if (gatewayOwner === undefined || gateway === undefined || gateway.socket.destroyed || gateway.role !== "gateway" || gateway.gatewayId !== gatewayId || gatewayOwner.gatewayId !== gatewayId || gatewayOwner.userId !== gateway.principal?.userId) {
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) {
404
637
  rejectedOpens += 1
405
638
  const known = gatewayOwner !== undefined || options.knownGatewayIds?.includes(gatewayId) === true
406
639
  await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: known ? "GATEWAY_UNAVAILABLE" : "UNKNOWN_GATEWAY_ID"})
@@ -412,11 +645,15 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
412
645
  return
413
646
  }
414
647
  gateway.nextGatewayStreamId += 2
648
+ const clientOwner = clients.get(/** @type {string} */ (client.connectionId))
649
+ if (clientOwner === undefined || clientOwner.physical !== client) throw protocolError("STALE_GENERATION")
415
650
  /** @type {Bridge} */
416
651
  const bridge = {
417
652
  client,
653
+ clientOwner,
418
654
  clientStreamId: frame.streamId,
419
655
  gateway,
656
+ gatewayOwner,
420
657
  gatewayStreamId,
421
658
  identifier: request.identifier,
422
659
  target,
@@ -428,10 +665,13 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
428
665
  clientEnded: false,
429
666
  gatewayEnded: false,
430
667
  clientToGatewayCompressor: new AdaptiveCompressor(),
431
- gatewayToClientCompressor: new AdaptiveCompressor()
668
+ gatewayToClientCompressor: new AdaptiveCompressor(),
669
+ clientResume: undefined,
670
+ gatewayResume: undefined,
671
+ reconciledResume: undefined
432
672
  }
433
- client.streams.set(frame.streamId, bridge)
434
- gateway.streams.set(gatewayStreamId, bridge)
673
+ clientOwner.streams.set(frame.streamId, bridge)
674
+ gatewayOwner.streams.set(gatewayStreamId, bridge)
435
675
  bridges += 1
436
676
  forwardControl(gateway, V3FrameType.OPEN, gatewayStreamId, 0, {identifier: request.identifier, name: request.name, target, initialWindow})
437
677
  }
@@ -513,8 +753,8 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
513
753
 
514
754
  /** @param {Bridge} bridge - Stream. */
515
755
  function removeBridge(bridge) {
516
- const existed = bridge.client.streams.delete(bridge.clientStreamId)
517
- bridge.gateway.streams.delete(bridge.gatewayStreamId)
756
+ const existed = bridge.clientOwner.streams.delete(bridge.clientStreamId)
757
+ bridge.gatewayOwner.streams.delete(bridge.gatewayStreamId)
518
758
  if (existed) bridges = Math.max(0, bridges - 1)
519
759
  }
520
760
 
@@ -535,10 +775,12 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
535
775
  removeBridge(bridge)
536
776
  }
537
777
 
538
- /** @param {HubPhysical} physical - Connection. @param {string} code - Reason. */
539
- 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) {
540
780
  if (physical.closing) return
541
781
  physical.closing = true
782
+ physical.retainOnDetach = retainStreams
783
+ physical.detachCode = code
542
784
  const forceTimer = setTimeout(() => physical.socket.destroy(), 1_000)
543
785
  forceTimer.unref?.()
544
786
  void sendControl(physical, V3FrameType.GOAWAY, 0, 0, {code}, true).finally(() => {
@@ -551,23 +793,57 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
551
793
  function detachPhysical(physical, code) {
552
794
  if (!physicals.delete(physical)) return
553
795
  physical.closing = true
796
+ clearResumeTimeout(physical)
554
797
  discardQueuedOutput(physical)
555
- for (const bridge of [...physical.streams.values()]) closeBridge(bridge, physical.role === "gateway" ? "GATEWAY_DISCONNECTED" : code, physical)
556
798
  if (physical.gatewayId !== undefined) {
557
799
  const owner = gateways.get(physical.gatewayId)
558
800
  if (physical.role === "gateway" && owner?.physical === physical) {
559
801
  owner.physical = undefined
560
- for (const client of [...physicals]) {
561
- 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)
562
809
  }
563
810
  }
564
811
  }
565
812
  if (physical.role === "client" && physical.connectionId !== undefined) {
566
813
  const owner = clients.get(physical.connectionId)
567
- 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
+ }
568
825
  }
569
826
  }
570
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
+
571
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. */
572
848
  function enqueueOutput(physical, bytes, allowClosing = false) {
573
849
  if (physical.socket.destroyed || (physical.closing && !allowClosing)) return Promise.resolve(false)
@@ -619,6 +895,8 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
619
895
  /** @param {HubPhysical} physical - Destination whose output transport failed. @param {string} [code] - Terminal code. */
620
896
  function abortOutput(physical, code = "OUTPUT_BACKPRESSURE_LIMIT") {
621
897
  physical.closing = true
898
+ physical.retainOnDetach = false
899
+ physical.detachCode ??= code
622
900
  discardQueuedOutput(physical)
623
901
  if (!physical.socket.destroyed) physical.socket.destroy(protocolError(code))
624
902
  }
@@ -647,7 +925,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
647
925
  queuedOutputBytes: [...physicals].reduce((total, physical) => total + physical.outputBytes, 0),
648
926
  retainedClientIdentities: clients.size,
649
927
  registeredGateways: gateways.size,
650
- 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,
651
929
  gatewayRegistrations,
652
930
  rejectedRegistrations,
653
931
  rejectedOpens,
@@ -667,6 +945,8 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
667
945
  for (const {socket} of pendingTls.values()) socket.destroy()
668
946
  for (const physical of [...physicals]) terminate(physical, "HUB_SHUTDOWN")
669
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)
670
950
  gateways.clear()
671
951
  clients.clear()
672
952
  }
@@ -690,5 +970,6 @@ function validateOptions(options) {
690
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")
691
971
  const frame = positiveInteger(options.maxFrameBytes, "maxFrameBytes", DEFAULT_FRAME_BYTES)
692
972
  const window = positiveInteger(options.initialWindowBytes, "initialWindowBytes", DEFAULT_WINDOW)
973
+ positiveInteger(options.resumeWindowMs, "resumeWindowMs", DEFAULT_RESUME_MS)
693
974
  if (frame > window) throw new RangeError("maxFrameBytes must not exceed initialWindowBytes")
694
975
  }
@@ -51,6 +51,7 @@ export async function startReverseHubRuntime(config) {
51
51
  resolveOpenGateways: ({principal, routeClass, target}) => [...(routeClassGateways.get(formatRouteClassRoute(principal.username, routeClass, target)) ?? [])]
52
52
  }),
53
53
  authorizeOpen: ({principal, gatewayId, target}) => authorizedRoutes.has(formatRoute(principal.username, gatewayId, target)),
54
+ resumeWindowMs: config.resumeWindowMs,
54
55
  handshakeTimeoutMs: config.handshakeTimeoutMs,
55
56
  maxInputBytesPerConnection: config.maxInputBytesPerConnection,
56
57
  compressionLimits: compressionLimits(config)