socketduct 0.1.15

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.
Files changed (138) hide show
  1. package/.dockerignore +11 -0
  2. package/.env.example +131 -0
  3. package/AGENTS.md +168 -0
  4. package/Dockerfile +30 -0
  5. package/README.md +134 -0
  6. package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
  7. package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
  8. package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
  9. package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
  10. package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
  11. package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
  12. package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
  13. package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
  14. package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
  15. package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
  16. package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
  17. package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
  18. package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
  19. package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
  20. package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
  21. package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
  22. package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
  23. package/changelog.d/20260902231542-reverse-close-position.md +1 -0
  24. package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
  25. package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
  26. package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
  27. package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
  28. package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
  29. package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
  30. package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
  31. package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
  32. package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
  33. package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
  34. package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
  35. package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
  36. package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
  37. package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
  38. package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
  39. package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
  40. package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
  41. package/docker-compose.dev.yml +19 -0
  42. package/docker-compose.reverse-gateway.yml +36 -0
  43. package/docker-compose.yml +73 -0
  44. package/docs/architecture.md +264 -0
  45. package/docs/docker-topology.md +164 -0
  46. package/docs/docker.md +281 -0
  47. package/docs/logging.md +185 -0
  48. package/docs/plans/initial-implementation.md +205 -0
  49. package/docs/plans/socket-docker-integration.md +221 -0
  50. package/docs/protocol.md +558 -0
  51. package/docs/releasing.md +18 -0
  52. package/docs/reverse-gateway.md +186 -0
  53. package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
  54. package/docs/running.md +215 -0
  55. package/docs/security.md +258 -0
  56. package/docs/session-lifecycle.md +127 -0
  57. package/docs/socket-api.md +448 -0
  58. package/eslint.config.js +36 -0
  59. package/package.json +44 -0
  60. package/scripts/deploy-gateway.js +194 -0
  61. package/scripts/test.js +28 -0
  62. package/src/auth/passwords.js +38 -0
  63. package/src/auth/static-token-store.js +41 -0
  64. package/src/auth/user-store.js +139 -0
  65. package/src/client/connect.js +76 -0
  66. package/src/client/deferred-virtual-socket.js +312 -0
  67. package/src/client/http-agent.js +101 -0
  68. package/src/client/https-agent.js +366 -0
  69. package/src/client/multiplexed-connection.js +1504 -0
  70. package/src/client/multiplexed-transport-set.js +917 -0
  71. package/src/client/multiplexed.js +3 -0
  72. package/src/client/socketduct-socket.js +1174 -0
  73. package/src/client/virtual-socket.js +256 -0
  74. package/src/config/environment.js +400 -0
  75. package/src/edge/tcp-edge.js +82 -0
  76. package/src/index.js +101 -0
  77. package/src/logging/structured-logger.js +745 -0
  78. package/src/protocol/compression.js +325 -0
  79. package/src/protocol/frame.js +341 -0
  80. package/src/protocol/v3-compression.js +44 -0
  81. package/src/protocol/v3-frame.js +209 -0
  82. package/src/proxy/multiplexed-relay.js +798 -0
  83. package/src/proxy/tcp-relay.js +1191 -0
  84. package/src/relay.js +3 -0
  85. package/src/reverse/client.js +721 -0
  86. package/src/reverse/common.js +88 -0
  87. package/src/reverse/gateway.js +663 -0
  88. package/src/reverse/hub.js +563 -0
  89. package/src/reverse/index.js +3 -0
  90. package/src/reverse/runtime.js +78 -0
  91. package/src/server/control-handler.js +170 -0
  92. package/src/server/primary.js +64 -0
  93. package/src/server/relay-runtime.js +124 -0
  94. package/src/server/worker.js +39 -0
  95. package/src/sessions/session.js +212 -0
  96. package/src/spool/spool-file.js +451 -0
  97. package/tensorbuzz.yml +36 -0
  98. package/test/e2e/gateway-process.test.js +262 -0
  99. package/test/e2e/reverse-gateway-process.test.js +285 -0
  100. package/test/fixtures/logging/enospc-matrix.mjs +238 -0
  101. package/test/fixtures/logging/enospc-recovery.mjs +146 -0
  102. package/test/fixtures/logging/process-writer.mjs +20 -0
  103. package/test/helpers/tls-certificates.js +115 -0
  104. package/test/integration/initial-target-recovery.test.js +518 -0
  105. package/test/integration/logging-recovery.test.js +163 -0
  106. package/test/integration/multiplexed-compatibility.test.js +254 -0
  107. package/test/integration/multiplexed-corrections.test.js +551 -0
  108. package/test/integration/multiplexed-failures.test.js +339 -0
  109. package/test/integration/multiplexed-transport-set.test.js +456 -0
  110. package/test/integration/multiplexed-transport.test.js +208 -0
  111. package/test/integration/reverse-gateway.test.js +1293 -0
  112. package/test/integration/socketduct-http-agent.test.js +435 -0
  113. package/test/integration/socketduct-https-agent.test.js +669 -0
  114. package/test/integration/stream-compression.test.js +1267 -0
  115. package/test/integration/tcp-forwarding.test.js +2878 -0
  116. package/test/integration/tls-forwarding.test.js +748 -0
  117. package/test/unit/auth/auth.test.js +64 -0
  118. package/test/unit/auth/static-token-store.test.js +14 -0
  119. package/test/unit/client/connect.test.js +58 -0
  120. package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
  121. package/test/unit/client/multiplexed-transport-set.test.js +906 -0
  122. package/test/unit/client/socketduct-socket.test.js +1124 -0
  123. package/test/unit/client/virtual-socket.test.js +98 -0
  124. package/test/unit/config/docker-gateway-files.test.js +274 -0
  125. package/test/unit/config/environment.test.js +507 -0
  126. package/test/unit/index.test.js +78 -0
  127. package/test/unit/logging/structured-logger.test.js +674 -0
  128. package/test/unit/protocol/compression.test.js +421 -0
  129. package/test/unit/protocol/frame.test.js +336 -0
  130. package/test/unit/protocol/v3-compression.test.js +31 -0
  131. package/test/unit/protocol/v3-frame.test.js +57 -0
  132. package/test/unit/release-configuration.test.js +21 -0
  133. package/test/unit/server/control-handler.test.js +291 -0
  134. package/test/unit/server/primary.test.js +59 -0
  135. package/test/unit/server/relay-runtime.test.js +434 -0
  136. package/test/unit/sessions/session.test.js +84 -0
  137. package/test/unit/spool/spool-file.test.js +295 -0
  138. package/tsconfig.json +20 -0
