socketduct 0.1.17 → 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.
- package/changelog.d/20260925120000-reverse-stream-transport-resume.md +1 -0
- package/docs/architecture.md +12 -5
- package/docs/protocol.md +43 -11
- package/docs/reverse-gateway.md +59 -24
- package/docs/security.md +15 -5
- package/docs/session-lifecycle.md +21 -10
- package/docs/socket-api.md +12 -5
- package/package.json +1 -1
- package/src/client/multiplexed-transport-set.js +1 -0
- package/src/reverse/client.js +255 -36
- package/src/reverse/gateway.js +265 -38
- package/src/reverse/hub.js +305 -24
- package/src/reverse/runtime.js +1 -0
- package/test/integration/reverse-gateway.test.js +576 -28
- package/test/integration/reverse-route-class.test.js +16 -12
package/src/reverse/gateway.js
CHANGED
|
@@ -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 {EventEmitter} from "node:events"
|
|
3
3
|
import {createConnection as netConnect} from "node:net"
|
|
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 {
|
|
10
13
|
asError,
|
|
11
14
|
createHubSocket,
|
|
@@ -25,6 +28,7 @@ import {
|
|
|
25
28
|
|
|
26
29
|
const DEFAULT_STREAM_BUFFER_BYTES = 4 * 1024 * 1024
|
|
27
30
|
const DEFAULT_TOTAL_BUFFER_BYTES = 32 * 1024 * 1024
|
|
31
|
+
const MANIFEST_PAGE_STREAMS = 64
|
|
28
32
|
|
|
29
33
|
/**
|
|
30
34
|
* @typedef {object} OutboundReverseGatewayOptions
|
|
@@ -39,6 +43,8 @@ const DEFAULT_TOTAL_BUFFER_BYTES = 32 * 1024 * 1024
|
|
|
39
43
|
* @property {number} [maxStreams] - Concurrent local target bound.
|
|
40
44
|
* @property {number} [maxStreamBufferBytes] - Per-stream unacknowledged target output bound.
|
|
41
45
|
* @property {number} [maxTotalBufferBytes] - Gateway-wide unacknowledged target output bound.
|
|
46
|
+
* @property {string} [spoolDirectory] - Durable target-output replay spool root.
|
|
47
|
+
* @property {number} [maxAgeMs] - Maximum reverse-stream spool age.
|
|
42
48
|
* @property {number} [connectTimeoutMs] - TLS/auth/register deadline.
|
|
43
49
|
* @property {number} [targetConnectTimeoutMs] - Local target connection deadline.
|
|
44
50
|
* @property {number} [reconnectDelayMs] - Initial reconnect delay.
|
|
@@ -56,7 +62,7 @@ const DEFAULT_TOTAL_BUFFER_BYTES = 32 * 1024 * 1024
|
|
|
56
62
|
* @property {string} identifier
|
|
57
63
|
* @property {{host: string, port: number}} target
|
|
58
64
|
* @property {import('node:net').Socket} targetSocket
|
|
59
|
-
* @property {'opening' | 'open' | 'half-closed'} state
|
|
65
|
+
* @property {'opening' | 'open' | 'half-closed' | 'closing'} state
|
|
60
66
|
* @property {number} receiveSequence
|
|
61
67
|
* @property {number} receiveAck
|
|
62
68
|
* @property {boolean} remoteEnded
|
|
@@ -73,9 +79,16 @@ const DEFAULT_TOTAL_BUFFER_BYTES = 32 * 1024 * 1024
|
|
|
73
79
|
* @property {boolean} targetClosed
|
|
74
80
|
* @property {string | undefined} targetResetCode
|
|
75
81
|
* @property {boolean} endSent
|
|
82
|
+
* @property {boolean} closeSent
|
|
76
83
|
* @property {ReturnType<typeof setTimeout> | undefined} openTimer
|
|
77
84
|
* @property {boolean} targetOpened
|
|
78
85
|
* @property {boolean} targetTerminalLogged
|
|
86
|
+
* @property {Promise<void>} openedReady
|
|
87
|
+
* @property {Promise<void>} spoolReady
|
|
88
|
+
* @property {Promise<void>} spoolQueue
|
|
89
|
+
* @property {number} spoolReservedBytes
|
|
90
|
+
* @property {Promise<void> | undefined} spoolRemovalPromise
|
|
91
|
+
* @property {boolean} spoolCleanupFailureRecorded
|
|
79
92
|
*/
|
|
80
93
|
|
|
81
94
|
/** One outbound home member carrying many hub-opened local target streams. */
|
|
@@ -90,6 +103,9 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
90
103
|
/** @type {boolean} */ #closing = false
|
|
91
104
|
/** @type {boolean} */ #fatal = false
|
|
92
105
|
/** @type {boolean} */ #referenced = true
|
|
106
|
+
/** @type {boolean} */ #resuming = false
|
|
107
|
+
/** @type {Array<Record<string, unknown>>} */ #resumeResults = []
|
|
108
|
+
/** @type {number} */ #resumeNextPage = 0
|
|
93
109
|
/** @type {ReturnType<typeof setTimeout> | undefined} */ #reconnectTimer
|
|
94
110
|
/** @type {Promise<void> | undefined} */ #startPromise
|
|
95
111
|
/** @type {(() => void) | undefined} */ #resolveStart
|
|
@@ -104,12 +120,21 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
104
120
|
/** @type {number} */ #bufferedBytes = 0
|
|
105
121
|
/** @type {number} */ #rawDataBytes = 0
|
|
106
122
|
/** @type {number} */ #wireDataBytes = 0
|
|
123
|
+
/** @type {SessionSpool} */ #spool
|
|
124
|
+
/** @type {number} */ #spoolBytes = 0
|
|
125
|
+
/** @type {number} */ #spoolCleanupFailures = 0
|
|
126
|
+
/** @type {Set<Promise<void>>} */ #spoolCleanupTasks = new Set()
|
|
107
127
|
|
|
108
128
|
/** @param {OutboundReverseGatewayOptions} options - Identity, hub, TLS and local policy. */
|
|
109
129
|
constructor(options) {
|
|
110
130
|
super()
|
|
111
131
|
validateOptions(options)
|
|
112
132
|
this.#options = options
|
|
133
|
+
this.#spool = new SessionSpool({
|
|
134
|
+
rootDir: options.spoolDirectory ?? join(tmpdir(), `socketduct-${typeof process.getuid === "function" ? process.getuid() : "user"}`, "reverse-gateway", `${options.gatewayId}-${options.connectionId}`),
|
|
135
|
+
maxBytes: options.maxStreamBufferBytes ?? DEFAULT_STREAM_BUFFER_BYTES,
|
|
136
|
+
maxAgeMs: options.maxAgeMs ?? 24 * 60 * 60 * 1000
|
|
137
|
+
})
|
|
113
138
|
this.#generation = options.initialGeneration ?? Date.now() * 1000
|
|
114
139
|
this.#reconnectDelay = options.reconnectDelayMs ?? 250
|
|
115
140
|
this.#sendWindowLimit = this.#initialWindow()
|
|
@@ -140,8 +165,8 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
140
165
|
targetOpens: this.#targetOpens,
|
|
141
166
|
targetOpenFailures: this.#targetOpenFailures,
|
|
142
167
|
queuedBytes: this.#bufferedBytes,
|
|
143
|
-
spoolBytes:
|
|
144
|
-
spoolCleanupFailures:
|
|
168
|
+
spoolBytes: this.#spoolBytes,
|
|
169
|
+
spoolCleanupFailures: this.#spoolCleanupFailures,
|
|
145
170
|
compression: this.#codec ?? null,
|
|
146
171
|
rawDataBytes: this.#rawDataBytes,
|
|
147
172
|
wireDataBytes: this.#wireDataBytes,
|
|
@@ -173,6 +198,9 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
173
198
|
}
|
|
174
199
|
this.#rejectStart?.(closedError())
|
|
175
200
|
this.#settleStart()
|
|
201
|
+
const cleanup = await Promise.allSettled([...this.#spoolCleanupTasks])
|
|
202
|
+
const failures = cleanup.filter((result) => result.status === "rejected").map((result) => asError(result.reason))
|
|
203
|
+
if (failures.length > 0) throw new AggregateError(failures, "Reverse gateway spool cleanup failed")
|
|
176
204
|
}
|
|
177
205
|
|
|
178
206
|
#connect() {
|
|
@@ -249,28 +277,21 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
249
277
|
if (frame.type !== V3FrameType.GATEWAY_REGISTERED) throw fatalError("GATEWAY_REGISTRATION_FAILED")
|
|
250
278
|
const detail = decodeV3Control(frame.payload)
|
|
251
279
|
if (detail.gatewayId !== this.#options.gatewayId || detail.generation !== generation) throw fatalError("GATEWAY_REGISTRATION_FAILED")
|
|
280
|
+
if (typeof detail.resumed !== "boolean") throw fatalError("GATEWAY_REGISTRATION_FAILED")
|
|
252
281
|
registered = true
|
|
253
|
-
|
|
254
|
-
this.#
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
logDiagnostic(this.#options.logger, "transport.reconnect_succeeded", {
|
|
266
|
-
generation,
|
|
267
|
-
attempt: this.#reconnectAttempts,
|
|
268
|
-
gatewayId: this.#options.gatewayId,
|
|
269
|
-
physicalConnects: this.#physicalConnects
|
|
270
|
-
})
|
|
282
|
+
const reconnecting = this.#physicalConnects > 0
|
|
283
|
+
if (reconnecting && this.#streams.size > 0) {
|
|
284
|
+
if (!detail.resumed) {
|
|
285
|
+
for (const stream of [...this.#streams.values()]) this.#destroyStream(stream, "RESUME_SESSION_LOST")
|
|
286
|
+
this.#completePhysical(generation, timer)
|
|
287
|
+
} else {
|
|
288
|
+
clearTimeout(timer)
|
|
289
|
+
this.#resuming = true
|
|
290
|
+
await this.#sendResumeManifest(generation)
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
this.#completePhysical(generation, timer)
|
|
271
294
|
}
|
|
272
|
-
this.#resolveStart?.()
|
|
273
|
-
this.#settleStart()
|
|
274
295
|
continue
|
|
275
296
|
}
|
|
276
297
|
await this.#handleFrame(frame)
|
|
@@ -290,6 +311,9 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
290
311
|
if (this.#physical !== physical) return
|
|
291
312
|
this.#physical = undefined
|
|
292
313
|
this.#codec = undefined
|
|
314
|
+
this.#resuming = false
|
|
315
|
+
this.#resumeResults = []
|
|
316
|
+
this.#resumeNextPage = 0
|
|
293
317
|
if (!this.#closing && !this.#fatal) this.#setState("reconnecting")
|
|
294
318
|
if (!this.#closing) {
|
|
295
319
|
logDiagnostic(this.#options.logger, "transport.disconnected", {
|
|
@@ -299,8 +323,11 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
299
323
|
...errorDiagnostics(attemptError, "TRANSPORT_CLOSED")
|
|
300
324
|
})
|
|
301
325
|
}
|
|
302
|
-
for (const stream of
|
|
303
|
-
|
|
326
|
+
for (const stream of this.#streams.values()) {
|
|
327
|
+
stream.sendWindow = stream.sendWindowLimit
|
|
328
|
+
for (const replay of stream.replay) replay.sent = false
|
|
329
|
+
stream.closeSent = false
|
|
330
|
+
}
|
|
304
331
|
if (this.#closing) return
|
|
305
332
|
if (this.#fatal) {
|
|
306
333
|
this.#setState("closed")
|
|
@@ -313,6 +340,8 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
313
340
|
})
|
|
314
341
|
this.#rejectStart?.(failure)
|
|
315
342
|
this.#settleStart()
|
|
343
|
+
const failureCode = /** @type {{code?: unknown}} */ (failure).code
|
|
344
|
+
for (const stream of [...this.#streams.values()]) this.#destroyStream(stream, typeof failureCode === "string" ? failureCode : "GATEWAY_REGISTRATION_FAILED")
|
|
316
345
|
return
|
|
317
346
|
}
|
|
318
347
|
this.#scheduleReconnect(attemptError)
|
|
@@ -324,6 +353,20 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
324
353
|
async #handleFrame(frame) {
|
|
325
354
|
if (frame.type === V3FrameType.PING) { await this.#sendControl(V3FrameType.PONG, 0, frame.sequence, {}); return }
|
|
326
355
|
if (frame.type === V3FrameType.PONG) return
|
|
356
|
+
if (frame.type === V3FrameType.RESUMED) {
|
|
357
|
+
if (!this.#resuming) throw new Error("Unexpected reverse gateway RESUMED response")
|
|
358
|
+
const manifest = decodeV3Control(frame.payload)
|
|
359
|
+
if (manifest.page !== this.#resumeNextPage || !Array.isArray(manifest.streams)) throw new Error("Invalid reverse gateway RESUMED response")
|
|
360
|
+
this.#resumeNextPage += 1
|
|
361
|
+
this.#resumeResults.push(...manifest.streams.filter((item) => typeof item === "object" && item !== null))
|
|
362
|
+
if (manifest.final !== true) return
|
|
363
|
+
for (const item of this.#resumeResults) await this.#applyResumeResult(item)
|
|
364
|
+
this.#resumeResults = []
|
|
365
|
+
this.#resumeNextPage = 0
|
|
366
|
+
this.#resuming = false
|
|
367
|
+
this.#completePhysical(this.#generation)
|
|
368
|
+
return
|
|
369
|
+
}
|
|
327
370
|
if (frame.type === V3FrameType.OPEN) { await this.#openStream(frame); return }
|
|
328
371
|
const stream = this.#streams.get(frame.streamId)
|
|
329
372
|
if (stream === undefined) {
|
|
@@ -343,6 +386,12 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
343
386
|
}
|
|
344
387
|
stream.receiveSequence = frame.sequence
|
|
345
388
|
stream.receiveAck = frame.sequence
|
|
389
|
+
try {
|
|
390
|
+
await this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream)))
|
|
391
|
+
} catch (error) {
|
|
392
|
+
this.#resetStream(stream, spoolErrorCode(error))
|
|
393
|
+
return
|
|
394
|
+
}
|
|
346
395
|
await this.#sendControl(V3FrameType.ACK, stream.id, frame.sequence, {})
|
|
347
396
|
return
|
|
348
397
|
}
|
|
@@ -359,6 +408,85 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
359
408
|
throw new Error("Unsupported reverse gateway stream frame")
|
|
360
409
|
}
|
|
361
410
|
|
|
411
|
+
/** Sends the target-owning half of the paired reverse-stream resume manifest. */
|
|
412
|
+
async #sendResumeManifest(/** @type {number} */ generation) {
|
|
413
|
+
const activeStreams = [...this.#streams.values()]
|
|
414
|
+
await Promise.all(activeStreams.map((stream) => this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream)))))
|
|
415
|
+
const streams = activeStreams.filter((stream) => this.#streams.get(stream.id) === stream).map((stream) => ({
|
|
416
|
+
streamId: stream.id,
|
|
417
|
+
identifier: stream.identifier,
|
|
418
|
+
clientToGatewaySequence: stream.receiveSequence,
|
|
419
|
+
clientToGatewayAck: stream.receiveAck,
|
|
420
|
+
gatewayToClientSequence: stream.sendSequence,
|
|
421
|
+
gatewayToClientAck: stream.sendAck,
|
|
422
|
+
localEnded: stream.targetEnded,
|
|
423
|
+
remoteEnded: stream.remoteEnded
|
|
424
|
+
}))
|
|
425
|
+
this.#resumeResults = []
|
|
426
|
+
this.#resumeNextPage = 0
|
|
427
|
+
const pageCount = Math.max(1, Math.ceil(streams.length / MANIFEST_PAGE_STREAMS))
|
|
428
|
+
for (let page = 0; page < pageCount; page += 1) {
|
|
429
|
+
await this.#sendControl(V3FrameType.RESUME, 0, generation, {
|
|
430
|
+
page,
|
|
431
|
+
final: page === pageCount - 1,
|
|
432
|
+
streams: streams.slice(page * MANIFEST_PAGE_STREAMS, (page + 1) * MANIFEST_PAGE_STREAMS)
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** @param {unknown} value - Hub resume result. */
|
|
438
|
+
async #applyResumeResult(value) {
|
|
439
|
+
if (typeof value !== "object" || value === null) throw new Error("Invalid reverse gateway resume result")
|
|
440
|
+
const result = /** @type {{streamId?: unknown, status?: unknown, clientToGatewaySequence?: unknown, clientToGatewayAck?: unknown, gatewayToClientSequence?: unknown, gatewayToClientAck?: unknown, clientEnded?: unknown, gatewayEnded?: unknown, opened?: unknown, code?: unknown}} */ (value)
|
|
441
|
+
if (!Number.isInteger(result.streamId)) throw new Error("Invalid reverse gateway resume stream ID")
|
|
442
|
+
const stream = this.#streams.get(/** @type {number} */ (result.streamId))
|
|
443
|
+
if (stream === undefined) return
|
|
444
|
+
if (result.status !== "resumed") {
|
|
445
|
+
this.#destroyStream(stream, typeof result.code === "string" ? result.code : "RESUME_REJECTED")
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
const clientToGatewaySequence = /** @type {number} */ (result.clientToGatewaySequence)
|
|
449
|
+
const clientToGatewayAck = /** @type {number} */ (result.clientToGatewayAck)
|
|
450
|
+
const gatewayToClientSequence = /** @type {number} */ (result.gatewayToClientSequence)
|
|
451
|
+
const gatewayToClientAck = /** @type {number} */ (result.gatewayToClientAck)
|
|
452
|
+
const validGatewayToClientBoundary = gatewayToClientSequence === stream.sendAck || stream.replay.some((chunk) => chunk.sequence === gatewayToClientSequence)
|
|
453
|
+
if (!Number.isSafeInteger(clientToGatewaySequence) || clientToGatewaySequence !== stream.receiveSequence || !Number.isSafeInteger(clientToGatewayAck) || clientToGatewayAck < stream.receiveAck || clientToGatewayAck > clientToGatewaySequence || !Number.isSafeInteger(gatewayToClientSequence) || gatewayToClientSequence < stream.sendAck || gatewayToClientSequence > stream.sendSequence || !Number.isSafeInteger(gatewayToClientAck) || gatewayToClientAck < stream.sendAck || gatewayToClientAck > gatewayToClientSequence || !validGatewayToClientBoundary || typeof result.clientEnded !== "boolean" || typeof result.gatewayEnded !== "boolean" || typeof result.opened !== "boolean") throw new Error("Invalid reverse gateway resume positions")
|
|
454
|
+
stream.receiveAck = clientToGatewayAck
|
|
455
|
+
this.#acknowledge(stream, gatewayToClientAck)
|
|
456
|
+
stream.sendWindow = Math.max(0, stream.sendWindowLimit - (gatewayToClientSequence - gatewayToClientAck))
|
|
457
|
+
for (const chunk of stream.replay) chunk.sent = chunk.sequence <= gatewayToClientSequence
|
|
458
|
+
stream.remoteEnded = /** @type {boolean} */ (result.clientEnded)
|
|
459
|
+
stream.endSent = /** @type {boolean} */ (result.gatewayEnded)
|
|
460
|
+
stream.closeSent = false
|
|
461
|
+
if (stream.targetOpened && result.opened !== true) await this.#sendControl(V3FrameType.OPENED, stream.id, 0, {identifier: stream.identifier, initialWindow: this.#initialWindow()})
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Marks one authenticated/registered physical generation ready. */
|
|
465
|
+
#completePhysical(/** @type {number} */ generation, /** @type {ReturnType<typeof setTimeout> | undefined} */ timer = undefined) {
|
|
466
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
467
|
+
this.#physicalConnects += 1
|
|
468
|
+
this.#reconnectDelay = this.#options.reconnectDelayMs ?? 250
|
|
469
|
+
this.#setState("ready")
|
|
470
|
+
logDiagnostic(this.#options.logger, "transport.connected", {
|
|
471
|
+
generation,
|
|
472
|
+
attempt: this.#physicalAttempts,
|
|
473
|
+
gatewayId: this.#options.gatewayId,
|
|
474
|
+
physicalConnects: this.#physicalConnects,
|
|
475
|
+
reconnect: this.#physicalConnects > 1
|
|
476
|
+
})
|
|
477
|
+
if (this.#physicalConnects > 1) {
|
|
478
|
+
logDiagnostic(this.#options.logger, "transport.reconnect_succeeded", {
|
|
479
|
+
generation,
|
|
480
|
+
attempt: this.#reconnectAttempts,
|
|
481
|
+
gatewayId: this.#options.gatewayId,
|
|
482
|
+
physicalConnects: this.#physicalConnects
|
|
483
|
+
})
|
|
484
|
+
}
|
|
485
|
+
this.#resolveStart?.()
|
|
486
|
+
this.#settleStart()
|
|
487
|
+
for (const stream of this.#streams.values()) void this.#pumpStream(stream).catch(() => this.#resetStream(stream, "TARGET_READ_FAILED"))
|
|
488
|
+
}
|
|
489
|
+
|
|
362
490
|
/** @param {import('../protocol/v3-frame.js').V3Frame} frame - Hub OPEN. */
|
|
363
491
|
async #openStream(frame) {
|
|
364
492
|
if (frame.sequence !== 0 || frame.streamId % 2 !== 0 || frame.streamId <= this.#lastStreamId) throw new Error("Invalid reverse gateway stream ID")
|
|
@@ -423,18 +551,28 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
423
551
|
targetClosed: false,
|
|
424
552
|
targetResetCode: undefined,
|
|
425
553
|
endSent: false,
|
|
554
|
+
closeSent: false,
|
|
426
555
|
openTimer: undefined,
|
|
427
556
|
targetOpened: false,
|
|
428
|
-
targetTerminalLogged: false
|
|
557
|
+
targetTerminalLogged: false,
|
|
558
|
+
openedReady: Promise.resolve(),
|
|
559
|
+
spoolReady: Promise.resolve(),
|
|
560
|
+
spoolQueue: Promise.resolve(),
|
|
561
|
+
spoolReservedBytes: 0,
|
|
562
|
+
spoolRemovalPromise: undefined,
|
|
563
|
+
spoolCleanupFailureRecorded: false
|
|
429
564
|
}
|
|
430
565
|
this.#streams.set(stream.id, stream)
|
|
566
|
+
stream.spoolReady = this.#spool.createNew({sessionId: stream.identifier, ownerId: "reverse-gateway-v3"})
|
|
567
|
+
.then(() => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream)))
|
|
568
|
+
stream.spoolQueue = stream.spoolReady.catch(() => {})
|
|
569
|
+
void stream.spoolReady.catch((error) => this.#resetStream(stream, spoolErrorCode(error)))
|
|
431
570
|
stream.openTimer = setTimeout(() => this.#resetStream(stream, "TARGET_OPEN_TIMEOUT"), this.#options.targetConnectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
|
|
432
571
|
stream.openTimer.unref?.()
|
|
433
572
|
targetSocket.once("connect", () => {
|
|
434
573
|
if (!this.#streams.has(stream.id)) return
|
|
435
574
|
if (stream.openTimer !== undefined) clearTimeout(stream.openTimer)
|
|
436
575
|
stream.openTimer = undefined
|
|
437
|
-
stream.state = "open"
|
|
438
576
|
stream.targetOpened = true
|
|
439
577
|
this.#targetOpens += 1
|
|
440
578
|
logDiagnostic(this.#options.logger, "reverse_gateway.target_opened", {
|
|
@@ -442,7 +580,14 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
442
580
|
generation: this.#generation,
|
|
443
581
|
gatewayId: this.#options.gatewayId
|
|
444
582
|
})
|
|
445
|
-
|
|
583
|
+
stream.openedReady = stream.spoolReady.then(async () => {
|
|
584
|
+
if (!this.#streams.has(stream.id)) return
|
|
585
|
+
await this.#sendControl(V3FrameType.OPENED, stream.id, 0, {identifier: stream.identifier, initialWindow: this.#initialWindow()})
|
|
586
|
+
if (!this.#streams.has(stream.id)) return
|
|
587
|
+
stream.state = "open"
|
|
588
|
+
await this.#pumpStream(stream)
|
|
589
|
+
})
|
|
590
|
+
void stream.openedReady.catch((error) => this.#resetStream(stream, spoolErrorCode(error)))
|
|
446
591
|
})
|
|
447
592
|
targetSocket.on("data", (chunk) => {
|
|
448
593
|
targetSocket.pause()
|
|
@@ -457,8 +602,8 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
457
602
|
})
|
|
458
603
|
targetSocket.once("error", () => {
|
|
459
604
|
if (!this.#streams.has(stream.id)) return
|
|
460
|
-
const code = stream.
|
|
461
|
-
this.#targetOpenFailures += stream.
|
|
605
|
+
const code = stream.targetOpened ? "TARGET_CLOSED" : "TARGET_OPEN_FAILED"
|
|
606
|
+
this.#targetOpenFailures += stream.targetOpened ? 0 : 1
|
|
462
607
|
this.#logTargetTerminal(stream, code)
|
|
463
608
|
stream.targetQueue = stream.targetQueue.then(async () => {
|
|
464
609
|
if (!this.#streams.has(stream.id)) return
|
|
@@ -478,7 +623,8 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
478
623
|
|
|
479
624
|
/** @param {GatewayStream} stream - Stream. @param {Buffer} chunk - Target output. */
|
|
480
625
|
async #retainTargetData(stream, chunk) {
|
|
481
|
-
|
|
626
|
+
await stream.openedReady
|
|
627
|
+
if (!this.#streams.has(stream.id)) return
|
|
482
628
|
for (let offset = 0; offset < chunk.length; offset += this.#maxFrameBytes()) {
|
|
483
629
|
const data = Buffer.from(chunk.subarray(offset, Math.min(chunk.length, offset + this.#maxFrameBytes())))
|
|
484
630
|
if (stream.bufferedBytes + data.length > (this.#options.maxStreamBufferBytes ?? DEFAULT_STREAM_BUFFER_BYTES) || this.#bufferedBytes + data.length > (this.#options.maxTotalBufferBytes ?? DEFAULT_TOTAL_BUFFER_BYTES)) {
|
|
@@ -489,13 +635,19 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
489
635
|
stream.replay.push({sequence: stream.sendSequence, data, sent: false})
|
|
490
636
|
stream.bufferedBytes += data.length
|
|
491
637
|
this.#bufferedBytes += data.length
|
|
638
|
+
stream.spoolReservedBytes += data.length
|
|
639
|
+
this.#spoolBytes += data.length
|
|
640
|
+
await this.#queueSpoolOperation(stream, async () => {
|
|
641
|
+
await this.#spool.appendChunk({sessionId: stream.identifier, ownerId: "reverse-gateway-v3", sequence: stream.sendSequence, data})
|
|
642
|
+
await this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream))
|
|
643
|
+
})
|
|
492
644
|
}
|
|
493
645
|
await this.#pumpStream(stream)
|
|
494
646
|
}
|
|
495
647
|
|
|
496
648
|
/** @param {GatewayStream} stream - Stream. */
|
|
497
649
|
async #pumpStream(stream) {
|
|
498
|
-
if (stream.pumping || !this.#streams.has(stream.id) || this.#state !== "ready") return
|
|
650
|
+
if (stream.pumping || stream.state === "opening" || !this.#streams.has(stream.id) || this.#state !== "ready") return
|
|
499
651
|
stream.pumping = true
|
|
500
652
|
try {
|
|
501
653
|
for (const chunk of stream.replay) {
|
|
@@ -513,18 +665,22 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
513
665
|
if (!stream.replay.some((chunk) => !chunk.sent)) {
|
|
514
666
|
if (stream.targetResetCode !== undefined) {
|
|
515
667
|
const code = stream.targetResetCode
|
|
668
|
+
if (!await this.#sendControl(V3FrameType.RESET, stream.id, 0, {code})) return
|
|
516
669
|
stream.targetResetCode = undefined
|
|
517
|
-
await this.#sendControl(V3FrameType.RESET, stream.id, 0, {code})
|
|
518
670
|
this.#destroyStream(stream, code)
|
|
519
671
|
return
|
|
520
672
|
}
|
|
521
673
|
if (stream.targetEnded && !stream.endSent) {
|
|
522
674
|
stream.endSent = true
|
|
523
|
-
await this.#sendControl(V3FrameType.END, stream.id, stream.sendSequence, {})
|
|
675
|
+
if (!await this.#sendControl(V3FrameType.END, stream.id, stream.sendSequence, {})) {
|
|
676
|
+
stream.endSent = false
|
|
677
|
+
return
|
|
678
|
+
}
|
|
524
679
|
}
|
|
525
|
-
if (stream.targetClosed && (!stream.targetEnded || stream.endSent)) {
|
|
526
|
-
await this.#sendControl(V3FrameType.CLOSE, stream.id, stream.sendSequence, {})
|
|
527
|
-
|
|
680
|
+
if (stream.targetClosed && !stream.closeSent && (!stream.targetEnded || stream.endSent)) {
|
|
681
|
+
if (!await this.#sendControl(V3FrameType.CLOSE, stream.id, stream.sendSequence, {})) return
|
|
682
|
+
stream.closeSent = true
|
|
683
|
+
stream.state = "closing"
|
|
528
684
|
}
|
|
529
685
|
}
|
|
530
686
|
} finally {
|
|
@@ -544,6 +700,13 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
544
700
|
stream.bufferedBytes = Math.max(0, stream.bufferedBytes - released)
|
|
545
701
|
this.#bufferedBytes = Math.max(0, this.#bufferedBytes - released)
|
|
546
702
|
stream.sendWindow = Math.min(stream.sendWindowLimit, stream.sendWindow + delta)
|
|
703
|
+
void this.#queueSpoolOperation(stream, async () => {
|
|
704
|
+
await this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream))
|
|
705
|
+
await this.#spool.truncateAcked(stream.identifier, sequence)
|
|
706
|
+
if (released > stream.spoolReservedBytes) throw new Error("Reverse gateway spool budget release underflow")
|
|
707
|
+
stream.spoolReservedBytes -= released
|
|
708
|
+
this.#spoolBytes = Math.max(0, this.#spoolBytes - released)
|
|
709
|
+
}).catch((error) => this.#resetStream(stream, spoolErrorCode(error)))
|
|
547
710
|
void this.#pumpStream(stream).catch(() => this.#resetStream(stream, "TARGET_READ_FAILED"))
|
|
548
711
|
}
|
|
549
712
|
|
|
@@ -571,6 +734,7 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
571
734
|
stream.replay = []
|
|
572
735
|
stream.bufferedBytes = 0
|
|
573
736
|
stream.targetSocket.destroy()
|
|
737
|
+
this.#trackSpoolCleanup(stream)
|
|
574
738
|
}
|
|
575
739
|
|
|
576
740
|
/** @param {GatewayStream} stream - Stream. @param {string} code - Terminal classification. */
|
|
@@ -609,6 +773,61 @@ export class OutboundReverseGateway extends EventEmitter {
|
|
|
609
773
|
if (!this.#referenced) this.#reconnectTimer.unref?.()
|
|
610
774
|
}
|
|
611
775
|
|
|
776
|
+
/** @param {GatewayStream} stream - Durable target-side stream state. */
|
|
777
|
+
#recoveryState(stream) {
|
|
778
|
+
return {
|
|
779
|
+
version: 3,
|
|
780
|
+
role: "reverse-gateway",
|
|
781
|
+
gatewayId: this.#options.gatewayId,
|
|
782
|
+
connectionId: this.#options.connectionId,
|
|
783
|
+
streamId: stream.id,
|
|
784
|
+
identifier: stream.identifier,
|
|
785
|
+
target: stream.target,
|
|
786
|
+
receiveSequence: stream.receiveSequence,
|
|
787
|
+
receiveAck: stream.receiveAck,
|
|
788
|
+
sendSequence: stream.sendSequence,
|
|
789
|
+
sendAck: stream.sendAck,
|
|
790
|
+
remoteEnded: stream.remoteEnded,
|
|
791
|
+
targetEnded: stream.targetEnded
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** @param {GatewayStream} stream - Spool owner. @param {() => Promise<void>} operation - Serialized durable operation. */
|
|
796
|
+
async #queueSpoolOperation(stream, operation) {
|
|
797
|
+
const next = stream.spoolQueue.then(async () => {
|
|
798
|
+
await stream.spoolReady
|
|
799
|
+
await operation()
|
|
800
|
+
})
|
|
801
|
+
stream.spoolQueue = next.catch(() => {})
|
|
802
|
+
await next
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** @param {GatewayStream} stream - Terminal stream. */
|
|
806
|
+
async #removeSpool(stream) {
|
|
807
|
+
if (stream.spoolRemovalPromise === undefined) {
|
|
808
|
+
stream.spoolRemovalPromise = this.#queueSpoolOperation(stream, async () => {
|
|
809
|
+
await this.#spool.remove(stream.identifier)
|
|
810
|
+
const removed = stream.spoolReservedBytes
|
|
811
|
+
stream.spoolReservedBytes = 0
|
|
812
|
+
this.#spoolBytes = Math.max(0, this.#spoolBytes - removed)
|
|
813
|
+
})
|
|
814
|
+
}
|
|
815
|
+
await stream.spoolRemovalPromise
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** @param {GatewayStream} stream - Terminal stream. */
|
|
819
|
+
#trackSpoolCleanup(stream) {
|
|
820
|
+
const task = this.#removeSpool(stream).catch((error) => {
|
|
821
|
+
if (!stream.spoolCleanupFailureRecorded) {
|
|
822
|
+
stream.spoolCleanupFailureRecorded = true
|
|
823
|
+
this.#spoolCleanupFailures += 1
|
|
824
|
+
}
|
|
825
|
+
throw error
|
|
826
|
+
})
|
|
827
|
+
this.#spoolCleanupTasks.add(task)
|
|
828
|
+
void task.catch(() => {}).finally(() => this.#spoolCleanupTasks.delete(task))
|
|
829
|
+
}
|
|
830
|
+
|
|
612
831
|
#settleStart() { this.#resolveStart = undefined; this.#rejectStart = undefined }
|
|
613
832
|
#setState(/** @type {'configured' | 'connecting' | 'registering' | 'ready' | 'reconnecting' | 'closed'} */ state) { if (this.#state === state) return; this.#state = state; this.emit("stateChange", state) }
|
|
614
833
|
#initialWindow() { return this.#options.initialWindowBytes ?? DEFAULT_WINDOW }
|
|
@@ -630,6 +849,8 @@ function validateOptions(options) {
|
|
|
630
849
|
positiveInteger(options.maxStreams, "maxStreams", DEFAULT_MAX_STREAMS)
|
|
631
850
|
positiveInteger(options.maxStreamBufferBytes, "maxStreamBufferBytes", DEFAULT_STREAM_BUFFER_BYTES)
|
|
632
851
|
positiveInteger(options.maxTotalBufferBytes, "maxTotalBufferBytes", DEFAULT_TOTAL_BUFFER_BYTES)
|
|
852
|
+
if (options.spoolDirectory !== undefined && (typeof options.spoolDirectory !== "string" || options.spoolDirectory.length === 0)) throw new TypeError("spoolDirectory must be a non-empty string")
|
|
853
|
+
positiveInteger(options.maxAgeMs, "maxAgeMs", 24 * 60 * 60 * 1000)
|
|
633
854
|
if (options.initialGeneration !== undefined && (!Number.isSafeInteger(options.initialGeneration) || options.initialGeneration < 0)) throw new RangeError("initialGeneration must be a non-negative safe integer")
|
|
634
855
|
}
|
|
635
856
|
|
|
@@ -645,7 +866,7 @@ function fatalError(/** @type {string} */ code) {
|
|
|
645
866
|
}
|
|
646
867
|
|
|
647
868
|
function connectionError(/** @type {string} */ code) {
|
|
648
|
-
if (code === "HUB_SHUTDOWN") return Object.assign(new Error("Reverse gateway hub is temporarily unavailable"), {code, fatal: false})
|
|
869
|
+
if (code === "HUB_SHUTDOWN" || code === "CLIENT_DISCONNECTED" || code === "GATEWAY_DISCONNECTED") return Object.assign(new Error("Reverse gateway hub is temporarily unavailable"), {code, fatal: false})
|
|
649
870
|
return fatalError(code)
|
|
650
871
|
}
|
|
651
872
|
|
|
@@ -654,6 +875,12 @@ function validPeerWindow(value) { return Number.isSafeInteger(value) && /** @typ
|
|
|
654
875
|
|
|
655
876
|
function closedError() { return Object.assign(new Error("Outbound reverse gateway is closed"), {code: "SOCKETDUCT_GATEWAY_CLOSED"}) }
|
|
656
877
|
|
|
878
|
+
/** @param {unknown} error - Durable spool failure. */
|
|
879
|
+
function spoolErrorCode(error) {
|
|
880
|
+
const code = /** @type {{code?: unknown}} */ (error).code
|
|
881
|
+
return typeof code === "string" ? code : "SPOOL_IO_FAILED"
|
|
882
|
+
}
|
|
883
|
+
|
|
657
884
|
function isFatalTransportError(/** @type {Error} */ error) {
|
|
658
885
|
const code = /** @type {{code?: unknown}} */ (error).code
|
|
659
886
|
return typeof code === "string" && (code.startsWith("ERR_TLS_") || code.startsWith("ERR_SSL_") || code.includes("CERT") || code.includes("VERIFY") || code.includes("SELF_SIGNED") || code.includes("ISSUER"))
|