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.
- package/.env.example +3 -0
- package/changelog.d/20260924120000-wan-route-classes.md +1 -0
- package/changelog.d/20260925120000-reverse-stream-transport-resume.md +1 -0
- package/docs/architecture.md +28 -8
- package/docs/protocol.md +59 -15
- package/docs/reverse-gateway.md +121 -55
- package/docs/security.md +28 -5
- package/docs/session-lifecycle.md +21 -10
- package/docs/socket-api.md +59 -23
- package/package.json +1 -1
- package/src/client/deferred-virtual-socket.js +16 -4
- package/src/client/multiplexed-transport-set.js +106 -65
- package/src/config/environment.js +24 -1
- package/src/reverse/client.js +283 -50
- package/src/reverse/common.js +1 -0
- package/src/reverse/gateway.js +265 -38
- package/src/reverse/hub.js +464 -52
- package/src/reverse/runtime.js +32 -6
- package/test/e2e/reverse-gateway-process.test.js +93 -0
- package/test/integration/reverse-gateway.test.js +576 -28
- package/test/integration/reverse-route-class.test.js +377 -0
- package/test/unit/client/multiplexed-transport-set.test.js +42 -0
- package/test/unit/config/environment.test.js +7 -1
package/src/reverse/client.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 {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
|
|
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
|
|
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
|
-
|
|
217
|
+
...this.#routeIdentityFields(),
|
|
184
218
|
recovered: false
|
|
185
219
|
})
|
|
186
220
|
if (callback !== undefined) installCallback(socket, callback)
|
|
187
|
-
|
|
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
|
-
/**
|
|
229
|
+
/** Process replacement cannot inherit the live reverse target/app socket pair. */
|
|
192
230
|
registerRecoveredConnection() {
|
|
193
|
-
throw Object.assign(new Error("Reverse gateway
|
|
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:
|
|
209
|
-
spoolCleanupFailures:
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
358
|
-
this.#
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
|
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
|
|
package/src/reverse/common.js
CHANGED
|
@@ -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
|