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.
@@ -1,11 +1,14 @@
1
1
  /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-jsdoc, jsdoc/require-param, jsdoc/require-property-description, jsdoc/require-returns */
2
2
  import {randomBytes} from "node:crypto"
3
3
  import {EventEmitter} from "node:events"
4
+ import {tmpdir} from "node:os"
5
+ import {join} from "node:path"
4
6
 
5
7
  import {availableCodecs, getMaxDecompressedBytes, ZSTD_CODEC, zstdAvailable} from "../protocol/compression.js"
6
8
  import {errorDiagnostics, logDiagnostic} from "../logging/structured-logger.js"
7
9
  import {decodeV3Data, encodeV3Data, AdaptiveCompressor} from "../protocol/v3-compression.js"
8
10
  import {decodeV3Control, encodeV3Control, encodeV3Frame, V3FrameDecoder, V3FrameType} from "../protocol/v3-frame.js"
11
+ import {SessionSpool} from "../spool/spool-file.js"
9
12
  import {SocketductSessionLostError, SocketductStreamError, VirtualSocket} from "../client/virtual-socket.js"
10
13
  import {
11
14
  asError,
@@ -20,16 +23,19 @@ import {
20
23
  parseTarget,
21
24
  positiveInteger,
22
25
  resolveVerifiedTls,
26
+ ROUTE_CLASS,
23
27
  STABLE_IDENTIFIER,
24
28
  writeSocket
25
29
  } from "./common.js"
26
30
 
27
31
  const DEFAULT_STREAM_BUFFER_BYTES = 4 * 1024 * 1024
28
32
  const DEFAULT_CONNECTION_BUFFER_BYTES = 32 * 1024 * 1024
33
+ const MANIFEST_PAGE_STREAMS = 64
29
34
 
30
35
  /**
31
36
  * @typedef {object} ReverseGatewayConnectionOptions
32
- * @property {string} gatewayId - Registered home gateway selected by this member.
37
+ * @property {string} [gatewayId] - Registered exact gateway selected by this legacy member.
38
+ * @property {string} [routeClass] - Server-resolved route class selected by this member.
33
39
  * @property {string} [connectionId] - Stable client transport identity.
34
40
  * @property {{host: string, port: number, token: string}} hub - Server3 hub endpoint.
35
41
  * @property {{caFile?: string, certFile?: string, keyFile?: string, servername?: string}} tls - Mandatory verified outer TLS.
@@ -38,6 +44,8 @@ const DEFAULT_CONNECTION_BUFFER_BYTES = 32 * 1024 * 1024
38
44
  * @property {number} [maxStreams] - Concurrent stream limit.
39
45
  * @property {number} [maxStreamBufferBytes] - Per-stream client output bound.
40
46
  * @property {number} [maxConnectionBufferBytes] - Aggregate client output bound.
47
+ * @property {string} [spoolDirectory] - Durable replay spool root.
48
+ * @property {number} [maxAgeMs] - Maximum reverse-stream spool age.
41
49
  * @property {number} [connectTimeoutMs] - TLS/auth deadline.
42
50
  * @property {number} [reconnectDelayMs] - Initial reconnect delay.
43
51
  * @property {number} [maxReconnectDelayMs] - Maximum reconnect delay.
@@ -80,9 +88,14 @@ const DEFAULT_CONNECTION_BUFFER_BYTES = 32 * 1024 * 1024
80
88
  * @property {boolean} localEnded
81
89
  * @property {boolean} endSent
82
90
  * @property {((error?: Error | null) => void) | undefined} endCallback
91
+ * @property {Promise<void>} spoolReady
92
+ * @property {Promise<void>} spoolQueue
93
+ * @property {number} spoolReservedBytes
94
+ * @property {Promise<void> | undefined} spoolRemovalPromise
95
+ * @property {boolean} spoolCleanupFailureRecorded
83
96
  */
84
97
 
85
- /** Server3/TensorBuzz-facing virtual socket connection to one registered gateway. */
98
+ /** Server3/TensorBuzz-facing virtual socket connection to an exact gateway or server-resolved route class. */
86
99
  export class ReverseGatewayConnection extends EventEmitter {
87
100
  /** @type {ReverseGatewayConnectionOptions} */ #options
88
101
  /** @type {string} */ #connectionId
@@ -97,6 +110,9 @@ export class ReverseGatewayConnection extends EventEmitter {
97
110
  /** @type {Error | undefined} */ #fatalError
98
111
  /** @type {boolean} */ #referenced = true
99
112
  /** @type {boolean} */ #warm = false
113
+ /** @type {boolean} */ #resuming = false
114
+ /** @type {Array<Record<string, unknown>>} */ #resumeResults = []
115
+ /** @type {number} */ #resumeNextPage = 0
100
116
  /** @type {ReturnType<typeof setTimeout> | undefined} */ #reconnectTimer
101
117
  /** @type {Promise<void> | undefined} */ #connectPromise
102
118
  /** @type {(() => void) | undefined} */ #resolveConnect
@@ -110,6 +126,11 @@ export class ReverseGatewayConnection extends EventEmitter {
110
126
  /** @type {number} */ #rawDataBytes = 0
111
127
  /** @type {number} */ #wireDataBytes = 0
112
128
  /** @type {number} */ #lastProgressedStreamId = 0
129
+ /** @type {Promise<void>} */ #openQueue = Promise.resolve()
130
+ /** @type {SessionSpool} */ #spool
131
+ /** @type {number} */ #spoolBytes = 0
132
+ /** @type {number} */ #spoolCleanupFailures = 0
133
+ /** @type {Set<Promise<void>>} */ #spoolCleanupTasks = new Set()
113
134
 
114
135
  /** @param {ReverseGatewayConnectionOptions} options - Hub route and safety policy. */
115
136
  constructor(options) {
@@ -117,6 +138,11 @@ export class ReverseGatewayConnection extends EventEmitter {
117
138
  validateOptions(options)
118
139
  this.#options = options
119
140
  this.#connectionId = options.connectionId ?? randomBytes(24).toString("base64url")
141
+ this.#spool = new SessionSpool({
142
+ rootDir: options.spoolDirectory ?? join(tmpdir(), `socketduct-${typeof process.getuid === "function" ? process.getuid() : "user"}`, "reverse-client", this.#connectionId),
143
+ maxBytes: options.maxStreamBufferBytes ?? DEFAULT_STREAM_BUFFER_BYTES,
144
+ maxAgeMs: options.maxAgeMs ?? 24 * 60 * 60 * 1000
145
+ })
120
146
  this.#generation = options.initialGeneration ?? Date.now() * 1000
121
147
  this.#reconnectDelay = options.reconnectDelayMs ?? 250
122
148
  this.#sendWindowLimit = this.#initialWindow()
@@ -174,23 +200,35 @@ export class ReverseGatewayConnection extends EventEmitter {
174
200
  progressingWrite: false,
175
201
  localEnded: false,
176
202
  endSent: false,
177
- endCallback: undefined
203
+ endCallback: undefined,
204
+ spoolReady: Promise.resolve(),
205
+ spoolQueue: Promise.resolve(),
206
+ spoolReservedBytes: 0,
207
+ spoolRemovalPromise: undefined,
208
+ spoolCleanupFailureRecorded: false
178
209
  }
179
210
  this.#streams.set(id, stream)
211
+ stream.spoolReady = this.#spool.createNew({sessionId: identifier, ownerId: "reverse-client-v3"})
212
+ .then(() => this.#spool.writeRecoveryState(identifier, this.#recoveryState(stream)))
213
+ stream.spoolQueue = stream.spoolReady.catch(() => {})
180
214
  logDiagnostic(this.#options.logger, "virtual_stream.opening", {
181
215
  streamId: id,
182
216
  generation: this.#generation,
183
- gatewayId: this.#options.gatewayId,
217
+ ...this.#routeIdentityFields(),
184
218
  recovered: false
185
219
  })
186
220
  if (callback !== undefined) installCallback(socket, callback)
187
- void this.#sendControl(V3FrameType.OPEN, id, 0, {gatewayId: this.#options.gatewayId, identifier, name, target, initialWindow: this.#initialWindow()}).catch((error) => socket._receiveError(asError(error)))
221
+ const opening = this.#openQueue
222
+ .then(() => stream.spoolReady)
223
+ .then(() => this.#streams.get(id) === stream ? this.#sendControl(V3FrameType.OPEN, id, 0, {...this.#routeIdentityFields(), identifier, name, target, initialWindow: this.#initialWindow()}) : undefined)
224
+ this.#openQueue = opening.then(() => undefined, () => undefined)
225
+ void opening.catch((error) => socket._receiveError(asError(error)))
188
226
  return socket
189
227
  }
190
228
 
191
- /** Reverse established operations are intentionally not recoverable across process/transport loss. */
229
+ /** Process replacement cannot inherit the live reverse target/app socket pair. */
192
230
  registerRecoveredConnection() {
193
- throw Object.assign(new Error("Reverse gateway streams cannot be recovered; reconcile the Docker operation before retrying"), {code: "REVERSE_STREAM_RECOVERY_UNSUPPORTED"})
231
+ throw Object.assign(new Error("Reverse gateway process recovery is unsupported; live streams resume only within the original process"), {code: "REVERSE_STREAM_RECOVERY_UNSUPPORTED"})
194
232
  }
195
233
 
196
234
  status() {
@@ -205,8 +243,8 @@ export class ReverseGatewayConnection extends EventEmitter {
205
243
  queuedBytes: this.#bufferedBytes,
206
244
  dataQueuedBytes: this.#bufferedBytes,
207
245
  controlQueuedBytes: 0,
208
- spoolBytes: 0,
209
- spoolCleanupFailures: 0,
246
+ spoolBytes: this.#spoolBytes,
247
+ spoolCleanupFailures: this.#spoolCleanupFailures,
210
248
  physicalConnects: this.#physicalConnects,
211
249
  reconnectAttempts: this.#reconnectAttempts,
212
250
  reconnectSuccesses: Math.max(0, this.#physicalConnects - 1),
@@ -237,9 +275,12 @@ export class ReverseGatewayConnection extends EventEmitter {
237
275
  }
238
276
  this.#rejectConnect?.(closedError())
239
277
  this.#settleConnect()
278
+ const cleanup = await Promise.allSettled([...this.#spoolCleanupTasks])
279
+ const failures = cleanup.filter((result) => result.status === "rejected").map((result) => asError(result.reason))
280
+ if (failures.length > 0) throw new AggregateError(failures, "Reverse client spool cleanup failed")
240
281
  }
241
282
 
242
- /** Detach is terminal because ambiguous reverse streams are never durably replayed. */
283
+ /** Orderly object detach is terminal; in-object physical reconnect uses paired resume. */
243
284
  detach() { return this.close() }
244
285
 
245
286
  _writeStream(/** @type {number} */ streamId, /** @type {Buffer} */ data, /** @type {(error?: Error | null) => void} */ callback) {
@@ -265,7 +306,7 @@ export class ReverseGatewayConnection extends EventEmitter {
265
306
  logDiagnostic(this.#options.logger, error === null ? "virtual_stream.closed" : "virtual_stream.error", {
266
307
  streamId,
267
308
  generation: this.#generation,
268
- gatewayId: this.#options.gatewayId,
309
+ ...this.#routeIdentityFields(),
269
310
  ...(error === null ? {} : errorDiagnostics(error))
270
311
  })
271
312
  this.#retireStream(stream, error ?? new SocketductStreamError("Reverse stream is not writable", "ERR_STREAM_DESTROYED"))
@@ -297,7 +338,7 @@ export class ReverseGatewayConnection extends EventEmitter {
297
338
  logDiagnostic(this.#options.logger, "transport.connecting", {
298
339
  generation,
299
340
  attempt: this.#physicalAttempts,
300
- gatewayId: this.#options.gatewayId,
341
+ ...this.#routeIdentityFields(),
301
342
  reconnect: this.#physicalAttempts > 1
302
343
  })
303
344
  const tls = resolveVerifiedTls(this.#options.tls)
@@ -308,7 +349,7 @@ export class ReverseGatewayConnection extends EventEmitter {
308
349
  logDiagnostic(this.#options.logger, "transport.failed", {
309
350
  generation,
310
351
  attempt: this.#physicalAttempts,
311
- gatewayId: this.#options.gatewayId,
352
+ ...this.#routeIdentityFields(),
312
353
  ...errorDiagnostics(error)
313
354
  })
314
355
  this.#scheduleReconnect(asError(error))
@@ -346,35 +387,29 @@ export class ReverseGatewayConnection extends EventEmitter {
346
387
  authenticated = true
347
388
  this.#codec = ZSTD_CODEC
348
389
  this.#sendWindowLimit = Math.min(this.#initialWindow(), detail.initialWindow)
349
- await writeSocket(physical, encodeV3Frame({type: V3FrameType.GATEWAY_SELECT, streamId: 0, sequence: generation}, encodeV3Control({gatewayId: this.#options.gatewayId})))
390
+ await writeSocket(physical, encodeV3Frame({type: V3FrameType.GATEWAY_SELECT, streamId: 0, sequence: generation}, encodeV3Control(this.#routeIdentityFields())))
350
391
  continue
351
392
  }
352
393
  if (!selected) {
353
394
  if (frame.type !== V3FrameType.GATEWAY_SELECTED) throw fatalError("GATEWAY_SELECTION_FAILED")
354
395
  const detail = decodeV3Control(frame.payload)
355
- if (detail.gatewayId !== this.#options.gatewayId || detail.generation !== generation) throw fatalError("GATEWAY_SELECTION_FAILED")
396
+ const selection = this.#routeIdentityFields()
397
+ if (detail.generation !== generation || ("gatewayId" in selection ? detail.gatewayId !== selection.gatewayId || Object.hasOwn(detail, "routeClass") : detail.routeClass !== selection.routeClass || Object.hasOwn(detail, "gatewayId"))) throw fatalError("GATEWAY_SELECTION_FAILED")
398
+ if (typeof detail.resumed !== "boolean") throw fatalError("GATEWAY_SELECTION_FAILED")
356
399
  selected = true
357
- clearTimeout(timer)
358
- this.#physicalConnects += 1
359
- this.#reconnectDelay = this.#options.reconnectDelayMs ?? 250
360
- this.#setState("ready")
361
- logDiagnostic(this.#options.logger, "transport.connected", {
362
- generation,
363
- attempt: this.#physicalAttempts,
364
- gatewayId: this.#options.gatewayId,
365
- physicalConnects: this.#physicalConnects,
366
- reconnect: this.#physicalConnects > 1
367
- })
368
- if (this.#physicalConnects > 1) {
369
- logDiagnostic(this.#options.logger, "transport.reconnect_succeeded", {
370
- generation,
371
- attempt: this.#reconnectAttempts,
372
- gatewayId: this.#options.gatewayId,
373
- physicalConnects: this.#physicalConnects
374
- })
400
+ const reconnecting = this.#physicalConnects > 0
401
+ if (reconnecting && this.#streams.size > 0) {
402
+ if (!detail.resumed) {
403
+ for (const stream of [...this.#streams.values()]) stream.socket._receiveError(new SocketductSessionLostError("Reverse gateway hub lost the retained stream", "RESUME_SESSION_LOST"))
404
+ this.#completePhysical(generation, timer)
405
+ } else {
406
+ clearTimeout(timer)
407
+ this.#resuming = true
408
+ await this.#sendResumeManifest(generation)
409
+ }
410
+ } else {
411
+ this.#completePhysical(generation, timer)
375
412
  }
376
- this.#resolveConnect?.()
377
- this.#settleConnect()
378
413
  continue
379
414
  }
380
415
  await this.#handleFrame(frame)
@@ -392,17 +427,23 @@ export class ReverseGatewayConnection extends EventEmitter {
392
427
  if (this.#physical !== physical) return
393
428
  this.#physical = undefined
394
429
  this.#codec = undefined
430
+ this.#resuming = false
431
+ this.#resumeResults = []
432
+ this.#resumeNextPage = 0
395
433
  if (!this.#closing && !this.#fatal) this.#setState("reconnecting")
396
- const lost = new SocketductSessionLostError("Reverse gateway transport was lost; operation outcome may be uncertain", "GATEWAY_DISCONNECTED")
434
+ const lost = new SocketductSessionLostError("Reverse gateway physical transport was interrupted", "GATEWAY_DISCONNECTED")
397
435
  if (!this.#closing) {
398
436
  logDiagnostic(this.#options.logger, "transport.disconnected", {
399
437
  generation,
400
- gatewayId: this.#options.gatewayId,
438
+ ...this.#routeIdentityFields(),
401
439
  virtualStreams: this.#streams.size,
402
440
  ...errorDiagnostics(attemptError ?? lost, "GATEWAY_DISCONNECTED")
403
441
  })
404
442
  }
405
- for (const stream of [...this.#streams.values()]) stream.socket._receiveError(lost)
443
+ for (const stream of this.#streams.values()) {
444
+ stream.sendWindow = stream.sendWindowLimit
445
+ for (const replay of stream.replay) replay.sent = false
446
+ }
406
447
  if (this.#closing) return
407
448
  if (this.#fatal) {
408
449
  const terminalError = attemptError ?? lost
@@ -411,11 +452,12 @@ export class ReverseGatewayConnection extends EventEmitter {
411
452
  logDiagnostic(this.#options.logger, "transport.failed", {
412
453
  generation,
413
454
  attempt: this.#physicalAttempts,
414
- gatewayId: this.#options.gatewayId,
455
+ ...this.#routeIdentityFields(),
415
456
  ...errorDiagnostics(terminalError, "GATEWAY_DISCONNECTED")
416
457
  })
417
458
  this.#rejectConnect?.(terminalError)
418
459
  this.#settleConnect()
460
+ for (const stream of [...this.#streams.values()]) stream.socket._receiveError(terminalError)
419
461
  return
420
462
  }
421
463
  if (this.#warm || this.#options.continuousReconnect !== false) this.#scheduleReconnect(attemptError)
@@ -427,6 +469,20 @@ export class ReverseGatewayConnection extends EventEmitter {
427
469
  async #handleFrame(frame) {
428
470
  if (frame.type === V3FrameType.PING) { await this.#sendControl(V3FrameType.PONG, 0, frame.sequence, {}); return }
429
471
  if (frame.type === V3FrameType.PONG) return
472
+ if (frame.type === V3FrameType.RESUMED) {
473
+ if (!this.#resuming) throw new Error("Unexpected reverse client RESUMED response")
474
+ const manifest = decodeV3Control(frame.payload)
475
+ if (manifest.page !== this.#resumeNextPage || !Array.isArray(manifest.streams)) throw new Error("Invalid reverse client RESUMED response")
476
+ this.#resumeNextPage += 1
477
+ this.#resumeResults.push(...manifest.streams.filter((item) => typeof item === "object" && item !== null))
478
+ if (manifest.final !== true) return
479
+ for (const item of this.#resumeResults) this.#applyResumeResult(item)
480
+ this.#resumeResults = []
481
+ this.#resumeNextPage = 0
482
+ this.#resuming = false
483
+ this.#completePhysical(this.#generation)
484
+ return
485
+ }
430
486
  const stream = this.#streams.get(frame.streamId)
431
487
  if (stream === undefined) {
432
488
  if (frame.type !== V3FrameType.CLOSE && frame.type !== V3FrameType.RESET) await this.#sendControl(V3FrameType.RESET, frame.streamId, 0, {code: "UNKNOWN_STREAM"})
@@ -441,7 +497,7 @@ export class ReverseGatewayConnection extends EventEmitter {
441
497
  logDiagnostic(this.#options.logger, "virtual_stream.opened", {
442
498
  streamId: stream.id,
443
499
  generation: this.#generation,
444
- gatewayId: this.#options.gatewayId,
500
+ ...this.#routeIdentityFields(),
445
501
  recovered: false
446
502
  })
447
503
  stream.socket._markConnected()
@@ -474,7 +530,7 @@ export class ReverseGatewayConnection extends EventEmitter {
474
530
  logDiagnostic(this.#options.logger, "virtual_stream.closed", {
475
531
  streamId: stream.id,
476
532
  generation: this.#generation,
477
- gatewayId: this.#options.gatewayId
533
+ ...this.#routeIdentityFields()
478
534
  })
479
535
  this.#retireStream(stream, new SocketductStreamError("Reverse stream closed before pending writes completed", "ERR_STREAM_DESTROYED"))
480
536
  stream.socket._receiveEnd()
@@ -493,6 +549,94 @@ export class ReverseGatewayConnection extends EventEmitter {
493
549
  throw new Error("Unsupported reverse client frame")
494
550
  }
495
551
 
552
+ /** Sends the client half of the paired reverse-stream resume manifest. */
553
+ async #sendResumeManifest(/** @type {number} */ generation) {
554
+ const activeStreams = [...this.#streams.values()]
555
+ await Promise.all(activeStreams.map((stream) => this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream)))))
556
+ const streams = activeStreams.filter((stream) => this.#streams.get(stream.id) === stream).map((stream) => ({
557
+ streamId: stream.id,
558
+ identifier: stream.identifier,
559
+ clientToGatewaySequence: stream.sendSequence,
560
+ clientToGatewayAck: stream.sendAck,
561
+ gatewayToClientSequence: stream.receiveSequence,
562
+ gatewayToClientAck: stream.receiveAck,
563
+ localEnded: stream.localEnded,
564
+ remoteEnded: stream.remoteEnded
565
+ }))
566
+ this.#resumeResults = []
567
+ this.#resumeNextPage = 0
568
+ const pageCount = Math.max(1, Math.ceil(streams.length / MANIFEST_PAGE_STREAMS))
569
+ for (let page = 0; page < pageCount; page += 1) {
570
+ await this.#sendControl(V3FrameType.RESUME, 0, generation, {
571
+ page,
572
+ final: page === pageCount - 1,
573
+ streams: streams.slice(page * MANIFEST_PAGE_STREAMS, (page + 1) * MANIFEST_PAGE_STREAMS)
574
+ })
575
+ }
576
+ }
577
+
578
+ /** @param {unknown} value - Hub resume result. */
579
+ #applyResumeResult(value) {
580
+ if (typeof value !== "object" || value === null) throw new Error("Invalid reverse client resume result")
581
+ const result = /** @type {{streamId?: unknown, status?: unknown, clientToGatewaySequence?: unknown, clientToGatewayAck?: unknown, gatewayToClientSequence?: unknown, gatewayToClientAck?: unknown, clientEnded?: unknown, gatewayEnded?: unknown, opened?: unknown, code?: unknown}} */ (value)
582
+ if (!Number.isInteger(result.streamId)) throw new Error("Invalid reverse client resume stream ID")
583
+ const stream = this.#streams.get(/** @type {number} */ (result.streamId))
584
+ if (stream === undefined) return
585
+ if (result.status !== "resumed") {
586
+ const code = typeof result.code === "string" ? result.code : "RESUME_REJECTED"
587
+ stream.socket._receiveError(new SocketductSessionLostError("Reverse gateway stream could not be resumed", code))
588
+ return
589
+ }
590
+ const clientToGatewaySequence = /** @type {number} */ (result.clientToGatewaySequence)
591
+ const clientToGatewayAck = /** @type {number} */ (result.clientToGatewayAck)
592
+ const gatewayToClientSequence = /** @type {number} */ (result.gatewayToClientSequence)
593
+ const gatewayToClientAck = /** @type {number} */ (result.gatewayToClientAck)
594
+ const validClientToGatewayBoundary = clientToGatewaySequence === stream.sendAck || stream.replay.some((chunk) => chunk.sequence === clientToGatewaySequence)
595
+ if (!Number.isSafeInteger(clientToGatewaySequence) || clientToGatewaySequence < stream.sendAck || clientToGatewaySequence > stream.sendSequence || !Number.isSafeInteger(clientToGatewayAck) || clientToGatewayAck < stream.sendAck || clientToGatewayAck > clientToGatewaySequence || !validClientToGatewayBoundary || !Number.isSafeInteger(gatewayToClientSequence) || gatewayToClientSequence !== stream.receiveSequence || !Number.isSafeInteger(gatewayToClientAck) || gatewayToClientAck < stream.receiveAck || gatewayToClientAck > gatewayToClientSequence || typeof result.clientEnded !== "boolean" || typeof result.gatewayEnded !== "boolean" || typeof result.opened !== "boolean") throw new Error("Invalid reverse client resume positions")
596
+ this.#acknowledge(stream, clientToGatewayAck)
597
+ stream.transmittedSequence = clientToGatewayAck
598
+ stream.receiveAck = gatewayToClientAck
599
+ stream.pendingReceiveAck = stream.pendingReceiveAck !== undefined && stream.pendingReceiveAck > stream.receiveAck ? stream.pendingReceiveAck : undefined
600
+ stream.sendWindow = Math.max(0, stream.sendWindowLimit - (clientToGatewaySequence - clientToGatewayAck))
601
+ for (const chunk of stream.replay) chunk.sent = chunk.sequence <= clientToGatewaySequence
602
+ stream.endSent = /** @type {boolean} */ (result.clientEnded)
603
+ if (result.gatewayEnded === true && !stream.remoteEnded) {
604
+ stream.remoteEnded = true
605
+ stream.socket._receiveEnd()
606
+ }
607
+ if (result.opened === true && stream.state === "opening") stream.state = "open"
608
+ }
609
+
610
+ /** Marks one authenticated/selected physical generation ready. */
611
+ #completePhysical(/** @type {number} */ generation, /** @type {ReturnType<typeof setTimeout> | undefined} */ timer = undefined) {
612
+ if (timer !== undefined) clearTimeout(timer)
613
+ this.#physicalConnects += 1
614
+ this.#reconnectDelay = this.#options.reconnectDelayMs ?? 250
615
+ this.#setState("ready")
616
+ logDiagnostic(this.#options.logger, "transport.connected", {
617
+ generation,
618
+ attempt: this.#physicalAttempts,
619
+ ...this.#routeIdentityFields(),
620
+ physicalConnects: this.#physicalConnects,
621
+ reconnect: this.#physicalConnects > 1
622
+ })
623
+ if (this.#physicalConnects > 1) {
624
+ logDiagnostic(this.#options.logger, "transport.reconnect_succeeded", {
625
+ generation,
626
+ attempt: this.#reconnectAttempts,
627
+ ...this.#routeIdentityFields(),
628
+ physicalConnects: this.#physicalConnects
629
+ })
630
+ }
631
+ this.#resolveConnect?.()
632
+ this.#settleConnect()
633
+ for (const stream of this.#streams.values()) {
634
+ if (stream.state !== "opening") stream.socket._markConnected()
635
+ void this.#pumpStream(stream).catch((error) => stream.socket._receiveError(asError(error)))
636
+ }
637
+ this.#progressPendingWrites()
638
+ }
639
+
496
640
  /** @param {ReverseClientStream} stream - Stream. */
497
641
  async #pumpStream(stream) {
498
642
  if (stream.pumping || stream.state === "opening" || !this.#streams.has(stream.id) || this.#state !== "ready") return
@@ -513,7 +657,11 @@ export class ReverseGatewayConnection extends EventEmitter {
513
657
  if (stream.localEnded && !stream.endSent && stream.replay.every((chunk) => chunk.sent)) {
514
658
  stream.endSent = true
515
659
  stream.state = "half-closed"
516
- await this.#sendControl(V3FrameType.END, stream.id, stream.sendSequence, {})
660
+ const delivered = await this.#sendControl(V3FrameType.END, stream.id, stream.sendSequence, {})
661
+ if (!delivered) {
662
+ stream.endSent = false
663
+ return
664
+ }
517
665
  this.#finishEnd(stream)
518
666
  }
519
667
  } finally { stream.pumping = false }
@@ -530,8 +678,7 @@ export class ReverseGatewayConnection extends EventEmitter {
530
678
  if (available <= 0) return
531
679
  const chunk = Buffer.from(pending.data.subarray(pending.offset, Math.min(pending.data.length, pending.offset + available)))
532
680
  try {
533
- if (this.#options.aggregateBudget?.reserveMemory !== undefined) this.#options.aggregateBudget.reserveMemory(chunk.length)
534
- else this.#options.aggregateBudget?.reserveData(chunk.length)
681
+ this.#options.aggregateBudget?.reserveData(chunk.length)
535
682
  } catch (error) {
536
683
  this.#finishWrite(stream, asError(error))
537
684
  return
@@ -541,11 +688,19 @@ export class ReverseGatewayConnection extends EventEmitter {
541
688
  stream.replay.push({sequence: stream.sendSequence, data: chunk, sent: false})
542
689
  stream.bufferedBytes += chunk.length
543
690
  this.#bufferedBytes += chunk.length
691
+ stream.spoolReservedBytes += chunk.length
692
+ this.#spoolBytes += chunk.length
693
+ await this.#queueSpoolOperation(stream, async () => {
694
+ await this.#spool.appendChunk({sessionId: stream.identifier, ownerId: "reverse-client-v3", sequence: stream.sendSequence, data: chunk})
695
+ await this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream))
696
+ })
544
697
  }
545
698
  await this.#pumpStream(stream)
546
699
  if (pending.offset === pending.data.length && stream.pendingWrite === pending) this.#finishWrite(stream)
547
700
  } catch (error) {
548
- this.#finishWrite(stream, asError(error))
701
+ const failure = asError(error)
702
+ this.#finishWrite(stream, failure)
703
+ stream.socket._receiveError(failure)
549
704
  } finally {
550
705
  stream.progressingWrite = false
551
706
  this.#progressPendingWrites()
@@ -598,10 +753,10 @@ export class ReverseGatewayConnection extends EventEmitter {
598
753
  if (!this.#streams.delete(stream.id)) return
599
754
  this.#bufferedBytes = Math.max(0, this.#bufferedBytes - stream.bufferedBytes)
600
755
  this.#options.aggregateBudget?.releaseMemory(stream.bufferedBytes)
601
- if (this.#options.aggregateBudget?.reserveMemory === undefined) this.#options.aggregateBudget?.releaseSpool(stream.bufferedBytes)
602
756
  stream.replay = []
603
757
  stream.bufferedBytes = 0
604
758
  this.#finishWrite(stream, pendingWriteError)
759
+ this.#trackSpoolCleanup(stream)
605
760
  this.#progressPendingWrites()
606
761
  }
607
762
 
@@ -616,8 +771,15 @@ export class ReverseGatewayConnection extends EventEmitter {
616
771
  stream.bufferedBytes = Math.max(0, stream.bufferedBytes - released)
617
772
  this.#bufferedBytes = Math.max(0, this.#bufferedBytes - released)
618
773
  this.#options.aggregateBudget?.releaseMemory(released)
619
- if (this.#options.aggregateBudget?.reserveMemory === undefined) this.#options.aggregateBudget?.releaseSpool(released)
620
774
  stream.sendWindow = Math.min(stream.sendWindowLimit, stream.sendWindow + delta)
775
+ void this.#queueSpoolOperation(stream, async () => {
776
+ await this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream))
777
+ await this.#spool.truncateAcked(stream.identifier, sequence)
778
+ if (released > stream.spoolReservedBytes) throw new Error("Reverse client spool budget release underflow")
779
+ stream.spoolReservedBytes -= released
780
+ this.#spoolBytes = Math.max(0, this.#spoolBytes - released)
781
+ this.#options.aggregateBudget?.releaseSpool(released)
782
+ }).catch((error) => stream.socket._receiveError(asError(error)))
621
783
  this.#progressPendingWrites()
622
784
  void this.#pumpStream(stream).catch((error) => stream.socket._receiveError(asError(error)))
623
785
  }
@@ -633,7 +795,9 @@ export class ReverseGatewayConnection extends EventEmitter {
633
795
  #ackReceived(stream, sequence) {
634
796
  if (sequence <= stream.receiveAck) return
635
797
  stream.receiveAck = sequence
636
- void this.#sendControl(V3FrameType.ACK, stream.id, sequence, {}).catch(() => {})
798
+ void this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream)))
799
+ .then(() => this.#sendControl(V3FrameType.ACK, stream.id, sequence, {}).catch(() => {}))
800
+ .catch((error) => stream.socket._receiveError(asError(error)))
637
801
  }
638
802
 
639
803
  /** @param {number} type - Type. @param {number} streamId - Stream. @param {number} sequence - Sequence. @param {Record<string, unknown>} body - Body. */
@@ -652,7 +816,7 @@ export class ReverseGatewayConnection extends EventEmitter {
652
816
  generation: this.#generation,
653
817
  attempt: this.#reconnectAttempts,
654
818
  delayMs: delay,
655
- gatewayId: this.#options.gatewayId,
819
+ ...this.#routeIdentityFields(),
656
820
  ...errorDiagnostics(error, "GATEWAY_DISCONNECTED")
657
821
  })
658
822
  this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, this.#options.maxReconnectDelayMs ?? 30_000)
@@ -671,15 +835,82 @@ export class ReverseGatewayConnection extends EventEmitter {
671
835
  }
672
836
  }
673
837
 
838
+ /** @param {ReverseClientStream} stream - Durable stream state. */
839
+ #recoveryState(stream) {
840
+ return {
841
+ version: 3,
842
+ role: "reverse-client",
843
+ connectionId: this.#connectionId,
844
+ streamId: stream.id,
845
+ identifier: stream.identifier,
846
+ name: stream.name,
847
+ target: stream.target,
848
+ sendSequence: stream.sendSequence,
849
+ sendAck: stream.sendAck,
850
+ receiveSequence: stream.receiveSequence,
851
+ receiveAck: stream.receiveAck,
852
+ localEnded: stream.localEnded,
853
+ remoteEnded: stream.remoteEnded
854
+ }
855
+ }
856
+
857
+ /** @param {ReverseClientStream} stream - Spool owner. @param {() => Promise<void>} operation - Serialized durable operation. */
858
+ async #queueSpoolOperation(stream, operation) {
859
+ const next = stream.spoolQueue.then(async () => {
860
+ await stream.spoolReady
861
+ await operation()
862
+ })
863
+ stream.spoolQueue = next.catch(() => {})
864
+ await next
865
+ }
866
+
867
+ /** @param {ReverseClientStream} stream - Terminal stream. */
868
+ async #removeSpool(stream) {
869
+ if (stream.spoolRemovalPromise === undefined) {
870
+ stream.spoolRemovalPromise = this.#queueSpoolOperation(stream, async () => {
871
+ await this.#spool.remove(stream.identifier)
872
+ const removed = stream.spoolReservedBytes
873
+ stream.spoolReservedBytes = 0
874
+ this.#spoolBytes = Math.max(0, this.#spoolBytes - removed)
875
+ this.#options.aggregateBudget?.releaseSpool(removed)
876
+ })
877
+ }
878
+ await stream.spoolRemovalPromise
879
+ }
880
+
881
+ /** @param {ReverseClientStream} stream - Terminal stream. */
882
+ #trackSpoolCleanup(stream) {
883
+ const task = this.#removeSpool(stream).catch((error) => {
884
+ if (!stream.spoolCleanupFailureRecorded) {
885
+ stream.spoolCleanupFailureRecorded = true
886
+ this.#spoolCleanupFailures += 1
887
+ }
888
+ throw error
889
+ })
890
+ this.#spoolCleanupTasks.add(task)
891
+ void task.catch(() => {}).finally(() => this.#spoolCleanupTasks.delete(task))
892
+ }
893
+
674
894
  #settleConnect() { this.#connectPromise = undefined; this.#resolveConnect = undefined; this.#rejectConnect = undefined }
675
895
  #setState(/** @type {'configured' | 'connecting' | 'ready' | 'reconnecting' | 'closed'} */ state, /** @type {Error | undefined} */ error = undefined) { if (this.#state === state) return; this.#state = state; this.emit("stateChange", state, error) }
896
+ /** @returns {{gatewayId: string} | {routeClass: string}} Stable physical route identity. */
897
+ #routeIdentityFields() {
898
+ return this.#options.routeClass === undefined
899
+ ? {gatewayId: /** @type {string} */ (this.#options.gatewayId)}
900
+ : {routeClass: this.#options.routeClass}
901
+ }
676
902
  #initialWindow() { return this.#options.initialWindowBytes ?? DEFAULT_WINDOW }
677
903
  #maxFrameBytes() { return Math.min(this.#options.maxFrameBytes ?? DEFAULT_FRAME_BYTES, getMaxDecompressedBytes(this.#options.compressionLimits), 64 * 1024) }
678
904
  }
679
905
 
680
906
  /** @param {ReverseGatewayConnectionOptions} options - Options. */
681
907
  function validateOptions(options) {
682
- if (typeof options !== "object" || options === null || typeof options.gatewayId !== "string" || !GATEWAY_ID.test(options.gatewayId)) throw new TypeError("Reverse gateway connection requires a valid gatewayId")
908
+ if (typeof options !== "object" || options === null) throw new TypeError("Reverse gateway connection options are required")
909
+ const hasGatewayId = Object.hasOwn(options, "gatewayId")
910
+ const hasRouteClass = Object.hasOwn(options, "routeClass")
911
+ if (hasGatewayId === hasRouteClass) throw new TypeError("Reverse gateway connection requires exactly one gatewayId or routeClass")
912
+ if (hasGatewayId && (typeof options.gatewayId !== "string" || !GATEWAY_ID.test(options.gatewayId))) throw new TypeError("Reverse gateway connection requires a valid gatewayId")
913
+ if (hasRouteClass && (typeof options.routeClass !== "string" || !ROUTE_CLASS.test(options.routeClass))) throw new TypeError("Reverse gateway connection requires a valid routeClass")
683
914
  if (options.connectionId !== undefined && (typeof options.connectionId !== "string" || !STABLE_IDENTIFIER.test(options.connectionId))) throw new TypeError("Reverse gateway connectionId must be 8-128 URL-safe characters")
684
915
  if (typeof options.hub !== "object" || options.hub === null || typeof options.hub.host !== "string" || options.hub.host.length === 0 || !Number.isInteger(options.hub.port) || options.hub.port < 1 || options.hub.port > 65_535 || typeof options.hub.token !== "string" || options.hub.token.length === 0) throw new TypeError("Reverse gateway connection requires hub host, port and token")
685
916
  resolveVerifiedTls(options.tls)
@@ -691,6 +922,8 @@ function validateOptions(options) {
691
922
  positiveInteger(options.maxStreams, "maxStreams", DEFAULT_MAX_STREAMS)
692
923
  positiveInteger(options.maxStreamBufferBytes, "maxStreamBufferBytes", DEFAULT_STREAM_BUFFER_BYTES)
693
924
  positiveInteger(options.maxConnectionBufferBytes, "maxConnectionBufferBytes", DEFAULT_CONNECTION_BUFFER_BYTES)
925
+ if (options.spoolDirectory !== undefined && (typeof options.spoolDirectory !== "string" || options.spoolDirectory.length === 0)) throw new TypeError("spoolDirectory must be a non-empty string")
926
+ positiveInteger(options.maxAgeMs, "maxAgeMs", 24 * 60 * 60 * 1000)
694
927
  if (options.initialGeneration !== undefined && (!Number.isSafeInteger(options.initialGeneration) || options.initialGeneration < 0)) throw new RangeError("initialGeneration must be a non-negative safe integer")
695
928
  }
696
929
 
@@ -3,6 +3,7 @@ import {readFileSync} from "node:fs"
3
3
  import {connect as tlsConnect} from "node:tls"
4
4
 
5
5
  export const GATEWAY_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/
6
+ export const ROUTE_CLASS = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/
6
7
  export const STABLE_IDENTIFIER = /^[A-Za-z0-9_-]{8,128}$/
7
8
  export const DEFAULT_WINDOW = 256 * 1024
8
9
  export const DEFAULT_FRAME_BYTES = 16 * 1024