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,1174 @@
1
+ import {Duplex} from "node:stream"
2
+ import {createConnection} from "node:net"
3
+ import {connect as tlsConnect} from "node:tls"
4
+ import {readFileSync} from "node:fs"
5
+
6
+ import {errorDiagnostics, logDiagnostic} from "../logging/structured-logger.js"
7
+ import {AdaptiveCompressor, availableCodecs, decodeStreamPayload, encodeStreamPayload, getMaxDecompressedBytes} from "../protocol/compression.js"
8
+ import {encodeCompactAck, encodeCompactData, encodeFrame, FrameDecoder} from "../protocol/frame.js"
9
+ import {SessionSpool as SessionSpoolStorage} from "../spool/spool-file.js"
10
+
11
+ const RELAY_CONNECT_TIMEOUT_MS = 10_000
12
+ const SESSION_CLOSE_TIMEOUT_MS = 1_000
13
+ export const kCloseSocketductSession = Symbol("closeSocketductSession")
14
+
15
+ export class SocketductRelayError extends Error {
16
+ /**
17
+ * @param {string} message - Safe relay-provided error message.
18
+ * @param {{code?: string, retryable?: boolean}} [details] - Machine-readable relay error details.
19
+ */
20
+ constructor(message, details = {}) {
21
+ super(message)
22
+ this.name = "SocketductRelayError"
23
+ this.code = details.code ?? "RELAY_ERROR"
24
+ this.retryable = details.retryable ?? false
25
+ }
26
+ }
27
+
28
+ /**
29
+ * @param {unknown} err - Error to inspect.
30
+ * @returns {boolean} - True when the error is a TLS verification/auth failure
31
+ * that should not be retried (wrong CA, hostname mismatch, self-signed,
32
+ * expired/revoked cert, or equivalent).
33
+ */
34
+ function isTlsVerificationError(err) {
35
+ if (!(err instanceof Error)) return false
36
+ const code = /** @type {string | undefined} */ ((/** @type {{code?: unknown}} */ (err)).code)
37
+ if (typeof code !== "string") return false
38
+ if (code.startsWith("ERR_TLS_") || code.startsWith("ERR_SSL_")) return true
39
+ // OpenSSL error codes surfaced by Node.js TLS for cert/verify failures
40
+ if (code.includes("CERT_") || code.includes("_SIGNED_CERT") || code.startsWith("UNABLE_TO_")) return true
41
+ return false
42
+ }
43
+
44
+ /**
45
+ * @typedef {import('../spool/spool-file.js').SessionSpool} SessionSpool
46
+ * @typedef {import('../spool/spool-file.js').SessionSpoolOptions} SessionSpoolOptions
47
+ * @typedef {{host: string, port: number}} SocketductTarget
48
+ * @typedef {object} SocketductTlsOptions
49
+ * @property {string} [caFile] - Path to CA certificate file for server verification.
50
+ * @property {string} [certFile] - Path to client certificate file for mTLS (requires keyFile).
51
+ * @property {string} [keyFile] - Path to client private key file for mTLS (requires certFile).
52
+ * @property {string} [servername] - SNI hostname for certificate identity validation.
53
+ * @property {boolean} [rejectUnauthorized] - Whether to reject unauthorized certificates. Defaults to true.
54
+ * @typedef {object} SocketductSocketOptions
55
+ * @property {string} relayHost - Relay host.
56
+ * @property {number} relayPort - Relay port.
57
+ * @property {string} token - Auth token.
58
+ * @property {string} sessionName - Human-readable session name.
59
+ * @property {SocketductTarget} target - Target requested for this session.
60
+ * @property {SessionSpool} spool - Spool storage.
61
+ * @property {number} [reconnectDelayMs] - Reconnect delay.
62
+ * @property {SocketductTlsOptions} [tls] - TLS client options.
63
+ * @property {boolean} [compressionEnabled] - Whether to offer stream.data compression when the runtime supports it. Default true.
64
+ * @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - Compression size and expansion-ratio bounds.
65
+ * @property {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional privacy-safe structured diagnostic logger.
66
+ * @typedef {object} SocketductSocketDependencies
67
+ * @property {(options: {host: string, port: number, tls?: Record<string, unknown>}) => import('node:net').Socket} [createRelaySocket] - Raw relay socket factory.
68
+ * @property {(callback: () => void, delay: number) => unknown} [setTimeout] - Timer factory.
69
+ * @property {(timer: unknown) => void} [clearTimeout] - Timer cleanup.
70
+ * @typedef {object} ConnectSocketductOptions
71
+ * @property {{host: string, port: number, token: string}} relay - Relay connection options.
72
+ * @property {string} sessionName - Human-readable session name.
73
+ * @property {SocketductTarget} target - Target requested for this session.
74
+ * @property {string} spoolDirectory - Spool directory.
75
+ * @property {{initialDelayMs?: number}} [reconnect] - Reconnect options.
76
+ * @property {SocketductTlsOptions} [tls] - TLS client options.
77
+ * @property {number} [maxBytes] - Maximum total spool bytes per session.
78
+ * @property {number} [maxAgeMs] - Maximum age of a spool session before operations are rejected.
79
+ * @property {boolean} [compressionEnabled] - Whether to offer stream.data compression when the runtime supports it. Default true.
80
+ * @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - Compression size and expansion-ratio bounds.
81
+ * @property {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional privacy-safe structured diagnostic logger.
82
+ */
83
+
84
+ /**
85
+ * @param {ConnectSocketductOptions} options - Public socket options.
86
+ * @param {() => void} [listener] - Optional connect listener.
87
+ * @returns {SocketductSocket} - Connected Socketduct socket.
88
+ */
89
+ export function connectSocketduct(options, listener) {
90
+ /** @type {SessionSpoolOptions} */
91
+ const spoolOptions = {rootDir: options.spoolDirectory}
92
+ if (options.maxBytes !== undefined) {
93
+ spoolOptions.maxBytes = options.maxBytes
94
+ }
95
+ if (options.maxAgeMs !== undefined) {
96
+ spoolOptions.maxAgeMs = options.maxAgeMs
97
+ }
98
+ /** @type {SocketductSocketOptions} */
99
+ const socketOptions = {
100
+ relayHost: options.relay.host,
101
+ relayPort: options.relay.port,
102
+ token: options.relay.token,
103
+ sessionName: options.sessionName,
104
+ target: options.target,
105
+ spool: new SessionSpoolStorage(spoolOptions)
106
+ }
107
+ if (options.reconnect?.initialDelayMs !== undefined) {
108
+ socketOptions.reconnectDelayMs = options.reconnect.initialDelayMs
109
+ }
110
+ if (options.tls !== undefined) {
111
+ socketOptions.tls = options.tls
112
+ }
113
+ if (options.compressionEnabled !== undefined) {
114
+ socketOptions.compressionEnabled = options.compressionEnabled
115
+ }
116
+ if (options.compressionLimits !== undefined) {
117
+ socketOptions.compressionLimits = options.compressionLimits
118
+ }
119
+ if (options.logger !== undefined) {
120
+ socketOptions.logger = options.logger
121
+ }
122
+ const socket = new SocketductSocket(socketOptions)
123
+ if (listener !== undefined) {
124
+ socket.once("connect", listener)
125
+ }
126
+ socket.connect()
127
+ return socket
128
+ }
129
+
130
+ export class SocketductSocket extends Duplex {
131
+ /** @type {SocketductSocketOptions} */
132
+ #options
133
+
134
+ /** @type {import('node:net').Socket | undefined} */
135
+ #relaySocket
136
+
137
+ /** @type {Record<string, unknown> | undefined} */
138
+ #tlsContext
139
+
140
+ /** @type {string | undefined} */
141
+ #sessionId
142
+
143
+ /** @type {number} */
144
+ #sequence = 0
145
+
146
+ /** @type {number} */
147
+ #ackedSequence = 0
148
+
149
+ /** @type {number} */
150
+ #targetReceivedSequence = 0
151
+
152
+ /** @type {boolean} */
153
+ #relayWritable = false
154
+
155
+ /** @type {import('node:net').Socket | undefined} */
156
+ #authenticatedRelaySocket
157
+
158
+ /** Codec negotiated on the current relay connection; undefined means uncompressed stream data. */
159
+ /** @type {string | undefined} */
160
+ #codec
161
+
162
+ /** @type {number | undefined} */
163
+ #framing
164
+
165
+ /** @type {AdaptiveCompressor | undefined} */
166
+ #compressor
167
+
168
+ /** @type {boolean} */
169
+ #closed = false
170
+
171
+ /** @type {boolean} */
172
+ #applicationEof = false
173
+
174
+ /** @type {boolean} */
175
+ #spoolCleanedUp = false
176
+
177
+ /** @type {boolean} */
178
+ #connectStarted = false
179
+
180
+ /** @type {boolean} */
181
+ #connectEmitted = false
182
+
183
+ /** @type {number} */
184
+ #physicalAttempts = 0
185
+
186
+ /** @type {number} */
187
+ #physicalConnects = 0
188
+
189
+ /** @type {number} */
190
+ #generation = 0
191
+
192
+ /** @type {unknown} */
193
+ #reconnectTimer
194
+
195
+ /** @type {((options: {destroySocket: boolean, reconnect: boolean, error?: unknown}) => void) | undefined} */
196
+ #terminateRelayAttempt
197
+
198
+ /** @type {(options: {host: string, port: number, tls?: Record<string, unknown>}) => import('node:net').Socket} */
199
+ #createRelaySocket
200
+
201
+ /** @type {(callback: () => void, delay: number) => unknown} */
202
+ #setTimeout
203
+
204
+ /** @type {(timer: unknown) => void} */
205
+ #clearTimeout
206
+
207
+ /** @type {unknown} */
208
+ #timeoutTimer
209
+
210
+ /** @type {number} */
211
+ #timeoutMs = 0
212
+
213
+ /** @type {Promise<void>} */
214
+ #spoolQueue = Promise.resolve()
215
+
216
+ /** @type {Promise<void>} */
217
+ #relayFrameQueue = Promise.resolve()
218
+
219
+ /** @type {Promise<void | boolean>} */
220
+ #sendQueue = Promise.resolve()
221
+
222
+ /** @type {Promise<boolean> | undefined} */
223
+ #sessionClosePromise
224
+
225
+ /** @type {((sent: boolean) => void) | undefined} */
226
+ #resolveSessionClose
227
+
228
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
229
+ #sessionCloseTimer
230
+
231
+ /** @type {import('node:net').Socket | undefined} */
232
+ #sessionCloseRelay
233
+
234
+ /** @type {boolean} */
235
+ #sessionCloseAcknowledged = false
236
+
237
+ /** @type {number} */
238
+ bytesRead = 0
239
+
240
+ /** @type {number} */
241
+ bytesWritten = 0
242
+
243
+ /** @type {boolean} */
244
+ connecting = false
245
+
246
+ /** @type {boolean} */
247
+ pending = true
248
+
249
+ /** @type {string | undefined} */
250
+ remoteAddress
251
+
252
+ /** @type {number | undefined} */
253
+ remotePort
254
+
255
+ /** @type {string | undefined} */
256
+ localAddress
257
+
258
+ /** @type {number | undefined} */
259
+ localPort
260
+
261
+ /** @returns {string | undefined} - Current session identifier. */
262
+ get sessionIdentifier() {
263
+ return this.#sessionId
264
+ }
265
+
266
+ /**
267
+ * @param {SocketductSocketOptions} options - Socket options.
268
+ * @param {SocketductSocketDependencies} [dependencies] - Runtime dependencies.
269
+ */
270
+ constructor(options, dependencies = {}) {
271
+ super()
272
+ getMaxDecompressedBytes(options.compressionLimits)
273
+ this.#options = options
274
+ this.#createRelaySocket = dependencies.createRelaySocket ?? ((relayOptions) => {
275
+ if (relayOptions.tls !== undefined) {
276
+ return tlsConnect({
277
+ host: relayOptions.host,
278
+ port: relayOptions.port,
279
+ ...relayOptions.tls
280
+ })
281
+ }
282
+ return createConnection({host: relayOptions.host, port: relayOptions.port})
283
+ })
284
+ this.#setTimeout = dependencies.setTimeout ?? ((callback, delay) => setTimeout(callback, delay))
285
+ this.#clearTimeout = dependencies.clearTimeout ?? ((timer) => clearTimeout(/** @type {ReturnType<typeof setTimeout>} */ (timer)))
286
+ this.remoteAddress = options.target.host
287
+ this.remotePort = options.target.port
288
+ }
289
+
290
+ /**
291
+ * Starts the relay connection.
292
+ * @returns {this} - This socket.
293
+ */
294
+ connect() {
295
+ if (this.#connectStarted || this.destroyed) {
296
+ return this
297
+ }
298
+
299
+ this.#connectStarted = true
300
+ this.connecting = true
301
+ logDiagnostic(this.#options.logger, "virtual_stream.opening")
302
+ this.#connectRelay()
303
+ return this
304
+ }
305
+
306
+ /**
307
+ * @param {number} timeout - Timeout in milliseconds.
308
+ * @param {() => void} [callback] - Timeout callback.
309
+ * @returns {this} - This socket.
310
+ */
311
+ setTimeout(timeout, callback) {
312
+ if (callback !== undefined) {
313
+ this.once("timeout", callback)
314
+ }
315
+ this.#timeoutMs = timeout
316
+ this.#clearTimeoutTimer()
317
+ if (timeout > 0) {
318
+ this.#armTimeoutTimer()
319
+ }
320
+ return this
321
+ }
322
+
323
+ /** @returns {void} - Clears the current idle timeout. */
324
+ #clearTimeoutTimer() {
325
+ if (this.#timeoutTimer !== undefined) {
326
+ this.#clearTimeout(this.#timeoutTimer)
327
+ this.#timeoutTimer = undefined
328
+ }
329
+ }
330
+
331
+ /** @returns {void} - Arms the idle timeout, resetting it on each call. */
332
+ #armTimeoutTimer() {
333
+ this.#clearTimeoutTimer()
334
+ if (this.#timeoutMs > 0) {
335
+ const timer = this.#setTimeout(() => {
336
+ if (this.#timeoutTimer !== timer) return
337
+ this.#timeoutTimer = undefined
338
+ this.emit("timeout")
339
+ }, this.#timeoutMs)
340
+ this.#timeoutTimer = timer
341
+ const unref = /** @type {{unref?: () => void}} */ (timer)?.unref
342
+ unref?.call(timer)
343
+ }
344
+ }
345
+
346
+ /** @returns {void} - Resets the idle timeout on socket activity. */
347
+ #resetTimeoutTimer() {
348
+ if (this.#timeoutMs > 0) {
349
+ this.#armTimeoutTimer()
350
+ }
351
+ }
352
+
353
+ /** @returns {this} - This socket. */
354
+ setNoDelay() {
355
+ return this
356
+ }
357
+
358
+ /** @returns {this} - This socket. */
359
+ setKeepAlive() {
360
+ return this
361
+ }
362
+
363
+ /** @returns {{address: string, family: string, port: number}} - Socket address. */
364
+ address() {
365
+ return {
366
+ address: this.localAddress ?? "0.0.0.0",
367
+ family: "IPv4",
368
+ port: this.localPort ?? 0
369
+ }
370
+ }
371
+
372
+ /** @returns {this} - This socket. */
373
+ ref() {
374
+ this.#relaySocket?.ref()
375
+ return this
376
+ }
377
+
378
+ /** @returns {this} - This socket. */
379
+ unref() {
380
+ this.#relaySocket?.unref()
381
+ return this
382
+ }
383
+
384
+ /**
385
+ * Sends the logical session close control exactly once when the relay is
386
+ * available. Transport owners use this before destroying a wrapped socket.
387
+ * @returns {Promise<boolean>} True when the close frame reached the relay socket.
388
+ */
389
+ [kCloseSocketductSession]() {
390
+ if (this.#sessionClosePromise !== undefined) return this.#sessionClosePromise
391
+ this.#closed = true
392
+ this.#relayWritable = false
393
+ const relaySocket = this.#relaySocket
394
+ if (this.#sessionId === undefined) {
395
+ if (this.#reconnectTimer !== undefined) {
396
+ this.#clearTimeout(this.#reconnectTimer)
397
+ this.#reconnectTimer = undefined
398
+ }
399
+ this.#sessionClosePromise = Promise.resolve(false)
400
+ return this.#sessionClosePromise
401
+ }
402
+ this.#sessionClosePromise = new Promise((resolve) => {
403
+ this.#resolveSessionClose = resolve
404
+ this.#sessionCloseTimer = setTimeout(() => this.#settleSessionClose(false), SESSION_CLOSE_TIMEOUT_MS)
405
+ this.#sessionCloseTimer.unref?.()
406
+ })
407
+ if (relaySocket !== undefined && this.#authenticatedRelaySocket === relaySocket && !relaySocket.destroyed) {
408
+ this.#sendSessionClose(relaySocket)
409
+ } else if (relaySocket === undefined || relaySocket.destroyed) {
410
+ if (this.#reconnectTimer !== undefined) {
411
+ this.#clearTimeout(this.#reconnectTimer)
412
+ this.#reconnectTimer = undefined
413
+ }
414
+ this.#connectRelay()
415
+ }
416
+ return this.#sessionClosePromise
417
+ }
418
+
419
+ /**
420
+ * Sends close only on the relay connection that has authenticated and
421
+ * completed create/resume. A failed transport remains eligible for the
422
+ * bounded close-only reconnect until the close acknowledgement deadline.
423
+ * @param {import('node:net').Socket} relaySocket - Authenticated relay socket.
424
+ */
425
+ #sendSessionClose(relaySocket) {
426
+ if (!this.#needsRelayForClose() || this.#relaySocket !== relaySocket || this.#authenticatedRelaySocket !== relaySocket || relaySocket.destroyed || this.#sessionCloseRelay === relaySocket) return
427
+ this.#sessionCloseRelay = relaySocket
428
+ void this.#writeFrameToRelay(relaySocket, {type: "session.close", direction: "client-to-relay"})
429
+ .then((sent) => {
430
+ if (!sent && this.#relaySocket === relaySocket && !relaySocket.destroyed) relaySocket.destroy()
431
+ })
432
+ .catch((error) => relaySocket.destroy(error instanceof Error ? error : new Error(String(error))))
433
+ }
434
+
435
+ /** @returns {boolean} Whether destruction is waiting for an authenticated logical close. */
436
+ #needsRelayForClose() {
437
+ return this.#closed && this.#sessionId !== undefined && this.#resolveSessionClose !== undefined && !this.#sessionCloseAcknowledged
438
+ }
439
+
440
+ /** @returns {boolean} Whether a normal or close-only relay attempt may run. */
441
+ #mayConnectRelay() {
442
+ return (!this.#closed && !this.destroyed) || this.#needsRelayForClose()
443
+ }
444
+
445
+ /** Ends the application-facing readable side exactly once. */
446
+ #endApplicationReadable() {
447
+ if (this.#applicationEof) return
448
+ this.#applicationEof = true
449
+ if (!this.destroyed) this.push(null)
450
+ }
451
+
452
+ /** @param {boolean} sent */
453
+ #settleSessionClose(sent) {
454
+ if (this.#resolveSessionClose === undefined) return
455
+ const resolve = this.#resolveSessionClose
456
+ this.#resolveSessionClose = undefined
457
+ if (this.#sessionCloseTimer !== undefined) {
458
+ clearTimeout(this.#sessionCloseTimer)
459
+ this.#sessionCloseTimer = undefined
460
+ }
461
+ this.#sessionCloseRelay = undefined
462
+ resolve(sent)
463
+ }
464
+
465
+ /**
466
+ * @override
467
+ * @returns {void} - Pulls data from the internal buffer.
468
+ */
469
+ _read() {
470
+ this.#relaySocket?.resume()
471
+ }
472
+
473
+ /**
474
+ * @override
475
+ * @param {Buffer} chunk - Data chunk.
476
+ * @param {string} _encoding - Encoding value.
477
+ * @param {(error?: Error | null) => void} callback - Completion callback.
478
+ * @returns {void} - Writes chunk to Socketduct.
479
+ */
480
+ _write(chunk, _encoding, callback) {
481
+ this.#resetTimeoutTimer()
482
+ this.#handleAppData(chunk)
483
+ .then(() => callback())
484
+ .catch((error) => callback(error instanceof Error ? error : new Error(String(error))))
485
+ }
486
+
487
+ /**
488
+ * Node calls this only for writes already queued behind an in-flight async
489
+ * write. Merge those original bytes without delaying a sole chunk.
490
+ * @override
491
+ * @param {{chunk: Buffer, encoding: string}[]} chunks - Already queued writes.
492
+ * @param {(error?: Error | null) => void} callback - Completion callback.
493
+ */
494
+ _writev(chunks, callback) {
495
+ this.#resetTimeoutTimer()
496
+ const maxBytes = getMaxDecompressedBytes(this.#options.compressionLimits)
497
+ const groups = []
498
+ let pending = []
499
+ let pendingBytes = 0
500
+ for (const entry of chunks) {
501
+ let offset = 0
502
+ while (offset < entry.chunk.length) {
503
+ const available = maxBytes - pendingBytes
504
+ const length = Math.min(available, entry.chunk.length - offset)
505
+ pending.push(entry.chunk.subarray(offset, offset + length))
506
+ pendingBytes += length
507
+ offset += length
508
+ if (pendingBytes === maxBytes) {
509
+ groups.push(Buffer.concat(pending, pendingBytes))
510
+ pending = []
511
+ pendingBytes = 0
512
+ }
513
+ }
514
+ }
515
+ if (pendingBytes > 0) groups.push(Buffer.concat(pending, pendingBytes))
516
+ groups.reduce((work, group) => work.then(() => this.#handleAppData(group)), Promise.resolve())
517
+ .then(() => callback())
518
+ .catch((error) => callback(error instanceof Error ? error : new Error(String(error))))
519
+ }
520
+
521
+ /**
522
+ * @override
523
+ * @param {(error?: Error | null) => void} callback - Completion callback.
524
+ * @returns {void} - Ends the socket.
525
+ */
526
+ _final(callback) {
527
+ this.#closed = true
528
+ if (this.#reconnectTimer !== undefined) {
529
+ this.#clearTimeout(this.#reconnectTimer)
530
+ this.#reconnectTimer = undefined
531
+ }
532
+ this.#relayWritable = false
533
+ const relaySocket = this.#relaySocket
534
+ if (relaySocket !== undefined && !relaySocket.destroyed && this.#sessionId !== undefined) {
535
+ this[kCloseSocketductSession]()
536
+ .then(() => {
537
+ relaySocket.destroy()
538
+ return this.#cleanupSpool()
539
+ })
540
+ .then(() => {
541
+ this.#endApplicationReadable()
542
+ callback()
543
+ })
544
+ .catch((error) => callback(error))
545
+ } else {
546
+ if (this.#terminateRelayAttempt !== undefined) {
547
+ this.#terminateRelayAttempt({destroySocket: true, reconnect: false})
548
+ } else {
549
+ relaySocket?.destroy()
550
+ }
551
+ this.#cleanupSpool()
552
+ .then(() => {
553
+ this.#endApplicationReadable()
554
+ callback()
555
+ })
556
+ .catch((error) => callback(error))
557
+ }
558
+ }
559
+
560
+ /**
561
+ * @override
562
+ * @param {Error | null} error - Destroy error.
563
+ * @param {(error?: Error | null) => void} callback - Completion callback.
564
+ * @returns {void} - Destroys resources.
565
+ */
566
+ _destroy(error, callback) {
567
+ this.#applicationEof = true
568
+ this.#closed = true
569
+ this.#relayWritable = false
570
+ this.connecting = false
571
+ this.pending = false
572
+ logDiagnostic(this.#options.logger, error === null ? "virtual_stream.closed" : "virtual_stream.error", error === null ? {} : errorDiagnostics(error))
573
+ if (this.#reconnectTimer !== undefined) {
574
+ this.#clearTimeout(this.#reconnectTimer)
575
+ this.#reconnectTimer = undefined
576
+ }
577
+ if (this.#timeoutTimer !== undefined) {
578
+ this.#clearTimeout(this.#timeoutTimer)
579
+ this.#timeoutTimer = undefined
580
+ }
581
+ const relaySocket = this.#relaySocket
582
+ this[kCloseSocketductSession]()
583
+ .then(() => {
584
+ if (this.#terminateRelayAttempt !== undefined) {
585
+ this.#terminateRelayAttempt({destroySocket: true, reconnect: false})
586
+ } else {
587
+ relaySocket?.destroy()
588
+ }
589
+ return this.#cleanupSpool()
590
+ })
591
+ .then(() => callback(error))
592
+ .catch((cleanupError) => {
593
+ callback(error ?? cleanupError)
594
+ })
595
+ }
596
+
597
+ /**
598
+ * @returns {Promise<void>} - Resolves after spool directory is removed.
599
+ */
600
+ async #cleanupSpool() {
601
+ if (this.#spoolCleanedUp) {
602
+ return
603
+ }
604
+ this.#spoolCleanedUp = true
605
+ await this.#spoolQueue
606
+ await this.#relayFrameQueue.catch(() => {})
607
+ const sessionId = this.#sessionId
608
+ if (sessionId === undefined) {
609
+ return
610
+ }
611
+ await this.#options.spool.remove(sessionId)
612
+ }
613
+
614
+ /** @returns {{ca?: Buffer, cert?: Buffer, key?: Buffer, servername?: string}} - Resolved TLS context. */
615
+ #resolveTlsContext() {
616
+ if (this.#tlsContext !== undefined) {
617
+ return /** @type {{ca?: Buffer, cert?: Buffer, key?: Buffer, servername?: string}} */ (this.#tlsContext)
618
+ }
619
+
620
+ const tls = /** @type {SocketductTlsOptions} */ (this.#options.tls)
621
+ /** @type {{ca?: Buffer, cert?: Buffer, key?: Buffer, servername?: string}} */
622
+ const context = {}
623
+
624
+ if (tls.servername !== undefined) {
625
+ context.servername = tls.servername
626
+ }
627
+
628
+ if (tls.caFile !== undefined) {
629
+ context.ca = readFileSync(tls.caFile)
630
+ }
631
+
632
+ if (tls.certFile !== undefined || tls.keyFile !== undefined) {
633
+ if (tls.certFile === undefined || tls.keyFile === undefined) {
634
+ throw new Error("TLS client certFile and keyFile must both be set (atomic pair)")
635
+ }
636
+ context.cert = readFileSync(tls.certFile)
637
+ context.key = readFileSync(tls.keyFile)
638
+ }
639
+
640
+ this.#tlsContext = context
641
+ return context
642
+ }
643
+
644
+ /** @returns {void} - Opens a relay connection. */
645
+ #connectRelay() {
646
+ if (!this.#mayConnectRelay()) {
647
+ return
648
+ }
649
+
650
+ this.#physicalAttempts += 1
651
+ const reconnect = this.#physicalAttempts > 1 || this.#sessionId !== undefined
652
+ logDiagnostic(this.#options.logger, "transport.connecting", {
653
+ generation: this.#generation,
654
+ attempt: this.#physicalAttempts,
655
+ reconnect
656
+ })
657
+
658
+ /** @type {import('node:net').Socket} */
659
+ let relaySocket
660
+
661
+ const tlsEnabled = this.#options.tls !== undefined
662
+ try {
663
+ if (tlsEnabled) {
664
+ const context = this.#resolveTlsContext()
665
+ relaySocket = this.#createRelaySocket({
666
+ host: this.#options.relayHost,
667
+ port: this.#options.relayPort,
668
+ tls: {
669
+ ...context,
670
+ rejectUnauthorized: this.#options.tls?.rejectUnauthorized !== false
671
+ }
672
+ })
673
+ } else {
674
+ relaySocket = this.#createRelaySocket({host: this.#options.relayHost, port: this.#options.relayPort})
675
+ }
676
+ } catch (error) {
677
+ logDiagnostic(this.#options.logger, "transport.failed", {
678
+ generation: this.#generation,
679
+ attempt: this.#physicalAttempts,
680
+ ...errorDiagnostics(error)
681
+ })
682
+ this.destroy(error instanceof Error ? error : new Error(String(error)))
683
+ return
684
+ }
685
+
686
+ this.#relaySocket = relaySocket
687
+ // Compression is negotiated per connection; assume raw until the relay agrees.
688
+ this.#codec = undefined
689
+ this.#framing = undefined
690
+ this.#compressor = undefined
691
+ const decoder = new FrameDecoder({compactDirection: "target-to-client"})
692
+ const readinessEvent = tlsEnabled ? "secureConnect" : "connect"
693
+ let attemptTerminated = false
694
+ /** @type {unknown} */
695
+ let connectTimer
696
+
697
+ const clearConnectTimer = () => {
698
+ if (connectTimer !== undefined) {
699
+ this.#clearTimeout(connectTimer)
700
+ connectTimer = undefined
701
+ }
702
+ }
703
+ const removeAttemptListeners = () => {
704
+ relaySocket.off(readinessEvent, onReady)
705
+ relaySocket.off("data", onData)
706
+ relaySocket.off("close", onClose)
707
+ relaySocket.off("error", onError)
708
+ }
709
+ const ignoreLateError = () => {}
710
+ /** @param {{destroySocket: boolean, reconnect: boolean, error?: unknown}} options */
711
+ const terminateAttempt = ({destroySocket, reconnect, error}) => {
712
+ if (attemptTerminated) {
713
+ return
714
+ }
715
+ attemptTerminated = true
716
+ clearConnectTimer()
717
+ removeAttemptListeners()
718
+ relaySocket.on("error", ignoreLateError)
719
+ if (this.#terminateRelayAttempt === terminateAttempt) {
720
+ this.#terminateRelayAttempt = undefined
721
+ }
722
+ if (this.#relaySocket === relaySocket) {
723
+ this.#relaySocket = undefined
724
+ }
725
+ if (this.#authenticatedRelaySocket === relaySocket) {
726
+ this.#authenticatedRelaySocket = undefined
727
+ }
728
+ if (this.#sessionCloseRelay === relaySocket) {
729
+ this.#sessionCloseRelay = undefined
730
+ }
731
+ this.#relayWritable = false
732
+ if (destroySocket && !relaySocket.destroyed) {
733
+ relaySocket.destroy()
734
+ }
735
+ if (reconnect && !this.#closed) {
736
+ logDiagnostic(this.#options.logger, "transport.disconnected", {
737
+ generation: this.#generation,
738
+ virtualStreams: this.destroyed ? 0 : 1,
739
+ ...errorDiagnostics(error, "TRANSPORT_CLOSED")
740
+ })
741
+ }
742
+ if (reconnect && this.#mayConnectRelay()) {
743
+ if (this.#needsRelayForClose()) {
744
+ this.#connectRelay()
745
+ } else {
746
+ this.#scheduleReconnect(error)
747
+ }
748
+ }
749
+ }
750
+ const onReady = () => {
751
+ if (attemptTerminated) {
752
+ return
753
+ }
754
+ clearConnectTimer()
755
+ relaySocket.off(readinessEvent, onReady)
756
+ this.localAddress = relaySocket.localAddress
757
+ this.localPort = relaySocket.localPort
758
+ const offer = this.#offeredCodecs()
759
+ const header = this.#sessionId === undefined
760
+ ? {
761
+ type: "session.create",
762
+ token: this.#options.token,
763
+ name: this.#options.sessionName,
764
+ target: this.#options.target,
765
+ ...(offer.length > 0 ? {compression: offer} : {}),
766
+ framing: [2]
767
+ }
768
+ : {
769
+ type: "session.resume",
770
+ token: this.#options.token,
771
+ identifier: this.#sessionId,
772
+ targetToClientAck: this.#targetReceivedSequence,
773
+ ...(offer.length > 0 ? {compression: offer} : {}),
774
+ framing: [2]
775
+ }
776
+ this.#writeFrameToRelay(relaySocket, header).catch((error) => this.destroy(error))
777
+ }
778
+ /** @param {Buffer} chunk */
779
+ const onData = (chunk) => {
780
+ if (attemptTerminated) {
781
+ return
782
+ }
783
+ let frames
784
+ try {
785
+ frames = decoder.push(chunk)
786
+ } catch {
787
+ relaySocket.destroy()
788
+ return
789
+ }
790
+
791
+ for (const frame of frames) {
792
+ this.#relayFrameQueue = this.#relayFrameQueue
793
+ .then(() => this.#handleRelayFrame(frame.header, frame.payload, frame.wireFormat, relaySocket))
794
+ .catch((error) => { this.destroy(error instanceof Error ? error : new Error(String(error))) })
795
+ }
796
+ }
797
+ const onClose = () => {
798
+ if (attemptTerminated) {
799
+ return
800
+ }
801
+ terminateAttempt({destroySocket: false, reconnect: true})
802
+ }
803
+ /** @param {Error} err */
804
+ const onError = (err) => {
805
+ if (attemptTerminated) {
806
+ return
807
+ }
808
+ if (isTlsVerificationError(err)) {
809
+ logDiagnostic(this.#options.logger, "transport.failed", {
810
+ generation: this.#generation,
811
+ attempt: this.#physicalAttempts,
812
+ ...errorDiagnostics(err)
813
+ })
814
+ terminateAttempt({destroySocket: true, reconnect: false, error: err})
815
+ this.destroy(err)
816
+ this.#settleSessionClose(false)
817
+ return
818
+ }
819
+ terminateAttempt({destroySocket: true, reconnect: true, error: err})
820
+ }
821
+
822
+ this.#terminateRelayAttempt = terminateAttempt
823
+ relaySocket.on(readinessEvent, onReady)
824
+ relaySocket.on("data", onData)
825
+ relaySocket.on("close", onClose)
826
+ relaySocket.on("error", onError)
827
+ connectTimer = this.#setTimeout(() => {
828
+ terminateAttempt({destroySocket: true, reconnect: true, error: Object.assign(new Error("Socketduct relay connection timed out"), {code: "ETIMEDOUT"})})
829
+ }, RELAY_CONNECT_TIMEOUT_MS)
830
+ if (connectTimer && typeof connectTimer === "object" && "unref" in connectTimer && typeof connectTimer.unref === "function") {
831
+ connectTimer.unref()
832
+ }
833
+ }
834
+
835
+ /**
836
+ * @param {import('../protocol/frame.js').FrameHeader} header - Frame header.
837
+ * @param {Buffer} payload - Frame payload.
838
+ * @param {"compact" | "envelope"} wireFormat - Decoder-proven wire format.
839
+ * @param {import('node:net').Socket} relaySocket - Relay socket that produced this frame.
840
+ * @returns {Promise<void>} - Resolves after handling.
841
+ */
842
+ async #handleRelayFrame(header, payload, wireFormat, relaySocket) {
843
+ const staleRelayFrame = this.#relaySocket !== relaySocket
844
+
845
+ if (staleRelayFrame) {
846
+ return
847
+ }
848
+
849
+ if (header.compact === true && this.#framing !== 2) {
850
+ throw new Error("Relay sent compact framing without negotiating version 2")
851
+ }
852
+
853
+ if (header.type === "session.created" || header.type === "session.resumed") {
854
+ if (header.framing !== 2) {
855
+ throw new Error("Relay did not select required framing version 2")
856
+ }
857
+ if (typeof header.identifier !== "string") {
858
+ throw new Error("Relay did not provide a session identifier")
859
+ }
860
+ const identifier = header.identifier
861
+ if (header.type === "session.created") {
862
+ this.#targetReceivedSequence = 0
863
+ await this.#options.spool.create({sessionId: identifier, ownerId: "edge"})
864
+ }
865
+ const selectedCodec = typeof header.compression === "string" ? header.compression : undefined
866
+ this.#codec = this.#offeredCodecs().includes(selectedCodec ?? "") ? selectedCodec : undefined
867
+ this.#framing = 2
868
+ this.#compressor = this.#codec === undefined ? undefined : new AdaptiveCompressor()
869
+ this.#sessionId = identifier
870
+ if (typeof header.serverLastAck === "number") {
871
+ this.#ackedSequence = Math.max(this.#ackedSequence, header.serverLastAck)
872
+ await this.#queueSpoolOperation(() => this.#options.spool.truncateAcked(identifier, this.#ackedSequence))
873
+ }
874
+ this.connecting = false
875
+ this.pending = false
876
+ this.#relayWritable = true
877
+ this.#authenticatedRelaySocket = relaySocket
878
+ this.#generation += 1
879
+ this.#physicalConnects += 1
880
+ const resumed = header.type === "session.resumed" || this.#physicalConnects > 1
881
+ logDiagnostic(this.#options.logger, "transport.connected", {
882
+ generation: this.#generation,
883
+ attempt: this.#physicalAttempts,
884
+ physicalConnects: this.#physicalConnects,
885
+ reconnect: resumed
886
+ })
887
+ if (resumed) {
888
+ logDiagnostic(this.#options.logger, "transport.reconnect_succeeded", {
889
+ generation: this.#generation,
890
+ attempt: this.#physicalAttempts,
891
+ physicalConnects: this.#physicalConnects
892
+ })
893
+ }
894
+ if (this.#needsRelayForClose()) {
895
+ this.#sendSessionClose(relaySocket)
896
+ return
897
+ }
898
+ await this.#queueSpoolOperation(() => this.#flushSpooledChunks(relaySocket))
899
+ if (this.#relaySocket !== relaySocket || relaySocket.destroyed) {
900
+ this.#relayWritable = false
901
+ return
902
+ }
903
+ if (!this.#connectEmitted) {
904
+ this.#connectEmitted = true
905
+ logDiagnostic(this.#options.logger, "virtual_stream.opened", {generation: this.#generation, recovered: false})
906
+ this.emit("connect")
907
+ }
908
+ this.emit("ready")
909
+ return
910
+ }
911
+
912
+ if (header.type === "error") {
913
+ const message = typeof header.message === "string" ? header.message : "Socketduct relay error"
914
+ const code = typeof header.code === "string" ? header.code : undefined
915
+ const retryable = typeof header.retryable === "boolean" ? header.retryable : undefined
916
+ this.destroy(new SocketductRelayError(message, {
917
+ ...(code === undefined ? {} : {code}),
918
+ ...(retryable === undefined ? {} : {retryable})
919
+ }))
920
+ return
921
+ }
922
+
923
+ if ((header.type === "stream.data" || header.type === "stream.ack") && wireFormat !== "compact") {
924
+ throw new Error(`Relay sent length-prefixed ${header.type} after framing version 2 was established`)
925
+ }
926
+
927
+ if (header.type === "stream.ack") {
928
+ if (typeof header.ackSequence === "number" && this.#sessionId !== undefined) {
929
+ this.#ackedSequence = Math.max(this.#ackedSequence, header.ackSequence)
930
+ await this.#queueSpoolOperation(() => this.#options.spool.truncateAcked(this.#sessionId ?? "", this.#ackedSequence))
931
+ }
932
+ return
933
+ }
934
+
935
+ if (header.type === "stream.data" && header.direction === "target-to-client") {
936
+ const sequence = header.sequence
937
+ if (typeof sequence !== "number" || !Number.isSafeInteger(sequence) || sequence <= 0) {
938
+ throw new Error("Relay sent stream.data without a valid sequence")
939
+ }
940
+
941
+ const data = await decodeStreamPayload(header, payload, this.#codec, this.#options.compressionLimits ?? {})
942
+
943
+ if (this.#applicationEof || this.destroyed) {
944
+ return
945
+ }
946
+
947
+ // Duplicate replay after a resume: already delivered, so drop it silently.
948
+ if (sequence <= this.#targetReceivedSequence) {
949
+ return
950
+ }
951
+
952
+ // Sequences are cumulative uncompressed byte offsets, so a contiguous frame
953
+ // ends exactly one payload past the last one. Fail closed on a gap or overlap
954
+ // rather than acknowledging bytes we never delivered.
955
+ const expectedSequence = this.#targetReceivedSequence + data.length
956
+ if (sequence !== expectedSequence) {
957
+ const detail = sequence < expectedSequence ? "overlap" : "gap"
958
+ throw new Error(`Relay stream.data sequence ${sequence} creates a ${detail} (expected contiguous offset ${expectedSequence})`)
959
+ }
960
+
961
+ this.#targetReceivedSequence = sequence
962
+ this.bytesRead += data.length
963
+ this.#resetTimeoutTimer()
964
+ if (!this.push(data)) {
965
+ relaySocket.pause()
966
+ }
967
+ await this.#writeFrameToRelay(relaySocket, {type: "stream.ack", direction: "target-to-client", ackSequence: sequence})
968
+ return
969
+ }
970
+
971
+ if (header.type === "session.close" && header.direction === "target-to-client") {
972
+ this.#relayWritable = false
973
+ // Settle after this queued frame handler releases #relayFrameQueue; destroy
974
+ // cleanup awaits that queue before removing the spool.
975
+ if (header.acknowledged === true) {
976
+ this.#sessionCloseAcknowledged = true
977
+ setImmediate(() => this.#settleSessionClose(true))
978
+ }
979
+ this.#endApplicationReadable()
980
+ }
981
+ }
982
+
983
+ /**
984
+ * @param {Buffer} chunk - App data chunk.
985
+ * @returns {Promise<void>} - Resolves after spooling.
986
+ */
987
+ async #handleAppData(chunk) {
988
+ if (!this.#connectStarted) {
989
+ this.connect()
990
+ }
991
+
992
+ if (this.#sessionId === undefined) {
993
+ await this.#waitForSession()
994
+ }
995
+
996
+ if (this.#closed || this.destroyed) {
997
+ return
998
+ }
999
+
1000
+ if (this.#sessionId === undefined) {
1001
+ throw new Error("Socketduct session is not available")
1002
+ }
1003
+
1004
+ // Sequences, spool offsets and byte counters stay in uncompressed units, and
1005
+ // the spool always stores raw chunks so a replay can be re-encoded later.
1006
+ this.#sequence += chunk.length
1007
+ this.bytesWritten += chunk.length
1008
+ await this.#queueSpoolOperation(() => this.#options.spool.appendChunk({
1009
+ sessionId: this.#sessionId ?? "",
1010
+ ownerId: "edge",
1011
+ sequence: this.#sequence,
1012
+ data: chunk
1013
+ }))
1014
+
1015
+ if (this.#relayWritable) {
1016
+ const {header, payload} = await this.#buildStreamDataFrame(this.#sequence, chunk)
1017
+ await this.#writeFrame(header, payload)
1018
+ }
1019
+ }
1020
+
1021
+ /** @returns {string[]} - Codecs this client offers, empty when compression is disabled or unsupported. */
1022
+ #offeredCodecs() {
1023
+ if (this.#options.compressionEnabled === false) {
1024
+ return []
1025
+ }
1026
+ return availableCodecs()
1027
+ }
1028
+
1029
+ /**
1030
+ * @param {number} sequence - Uncompressed cumulative byte offset of this chunk.
1031
+ * @param {Buffer} chunk - Raw payload bytes.
1032
+ * @returns {Promise<{header: import('../protocol/frame.js').FrameHeader, payload: Buffer}>} - Frame to send.
1033
+ */
1034
+ async #buildStreamDataFrame(sequence, chunk) {
1035
+ const encoded = await encodeStreamPayload(chunk, this.#codec, this.#compressor, this.#options.compressionLimits ?? {})
1036
+ /** @type {import('../protocol/frame.js').FrameHeader} */
1037
+ const header = {type: "stream.data", direction: "client-to-target", session: this.#sessionId, sequence}
1038
+ if (encoded.encoding !== undefined) {
1039
+ header.encoding = encoded.encoding
1040
+ header.dataLength = encoded.dataLength
1041
+ }
1042
+ return {header, payload: encoded.payload}
1043
+ }
1044
+
1045
+ /**
1046
+ * @param {() => Promise<void>} operation - Spool operation to serialize.
1047
+ * @returns {Promise<void>} - Resolves after the operation.
1048
+ */
1049
+ async #queueSpoolOperation(operation) {
1050
+ const next = this.#spoolQueue.then(operation)
1051
+ this.#spoolQueue = next.catch(() => {})
1052
+ await next
1053
+ }
1054
+
1055
+ /** @returns {Promise<void>} - Resolves when a session is available. */
1056
+ async #waitForSession() {
1057
+ while (!this.#closed && !this.destroyed && this.#sessionId === undefined) {
1058
+ await new Promise((resolve) => { setTimeout(resolve, 5) })
1059
+ }
1060
+ }
1061
+
1062
+ /**
1063
+ * @param {import('node:net').Socket} relaySocket - Authenticated relay socket that should receive replayed frames.
1064
+ * @returns {Promise<void>} - Sends unacknowledged spool chunks.
1065
+ */
1066
+ async #flushSpooledChunks(relaySocket) {
1067
+ if (this.#sessionId === undefined) {
1068
+ return
1069
+ }
1070
+
1071
+ let lastSentSequence = this.#ackedSequence
1072
+ while (!this.#closed && !this.destroyed) {
1073
+ const chunks = await this.#options.spool.readChunksAfter(this.#sessionId, lastSentSequence)
1074
+ if (chunks.length === 0) {
1075
+ return
1076
+ }
1077
+
1078
+ for (const chunk of chunks) {
1079
+ const {header, payload} = await this.#buildStreamDataFrame(chunk.sequence, chunk.data)
1080
+ const sent = await this.#writeFrameToRelay(relaySocket, header, payload)
1081
+ if (!sent) {
1082
+ return
1083
+ }
1084
+ lastSentSequence = Math.max(lastSentSequence, chunk.sequence)
1085
+ }
1086
+ }
1087
+ }
1088
+
1089
+ /**
1090
+ * @param {import('node:net').Socket} relaySocket - Relay socket that should receive the frame.
1091
+ * @param {import('../protocol/frame.js').FrameHeader} header - Frame header.
1092
+ * @param {Buffer} [payload] - Frame payload.
1093
+ * @returns {Promise<boolean>} - Resolves true when sent, false if the relay was interrupted.
1094
+ */
1095
+ async #writeFrameToRelay(relaySocket, header, payload = Buffer.alloc(0)) {
1096
+ const operation = this.#sendQueue.then(() => this.#sendFrameToRelayNow(relaySocket, header, payload))
1097
+ this.#sendQueue = operation.catch(() => {})
1098
+ return operation
1099
+ }
1100
+
1101
+ /**
1102
+ * @param {import('../protocol/frame.js').FrameHeader} header - Frame header.
1103
+ * @param {Buffer} [payload] - Frame payload.
1104
+ * @returns {Promise<boolean>} - Resolves true when sent, false if the relay was interrupted.
1105
+ */
1106
+ async #writeFrame(header, payload = Buffer.alloc(0)) {
1107
+ const relaySocket = this.#relaySocket
1108
+ if (relaySocket === undefined) {
1109
+ return false
1110
+ }
1111
+ return this.#writeFrameToRelay(relaySocket, header, payload)
1112
+ }
1113
+
1114
+ /**
1115
+ * @param {import('node:net').Socket} relaySocket - Relay socket that should receive the frame.
1116
+ * @param {import('../protocol/frame.js').FrameHeader} header - Frame header.
1117
+ * @param {Buffer} [payload] - Frame payload.
1118
+ * @returns {Promise<boolean>} - Resolves true when sent, false if the relay was interrupted.
1119
+ */
1120
+ async #sendFrameToRelayNow(relaySocket, header, payload = Buffer.alloc(0)) {
1121
+ if (this.#relaySocket !== relaySocket || relaySocket.destroyed) {
1122
+ return false
1123
+ }
1124
+
1125
+ let encodedFrame
1126
+ if (this.#framing === 2 && header.type === "stream.data") {
1127
+ encodedFrame = encodeCompactData({sequence: /** @type {number} */ (header.sequence), payload, ...(header.encoding === "zstd" ? {dataLength: /** @type {number} */ (header.dataLength)} : {})})
1128
+ } else if (this.#framing === 2 && header.type === "stream.ack") {
1129
+ encodedFrame = encodeCompactAck(/** @type {number} */ (header.ackSequence))
1130
+ } else {
1131
+ encodedFrame = encodeFrame(header, payload)
1132
+ }
1133
+ if (!relaySocket.write(encodedFrame)) {
1134
+ return new Promise((resolve) => {
1135
+ const onDrain = () => {
1136
+ cleanup()
1137
+ resolve(true)
1138
+ }
1139
+ const onClose = () => {
1140
+ cleanup()
1141
+ resolve(false)
1142
+ }
1143
+ const cleanup = () => {
1144
+ relaySocket.off("drain", onDrain)
1145
+ relaySocket.off("close", onClose)
1146
+ }
1147
+ relaySocket.once("drain", onDrain)
1148
+ relaySocket.once("close", onClose)
1149
+ })
1150
+ }
1151
+ return true
1152
+ }
1153
+
1154
+ /**
1155
+ * @param {unknown} [error] - Safe-to-classify reconnect cause.
1156
+ * @returns {void} Schedules a reconnect.
1157
+ */
1158
+ #scheduleReconnect(error) {
1159
+ if (!this.#mayConnectRelay() || this.#reconnectTimer) {
1160
+ return
1161
+ }
1162
+ const delayMs = this.#options.reconnectDelayMs ?? 25
1163
+ logDiagnostic(this.#options.logger, "transport.reconnect_scheduled", {
1164
+ generation: this.#generation,
1165
+ attempt: this.#physicalAttempts,
1166
+ delayMs,
1167
+ ...errorDiagnostics(error, "TRANSPORT_CLOSED")
1168
+ })
1169
+ this.#reconnectTimer = this.#setTimeout(() => {
1170
+ this.#reconnectTimer = undefined
1171
+ this.#connectRelay()
1172
+ }, delayMs)
1173
+ }
1174
+ }