@@ -0,0 +1,1504 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param-type, jsdoc/require-returns, jsdoc/require-returns-description */
2
+ import {randomBytes} from "node:crypto"
3
+ import {EventEmitter} from "node:events"
4
+ import {readFileSync} from "node:fs"
5
+ import {tmpdir} from "node:os"
6
+ import {join} from "node:path"
7
+ import {createConnection as netConnect} from "node:net"
8
+ import {connect as tlsConnect} from "node:tls"
9
+
10
+ import {errorDiagnostics, logDiagnostic} from "../logging/structured-logger.js"
11
+ import {decodeV3Control, encodeV3Control, encodeV3Frame, V3FrameDecoder, V3FrameType} from "../protocol/v3-frame.js"
12
+ import {SessionSpool} from "../spool/spool-file.js"
13
+ import {SocketductSessionLostError, SocketductStreamError, VirtualSocket} from "./virtual-socket.js"
14
+
15
+ const DEFAULT_WINDOW = 256 * 1024
16
+ const DEFAULT_FRAME_BYTES = 16 * 1024
17
+ const DEFAULT_STREAM_BYTES = 4 * 1024 * 1024
18
+ const DEFAULT_CONNECTION_BYTES = 32 * 1024 * 1024
19
+ const DEFAULT_MAX_STREAMS = 4096
20
+ const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
21
+ const DEFAULT_SPOOL_MAX_AGE_MS = 24 * 60 * 60 * 1000
22
+ const MANIFEST_PAGE_STREAMS = 64
23
+ const MAX_CONTROL_QUEUE_BYTES = 1024 * 1024
24
+ const STABLE_IDENTIFIER = /^[A-Za-z0-9_-]{8,128}$/
25
+
26
+ /**
27
+ * @typedef {object} MultiplexedConnectionOptions
28
+ * @property {{host: string, port: number, token: string}} relay - Authenticated relay endpoint.
29
+ * @property {{caFile?: string, certFile?: string, keyFile?: string, servername?: string, rejectUnauthorized?: boolean}} [tls] - Outer TLS settings.
30
+ * @property {string} [spoolDirectory] - Temporary replay spool root.
31
+ * @property {number} [maxAgeMs] - Maximum durable stream spool age, default 24 hours.
32
+ * @property {string} [connectionId] - Stable caller-owned identity required for recovery across client objects.
33
+ * @property {number} [initialWindowBytes] - Per-direction flow-control window.
34
+ * @property {number} [maxFrameBytes] - Maximum DATA payload.
35
+ * @property {number} [maxStreamBufferBytes] - Per-stream unacknowledged byte bound.
36
+ * @property {number} [maxConnectionBufferBytes] - Aggregate unacknowledged byte bound.
37
+ * @property {number} [maxStreams] - Concurrent stream bound.
38
+ * @property {number} [reconnectDelayMs] - Delay before the one shared reconnect.
39
+ * @property {number} [maxReconnectAttempts] - Bounded physical reconnect attempts.
40
+ * @property {number} [connectTimeoutMs] - Per-attempt physical/auth timeout.
41
+ * @property {number} [heartbeatIntervalMs] - Connection heartbeat interval, default 30 seconds.
42
+ * @property {number} [heartbeatTimeoutMs] - Missing-PONG reconnect threshold, default 90 seconds.
43
+ * @property {(options: {host: string, port: number, tls?: Record<string, unknown>}) => import('node:net').Socket} [createPhysicalSocket] - Test/custom transport factory.
44
+ * @property {boolean} [continuousReconnect] - Keep a warmed member reconnecting until explicitly closed.
45
+ * @property {{reserveData(bytes: number): void, releaseMemory(bytes: number): void, releaseSpool(bytes: number): void, reserveControl(bytes: number): void, releaseControl(bytes: number): void}} [aggregateBudget] - Optional process-wide set budget.
46
+ * @property {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional privacy-safe structured diagnostic logger.
47
+ * @typedef {object} VirtualConnectionOptions
48
+ * @property {string} [host] - Target host.
49
+ * @property {number} [port] - Target port.
50
+ * @property {{host: string, port: number}} [target] - Explicit target.
51
+ * @property {string} [sessionName] - Human-readable stream name.
52
+ * @property {string} [identifier] - Stable resumable identifier.
53
+ * @property {boolean} [recover] - Recover this stream from its existing durable v3 spool.
54
+ * @property {boolean} [allowHalfOpen] - Duplex half-close behavior.
55
+ * @property {number} [readableHighWaterMark] - Readable buffer threshold.
56
+ * @property {number} [writableHighWaterMark] - Writable buffer threshold.
57
+ * @typedef {{wire: Buffer, type: number, payloadBytes: number}} QueuedFrame
58
+ * @typedef {{sequence: number, data: Buffer}} ReplayChunk
59
+ * @typedef {object} ClientStream
60
+ * @property {number} id - Protocol stream ID.
61
+ * @property {string} identifier - Stable resume ID.
62
+ * @property {string} name - Human name.
63
+ * @property {{host: string, port: number}} target - Target endpoint.
64
+ * @property {VirtualSocket} socket - Public Duplex.
65
+ * @property {"opening" | "open" | "half-closed" | "closed" | "reset"} state - Lifecycle.
66
+ * @property {boolean} opened - Whether OPENED was observed.
67
+ * @property {boolean} openingSent - Whether OPEN was sent in this generation.
68
+ * @property {boolean} localEnded - Local writable EOF.
69
+ * @property {boolean} remoteEnded - Remote readable EOF.
70
+ * @property {boolean} remoteClosed - Relay terminal close.
71
+ * @property {number} sendSequence - Last locally allocated byte sequence.
72
+ * @property {number} sendAck - Last relay acknowledgement.
73
+ * @property {number} receiveSequence - Last contiguous relay byte sequence.
74
+ * @property {number} receiveAck - Last acknowledgement sent to relay.
75
+ * @property {number} sendWindow - Available peer receive window.
76
+ * @property {number} receiveUnackedBytes - Bytes delivered but not acknowledged.
77
+ * @property {number | undefined} pendingReceiveAck - Withheld ACK while readable backpressured.
78
+ * @property {number} bufferedBytes - Retained unacknowledged send bytes.
79
+ * @property {number} spoolReservedBytes - Shared budget retained until durable cleanup succeeds.
80
+ * @property {ReplayChunk[]} replay - Raw unacknowledged bytes.
81
+ * @property {QueuedFrame[]} queue - Fair-scheduler frames.
82
+ * @property {Promise<void>} spoolReady - Spool initialization.
83
+ * @property {Promise<void>} spoolQueue - Serialized append/truncate/remove work.
84
+ * @property {boolean} spoolRemovalQueued - Idempotent terminal cleanup flag.
85
+ * @property {Promise<void> | undefined} spoolRemovalPromise - Observable terminal cleanup result.
86
+ * @property {boolean} spoolCleanupFailureRecorded - Whether cleanup failure was counted.
87
+ * @property {boolean} ownsSpool - Whether terminal cleanup may remove this spool.
88
+ * @property {boolean} recoveredObject - Whether receive state came from a replaced client object.
89
+ * @typedef {object} ClientRecoveryState
90
+ * @property {number} version - Recovery schema version.
91
+ * @property {string} connectionId - Stable physical connection identity.
92
+ * @property {number} generation - Last authenticated generation.
93
+ * @property {number} streamId - Stable wire stream ID.
94
+ * @property {string} identifier - Stable stream identifier.
95
+ * @property {string} name - Human-readable stream name.
96
+ * @property {{host: string, port: number}} target - Authorized target identity.
97
+ * @property {boolean} opened - Whether the target was opened.
98
+ * @property {boolean} openingSent - Whether OPEN may have reached the relay.
99
+ * @property {boolean} localEnded - Whether local EOF was sent.
100
+ * @property {number} sendSequence - Highest durable client byte sequence.
101
+ * @property {number} sendAck - Highest relay-acknowledged client byte sequence.
102
+ * @property {number} receiveSequence - Highest accepted target byte sequence.
103
+ * @property {number} receiveAck - Highest durably acknowledged target byte sequence.
104
+ */
105
+
106
+ /** One authenticated physical transport carrying many virtual TCP streams. */
107
+ export class MultiplexedSocketductConnection extends EventEmitter {
108
+ /** @type {MultiplexedConnectionOptions} */
109
+ #options
110
+
111
+ /** @type {Map<number, ClientStream>} */
112
+ #streams = new Map()
113
+
114
+ /** @type {number} */
115
+ #nextStreamId = 1
116
+
117
+ /** @type {import('node:net').Socket | undefined} */
118
+ #physical
119
+
120
+ /** @type {Promise<void> | undefined} */
121
+ #connectPromise
122
+
123
+ /** @type {Promise<void> | undefined} */
124
+ #warmPromise
125
+
126
+ /** @type {(() => void) | undefined} */
127
+ #resolveConnect
128
+
129
+ /** @type {((error: Error) => void) | undefined} */
130
+ #rejectConnect
131
+
132
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
133
+ #reconnectTimer
134
+
135
+ /** @type {WeakMap<import('node:net').Socket, ReturnType<typeof setTimeout>>} */
136
+ #attemptTimers = new WeakMap()
137
+
138
+ /** @type {boolean} */
139
+ #closing = false
140
+
141
+ /** @type {boolean} */
142
+ #warm = false
143
+
144
+ /** @type {"configured" | "connecting" | "ready" | "reconnecting" | "disconnected" | "closed"} */
145
+ #physicalState = "configured"
146
+
147
+ /** @type {boolean} */
148
+ #preserveSpools = false
149
+
150
+ /** @type {boolean} */
151
+ #authenticated = false
152
+
153
+ /** @type {boolean} */
154
+ #hadAuthenticatedGeneration = false
155
+
156
+ /** @type {boolean} */
157
+ #resuming = false
158
+
159
+ /** @type {number} */
160
+ #generation = 0
161
+
162
+ /** @type {number} */
163
+ #reconnectAttempts = 0
164
+
165
+ /** @type {number} */
166
+ #reconnectAttemptTotal = 0
167
+
168
+ /** @type {number} */
169
+ #physicalConnects = 0
170
+
171
+ /** @type {number} */
172
+ #physicalAttempts = 0
173
+
174
+ /** @type {number} */
175
+ #reconnectSuccesses = 0
176
+
177
+ /** @type {number} */
178
+ #resumeFailures = 0
179
+
180
+ /** @type {Array<Record<string, unknown>>} */
181
+ #resumeResults = []
182
+
183
+ /** @type {number} */
184
+ #resumeNextPage = 0
185
+
186
+ /** @type {number} */
187
+ #aggregateBufferedBytes = 0
188
+
189
+ /** @type {number} */
190
+ #aggregateSpoolBytes = 0
191
+
192
+ /** @type {number} */
193
+ #spoolCleanupFailures = 0
194
+
195
+ /** @type {Set<Promise<void>>} */
196
+ #spoolCleanupTasks = new Set()
197
+
198
+ /** @type {Buffer[]} */
199
+ #controlQueue = []
200
+
201
+ /** @type {number} */
202
+ #controlQueuedBytes = 0
203
+
204
+ /** @type {boolean} */
205
+ #pumping = false
206
+
207
+ /** @type {number} */
208
+ #roundRobinCursor = 0
209
+
210
+ /** @type {Promise<void>} */
211
+ #openQueue = Promise.resolve()
212
+
213
+ /** @type {string} */
214
+ #connectionId
215
+
216
+ /** @type {ReturnType<typeof setInterval> | undefined} */
217
+ #heartbeatTimer
218
+
219
+ /** @type {number} */
220
+ #lastPongAt = 0
221
+
222
+ /** @type {number} */
223
+ #pingSequence = 0
224
+
225
+ /** @type {SessionSpool} */
226
+ #spool
227
+
228
+ /** @type {(options: {host: string, port: number, tls?: Record<string, unknown>}) => import('node:net').Socket} */
229
+ #createPhysicalSocket
230
+
231
+ /** @param {MultiplexedConnectionOptions} options - Connection and safety policy. */
232
+ constructor(options) {
233
+ super()
234
+ validateConnectionOptions(options)
235
+ this.#options = options
236
+ this.#connectionId = options.connectionId ?? randomBytes(24).toString("base64url")
237
+ const spoolDirectory = options.spoolDirectory ?? join(tmpdir(), `socketduct-${typeof process.getuid === "function" ? process.getuid() : "user"}`, "multiplexed")
238
+ this.#spool = new SessionSpool({rootDir: spoolDirectory, maxBytes: this.#maxStreamBytes(), maxAgeMs: options.maxAgeMs ?? DEFAULT_SPOOL_MAX_AGE_MS})
239
+ this.#createPhysicalSocket = options.createPhysicalSocket ?? createPhysicalSocket
240
+ this.createConnection = this.createConnection.bind(this)
241
+ }
242
+
243
+ /**
244
+ * Warms and authenticates this member without allocating a target stream.
245
+ * Concurrent calls share the same physical connection promise.
246
+ * @returns {Promise<void>} Resolves when the member becomes ready.
247
+ */
248
+ connect() {
249
+ if (this.#closing) return Promise.reject(Object.assign(new Error("Multiplexed Socketduct connection is closed"), {code: "SOCKETDUCT_CONNECTION_CLOSED"}))
250
+ if (this.#warmPromise !== undefined) return this.#warmPromise
251
+ this.#warm = true
252
+ this.#warmPromise = Promise.all([...this.#streams.values()].map((stream) => stream.spoolReady)).then(() => this.#ensureConnected())
253
+ return this.#warmPromise
254
+ }
255
+
256
+ /**
257
+ * Allocates a virtual stream without allocating another physical transport.
258
+ * @param {VirtualConnectionOptions} options - Target/socket options.
259
+ * @param {(error: Error | null, socket: VirtualSocket) => void} [callback] - Node-style optional callback.
260
+ * @returns {VirtualSocket} Socket-shaped Duplex.
261
+ */
262
+ createConnection(options, callback) {
263
+ return this.#createConnection(options, callback, true)
264
+ }
265
+
266
+ /**
267
+ * Registers durable recovery state without starting a physical attempt.
268
+ * @param {VirtualConnectionOptions} options - Recovery options.
269
+ * @param {(error: Error | null, socket: VirtualSocket) => void} [callback] - Node-style optional callback.
270
+ * @returns {VirtualSocket} Cold registered socket.
271
+ */
272
+ registerRecoveredConnection(options, callback) {
273
+ if (options.recover !== true) throw new TypeError("registerRecoveredConnection requires recover: true")
274
+ return this.#createConnection(options, callback, false)
275
+ }
276
+
277
+ /**
278
+ * @param {VirtualConnectionOptions} options - Target/socket options.
279
+ * @param {((error: Error | null, socket: VirtualSocket) => void) | undefined} callback - Node-style optional callback.
280
+ * @param {boolean} connectOnReady - Whether creation should start the physical connection.
281
+ * @returns {VirtualSocket} Socket-shaped Duplex.
282
+ */
283
+ #createConnection(options, callback, connectOnReady) {
284
+ if (this.#closing) throw new Error("Multiplexed Socketduct connection is closed")
285
+ if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) throw new Error("Maximum virtual stream count exceeded")
286
+ const target = normalizeTarget(options)
287
+ const identifier = options.identifier ?? randomBytes(24).toString("base64url")
288
+ if (!STABLE_IDENTIFIER.test(identifier)) throw new TypeError("Virtual stream identifier must be 8-128 URL-safe opaque characters")
289
+ const recovery = options.recover === true
290
+ ? validateRecoveryState(this.#spool.readRecoveryStateSync(identifier, "edge-v3"), {
291
+ connectionId: this.#connectionId,
292
+ identifier,
293
+ target,
294
+ ...(options.sessionName === undefined ? {} : {sessionName: options.sessionName})
295
+ })
296
+ : undefined
297
+ if (recovery !== undefined && this.#physicalAttempts > 0) throw new Error("Recovered streams must be registered before the replacement physical connection starts")
298
+ const id = recovery?.streamId ?? this.#allocateStreamId()
299
+ if (this.#streams.has(id)) throw new Error(`V3 stream ID ${id} is already active`)
300
+ if (recovery !== undefined) {
301
+ this.#nextStreamId = Math.max(this.#nextStreamId, id + 2)
302
+ this.#generation = Math.max(this.#generation, recovery.generation)
303
+ this.#hadAuthenticatedGeneration = true
304
+ }
305
+ const name = options.sessionName ?? recovery?.name ?? `socketduct-v3-${id}`
306
+ if (typeof name !== "string" || name.length === 0 || name.length > 256) throw new TypeError("Virtual stream name must be 1-256 characters")
307
+ const socketOptions = {
308
+ ...target,
309
+ ...(options.allowHalfOpen === undefined ? {} : {allowHalfOpen: options.allowHalfOpen}),
310
+ ...(options.readableHighWaterMark === undefined ? {} : {readableHighWaterMark: options.readableHighWaterMark}),
311
+ ...(options.writableHighWaterMark === undefined ? {} : {writableHighWaterMark: options.writableHighWaterMark})
312
+ }
313
+ const socket = new VirtualSocket(this, id, socketOptions)
314
+ const stream = /** @type {ClientStream} */ ({
315
+ id,
316
+ identifier,
317
+ name,
318
+ target,
319
+ socket,
320
+ state: recovery?.localEnded === true ? "half-closed" : (recovery?.opened === true ? "open" : "opening"),
321
+ opened: recovery?.opened ?? false,
322
+ openingSent: recovery?.openingSent ?? false,
323
+ localEnded: recovery?.localEnded ?? false,
324
+ remoteEnded: false,
325
+ remoteClosed: false,
326
+ sendSequence: recovery?.sendSequence ?? 0,
327
+ sendAck: recovery?.sendAck ?? 0,
328
+ // A replacement VirtualSocket cannot inherit bytes buffered only by the
329
+ // previous object; restart delivery at the durable acknowledgement.
330
+ receiveSequence: recovery?.receiveAck ?? 0,
331
+ receiveAck: recovery?.receiveAck ?? 0,
332
+ sendWindow: this.#initialWindow(),
333
+ receiveUnackedBytes: 0,
334
+ pendingReceiveAck: undefined,
335
+ bufferedBytes: 0,
336
+ spoolReservedBytes: 0,
337
+ replay: [],
338
+ queue: [],
339
+ spoolReady: Promise.resolve(),
340
+ spoolQueue: Promise.resolve(),
341
+ spoolRemovalQueued: false,
342
+ spoolRemovalPromise: undefined,
343
+ spoolCleanupFailureRecorded: false,
344
+ ownsSpool: recovery !== undefined,
345
+ recoveredObject: recovery !== undefined
346
+ })
347
+ stream.spoolReady = recovery === undefined
348
+ ? this.#spool.createNew({sessionId: identifier, ownerId: "edge-v3"})
349
+ .then(() => {
350
+ stream.ownsSpool = true
351
+ return this.#spool.writeRecoveryState(identifier, this.#recoveryState(stream))
352
+ })
353
+ : this.#spool.readChunksAfter(identifier, stream.sendAck).then((chunks) => {
354
+ validateRecoveredChunks(stream, chunks, this.#maxStreamBytes())
355
+ if (this.#streams.get(stream.id) !== stream) return
356
+ const bufferedBytes = chunks.reduce((sum, chunk) => sum + chunk.data.length, 0)
357
+ if (this.#aggregateBufferedBytes + bufferedBytes > this.#maxConnectionBytes()) throw new Error("Recovered Socketduct data exceeds the connection buffer limit")
358
+ this.#options.aggregateBudget?.reserveData(bufferedBytes)
359
+ stream.replay = chunks
360
+ stream.bufferedBytes = bufferedBytes
361
+ stream.spoolReservedBytes = bufferedBytes
362
+ this.#aggregateBufferedBytes += bufferedBytes
363
+ this.#aggregateSpoolBytes += bufferedBytes
364
+ })
365
+ stream.spoolQueue = stream.spoolReady.catch(() => {})
366
+ this.#streams.set(id, stream)
367
+ logDiagnostic(this.#options.logger, "virtual_stream.opening", {
368
+ streamId: id,
369
+ generation: this.#generation,
370
+ recovered: recovery !== undefined
371
+ })
372
+ if (callback !== undefined) installConnectCallback(socket, callback)
373
+ if (connectOnReady) {
374
+ stream.spoolReady
375
+ .then(() => this.#streams.get(stream.id) === stream && !stream.socket.destroyed ? this.#ensureConnected() : undefined)
376
+ .then(() => this.#openStream(stream))
377
+ .catch((error) => socket._receiveError(asError(error)))
378
+ } else {
379
+ stream.spoolReady.catch((error) => socket._receiveError(asError(error)))
380
+ }
381
+ return socket
382
+ }
383
+
384
+ /** @returns {{physicalState: string, connectionGeneration: number, virtualStreams: number, streamStates: Record<string, number>, queuedBytes: number, dataQueuedBytes: number, controlQueuedBytes: number, spoolBytes: number, spoolCleanupFailures: number, physicalConnects: number, reconnectAttempts: number, reconnectSuccesses: number, resumeFailures: number}} Privacy-safe status. */
385
+ status() {
386
+ /** @type {Record<string, number>} */
387
+ const streamStates = {}
388
+ for (const stream of this.#streams.values()) {
389
+ streamStates[stream.state] = (streamStates[stream.state] ?? 0) + 1
390
+ }
391
+ return {
392
+ physicalState: this.#physicalState,
393
+ connectionGeneration: this.#generation,
394
+ virtualStreams: this.#streams.size,
395
+ streamStates,
396
+ queuedBytes: this.#aggregateBufferedBytes + this.#controlQueuedBytes,
397
+ dataQueuedBytes: this.#aggregateBufferedBytes,
398
+ controlQueuedBytes: this.#controlQueuedBytes,
399
+ spoolBytes: this.#aggregateSpoolBytes,
400
+ spoolCleanupFailures: this.#spoolCleanupFailures,
401
+ physicalConnects: this.#physicalConnects,
402
+ reconnectAttempts: this.#reconnectAttemptTotal,
403
+ reconnectSuccesses: this.#reconnectSuccesses,
404
+ resumeFailures: this.#resumeFailures
405
+ }
406
+ }
407
+
408
+ /** @returns {{address?: string, port?: number}} Current physical local address. */
409
+ physicalAddress() {
410
+ const address = this.#physical?.localAddress
411
+ const port = this.#physical?.localPort
412
+ return {
413
+ ...(address === undefined ? {} : {address}),
414
+ ...(port === undefined ? {} : {port})
415
+ }
416
+ }
417
+
418
+ /** @returns {this} */
419
+ ref() {
420
+ this.#physical?.ref()
421
+ this.#reconnectTimer?.ref?.()
422
+ return this
423
+ }
424
+
425
+ /** @returns {this} */
426
+ unref() {
427
+ this.#physical?.unref()
428
+ this.#reconnectTimer?.unref?.()
429
+ return this
430
+ }
431
+
432
+ /** Gracefully closes all virtual streams and the shared transport. @returns {Promise<void>} */
433
+ async close() {
434
+ if (this.#closing) return
435
+ this.#closing = true
436
+ this.#setPhysicalState("closed")
437
+ if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
438
+ this.#reconnectTimer = undefined
439
+ this.#stopHeartbeat()
440
+ this.#clearControlQueue()
441
+ if (this.#physical !== undefined && !this.#physical.destroyed && this.#authenticated) {
442
+ try {
443
+ this.#physical.end(encodeV3Frame({type: V3FrameType.GOAWAY, streamId: 0, sequence: 0}, encodeV3Control({reason: "client_close"})))
444
+ } catch {
445
+ this.#physical.destroy()
446
+ }
447
+ }
448
+ /** @type {Error[]} */
449
+ const cleanupErrors = []
450
+ for (const stream of [...this.#streams.values()]) {
451
+ stream.remoteClosed = true
452
+ if (!stream.socket.destroyed) stream.socket.destroy()
453
+ if (stream.ownsSpool) {
454
+ try {
455
+ await this.#removeSpool(stream)
456
+ } catch (error) {
457
+ const cleanupError = asError(error)
458
+ this.#handleSpoolCleanupFailure(stream, cleanupError)
459
+ cleanupErrors.push(cleanupError)
460
+ }
461
+ }
462
+ }
463
+ const pendingResults = await Promise.allSettled([...this.#spoolCleanupTasks])
464
+ for (const result of pendingResults) {
465
+ if (result.status === "rejected") {
466
+ const cleanupError = asError(result.reason)
467
+ if (!cleanupErrors.includes(cleanupError)) cleanupErrors.push(cleanupError)
468
+ }
469
+ }
470
+ this.#streams.clear()
471
+ this.#physical?.destroy()
472
+ this.#physical = undefined
473
+ this.#authenticated = false
474
+ this.#rejectConnect?.(new Error("Multiplexed Socketduct connection closed"))
475
+ this.#settleConnect()
476
+ if (cleanupErrors.length > 0) throw new AggregateError(cleanupErrors, "Socketduct durable spool cleanup failed")
477
+ }
478
+
479
+ /**
480
+ * Detaches the physical transport while preserving active stream spools for
481
+ * a new connection object using the same caller-provided connectionId.
482
+ * @returns {Promise<void>} Resolves after recovery state is durable.
483
+ */
484
+ async detach() {
485
+ if (this.#closing) return
486
+ if (this.#options.connectionId === undefined) throw new Error("detach() requires an explicit stable connectionId")
487
+ this.#preserveSpools = true
488
+ this.#closing = true
489
+ this.#setPhysicalState("closed")
490
+ if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
491
+ this.#reconnectTimer = undefined
492
+ this.#stopHeartbeat()
493
+ try {
494
+ await Promise.all([...this.#streams.values()].map((stream) => this.#persistStreamState(stream)))
495
+ if (this.#streams.size === 0 && this.#physical !== undefined && !this.#physical.destroyed && this.#authenticated) {
496
+ const physical = this.#physical
497
+ await new Promise((resolve) => {
498
+ physical.once("close", resolve)
499
+ physical.end(encodeV3Frame({type: V3FrameType.GOAWAY, streamId: 0, sequence: 0}, encodeV3Control({reason: "client_detach_empty"})))
500
+ })
501
+ }
502
+ } finally {
503
+ this.#physical?.destroy()
504
+ this.#physical = undefined
505
+ this.#authenticated = false
506
+ this.#rejectConnect?.(new SocketductSessionLostError("Socketduct connection detached for durable recovery", "CONNECTION_DETACHED"))
507
+ this.#settleConnect()
508
+ }
509
+ }
510
+
511
+ /**
512
+ * @param {number} streamId - Stream. @param {Buffer} data - Bytes. @param {(error?: Error | null) => void} callback - Completion. @returns {void}
513
+ * @param data
514
+ * @param callback
515
+ */
516
+ _writeStream(streamId, data, callback) {
517
+ const stream = this.#streams.get(streamId)
518
+ if (this.#closing || stream === undefined || stream.state === "closed" || stream.state === "reset" || stream.localEnded) {
519
+ callback(new SocketductStreamError("Virtual stream is not writable", "ERR_STREAM_DESTROYED"))
520
+ return
521
+ }
522
+ if (stream.sendSequence > Number.MAX_SAFE_INTEGER - data.length) {
523
+ callback(new SocketductStreamError("Virtual stream sequence space exhausted", "SEQUENCE_EXHAUSTED"))
524
+ return
525
+ }
526
+ if (stream.bufferedBytes + data.length > this.#maxStreamBytes() || this.#aggregateBufferedBytes + data.length > this.#maxConnectionBytes()) {
527
+ const error = new SocketductStreamError("Socketduct stream buffer limit exceeded", "SOCKETDUCT_BUFFER_LIMIT")
528
+ callback(error)
529
+ return
530
+ }
531
+ try {
532
+ this.#options.aggregateBudget?.reserveData(data.length)
533
+ } catch (error) {
534
+ callback(asError(error))
535
+ return
536
+ }
537
+ /** @type {ReplayChunk[]} */
538
+ const chunks = []
539
+ for (let offset = 0; offset < data.length; offset += this.#maxFrameBytes()) {
540
+ const part = Buffer.from(data.subarray(offset, Math.min(data.length, offset + this.#maxFrameBytes())))
541
+ stream.sendSequence += part.length
542
+ chunks.push({sequence: stream.sendSequence, data: part})
543
+ }
544
+ stream.replay.push(...chunks)
545
+ stream.bufferedBytes += data.length
546
+ stream.spoolReservedBytes += data.length
547
+ this.#aggregateBufferedBytes += data.length
548
+ this.#aggregateSpoolBytes += data.length
549
+ const persist = async () => {
550
+ await this.#queueSpoolOperation(stream, async () => {
551
+ for (const chunk of chunks) {
552
+ await this.#spool.appendChunk({sessionId: stream.identifier, ownerId: "edge-v3", sequence: chunk.sequence, data: chunk.data})
553
+ }
554
+ await this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream))
555
+ })
556
+ if (this.#streams.get(stream.id) === stream) for (const chunk of chunks) this.#queueData(stream, chunk)
557
+ }
558
+ persist().then(() => callback(), (error) => callback(asError(error)))
559
+ }
560
+
561
+ /**
562
+ * @param {number} streamId - Stream. @param {(error?: Error | null) => void} callback - Completion. @returns {void}
563
+ * @param callback
564
+ */
565
+ _endStream(streamId, callback) {
566
+ const stream = this.#streams.get(streamId)
567
+ if (stream === undefined || stream.localEnded) {
568
+ callback()
569
+ return
570
+ }
571
+ stream.localEnded = true
572
+ stream.state = "half-closed"
573
+ this.#persistStreamState(stream).then(() => {
574
+ this.#queueStreamControl(stream, V3FrameType.END, stream.sendSequence)
575
+ callback()
576
+ }, (error) => callback(asError(error)))
577
+ }
578
+
579
+ /**
580
+ * @param {number} streamId - Stream. @param {Error | null} error - Cause. @returns {void}
581
+ * @param error
582
+ */
583
+ _destroyStream(streamId, error) {
584
+ const stream = this.#streams.get(streamId)
585
+ if (stream === undefined) return
586
+ this.#streams.delete(streamId)
587
+ this.#aggregateBufferedBytes = Math.max(0, this.#aggregateBufferedBytes - stream.bufferedBytes)
588
+ this.#options.aggregateBudget?.releaseMemory(stream.bufferedBytes)
589
+ stream.state = error === null ? "closed" : "reset"
590
+ logDiagnostic(this.#options.logger, error === null ? "virtual_stream.closed" : "virtual_stream.error", {
591
+ streamId,
592
+ generation: this.#generation,
593
+ ...(error === null ? {} : errorDiagnostics(error))
594
+ })
595
+ if (!stream.remoteClosed && this.#authenticated && !this.#resuming) {
596
+ this.#queueControlFrame(V3FrameType.RESET, streamId, 0, {
597
+ code: error instanceof SocketductStreamError ? error.code : "CLIENT_CANCELLED"
598
+ })
599
+ }
600
+ if (!this.#preserveSpools) {
601
+ this.#trackSpoolCleanup(stream)
602
+ }
603
+ if (this.#streams.size === 0 && !this.#authenticated && this.#physical !== undefined) this.#physical.destroy()
604
+ }
605
+
606
+ /** @param {number} streamId - Stream. @returns {void} */
607
+ _resumeReadable(streamId) {
608
+ const stream = this.#streams.get(streamId)
609
+ if (stream?.pendingReceiveAck === undefined) return
610
+ const ack = stream.pendingReceiveAck
611
+ stream.pendingReceiveAck = undefined
612
+ this.#ackReceived(stream, ack)
613
+ }
614
+
615
+ /** @returns {number} */
616
+ #allocateStreamId() {
617
+ const id = this.#nextStreamId
618
+ if (id > 0xffff_fffd) throw new Error("V3 stream ID space exhausted for this connection generation")
619
+ this.#nextStreamId += 2
620
+ return id
621
+ }
622
+
623
+ /** @returns {Promise<void>} */
624
+ #ensureConnected() {
625
+ if (this.#authenticated && !this.#resuming && this.#physical !== undefined && !this.#physical.destroyed) return Promise.resolve()
626
+ if (this.#connectPromise !== undefined) return this.#connectPromise
627
+ this.#connectPromise = new Promise((resolve, reject) => {
628
+ this.#resolveConnect = resolve
629
+ this.#rejectConnect = reject
630
+ })
631
+ this.#connectPhysical()
632
+ return this.#connectPromise
633
+ }
634
+
635
+ /** @returns {void} */
636
+ #connectPhysical() {
637
+ if (this.#closing) return
638
+ const wasConnected = this.#hadAuthenticatedGeneration
639
+ const retryAttempt = this.#physicalAttempts > 0
640
+ logDiagnostic(this.#options.logger, "transport.connecting", {
641
+ generation: this.#generation,
642
+ attempt: this.#physicalAttempts + 1,
643
+ reconnect: wasConnected || retryAttempt
644
+ })
645
+ this.#setPhysicalState(wasConnected || retryAttempt ? "reconnecting" : "connecting")
646
+ this.#physicalAttempts += 1
647
+ if (retryAttempt) {
648
+ this.#reconnectAttempts += 1
649
+ this.#reconnectAttemptTotal += 1
650
+ }
651
+ let physical
652
+ try {
653
+ physical = this.#createPhysicalSocket({
654
+ host: this.#options.relay.host,
655
+ port: this.#options.relay.port,
656
+ ...(this.#options.tls === undefined ? {} : {tls: resolveTls(this.#options.tls)})
657
+ })
658
+ } catch (error) {
659
+ this.#failAttempt(asError(error), true)
660
+ return
661
+ }
662
+ this.#physical = physical
663
+ this.#authenticated = false
664
+ this.#resuming = wasConnected
665
+ const decoderLimit = Math.max(64 * 1024, this.#maxFrameBytes())
666
+ const decoder = new V3FrameDecoder({maxFrameBytes: decoderLimit, maxBufferedBytes: decoderLimit * 2})
667
+ const readyEvent = this.#options.tls === undefined ? "connect" : "secureConnect"
668
+ /** @type {Error & {code?: string} | undefined} */
669
+ let attemptError
670
+ const timer = setTimeout(() => {
671
+ attemptError = Object.assign(new Error("Socketduct v3 connect/auth timeout"), {code: "SOCKETDUCT_AUTH_TIMEOUT"})
672
+ physical.destroy(attemptError)
673
+ }, this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
674
+ this.#attemptTimers.set(physical, timer)
675
+ timer.unref?.()
676
+ const onReady = () => {
677
+ if (this.#physical !== physical || this.#closing) return
678
+ this.#generation += 1
679
+ this.#physicalConnects += 1
680
+ const generation = this.#generation
681
+ // Persist the attempted generation before AUTH can reach the relay. A
682
+ // crash after AUTH but before AUTH_OK can then advance past it safely.
683
+ void Promise.all([...this.#streams.values()].map((stream) => this.#persistStreamState(stream))).then(() => {
684
+ if (this.#physical !== physical || physical.destroyed || this.#closing) return
685
+ const auth = encodeV3Control({
686
+ token: this.#options.relay.token,
687
+ versions: [3],
688
+ connectionId: this.#connectionId,
689
+ generation,
690
+ initialWindow: this.#initialWindow(),
691
+ compression: []
692
+ })
693
+ physical.write(encodeV3Frame({type: V3FrameType.AUTH, streamId: 0, sequence: generation}, auth))
694
+ }, (error) => physical.destroy(asError(error)))
695
+ }
696
+ physical.once(readyEvent, onReady)
697
+ physical.on("data", (chunk) => {
698
+ let frames
699
+ try {
700
+ frames = decoder.push(chunk)
701
+ } catch (error) {
702
+ physical.destroy(asError(error))
703
+ return
704
+ }
705
+ for (const frame of frames) {
706
+ try {
707
+ this.#handleFrame(frame, physical)
708
+ } catch (error) {
709
+ physical.destroy(asError(error))
710
+ }
711
+ }
712
+ })
713
+ physical.once("error", (error) => {
714
+ attemptError = error
715
+ if (isFatalTransportError(error)) this.#failAll(error)
716
+ })
717
+ physical.once("close", () => {
718
+ this.#clearAttemptTimeout(physical)
719
+ if (this.#physical !== physical) return
720
+ this.#physical = undefined
721
+ this.#authenticated = false
722
+ this.#stopHeartbeat()
723
+ this.#clearControlQueue()
724
+ this.#roundRobinCursor = 0
725
+ for (const stream of this.#streams.values()) {
726
+ stream.queue = []
727
+ }
728
+ if (this.#closing) return
729
+ logDiagnostic(this.#options.logger, "transport.disconnected", {
730
+ generation: this.#generation,
731
+ virtualStreams: this.#streams.size,
732
+ ...errorDiagnostics(attemptError, "TRANSPORT_CLOSED")
733
+ })
734
+ if (this.#streams.size === 0 && !this.#warm) {
735
+ this.#setPhysicalState("disconnected")
736
+ this.#rejectConnect?.(attemptError ?? new Error("Socketduct physical attempt closed"))
737
+ this.#settleConnect()
738
+ return
739
+ }
740
+ if (attemptError?.code === "SOCKETDUCT_AUTH_TIMEOUT" && !this.#hadAuthenticatedGeneration && this.#options.continuousReconnect !== true) {
741
+ this.#setPhysicalState("disconnected")
742
+ this.#rejectConnect?.(attemptError)
743
+ this.#settleConnect()
744
+ for (const stream of [...this.#streams.values()]) stream.socket._receiveError(attemptError)
745
+ return
746
+ }
747
+ this.#scheduleReconnect()
748
+ })
749
+ }
750
+
751
+ /**
752
+ * @param {import('../protocol/v3-frame.js').V3Frame} frame - Decoded frame. @param {import('node:net').Socket} physical - Source. @returns {void}
753
+ * @param physical
754
+ */
755
+ #handleFrame(frame, physical) {
756
+ if (this.#physical !== physical) return
757
+ if (frame.type === V3FrameType.AUTH_OK) {
758
+ // Attempt timeout ownership extends through AUTH_OK.
759
+ this.#clearAttemptTimeout(physical)
760
+ if (this.#authenticated) throw new Error("Relay repeated V3 authentication")
761
+ const control = decodeV3Control(frame.payload)
762
+ if (control.version !== 3 || control.connectionId !== this.#connectionId || control.generation !== this.#generation || typeof control.resumed !== "boolean") throw new Error("Relay did not negotiate Socketduct protocol v3")
763
+ if (this.#resuming && control.resumed === false) {
764
+ const hasResumeState = [...this.#streams.values()].some((stream) => stream.opened || stream.openingSent)
765
+ if (hasResumeState) {
766
+ this.#failAll(new SocketductSessionLostError("Relay lost the multiplexed connection resume state", "CONNECTION_RESUME_LOST"))
767
+ return
768
+ }
769
+ this.#resuming = false
770
+ } else if (!this.#resuming && control.resumed === true) {
771
+ // The previous AUTH may have reached the relay even when AUTH_OK did not
772
+ // reach this client. Complete an empty/partial manifest safely.
773
+ this.#resuming = true
774
+ }
775
+ this.#hadAuthenticatedGeneration = true
776
+ this.#authenticated = true
777
+ void this.#finishAuthenticatedGeneration(physical).catch((error) => physical.destroy(asError(error)))
778
+ return
779
+ }
780
+ if (frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.GOAWAY) {
781
+ const detail = decodeV3Control(frame.payload)
782
+ const code = typeof detail.code === "string" ? detail.code : "AUTH_FAILED"
783
+ this.#failAll(new SocketductSessionLostError("Socketduct physical connection was rejected", code))
784
+ return
785
+ }
786
+ if (!this.#authenticated) throw new Error("V3 frame received before authentication")
787
+ if (frame.type === V3FrameType.PING) {
788
+ this.#queueControlFrame(V3FrameType.PONG, 0, frame.sequence, {})
789
+ return
790
+ }
791
+ if (frame.type === V3FrameType.PONG) {
792
+ this.#lastPongAt = Date.now()
793
+ return
794
+ }
795
+ if (frame.type === V3FrameType.RESUMED) {
796
+ if (!this.#resuming) throw new Error("Unexpected V3 resume response")
797
+ const manifest = decodeV3Control(frame.payload)
798
+ if (manifest.page !== this.#resumeNextPage || !Array.isArray(manifest.streams)) throw new Error("Invalid paginated V3 resume response")
799
+ this.#resumeNextPage += 1
800
+ this.#resumeResults.push(...manifest.streams.filter((item) => typeof item === "object" && item !== null))
801
+ if (manifest.final !== true) return
802
+ for (const item of this.#resumeResults) this.#applyResumeResult(item)
803
+ this.#resumeResults = []
804
+ this.#resuming = false
805
+ this.#reconnectSuccesses += 1
806
+ this.#roundRobinCursor = 0
807
+ for (const stream of this.#streams.values()) {
808
+ if (!stream.opened) continue
809
+ stream.queue = stream.queue.filter((entry) => entry.type !== V3FrameType.DATA)
810
+ for (const chunk of stream.replay) {
811
+ if (chunk.sequence > stream.sendAck) this.#queueData(stream, chunk)
812
+ }
813
+ if (stream.localEnded) this.#queueStreamControl(stream, V3FrameType.END, stream.sendSequence)
814
+ }
815
+ this.#completeConnection()
816
+ return
817
+ }
818
+ const stream = this.#streams.get(frame.streamId)
819
+ if (stream === undefined) {
820
+ if (frame.type !== V3FrameType.CLOSE && frame.type !== V3FrameType.RESET) this.#queueControlFrame(V3FrameType.RESET, frame.streamId, 0, {code: "UNKNOWN_STREAM"})
821
+ return
822
+ }
823
+ if (frame.type === V3FrameType.OPENED) {
824
+ const detail = decodeV3Control(frame.payload)
825
+ if (detail.identifier !== stream.identifier) throw new Error("Relay opened response identifier mismatch")
826
+ stream.opened = true
827
+ stream.state = "open"
828
+ logDiagnostic(this.#options.logger, "virtual_stream.opened", {
829
+ streamId: stream.id,
830
+ generation: this.#generation,
831
+ recovered: false
832
+ })
833
+ stream.sendWindow = typeof detail.initialWindow === "number" ? Math.min(detail.initialWindow, this.#initialWindow()) : this.#initialWindow()
834
+ void this.#persistStreamState(stream).then(() => {
835
+ if (this.#streams.get(stream.id) !== stream) return
836
+ stream.socket._markConnected()
837
+ if (!stream.queue.some((entry) => entry.type === V3FrameType.DATA)) {
838
+ for (const chunk of stream.replay) if (chunk.sequence > stream.sendAck) this.#queueData(stream, chunk)
839
+ }
840
+ this.#schedulePump()
841
+ }, (error) => stream.socket._receiveError(asError(error)))
842
+ return
843
+ }
844
+ if (frame.type === V3FrameType.DATA) {
845
+ if (frame.flags !== 0) throw new Error("V3 DATA compression was not negotiated")
846
+ const expected = stream.receiveSequence + frame.payload.length
847
+ if (frame.sequence !== expected || frame.payload.length > this.#maxFrameBytes()) throw new Error("Non-contiguous or oversized V3 stream data")
848
+ stream.receiveSequence = frame.sequence
849
+ stream.receiveUnackedBytes += frame.payload.length
850
+ if (stream.receiveUnackedBytes > this.#initialWindow()) throw new Error("V3 peer exceeded receive window")
851
+ const canContinue = stream.socket._receive(frame.payload)
852
+ if (canContinue) this.#ackReceived(stream, frame.sequence)
853
+ else stream.pendingReceiveAck = frame.sequence
854
+ return
855
+ }
856
+ if (frame.type === V3FrameType.ACK) {
857
+ this.#applyAck(stream, frame.sequence)
858
+ return
859
+ }
860
+ if (frame.type === V3FrameType.WINDOW) {
861
+ if (frame.sequence > this.#initialWindow()) throw new Error("Invalid V3 window update")
862
+ stream.sendWindow = Math.min(this.#initialWindow(), stream.sendWindow + frame.sequence)
863
+ this.#schedulePump()
864
+ return
865
+ }
866
+ if (frame.type === V3FrameType.END) {
867
+ stream.remoteEnded = true
868
+ stream.socket._receiveEnd()
869
+ return
870
+ }
871
+ if (frame.type === V3FrameType.CLOSE) {
872
+ // CLOSE is terminal but acknowledged symmetrically. Keep the reply in the
873
+ // connection control queue so VirtualSocket auto-destruction cannot drop it.
874
+ this.#queueControlFrame(V3FrameType.CLOSE, stream.id, frame.sequence, {})
875
+ stream.remoteClosed = true
876
+ stream.state = "closed"
877
+ stream.socket._receiveEnd()
878
+ return
879
+ }
880
+ if (frame.type === V3FrameType.RESET) {
881
+ const detail = decodeV3Control(frame.payload)
882
+ const code = typeof detail.code === "string" ? detail.code : "STREAM_RESET"
883
+ stream.remoteClosed = true
884
+ stream.state = "reset"
885
+ const lost = code.startsWith("RESUME_") || code === "SESSION_LOST" || code.includes("UNCERTAIN")
886
+ stream.socket._receiveError(lost
887
+ ? new SocketductSessionLostError("Virtual stream could not be resumed", code)
888
+ : new SocketductStreamError("Virtual stream was reset by relay", code))
889
+ return
890
+ }
891
+ throw new Error(`Unsupported V3 frame type from relay: ${frame.type}`)
892
+ }
893
+
894
+ /**
895
+ * Makes the authenticated generation durable before opening or resuming.
896
+ * @param {import('node:net').Socket} physical - Attempt owner.
897
+ * @returns {Promise<void>} Completion.
898
+ */
899
+ async #finishAuthenticatedGeneration(physical) {
900
+ await Promise.all([...this.#streams.values()].map((stream) => this.#persistStreamState(stream)))
901
+ if (this.#physical !== physical || physical.destroyed || !this.#authenticated) return
902
+ if (!this.#resuming) {
903
+ this.#completeConnection()
904
+ return
905
+ }
906
+ const streams = [...this.#streams.values()].filter((stream) => stream.opened || stream.openingSent).map((stream) => ({
907
+ streamId: stream.id,
908
+ identifier: stream.identifier,
909
+ clientToTargetAck: stream.sendAck,
910
+ // A surviving VirtualSocket still owns bytes accepted into its readable
911
+ // buffer. A replacement object owns only its durable acknowledgement.
912
+ targetToClientAck: stream.recoveredObject ? stream.receiveAck : stream.receiveSequence,
913
+ localEnded: stream.localEnded
914
+ }))
915
+ this.#resumeResults = []
916
+ this.#resumeNextPage = 0
917
+ const pageCount = Math.max(1, Math.ceil(streams.length / MANIFEST_PAGE_STREAMS))
918
+ for (let page = 0; page < pageCount; page += 1) {
919
+ this.#queueControlFrame(V3FrameType.RESUME, 0, this.#generation, {
920
+ page,
921
+ final: page === pageCount - 1,
922
+ streams: streams.slice(page * MANIFEST_PAGE_STREAMS, (page + 1) * MANIFEST_PAGE_STREAMS)
923
+ })
924
+ }
925
+ }
926
+
927
+ /** @param {unknown} item - Resume item. @returns {void} */
928
+ #applyResumeResult(item) {
929
+ if (typeof item !== "object" || item === null) return
930
+ const result = /** @type {{streamId?: unknown, status?: unknown, opened?: unknown, clientToTargetAck?: unknown, targetToClientAck?: unknown, code?: unknown}} */ (item)
931
+ if (typeof result.streamId !== "number") return
932
+ const stream = this.#streams.get(result.streamId)
933
+ if (stream === undefined) return
934
+ if (result.status !== "resumed") {
935
+ this.#resumeFailures += 1
936
+ stream.remoteClosed = true
937
+ stream.socket._receiveError(new SocketductSessionLostError("Virtual stream could not be resumed", typeof result.code === "string" ? result.code : "RESUME_REJECTED"))
938
+ return
939
+ }
940
+ if (typeof result.clientToTargetAck === "number") this.#applyAck(stream, result.clientToTargetAck)
941
+ if (typeof result.targetToClientAck === "number" && result.targetToClientAck > stream.receiveSequence) throw new Error("Relay resume acknowledgement exceeds received sequence")
942
+ if (typeof result.targetToClientAck === "number") {
943
+ stream.receiveAck = Math.max(stream.receiveAck, result.targetToClientAck)
944
+ stream.receiveUnackedBytes = Math.max(0, stream.receiveSequence - stream.receiveAck)
945
+ if (stream.pendingReceiveAck !== undefined && stream.pendingReceiveAck <= stream.receiveAck) stream.pendingReceiveAck = undefined
946
+ }
947
+ // Window credit belongs to a physical generation. Recompute it from the
948
+ // acknowledged durable replay set instead of retaining spent old credit.
949
+ stream.sendWindow = this.#initialWindow()
950
+ if (result.opened === true) stream.opened = true
951
+ stream.openingSent = true
952
+ stream.recoveredObject = false
953
+ void this.#persistStreamState(stream).catch((error) => stream.socket._receiveError(asError(error)))
954
+ }
955
+
956
+ /** @param {ClientStream} stream - Stream. @returns {void} */
957
+ #openStream(stream) {
958
+ if (stream.socket.destroyed || stream.opened || stream.openingSent) return
959
+ stream.openingSent = true
960
+ const open = this.#openQueue.then(() => this.#persistStreamState(stream)).then(() => {
961
+ if (this.#streams.get(stream.id) !== stream || stream.socket.destroyed) return
962
+ this.#queueControlFrame(V3FrameType.OPEN, stream.id, 0, {
963
+ identifier: stream.identifier,
964
+ name: stream.name,
965
+ target: stream.target,
966
+ initialWindow: this.#initialWindow()
967
+ })
968
+ })
969
+ this.#openQueue = open.catch(() => {})
970
+ void open.catch((error) => stream.socket._receiveError(asError(error)))
971
+ }
972
+
973
+ /**
974
+ * @param {ClientStream} stream - Stream. @param {ReplayChunk} chunk - Raw bytes. @returns {void}
975
+ * @param chunk
976
+ */
977
+ #queueData(stream, chunk) {
978
+ const wire = encodeV3Frame({type: V3FrameType.DATA, streamId: stream.id, sequence: chunk.sequence}, chunk.data)
979
+ stream.queue.push({wire, type: V3FrameType.DATA, payloadBytes: chunk.data.length})
980
+ this.#schedulePump()
981
+ }
982
+
983
+ /**
984
+ * @param {ClientStream} stream - Stream. @param {number} type - Frame type. @param {number} sequence - Position. @returns {void}
985
+ * @param type
986
+ * @param sequence
987
+ */
988
+ #queueStreamControl(stream, type, sequence) {
989
+ stream.queue.push({wire: encodeV3Frame({type, streamId: stream.id, sequence}), type, payloadBytes: 0})
990
+ this.#schedulePump()
991
+ }
992
+
993
+ /**
994
+ * @param {number} type - Frame type. @param {number} streamId - Stream ID. @param {number} sequence - Position. @param {Record<string, unknown>} value - Control body. @returns {void}
995
+ * @param streamId
996
+ * @param sequence
997
+ * @param value
998
+ */
999
+ #queueControlFrame(type, streamId, sequence, value) {
1000
+ if (this.#closing) return
1001
+ const wire = encodeV3Frame({type, streamId, sequence}, encodeV3Control(value))
1002
+ if (this.#controlQueuedBytes + wire.length > MAX_CONTROL_QUEUE_BYTES) {
1003
+ this.#failAll(new SocketductSessionLostError("Socketduct control queue limit exceeded", "CONTROL_QUEUE_LIMIT"))
1004
+ return
1005
+ }
1006
+ try {
1007
+ this.#options.aggregateBudget?.reserveControl(wire.length)
1008
+ } catch (error) {
1009
+ this.#failAll(asError(error))
1010
+ return
1011
+ }
1012
+ this.#controlQueue.push(wire)
1013
+ this.#controlQueuedBytes += wire.length
1014
+ this.#schedulePump()
1015
+ }
1016
+
1017
+ /** @returns {void} */
1018
+ #schedulePump() {
1019
+ if (this.#pumping || !this.#authenticated || this.#physical === undefined || this.#physical.destroyed) return
1020
+ this.#pumping = true
1021
+ setImmediate(() => void this.#pump())
1022
+ }
1023
+
1024
+ /** @returns {Promise<void>} */
1025
+ async #pump() {
1026
+ const ownerPhysical = this.#physical
1027
+ try {
1028
+ while (this.#authenticated && ownerPhysical !== undefined && this.#physical === ownerPhysical && !ownerPhysical.destroyed) {
1029
+ const physical = ownerPhysical
1030
+ const control = this.#controlQueue.shift()
1031
+ if (control !== undefined) {
1032
+ this.#controlQueuedBytes = Math.max(0, this.#controlQueuedBytes - control.length)
1033
+ this.#options.aggregateBudget?.releaseControl(control.length)
1034
+ if (!await writePhysical(physical, control)) break
1035
+ continue
1036
+ }
1037
+ if (this.#resuming) break
1038
+ const streams = [...this.#streams.values()]
1039
+ if (streams.length === 0) break
1040
+ let selected
1041
+ for (let index = 0; index < streams.length; index += 1) {
1042
+ const cursor = (this.#roundRobinCursor + index) % streams.length
1043
+ const candidate = /** @type {ClientStream} */ (streams[cursor])
1044
+ const frame = candidate.queue[0]
1045
+ if (frame !== undefined && candidate.opened && (frame.type !== V3FrameType.DATA || frame.payloadBytes <= candidate.sendWindow)) {
1046
+ selected = {stream: candidate, frame, next: (cursor + 1) % streams.length}
1047
+ break
1048
+ }
1049
+ }
1050
+ if (selected === undefined) break
1051
+ selected.stream.queue.shift()
1052
+ this.#roundRobinCursor = selected.next
1053
+ if (selected.frame.type === V3FrameType.DATA) selected.stream.sendWindow -= selected.frame.payloadBytes
1054
+ if (!await writePhysical(physical, selected.frame.wire)) break
1055
+ await new Promise((resolve) => setImmediate(resolve))
1056
+ }
1057
+ } finally {
1058
+ this.#pumping = false
1059
+ if (this.#controlQueue.length > 0 || (!this.#resuming && [...this.#streams.values()].some((stream) => {
1060
+ const frame = stream.queue[0]
1061
+ return stream.opened && frame !== undefined && (frame.type !== V3FrameType.DATA || frame.payloadBytes <= stream.sendWindow)
1062
+ }))) this.#schedulePump()
1063
+ }
1064
+ }
1065
+
1066
+ /**
1067
+ * @param {ClientStream} stream - Stream. @param {number} sequence - Ack. @returns {void}
1068
+ * @param sequence
1069
+ */
1070
+ #applyAck(stream, sequence) {
1071
+ if (!Number.isSafeInteger(sequence) || sequence < stream.sendAck || sequence > stream.sendSequence) throw new Error("Invalid V3 stream acknowledgement")
1072
+ if (sequence === stream.sendAck) return
1073
+ const oldAck = stream.sendAck
1074
+ stream.sendAck = sequence
1075
+ let removed = 0
1076
+ stream.replay = stream.replay.filter((chunk) => {
1077
+ if (chunk.sequence <= sequence) {
1078
+ removed += chunk.data.length
1079
+ return false
1080
+ }
1081
+ return true
1082
+ })
1083
+ stream.bufferedBytes = Math.max(0, stream.bufferedBytes - removed)
1084
+ this.#aggregateBufferedBytes = Math.max(0, this.#aggregateBufferedBytes - removed)
1085
+ this.#options.aggregateBudget?.releaseMemory(removed)
1086
+ stream.sendWindow = Math.min(this.#initialWindow(), stream.sendWindow + (sequence - oldAck))
1087
+ void this.#queueSpoolOperation(stream, async () => {
1088
+ await this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream))
1089
+ await this.#spool.truncateAcked(stream.identifier, sequence)
1090
+ if (removed > stream.spoolReservedBytes) throw new Error("Socketduct stream spool budget release underflow")
1091
+ stream.spoolReservedBytes -= removed
1092
+ this.#aggregateSpoolBytes -= removed
1093
+ this.#options.aggregateBudget?.releaseSpool(removed)
1094
+ }).catch((error) => stream.socket._receiveError(asError(error)))
1095
+ this.#schedulePump()
1096
+ }
1097
+
1098
+ /**
1099
+ * @param {ClientStream} stream - Stream. @param {number} sequence - Ack. @returns {void}
1100
+ * @param sequence
1101
+ */
1102
+ #ackReceived(stream, sequence) {
1103
+ const delta = sequence - stream.receiveAck
1104
+ if (delta <= 0) return
1105
+ stream.receiveAck = sequence
1106
+ stream.receiveUnackedBytes = Math.max(0, stream.receiveUnackedBytes - delta)
1107
+ const generation = this.#generation
1108
+ void this.#persistStreamState(stream).then(() => {
1109
+ if (this.#streams.get(stream.id) === stream && !stream.remoteClosed && this.#generation === generation && this.#authenticated && !this.#resuming) this.#queueControlFrame(V3FrameType.ACK, stream.id, sequence, {})
1110
+ }, (error) => stream.socket._receiveError(asError(error)))
1111
+ }
1112
+
1113
+ /** @returns {void} */
1114
+ #completeConnection() {
1115
+ this.#resuming = false
1116
+ this.#reconnectAttempts = 0
1117
+ this.#startHeartbeat()
1118
+ this.#setPhysicalState("ready")
1119
+ logDiagnostic(this.#options.logger, "transport.connected", {
1120
+ generation: this.#generation,
1121
+ attempt: this.#physicalAttempts,
1122
+ physicalConnects: this.#physicalConnects,
1123
+ reconnect: this.#physicalConnects > 1
1124
+ })
1125
+ if (this.#physicalConnects > 1) {
1126
+ logDiagnostic(this.#options.logger, "transport.reconnect_succeeded", {
1127
+ generation: this.#generation,
1128
+ attempt: this.#reconnectAttemptTotal,
1129
+ physicalConnects: this.#physicalConnects
1130
+ })
1131
+ }
1132
+ this.#resolveConnect?.()
1133
+ this.#settleConnect()
1134
+ for (const stream of this.#streams.values()) {
1135
+ if (!stream.opened) this.#openStream(stream)
1136
+ else stream.socket._markConnected()
1137
+ }
1138
+ this.#schedulePump()
1139
+ }
1140
+
1141
+ /** @returns {void} */
1142
+ #settleConnect() {
1143
+ this.#connectPromise = undefined
1144
+ this.#resolveConnect = undefined
1145
+ this.#rejectConnect = undefined
1146
+ }
1147
+
1148
+ /**
1149
+ * @param {Error} error - Failure. @param {boolean} retry - Whether retryable. @returns {void}
1150
+ * @param retry
1151
+ */
1152
+ #failAttempt(error, retry) {
1153
+ if (!retry || (this.#options.continuousReconnect !== true && this.#reconnectAttempts >= (this.#options.maxReconnectAttempts ?? 8))) {
1154
+ this.#failAll(error)
1155
+ return
1156
+ }
1157
+ logDiagnostic(this.#options.logger, "transport.failed", {
1158
+ generation: this.#generation,
1159
+ attempt: this.#physicalAttempts,
1160
+ ...errorDiagnostics(error)
1161
+ })
1162
+ this.#scheduleReconnect()
1163
+ }
1164
+
1165
+ /** @returns {void} */
1166
+ #scheduleReconnect() {
1167
+ if (this.#closing || this.#reconnectTimer !== undefined) return
1168
+ if (this.#options.continuousReconnect !== true && this.#reconnectAttempts >= (this.#options.maxReconnectAttempts ?? 8)) {
1169
+ this.#failAll(new SocketductSessionLostError("Socketduct reconnect limit exceeded", "RECONNECT_EXHAUSTED"))
1170
+ return
1171
+ }
1172
+ this.#setPhysicalState("reconnecting")
1173
+ const delayMs = this.#options.reconnectDelayMs ?? 25
1174
+ logDiagnostic(this.#options.logger, "transport.reconnect_scheduled", {
1175
+ generation: this.#generation,
1176
+ attempt: this.#reconnectAttemptTotal + 1,
1177
+ delayMs
1178
+ })
1179
+ this.#reconnectTimer = setTimeout(() => {
1180
+ this.#reconnectTimer = undefined
1181
+ if (this.#connectPromise === undefined) {
1182
+ this.#connectPromise = new Promise((resolve, reject) => {
1183
+ this.#resolveConnect = resolve
1184
+ this.#rejectConnect = reject
1185
+ })
1186
+ this.#connectPromise.catch(() => {})
1187
+ }
1188
+ this.#connectPhysical()
1189
+ }, delayMs)
1190
+ this.#reconnectTimer.unref?.()
1191
+ }
1192
+
1193
+ /** @param {Error} error - Terminal connection failure. @returns {void} */
1194
+ #failAll(error) {
1195
+ logDiagnostic(this.#options.logger, "transport.failed", {
1196
+ generation: this.#generation,
1197
+ attempt: this.#physicalAttempts,
1198
+ ...errorDiagnostics(error)
1199
+ })
1200
+ this.#closing = true
1201
+ this.#setPhysicalState("closed", error)
1202
+ this.#stopHeartbeat()
1203
+ this.#clearControlQueue()
1204
+ this.#physical?.destroy()
1205
+ this.#rejectConnect?.(error)
1206
+ this.#settleConnect()
1207
+ for (const stream of [...this.#streams.values()]) stream.socket._receiveError(error)
1208
+ }
1209
+
1210
+ /** @returns {number} */
1211
+ #initialWindow() { return this.#options.initialWindowBytes ?? DEFAULT_WINDOW }
1212
+
1213
+ /** @returns {number} */
1214
+ #maxFrameBytes() { return Math.min(this.#options.maxFrameBytes ?? DEFAULT_FRAME_BYTES, 64 * 1024) }
1215
+
1216
+ /** @returns {number} */
1217
+ #maxStreamBytes() { return this.#options.maxStreamBufferBytes ?? DEFAULT_STREAM_BYTES }
1218
+
1219
+ /** @returns {number} */
1220
+ #maxConnectionBytes() { return this.#options.maxConnectionBufferBytes ?? DEFAULT_CONNECTION_BYTES }
1221
+
1222
+ /** @returns {void} */
1223
+ #startHeartbeat() {
1224
+ this.#stopHeartbeat()
1225
+ const interval = this.#options.heartbeatIntervalMs ?? 30_000
1226
+ const timeout = this.#options.heartbeatTimeoutMs ?? 90_000
1227
+ this.#lastPongAt = Date.now()
1228
+ this.#heartbeatTimer = setInterval(() => {
1229
+ if (!this.#authenticated || this.#physical === undefined) return
1230
+ if (Date.now() - this.#lastPongAt > timeout) {
1231
+ this.#physical.destroy(new Error("Socketduct heartbeat timeout"))
1232
+ return
1233
+ }
1234
+ this.#pingSequence += 1
1235
+ this.#queueControlFrame(V3FrameType.PING, 0, this.#pingSequence, {})
1236
+ }, interval)
1237
+ this.#heartbeatTimer.unref?.()
1238
+ }
1239
+
1240
+ /** @returns {void} */
1241
+ #stopHeartbeat() {
1242
+ if (this.#heartbeatTimer !== undefined) clearInterval(this.#heartbeatTimer)
1243
+ this.#heartbeatTimer = undefined
1244
+ }
1245
+
1246
+ /** @returns {void} */
1247
+ #clearControlQueue() {
1248
+ if (this.#controlQueuedBytes > 0) this.#options.aggregateBudget?.releaseControl(this.#controlQueuedBytes)
1249
+ this.#controlQueue = []
1250
+ this.#controlQueuedBytes = 0
1251
+ }
1252
+
1253
+ /**
1254
+ * @param {"configured" | "connecting" | "ready" | "reconnecting" | "disconnected" | "closed"} state - New lifecycle state.
1255
+ * @param {Error} [error] - Terminal error.
1256
+ * @returns {void}
1257
+ */
1258
+ #setPhysicalState(state, error) {
1259
+ if (this.#physicalState === state) return
1260
+ this.#physicalState = state
1261
+ this.emit("stateChange", state, error)
1262
+ }
1263
+
1264
+ /** @param {import('node:net').Socket} physical - Attempt owner. @returns {void} */
1265
+ #clearAttemptTimeout(physical) {
1266
+ const timer = this.#attemptTimers.get(physical)
1267
+ if (timer !== undefined) clearTimeout(timer)
1268
+ this.#attemptTimers.delete(physical)
1269
+ }
1270
+
1271
+ /** @param {ClientStream} stream - Stream. @returns {ClientRecoveryState} */
1272
+ #recoveryState(stream) {
1273
+ return {
1274
+ version: 3,
1275
+ connectionId: this.#connectionId,
1276
+ generation: this.#generation,
1277
+ streamId: stream.id,
1278
+ identifier: stream.identifier,
1279
+ name: stream.name,
1280
+ target: stream.target,
1281
+ opened: stream.opened,
1282
+ openingSent: stream.openingSent,
1283
+ localEnded: stream.localEnded,
1284
+ sendSequence: stream.sendSequence,
1285
+ sendAck: stream.sendAck,
1286
+ receiveSequence: stream.receiveSequence,
1287
+ receiveAck: stream.receiveAck
1288
+ }
1289
+ }
1290
+
1291
+ /** @param {ClientStream} stream - Stream. @returns {Promise<void>} */
1292
+ async #persistStreamState(stream) {
1293
+ await this.#queueSpoolOperation(stream, () => this.#spool.writeRecoveryState(stream.identifier, this.#recoveryState(stream)))
1294
+ }
1295
+
1296
+ /**
1297
+ * @param {ClientStream} stream - Stream spool owner. @param {() => Promise<void>} operation - Serialized operation. @returns {Promise<void>}
1298
+ * @param operation
1299
+ */
1300
+ async #queueSpoolOperation(stream, operation) {
1301
+ const next = stream.spoolQueue.then(async () => {
1302
+ await stream.spoolReady
1303
+ await operation()
1304
+ })
1305
+ stream.spoolQueue = next.catch(() => {})
1306
+ await next
1307
+ }
1308
+
1309
+ /** @param {ClientStream} stream - Stream spool owner. @returns {Promise<void>} */
1310
+ async #removeSpool(stream) {
1311
+ if (stream.spoolRemovalPromise === undefined) {
1312
+ stream.spoolRemovalQueued = true
1313
+ stream.spoolRemovalPromise = this.#queueSpoolOperation(stream, async () => {
1314
+ await this.#spool.remove(stream.identifier)
1315
+ const removed = stream.spoolReservedBytes
1316
+ stream.spoolReservedBytes = 0
1317
+ this.#aggregateSpoolBytes -= removed
1318
+ this.#options.aggregateBudget?.releaseSpool(removed)
1319
+ })
1320
+ }
1321
+ await stream.spoolRemovalPromise
1322
+ }
1323
+
1324
+ /** @param {ClientStream} stream - Destroyed spool owner. @returns {void} */
1325
+ #trackSpoolCleanup(stream) {
1326
+ const removal = stream.ownsSpool
1327
+ ? this.#removeSpool(stream)
1328
+ : stream.spoolReady.then(() => stream.ownsSpool ? this.#removeSpool(stream) : undefined)
1329
+ const task = removal.catch((cleanupError) => {
1330
+ const error = asError(cleanupError)
1331
+ this.#handleSpoolCleanupFailure(stream, error)
1332
+ throw error
1333
+ })
1334
+ this.#spoolCleanupTasks.add(task)
1335
+ void task.catch(() => {}).finally(() => this.#spoolCleanupTasks.delete(task))
1336
+ }
1337
+
1338
+ /**
1339
+ * @param {ClientStream} stream - Failed spool owner.
1340
+ * @param {Error} error - Durable cleanup failure.
1341
+ * @returns {void}
1342
+ */
1343
+ #handleSpoolCleanupFailure(stream, error) {
1344
+ if (stream.spoolCleanupFailureRecorded) return
1345
+ stream.spoolCleanupFailureRecorded = true
1346
+ this.#spoolCleanupFailures += 1
1347
+ if (!this.#closing) {
1348
+ const lifecycleError = new SocketductSessionLostError("Socketduct durable spool cleanup failed", "SPOOL_CLEANUP_FAILED")
1349
+ lifecycleError.cause = error
1350
+ this.#failAll(lifecycleError)
1351
+ }
1352
+ }
1353
+ }
1354
+
1355
+ /**
1356
+ * Constructs a reusable protocol-v3 physical connection. No network resource is
1357
+ * allocated until its first virtual stream is opened.
1358
+ * @param {MultiplexedConnectionOptions} options - Connection options.
1359
+ * @returns {MultiplexedSocketductConnection} Shared transport owner.
1360
+ */
1361
+ export function createMultiplexedSocketductConnection(options) {
1362
+ return new MultiplexedSocketductConnection(options)
1363
+ }
1364
+
1365
+ /** @param {MultiplexedConnectionOptions} options - Options. @returns {void} */
1366
+ function validateConnectionOptions(options) {
1367
+ if (typeof options !== "object" || options === null || typeof options.relay?.host !== "string" || !validPort(options.relay?.port) || typeof options.relay?.token !== "string" || options.relay.token.length === 0) {
1368
+ throw new TypeError("Multiplexed Socketduct relay host, port, and token are required")
1369
+ }
1370
+ if (options.connectionId !== undefined && !STABLE_IDENTIFIER.test(options.connectionId)) throw new TypeError("connectionId must be 8-128 URL-safe opaque characters")
1371
+ if (options.continuousReconnect !== undefined && typeof options.continuousReconnect !== "boolean") throw new TypeError("continuousReconnect must be a boolean")
1372
+ for (const [name, value] of Object.entries({
1373
+ initialWindowBytes: options.initialWindowBytes,
1374
+ maxFrameBytes: options.maxFrameBytes,
1375
+ maxStreamBufferBytes: options.maxStreamBufferBytes,
1376
+ maxConnectionBufferBytes: options.maxConnectionBufferBytes,
1377
+ maxStreams: options.maxStreams,
1378
+ maxReconnectAttempts: options.maxReconnectAttempts,
1379
+ connectTimeoutMs: options.connectTimeoutMs,
1380
+ maxAgeMs: options.maxAgeMs,
1381
+ heartbeatIntervalMs: options.heartbeatIntervalMs,
1382
+ heartbeatTimeoutMs: options.heartbeatTimeoutMs
1383
+ })) {
1384
+ if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) throw new RangeError(`${name} must be a positive integer`)
1385
+ }
1386
+ if ((options.tls?.certFile === undefined) !== (options.tls?.keyFile === undefined)) throw new Error("TLS certFile and keyFile must both be set")
1387
+ if ((options.heartbeatTimeoutMs ?? 90_000) <= (options.heartbeatIntervalMs ?? 30_000)) throw new RangeError("heartbeatTimeoutMs must exceed heartbeatIntervalMs")
1388
+ if ((options.maxFrameBytes ?? DEFAULT_FRAME_BYTES) > (options.initialWindowBytes ?? DEFAULT_WINDOW)) throw new RangeError("maxFrameBytes must not exceed initialWindowBytes")
1389
+ if ((options.initialWindowBytes ?? DEFAULT_WINDOW) > (options.maxStreamBufferBytes ?? DEFAULT_STREAM_BYTES)) throw new RangeError("initialWindowBytes must not exceed maxStreamBufferBytes")
1390
+ }
1391
+
1392
+ /**
1393
+ * @param {Record<string, unknown>} value - Parsed durable state.
1394
+ * @param {{connectionId: string, identifier: string, target: {host: string, port: number}, sessionName?: string}} expected - Caller-owned identity.
1395
+ * @returns {ClientRecoveryState} Validated recovery state.
1396
+ */
1397
+ function validateRecoveryState(value, expected) {
1398
+ const state = /** @type {Partial<ClientRecoveryState>} */ (value)
1399
+ const sequences = [state.generation, state.streamId, state.sendSequence, state.sendAck, state.receiveSequence, state.receiveAck]
1400
+ const validSequences = sequences.every((sequence) => Number.isSafeInteger(sequence) && /** @type {number} */ (sequence) >= 0)
1401
+ const validTarget = state.target?.host === expected.target.host && state.target?.port === expected.target.port
1402
+ if (state.version !== 3 || state.connectionId !== expected.connectionId || state.identifier !== expected.identifier || !validSequences || state.generation === 0 || state.streamId === 0 || /** @type {number} */ (state.streamId) % 2 === 0 || /** @type {number} */ (state.streamId) > 0xffff_fffd || typeof state.name !== "string" || state.name.length === 0 || state.name.length > 256 || !validTarget || typeof state.opened !== "boolean" || state.openingSent !== true || typeof state.localEnded !== "boolean" || /** @type {number} */ (state.sendAck) > /** @type {number} */ (state.sendSequence) || /** @type {number} */ (state.receiveAck) > /** @type {number} */ (state.receiveSequence) || (expected.sessionName !== undefined && state.name !== expected.sessionName)) {
1403
+ throw new Error("Invalid Socketduct recovery identity or sequence state")
1404
+ }
1405
+ return /** @type {ClientRecoveryState} */ (state)
1406
+ }
1407
+
1408
+ /**
1409
+ * @param {ClientStream} stream - Recovered owner.
1410
+ * @param {import('../spool/spool-file.js').SpoolChunk[]} chunks - Durable chunks after ACK.
1411
+ * @param {number} maxBytes - Per-stream bound.
1412
+ * @returns {void}
1413
+ */
1414
+ function validateRecoveredChunks(stream, chunks, maxBytes) {
1415
+ let sequence = stream.sendAck
1416
+ let bytes = 0
1417
+ for (const chunk of chunks) {
1418
+ bytes += chunk.data.length
1419
+ if (chunk.data.length === 0 || chunk.sequence !== sequence + chunk.data.length || chunk.sequence > stream.sendSequence) throw new Error("Invalid Socketduct recovery chunk sequence")
1420
+ sequence = chunk.sequence
1421
+ }
1422
+ if (sequence !== stream.sendSequence || bytes > maxBytes) throw new Error("Invalid or oversized Socketduct recovery replay state")
1423
+ }
1424
+
1425
+ /** @param {VirtualConnectionOptions} options - Options. @returns {{host: string, port: number}} */
1426
+ function normalizeTarget(options) {
1427
+ const target = options.target ?? {host: options.host, port: options.port}
1428
+ if (typeof target.host !== "string" || target.host.length === 0 || target.host.length > 253 || !validPort(target.port)) throw new TypeError("Virtual connection target host and port are required")
1429
+ return {host: target.host, port: target.port}
1430
+ }
1431
+
1432
+ /** @param {unknown} port - Port. @returns {port is number} */
1433
+ function validPort(port) { return typeof port === "number" && Number.isInteger(port) && port >= 1 && port <= 65_535 }
1434
+
1435
+ /** @param {{caFile?: string, certFile?: string, keyFile?: string, servername?: string, rejectUnauthorized?: boolean}} options - TLS files. @returns {Record<string, unknown>} */
1436
+ function resolveTls(options) {
1437
+ return {
1438
+ rejectUnauthorized: options.rejectUnauthorized !== false,
1439
+ ...(options.caFile === undefined ? {} : {ca: readFileSync(options.caFile)}),
1440
+ ...(options.certFile === undefined ? {} : {cert: readFileSync(options.certFile)}),
1441
+ ...(options.keyFile === undefined ? {} : {key: readFileSync(options.keyFile)}),
1442
+ ...(options.servername === undefined ? {} : {servername: options.servername})
1443
+ }
1444
+ }
1445
+
1446
+ /** @param {{host: string, port: number, tls?: Record<string, unknown>}} options - Transport. @returns {import('node:net').Socket} */
1447
+ function createPhysicalSocket(options) {
1448
+ return options.tls === undefined ? netConnect({host: options.host, port: options.port}) : tlsConnect({host: options.host, port: options.port, ...options.tls})
1449
+ }
1450
+
1451
+ /**
1452
+ * @param {import('node:net').Socket} socket - Physical socket. @param {Buffer} bytes - Frame. @returns {Promise<boolean>}
1453
+ * @param bytes
1454
+ */
1455
+ async function writePhysical(socket, bytes) {
1456
+ if (socket.destroyed || !socket.writable) return false
1457
+ if (socket.write(bytes)) return true
1458
+ return await new Promise((resolve) => {
1459
+ const cleanup = () => {
1460
+ socket.off("drain", onDrain)
1461
+ socket.off("close", onClose)
1462
+ }
1463
+ const onDrain = () => { cleanup(); resolve(true) }
1464
+ const onClose = () => { cleanup(); resolve(false) }
1465
+ socket.once("drain", onDrain)
1466
+ socket.once("close", onClose)
1467
+ })
1468
+ }
1469
+
1470
+ /**
1471
+ * @param {VirtualSocket} socket - Socket. @param {(error: Error | null, socket: VirtualSocket) => void} callback - Callback. @returns {void}
1472
+ * @param callback
1473
+ */
1474
+ function installConnectCallback(socket, callback) {
1475
+ let settled = false
1476
+ const cleanup = () => {
1477
+ socket.off("connect", onConnect)
1478
+ socket.off("error", onError)
1479
+ }
1480
+ const onConnect = () => {
1481
+ if (settled) return
1482
+ settled = true
1483
+ cleanup()
1484
+ callback(null, socket)
1485
+ }
1486
+ /** @param {Error} error - Pre-connect error. */
1487
+ const onError = (error) => {
1488
+ if (settled) return
1489
+ settled = true
1490
+ cleanup()
1491
+ callback(error, socket)
1492
+ }
1493
+ socket.once("connect", onConnect)
1494
+ socket.once("error", onError)
1495
+ }
1496
+
1497
+ /** @param {unknown} value - Thrown value. @returns {Error} */
1498
+ function asError(value) { return value instanceof Error ? value : new Error(String(value)) }
1499
+
1500
+ /** @param {Error} error - Transport error. @returns {boolean} */
1501
+ function isFatalTransportError(error) {
1502
+ const code = /** @type {{code?: unknown}} */ (error).code
1503
+ return typeof code === "string" && (code.startsWith("ERR_TLS_") || code.startsWith("ERR_SSL_") || code.includes("CERT"))
1504
+ }