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,1191 @@
1
+ import {createConnection, createServer} from "node:net"
2
+ import {createServer as createTlsServer} from "node:tls"
3
+ import {readFileSync} from "node:fs"
4
+
5
+ import {AdaptiveCompressor, availableCodecs, decodeStreamPayload, encodeStreamPayload, getMaxDecompressedBytes} from "../protocol/compression.js"
6
+ import {encodeCompactAck, encodeCompactData, encodeFrame, FrameDecoder} from "../protocol/frame.js"
7
+ import {handleControlMessage} from "../server/control-handler.js"
8
+
9
+ /**
10
+ * @typedef {import('../protocol/compression.js').CompressionLimits} CompressionLimits
11
+ * @typedef {import('../sessions/session.js').SessionStore} SessionStore
12
+ * @typedef {{host: string, port: number}} SessionTarget
13
+ * @typedef {{userId: string, username: string}} Principal
14
+ * @typedef {object} TokenAuthenticator
15
+ * @property {(value: string) => Principal | undefined} authenticate - Authenticate a bearer token.
16
+ * @typedef {{sequence: number, chunk: Buffer}} SentChunk
17
+ * @typedef {object} TcpRelayOptions
18
+ * @property {string} [host] - Relay listen host.
19
+ * @property {number} [port] - Relay listen port.
20
+ * @property {number} [resumeWindowMs] - How long to keep session target sockets after tunnel drops.
21
+ * @property {number} [maxReplayBytes] - Maximum bytes of unacknowledged target-to-client data retained for replay. Default 16 MiB.
22
+ * @property {number} [maxTargetBufferBytes] - Per-session byte limit for buffered target data while the tunnel is detached. Default 1 MiB.
23
+ * @property {number} [maxTotalBufferBytes] - Process-wide byte limit for buffered target data across all sessions. Default 16 MiB.
24
+ * @property {number} [initialTargetRetryDelayMs] - Initial target acquisition retry delay. Default 250ms.
25
+ * @property {number} [initialTargetRetryMaxDelayMs] - Maximum initial target acquisition retry delay. Default 5000ms.
26
+ * @property {number} [initialTargetConnectTimeoutMs] - Maximum time for one initial target TCP connect attempt. Default 10000ms.
27
+ * @property {number} [handshakeTimeoutMs] - Milliseconds to wait for auth after TCP/TLS connect before closing the socket. Default 10000.
28
+ * @property {number} [maxInputBytesPerConnection] - Per-connection input byte budget before the first authenticated session frame. Default 65536.
29
+ * @property {boolean} [compressionEnabled] - Whether to negotiate stream.data compression when the runtime supports it. Default true.
30
+ * @property {CompressionLimits} [compressionLimits] - Compression size and expansion-ratio bounds.
31
+ * @property {(input: {principal: Principal, target: SessionTarget}) => boolean} authorizeTarget - Target authorization policy.
32
+ * @property {TokenAuthenticator} tokens - Token store.
33
+ * @property {SessionStore} sessions - Session store.
34
+ * @property {(options: {host: string, port: number}) => import('node:net').Socket} [createTargetConnection] - Target socket factory.
35
+ * @property {{certFile: string, keyFile: string, clientCaFile?: string}} [tls] - TLS configuration.
36
+ * @typedef {object} TcpRelayHandle
37
+ * @property {import('node:tls').Server | import('node:net').Server} server - Relay server.
38
+ * @property {number} port - Bound relay port.
39
+ * @property {() => {activeTunnels: number, activeTargets: number, sessions: number, attachedSessions: number, detachedSessions: number, bufferedTargetBytes: number, targetConnectionAttempts: number, targetRetryTimers: number}} status - Safe runtime counters.
40
+ * @property {() => Promise<void>} closeActiveTunnel - Closes active tunnel sockets after their relay-side close handlers ran.
41
+ * @property {() => Promise<void>} close - Close function.
42
+ */
43
+
44
+ /** @typedef {{active: boolean, announced: boolean}} SessionLifecycle */
45
+
46
+ /**
47
+ * @param {TcpRelayOptions} options - Relay options.
48
+ * @returns {Promise<TcpRelayHandle>} - Started relay.
49
+ */
50
+ export async function startTcpRelay(options) {
51
+ /** @type {Set<import('node:net').Socket>} */
52
+ const tunnels = new Set()
53
+ /** @type {Map<import('node:net').Socket, {promise?: Promise<void>}>} */
54
+ const tunnelCloseSettlements = new Map()
55
+ /** @type {Set<import('node:net').Socket>} */
56
+ const targets = new Set()
57
+ /** @type {Map<string, import('node:net').Socket>} */
58
+ const sessionTargets = new Map()
59
+ /** @type {Map<string, import('node:net').Socket>} */
60
+ const sessionInitialTargetAttempts = new Map()
61
+ /** @type {Map<string, ReturnType<typeof setTimeout>>} */
62
+ const sessionInitialTargetRetryTimers = new Map()
63
+ /** @type {Map<string, ReturnType<typeof setTimeout>>} */
64
+ const sessionInitialTargetConnectTimers = new Map()
65
+ /**
66
+ * Active tunnel send functions, one per session. Each session has at most one
67
+ * owning tunnel at a time. On resume the new tunnel's sendFrame replaces the
68
+ * old one atomically.
69
+ *
70
+ * **Sender-capture rule**: functions that deliver target data MUST capture
71
+ * the sender from this map at enqueue time, not at delivery time, because
72
+ * `queueSessionSend` defers execution. A target data event observed with a
73
+ * sender must use that sender; an event observed detached (sender undefined)
74
+ * must buffer. Resume replay/flush captures the resumed sender that owns its
75
+ * queued operation so stale work cannot adopt a later tunnel.
76
+ * @type {Map<string, (header: import('../protocol/frame.js').FrameHeader, payload?: Buffer) => Promise<boolean>>}
77
+ */
78
+ const sessionSenders = new Map()
79
+ /** @type {Map<string, Buffer[]>} */
80
+ const sessionTargetBuffers = new Map()
81
+ /** @type {Map<string, SentChunk[]>} */
82
+ const sessionTargetSentChunks = new Map()
83
+ /** @type {Map<string, ReturnType<typeof setTimeout>>} */
84
+ const sessionTargetExpiryTimers = new Map()
85
+ /** @type {Map<string, import('../protocol/frame.js').FrameHeader>} */
86
+ const sessionTargetTerminalState = new Map()
87
+
88
+ /** @type {Map<string, number>} */
89
+ const sessionPendingClientSequence = new Map()
90
+
91
+ /** Codec negotiated on the connection currently serving each session. */
92
+ /** @type {Map<string, string>} */
93
+ const sessionSendCodec = new Map()
94
+
95
+ /** @type {Map<string, AdaptiveCompressor>} */
96
+ const sessionCompressor = new Map()
97
+
98
+ /** @type {Map<string, {tail: Promise<void>}>} */
99
+ const sessionSendQueue = new Map()
100
+
101
+ /** @type {Map<string, {lifecycle: SessionLifecycle, sender: (header: import('../protocol/frame.js').FrameHeader, payload?: Buffer) => Promise<boolean>, chunks: Buffer[], bytes: number}>} */
102
+ const sessionQueuedTargetBatch = new Map()
103
+
104
+ /** Object identity prevents stale async work from attaching to a reused identifier. */
105
+ /** @type {Map<string, SessionLifecycle>} */
106
+ const sessionLifecycles = new Map()
107
+
108
+ /** @type {Map<string, number>} */
109
+ const sessionTargetBufferBytes = new Map()
110
+ let totalProcessBufferBytes = 0
111
+
112
+ const compressionLimits = options.compressionLimits ?? {}
113
+ getMaxDecompressedBytes(compressionLimits)
114
+ const supportedCodecs = options.compressionEnabled === false ? [] : availableCodecs()
115
+
116
+ const maxReplayBytes = options.maxReplayBytes ?? (16 * 1024 * 1024)
117
+ const maxTargetBufferBytes = options.maxTargetBufferBytes ?? (1_048_576)
118
+ const maxTotalBufferBytes = options.maxTotalBufferBytes ?? (16_777_216)
119
+ const initialTargetRetryDelayMs = options.initialTargetRetryDelayMs ?? 250
120
+ const initialTargetRetryMaxDelayMs = options.initialTargetRetryMaxDelayMs ?? 5_000
121
+ const initialTargetConnectTimeoutMs = options.initialTargetConnectTimeoutMs ?? 10_000
122
+ const createTargetConnection = options.createTargetConnection ?? ((target) => createConnection(target))
123
+ const handshakeTimeoutMs = options.handshakeTimeoutMs ?? 10_000
124
+ const maxInputBytesPerConnection = options.maxInputBytesPerConnection ?? 65_536
125
+ if (!Number.isSafeInteger(initialTargetRetryDelayMs) || initialTargetRetryDelayMs <= 0) {
126
+ throw new Error("initialTargetRetryDelayMs must be a positive integer")
127
+ }
128
+ if (!Number.isSafeInteger(initialTargetRetryMaxDelayMs) || initialTargetRetryMaxDelayMs < initialTargetRetryDelayMs) {
129
+ throw new Error("initialTargetRetryMaxDelayMs must be an integer greater than or equal to initialTargetRetryDelayMs")
130
+ }
131
+ if (!Number.isSafeInteger(initialTargetConnectTimeoutMs) || initialTargetConnectTimeoutMs <= 0) {
132
+ throw new Error("initialTargetConnectTimeoutMs must be a positive integer")
133
+ }
134
+ let closing = false
135
+
136
+ const tlsEnabled = options.tls !== undefined
137
+ /** @type {{cert: Buffer, key: Buffer, ca?: Buffer} | undefined} */
138
+ let tlsContext
139
+ if (tlsEnabled) {
140
+ const tlsOptions = /** @type {NonNullable<typeof options.tls>} */ (options.tls)
141
+ tlsContext = {
142
+ cert: readFileSync(tlsOptions.certFile),
143
+ key: readFileSync(tlsOptions.keyFile)
144
+ }
145
+ if (tlsOptions.clientCaFile !== undefined) {
146
+ tlsContext.ca = readFileSync(tlsOptions.clientCaFile)
147
+ }
148
+ }
149
+
150
+ /** @type {(identifier: string) => void} */
151
+ const clearSessionTargetExpiry = (identifier) => {
152
+ const timer = sessionTargetExpiryTimers.get(identifier)
153
+ if (timer !== undefined) {
154
+ clearTimeout(timer)
155
+ sessionTargetExpiryTimers.delete(identifier)
156
+ }
157
+ }
158
+
159
+ /** @type {(identifier: string) => void} */
160
+ const clearInitialTargetRetry = (identifier) => {
161
+ const timer = sessionInitialTargetRetryTimers.get(identifier)
162
+ if (timer !== undefined) {
163
+ clearTimeout(timer)
164
+ sessionInitialTargetRetryTimers.delete(identifier)
165
+ }
166
+ }
167
+
168
+ /** @type {(identifier: string) => void} */
169
+ const clearInitialTargetConnectTimeout = (identifier) => {
170
+ const timer = sessionInitialTargetConnectTimers.get(identifier)
171
+ if (timer !== undefined) {
172
+ clearTimeout(timer)
173
+ sessionInitialTargetConnectTimers.delete(identifier)
174
+ }
175
+ }
176
+
177
+ /** @type {(identifier: string) => void} */
178
+ const destroyInitialTargetAttempt = (identifier) => {
179
+ clearInitialTargetRetry(identifier)
180
+ clearInitialTargetConnectTimeout(identifier)
181
+ const attempt = sessionInitialTargetAttempts.get(identifier)
182
+ sessionInitialTargetAttempts.delete(identifier)
183
+ if (attempt !== undefined) {
184
+ attempt.removeAllListeners()
185
+ attempt.destroy()
186
+ }
187
+ }
188
+
189
+ /** @type {(identifier: string) => void} */
190
+ const destroySessionTarget = (identifier) => {
191
+ destroyInitialTargetAttempt(identifier)
192
+ const lifecycle = sessionLifecycles.get(identifier)
193
+ if (lifecycle !== undefined) {
194
+ lifecycle.active = false
195
+ sessionLifecycles.delete(identifier)
196
+ }
197
+ clearSessionTargetExpiry(identifier)
198
+ const bufferBytes = sessionTargetBufferBytes.get(identifier) ?? 0
199
+ if (bufferBytes > 0) {
200
+ totalProcessBufferBytes -= bufferBytes
201
+ sessionTargetBufferBytes.delete(identifier)
202
+ }
203
+ sessionTargetBuffers.delete(identifier)
204
+ sessionTargetSentChunks.delete(identifier)
205
+ sessionTargetTerminalState.delete(identifier)
206
+ sessionPendingClientSequence.delete(identifier)
207
+ sessionSendCodec.delete(identifier)
208
+ sessionCompressor.delete(identifier)
209
+ sessionSendQueue.delete(identifier)
210
+ sessionQueuedTargetBatch.delete(identifier)
211
+ const sessionTarget = sessionTargets.get(identifier)
212
+ sessionTargets.delete(identifier)
213
+ if (sessionTarget !== undefined) {
214
+ targets.delete(sessionTarget)
215
+ sessionTarget.removeAllListeners()
216
+ sessionTarget.destroy()
217
+ }
218
+ }
219
+
220
+ /** @type {(identifier: string, ackSequence: number) => void} */
221
+ const acknowledgeTargetData = (identifier, ackSequence) => {
222
+ const chunks = sessionTargetSentChunks.get(identifier)
223
+ if (chunks === undefined || chunks.length === 0) {
224
+ return
225
+ }
226
+ const remaining = chunks.filter((entry) => entry.sequence > ackSequence)
227
+ if (remaining.length === 0) {
228
+ sessionTargetSentChunks.delete(identifier)
229
+ } else {
230
+ sessionTargetSentChunks.set(identifier, remaining)
231
+ }
232
+ }
233
+
234
+ /** @type {(identifier: string) => number} */
235
+ const currentReplayBytes = (identifier) => {
236
+ const chunks = sessionTargetSentChunks.get(identifier)
237
+ if (chunks === undefined) {
238
+ return 0
239
+ }
240
+ return chunks.reduce((sum, entry) => sum + entry.chunk.length, 0)
241
+ }
242
+
243
+ /**
244
+ * Encodes one target-to-client chunk for the wire. Retained replay chunks stay
245
+ * raw, so a replayed frame is re-encoded under whatever codec the current
246
+ * connection negotiated (including none).
247
+ * @type {(identifier: string, lifecycle: SessionLifecycle, sender: (header: import('../protocol/frame.js').FrameHeader, payload?: Buffer) => Promise<boolean>, sequence: number, chunk: Buffer) => Promise<boolean>}
248
+ */
249
+ const sendStreamData = async (identifier, lifecycle, sender, sequence, chunk) => {
250
+ if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle) {
251
+ return false
252
+ }
253
+ const codec = sessionSendCodec.get(identifier)
254
+ const encoded = await encodeStreamPayload(chunk, codec, sessionCompressor.get(identifier), compressionLimits)
255
+ if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle) {
256
+ return false
257
+ }
258
+ /** @type {import('../protocol/frame.js').FrameHeader} */
259
+ const header = {type: "stream.data", direction: "target-to-client", sequence}
260
+ if (encoded.encoding !== undefined) {
261
+ header.encoding = encoded.encoding
262
+ header.dataLength = encoded.dataLength
263
+ }
264
+ // A close while awaiting socket drain does not discard bytes already
265
+ // observed with this captured sender: retain them for replay on resume.
266
+ await sender(header, encoded.payload)
267
+ return lifecycle.active && sessionLifecycles.get(identifier) === lifecycle
268
+ }
269
+
270
+ /**
271
+ * Replays all unacknowledged target-to-client chunks for a session. Called
272
+ * on resume inside the session send queue after the sender has been
273
+ * registered. The resume operation supplies its owning sender so stale work
274
+ * cannot adopt a later tunnel after a second disconnect.
275
+ * @param {string} identifier - Session identifier.
276
+ * @param {SessionLifecycle} lifecycle - Current live session lifecycle.
277
+ * @param {(header: import('../protocol/frame.js').FrameHeader, payload?: Buffer) => Promise<boolean>} sender - Sender owned by this resume operation.
278
+ * @returns {Promise<void>} - Resolves after all replays are sent.
279
+ */
280
+ const replayUnackedTargetData = async (identifier, lifecycle, sender) => {
281
+ if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle || sessionSenders.get(identifier) !== sender) {
282
+ return
283
+ }
284
+ const chunks = sessionTargetSentChunks.get(identifier)
285
+ if (chunks === undefined || chunks.length === 0) {
286
+ return
287
+ }
288
+ for (const entry of chunks) {
289
+ if (!await sendStreamData(identifier, lifecycle, sender, entry.sequence, entry.chunk)) {
290
+ return
291
+ }
292
+ }
293
+ }
294
+
295
+ /** @type {(identifier: string) => void} */
296
+ const expireSessionTargetLater = (identifier) => {
297
+ clearSessionTargetExpiry(identifier)
298
+ const lifecycle = sessionLifecycles.get(identifier)
299
+ const timer = setTimeout(() => {
300
+ if (lifecycle === undefined || !lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle) {
301
+ return
302
+ }
303
+ sessionTargetExpiryTimers.delete(identifier)
304
+ destroySessionTarget(identifier)
305
+ try {
306
+ if (options.sessions.get(identifier) !== undefined) {
307
+ options.sessions.transition(identifier, "expired")
308
+ }
309
+ } catch {
310
+ // session already in a terminal state
311
+ }
312
+ options.sessions.delete(identifier)
313
+ }, options.resumeWindowMs ?? 30_000)
314
+ sessionTargetExpiryTimers.set(identifier, timer)
315
+ }
316
+
317
+ /**
318
+ * Serializes all target-to-client encoding, replay, and delivery work for a
319
+ * session. Zstd runs asynchronously, so this queue — not callback completion
320
+ * order — defines the protocol's uncompressed sequence order.
321
+ *
322
+ * **Sender-capture rule**: because operations are deferred through this queue,
323
+ * callers that deliver target data MUST capture the sender from
324
+ * `sessionSenders` before calling this function, not inside the queued
325
+ * operation. Otherwise a queued operation could observe a different tunnel's
326
+ * sender after a disconnect/resume cycle.
327
+ * @param {string} identifier - Session identifier.
328
+ * @param {() => Promise<void>} operation - One ordered operation.
329
+ * @returns {Promise<void>} - Resolves after the operation completes.
330
+ */
331
+ const queueSessionSend = (identifier, operation) => {
332
+ const queue = sessionSendQueue.get(identifier)
333
+ // Start an idle queue synchronously. Async compression may yield, but a sole
334
+ // target chunk is never held merely to see whether another chunk arrives.
335
+ const next = queue === undefined
336
+ ? Promise.resolve(operation())
337
+ : queue.tail.then(operation)
338
+ const state = {tail: next.catch(() => {})}
339
+ sessionSendQueue.set(identifier, state)
340
+ state.tail.finally(() => {
341
+ if (sessionSendQueue.get(identifier) === state) sessionSendQueue.delete(identifier)
342
+ })
343
+ return next
344
+ }
345
+
346
+ /**
347
+ * Persists a target terminal result before placing its delivery behind any
348
+ * queued data. The first terminal result wins (notably, error before close),
349
+ * and the state remains resumable until normal session destruction/expiry.
350
+ * @param {string} identifier - Session identifier.
351
+ * @param {SessionLifecycle} lifecycle - Lifecycle captured by the target.
352
+ * @param {import('../protocol/frame.js').FrameHeader} terminal - Terminal frame.
353
+ */
354
+ const persistAndQueueTargetTerminal = (identifier, lifecycle, terminal) => {
355
+ if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle || sessionTargetTerminalState.has(identifier)) return
356
+ sessionTargetTerminalState.set(identifier, terminal)
357
+ const sender = sessionSenders.get(identifier)
358
+ if (sender === undefined) return
359
+ void queueSessionSend(identifier, async () => {
360
+ if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle ||
361
+ sessionSenders.get(identifier) !== sender || sessionTargetTerminalState.get(identifier) !== terminal) return
362
+ await sender(terminal)
363
+ })
364
+ }
365
+
366
+ /**
367
+ * Enqueues one target-to-client chunk for ordered delivery. Captures the
368
+ * current sender at enqueue time so the queued operation cannot later read
369
+ * a different tunnel's sender after a disconnect/resume cycle. When the
370
+ * session is detached (sender undefined) the chunk will be buffered.
371
+ * @param {string} identifier - Session identifier.
372
+ * @param {SessionLifecycle} lifecycle - Lifecycle captured by the target socket.
373
+ * @param {Buffer} chunk - Raw target data chunk.
374
+ * @returns {Promise<void>} - Resolves after the chunk is delivered or buffered.
375
+ */
376
+ const deliverTargetChunk = (identifier, lifecycle, chunk) => {
377
+ if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle) {
378
+ return Promise.resolve()
379
+ }
380
+ const sender = sessionSenders.get(identifier)
381
+ if (sender === undefined) {
382
+ bufferDetachedTargetChunk(identifier, lifecycle, chunk)
383
+ return Promise.resolve()
384
+ }
385
+
386
+ const maxBytes = getMaxDecompressedBytes(compressionLimits)
387
+ if (chunk.length > maxBytes) {
388
+ const deliveries = []
389
+ for (let offset = 0; offset < chunk.length; offset += maxBytes) {
390
+ deliveries.push(deliverTargetChunk(identifier, lifecycle, chunk.subarray(offset, Math.min(offset + maxBytes, chunk.length))))
391
+ }
392
+ return Promise.all(deliveries).then(() => {})
393
+ }
394
+ const queuedBatch = sessionQueuedTargetBatch.get(identifier)
395
+ if (sessionSendQueue.has(identifier) && queuedBatch !== undefined &&
396
+ queuedBatch.lifecycle === lifecycle && queuedBatch.sender === sender &&
397
+ queuedBatch.bytes + chunk.length <= maxBytes) {
398
+ // Copy while the target's data callback still owns the bytes. This batch
399
+ // exists only because earlier async send/compression work is in flight.
400
+ queuedBatch.chunks.push(Buffer.from(chunk))
401
+ queuedBatch.bytes += chunk.length
402
+ return sessionSendQueue.get(identifier)?.tail ?? Promise.resolve()
403
+ }
404
+
405
+ if (sessionSendQueue.has(identifier)) {
406
+ const batch = {lifecycle, sender, chunks: [Buffer.from(chunk)], bytes: chunk.length}
407
+ sessionQueuedTargetBatch.set(identifier, batch)
408
+ return queueSessionSend(identifier, async () => {
409
+ if (sessionQueuedTargetBatch.get(identifier) === batch) sessionQueuedTargetBatch.delete(identifier)
410
+ const merged = batch.chunks.length === 1 ? batch.chunks[0] : Buffer.concat(batch.chunks, batch.bytes)
411
+ if (merged !== undefined) await deliverTargetChunkNow(identifier, lifecycle, merged, sender)
412
+ })
413
+ }
414
+
415
+ return queueSessionSend(identifier, async () => {
416
+ await deliverTargetChunkNow(identifier, lifecycle, chunk, sender)
417
+ })
418
+ }
419
+
420
+ /**
421
+ * Copies, charges and admits detached target data synchronously, before any
422
+ * earlier asynchronous send in the ordered queue can complete.
423
+ * @param {string} identifier
424
+ * @param {SessionLifecycle} lifecycle
425
+ * @param {Buffer} chunk
426
+ */
427
+ const bufferDetachedTargetChunk = (identifier, lifecycle, chunk) => {
428
+ if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle) return
429
+
430
+ const chunkCopy = Buffer.from(chunk)
431
+ const previousSessionBytes = sessionTargetBufferBytes.get(identifier) ?? 0
432
+ const newSessionBytes = previousSessionBytes + chunkCopy.length
433
+ const perSessionOverflow = newSessionBytes > maxTargetBufferBytes
434
+ const newTotalBytes = totalProcessBufferBytes + chunkCopy.length
435
+ const totalOverflow = newTotalBytes > maxTotalBufferBytes
436
+
437
+ if (perSessionOverflow || totalOverflow) {
438
+ if (previousSessionBytes > 0) totalProcessBufferBytes -= previousSessionBytes
439
+ sessionTargetBufferBytes.delete(identifier)
440
+ sessionTargetBuffers.delete(identifier)
441
+ const terminalError = perSessionOverflow
442
+ ? {type: "error", code: "TARGET_BUFFER_OVERFLOW", message: `Session target-to-client buffer exceeded the per-session limit of ${maxTargetBufferBytes} bytes while the tunnel was detached.`, retryable: false}
443
+ : {type: "error", code: "TARGET_BUFFER_OVERFLOW", message: `Process-wide target-to-client buffer exceeded the limit of ${maxTotalBufferBytes} bytes while the tunnel was detached.`, retryable: false}
444
+ sessionTargetTerminalState.set(identifier, terminalError)
445
+ const sessionTarget = sessionTargets.get(identifier)
446
+ sessionTarget?.destroy()
447
+ sessionTargets.delete(identifier)
448
+ clearSessionTargetExpiry(identifier)
449
+ return
450
+ }
451
+
452
+ totalProcessBufferBytes = newTotalBytes
453
+ sessionTargetBufferBytes.set(identifier, newSessionBytes)
454
+ const buffer = sessionTargetBuffers.get(identifier) ?? []
455
+ buffer.push(chunkCopy)
456
+ sessionTargetBuffers.set(identifier, buffer)
457
+ }
458
+
459
+ /**
460
+ * Delivers (or buffers) one target-to-client chunk. The sender must be
461
+ * captured by the caller at enqueue time — see {@link deliverTargetChunk}.
462
+ *
463
+ * When the captured sender is undefined the chunk is buffered and will be
464
+ * flushed later by {@link flushBufferedTargetChunksNow} on resume. When a
465
+ * sender is present the chunk is encoded, sent, and retained for replay.
466
+ * @param {string} identifier - Session identifier.
467
+ * @param {SessionLifecycle} lifecycle - Lifecycle captured by the target socket.
468
+ * @param {Buffer} chunk - Raw target data chunk.
469
+ * @param {((header: import('../protocol/frame.js').FrameHeader, payload?: Buffer) => Promise<boolean>) | undefined} sender - Captured sender (undefined if detached).
470
+ * @returns {Promise<boolean>} - True when the chunk entered replay retention.
471
+ */
472
+ async function deliverTargetChunkNow(identifier, lifecycle, chunk, sender) {
473
+ if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle || sender === undefined) {
474
+ return false
475
+ }
476
+
477
+ // Sequences, replay retention and every byte limit stay in uncompressed units.
478
+ const current = options.sessions.get(identifier)?.relaySequence ?? 0
479
+ const nextRelaySequence = current + chunk.length
480
+ if (!await sendStreamData(identifier, lifecycle, sender, nextRelaySequence, chunk)) return false
481
+
482
+ // Do not move protocol state until async encoding has completed successfully.
483
+ if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle || options.sessions.get(identifier) === undefined) return false
484
+ options.sessions.updateSequences(identifier, {relaySequence: nextRelaySequence})
485
+
486
+ const sentChunks = sessionTargetSentChunks.get(identifier) ?? []
487
+ sentChunks.push({sequence: nextRelaySequence, chunk: Buffer.from(chunk)})
488
+ sessionTargetSentChunks.set(identifier, sentChunks)
489
+
490
+ const newTotal = currentReplayBytes(identifier)
491
+ if (newTotal > maxReplayBytes) {
492
+ const sessionTarget = sessionTargets.get(identifier)
493
+ const terminalError = {type: "error", code: "SPOOL_TOO_LARGE", message: "Unacknowledged replay buffer exceeded maximum size.", retryable: false}
494
+ sender(terminalError)
495
+ sessionTargetTerminalState.set(identifier, terminalError)
496
+ sessionTarget?.destroy()
497
+ sessionTargets.delete(identifier)
498
+ clearSessionTargetExpiry(identifier)
499
+ }
500
+ return true
501
+ }
502
+
503
+ /**
504
+ * Flushes all buffered target-to-client chunks for a session. Called on
505
+ * resume after replay with the sender owned by that resume operation.
506
+ * @param {string} identifier - Session identifier.
507
+ * @param {SessionLifecycle} lifecycle - Current live session lifecycle.
508
+ * @param {(header: import('../protocol/frame.js').FrameHeader, payload?: Buffer) => Promise<boolean>} sender - Sender owned by this resume operation.
509
+ * @returns {Promise<void>} - Resolves after all buffered chunks are delivered.
510
+ */
511
+ const flushBufferedTargetChunksNow = async (identifier, lifecycle, sender) => {
512
+ if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle || sessionSenders.get(identifier) !== sender) return
513
+ const chunks = sessionTargetBuffers.get(identifier)
514
+ while (chunks !== undefined && chunks.length > 0) {
515
+ if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle || sessionSenders.get(identifier) !== sender) return
516
+ const chunk = chunks[0]
517
+ if (chunk === undefined || !await deliverTargetChunkNow(identifier, lifecycle, chunk, sender)) return
518
+
519
+ // Transfer ownership only after the chunk has entered replay retention.
520
+ // If the tunnel detaches before the next iteration, the remaining chunks
521
+ // and their accounting stay available for the following resume.
522
+ chunks.shift()
523
+ const previousSessionBytes = sessionTargetBufferBytes.get(identifier) ?? 0
524
+ const remainingSessionBytes = previousSessionBytes - chunk.length
525
+ totalProcessBufferBytes -= chunk.length
526
+ if (remainingSessionBytes > 0) {
527
+ sessionTargetBufferBytes.set(identifier, remainingSessionBytes)
528
+ } else {
529
+ sessionTargetBufferBytes.delete(identifier)
530
+ sessionTargetBuffers.delete(identifier)
531
+ }
532
+ }
533
+ }
534
+
535
+ const server = tlsEnabled
536
+ ? createTlsServer({
537
+ .../** @type {NonNullable<typeof tlsContext>} */ (tlsContext),
538
+ requestCert: tlsContext?.ca !== undefined,
539
+ rejectUnauthorized: true
540
+ }, (socket) => {
541
+ setupTunnel(socket)
542
+ })
543
+ : createServer((socket) => {
544
+ setupTunnel(socket)
545
+ })
546
+
547
+ /** @type {(socket: import('node:net').Socket) => void} */
548
+ function setupTunnel(socket) {
549
+ tunnels.add(socket)
550
+ /** @type {{promise?: Promise<void>}} */
551
+ const tunnelCloseSettlement = {}
552
+ tunnelCloseSettlements.set(socket, tunnelCloseSettlement)
553
+ const decoder = new FrameDecoder({maxInputBytes: maxInputBytesPerConnection, compactDirection: "client-to-target"})
554
+ let authenticatedSession = false
555
+ /** @type {string | undefined} */
556
+ let sessionIdentifier
557
+ /** @type {SessionLifecycle | undefined} */
558
+ let tunnelSessionLifecycle
559
+ /** Codec negotiated for this connection; undefined means uncompressed compact data. */
560
+ /** @type {string | undefined} */
561
+ let connectionCodec
562
+ /** @type {number | undefined} */
563
+ let connectionFraming
564
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
565
+ let handshakeTimer
566
+
567
+ const clearHandshakeTimeout = () => {
568
+ if (handshakeTimer !== undefined) {
569
+ clearTimeout(handshakeTimer)
570
+ handshakeTimer = undefined
571
+ }
572
+ }
573
+
574
+ handshakeTimer = setTimeout(() => {
575
+ if (!authenticatedSession) {
576
+ const errorFrame = encodeFrame({type: "error", code: "HANDSHAKE_TIMEOUT", message: "Authentication not completed within the handshake deadline.", retryable: false})
577
+ try {
578
+ if (!socket.destroyed && socket.writable) {
579
+ socket.end(errorFrame)
580
+ }
581
+ } catch {
582
+ // socket may already be destroyed
583
+ }
584
+ }
585
+ }, handshakeTimeoutMs)
586
+
587
+ /** @type {(header: import('../protocol/frame.js').FrameHeader, payload?: Buffer) => Promise<boolean>} */
588
+ const sendFrame = (header, payload = Buffer.alloc(0)) => {
589
+ if (socket.destroyed || socket.writableEnded || !socket.writable) {
590
+ return Promise.resolve(false)
591
+ }
592
+
593
+ try {
594
+ let bytes
595
+ if ((header.type === "stream.data" || header.type === "stream.ack") && connectionFraming !== 2) {
596
+ throw new Error("Refusing to emit a non-compact stream record")
597
+ }
598
+ if (header.type === "stream.data") {
599
+ bytes = encodeCompactData({sequence: /** @type {number} */ (header.sequence), payload, ...(header.encoding === "zstd" ? {dataLength: /** @type {number} */ (header.dataLength)} : {})})
600
+ } else if (header.type === "stream.ack") {
601
+ bytes = encodeCompactAck(/** @type {number} */ (header.ackSequence))
602
+ } else {
603
+ bytes = encodeFrame(header, payload)
604
+ }
605
+ const writable = socket.write(bytes, (error) => {
606
+ if (error !== null && error !== undefined) {
607
+ socket.destroy(error)
608
+ }
609
+ })
610
+ if (writable) return Promise.resolve(true)
611
+ return new Promise((resolve) => {
612
+ const cleanup = () => {
613
+ socket.off("drain", onDrain)
614
+ socket.off("close", onClose)
615
+ }
616
+ const onDrain = () => {
617
+ cleanup()
618
+ resolve(true)
619
+ }
620
+ const onClose = () => {
621
+ cleanup()
622
+ resolve(false)
623
+ }
624
+ socket.once("drain", onDrain)
625
+ socket.once("close", onClose)
626
+ })
627
+ } catch (error) {
628
+ socket.destroy(error instanceof Error ? error : undefined)
629
+ return Promise.resolve(false)
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Removes a session that never crossed the readiness/announcement boundary.
635
+ * Object identity prevents a stale tunnel callback from deleting a reused id.
636
+ * @param {string} identifier
637
+ * @param {SessionLifecycle} lifecycle
638
+ */
639
+ const deleteUnannouncedSession = (identifier, lifecycle) => {
640
+ if (lifecycle.announced || sessionLifecycles.get(identifier) !== lifecycle) return
641
+ destroySessionTarget(identifier)
642
+ sessionSenders.delete(identifier)
643
+ options.sessions.delete(identifier)
644
+ if (sessionIdentifier === identifier) {
645
+ sessionIdentifier = undefined
646
+ tunnelSessionLifecycle = undefined
647
+ }
648
+ }
649
+
650
+ /**
651
+ * Installs handlers for a target that has crossed the TCP connect boundary.
652
+ * Initial connection failures never enter this terminal target lifecycle.
653
+ * @param {string} identifier
654
+ * @param {SessionLifecycle} lifecycle
655
+ * @param {import('node:net').Socket} targetSocket
656
+ */
657
+ const attachConnectedTarget = (identifier, lifecycle, targetSocket) => {
658
+ targetSocket.on("data", (chunk) => {
659
+ void deliverTargetChunk(identifier, lifecycle, chunk).catch((error) => {
660
+ if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle) return
661
+ const terminalError = {
662
+ type: "error",
663
+ code: "TARGET_CLOSED",
664
+ message: "Target-to-client delivery failed.",
665
+ retryable: false
666
+ }
667
+ persistAndQueueTargetTerminal(identifier, lifecycle, terminalError)
668
+ targetSocket.destroy(error instanceof Error ? error : undefined)
669
+ })
670
+ })
671
+ targetSocket.on("error", () => {
672
+ if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle) return
673
+ targets.delete(targetSocket)
674
+ if (sessionTargets.get(identifier) === targetSocket) sessionTargets.delete(identifier)
675
+ if (!sessionTargetTerminalState.has(identifier)) {
676
+ persistAndQueueTargetTerminal(identifier, lifecycle, {
677
+ type: "error",
678
+ code: "TARGET_CLOSED",
679
+ message: "Target connection failed.",
680
+ retryable: false
681
+ })
682
+ }
683
+ })
684
+ targetSocket.on("close", () => {
685
+ targets.delete(targetSocket)
686
+ if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle) return
687
+ if (sessionTargets.get(identifier) === targetSocket) sessionTargets.delete(identifier)
688
+ if (!sessionTargetTerminalState.has(identifier)) {
689
+ persistAndQueueTargetTerminal(identifier, lifecycle, {type: "session.close", direction: "target-to-client"})
690
+ }
691
+ })
692
+ }
693
+
694
+ /**
695
+ * Starts one bounded-rate initial target acquisition loop for this tunnel.
696
+ * @param {string} identifier
697
+ * @param {SessionLifecycle} lifecycle
698
+ * @param {import('../protocol/frame.js').FrameHeader} createdResponse
699
+ */
700
+ const startInitialTargetAcquisition = (identifier, lifecycle, createdResponse) => {
701
+ let retryDelayMs = initialTargetRetryDelayMs
702
+
703
+ const acquisitionStopped = () => closing || socket.destroyed || socket.writableEnded || !socket.writable ||
704
+ !lifecycle.active || lifecycle.announced || sessionLifecycles.get(identifier) !== lifecycle ||
705
+ options.sessions.get(identifier)?.state !== "creating"
706
+
707
+ const scheduleRetry = () => {
708
+ if (acquisitionStopped() || sessionInitialTargetRetryTimers.has(identifier)) return
709
+ const timer = setTimeout(() => {
710
+ if (sessionInitialTargetRetryTimers.get(identifier) !== timer) return
711
+ sessionInitialTargetRetryTimers.delete(identifier)
712
+ attemptTarget()
713
+ }, retryDelayMs)
714
+ sessionInitialTargetRetryTimers.set(identifier, timer)
715
+ retryDelayMs = Math.min(retryDelayMs * 2, initialTargetRetryMaxDelayMs)
716
+ }
717
+
718
+ const attemptTarget = () => {
719
+ if (acquisitionStopped() || sessionInitialTargetAttempts.has(identifier) || sessionInitialTargetRetryTimers.has(identifier)) return
720
+ const session = options.sessions.get(identifier)
721
+ if (session === undefined) return
722
+ const attempt = createTargetConnection({host: session.target.host, port: session.target.port})
723
+ sessionInitialTargetAttempts.set(identifier, attempt)
724
+ let settled = false
725
+
726
+ const clearAttempt = () => {
727
+ clearInitialTargetConnectTimeout(identifier)
728
+ if (sessionInitialTargetAttempts.get(identifier) === attempt) {
729
+ sessionInitialTargetAttempts.delete(identifier)
730
+ }
731
+ attempt.off("connect", onConnect)
732
+ attempt.off("error", onFailure)
733
+ attempt.off("close", onFailure)
734
+ }
735
+ const onFailure = () => {
736
+ if (settled) return
737
+ settled = true
738
+ clearAttempt()
739
+ attempt.destroy()
740
+ scheduleRetry()
741
+ }
742
+ const onConnect = () => {
743
+ if (settled) return
744
+ settled = true
745
+ clearAttempt()
746
+ if (acquisitionStopped()) {
747
+ attempt.destroy()
748
+ return
749
+ }
750
+
751
+ clearInitialTargetRetry(identifier)
752
+ sessionTargets.set(identifier, attempt)
753
+ targets.add(attempt)
754
+ attachConnectedTarget(identifier, lifecycle, attempt)
755
+ options.sessions.transition(identifier, "active")
756
+
757
+ void (async () => {
758
+ const announced = await sendFrame({...createdResponse, state: "active"})
759
+ if (!announced || closing || socket.destroyed || !lifecycle.active ||
760
+ sessionLifecycles.get(identifier) !== lifecycle) {
761
+ deleteUnannouncedSession(identifier, lifecycle)
762
+ return
763
+ }
764
+ lifecycle.announced = true
765
+ // Only a terminal observed before publishing this sender lacks its
766
+ // own ordered queue entry. Later terminals enqueue behind live data.
767
+ const detachedTerminalState = sessionTargetTerminalState.get(identifier)
768
+ sessionSenders.set(identifier, sendFrame)
769
+ if (connectionCodec === undefined) {
770
+ sessionSendCodec.delete(identifier)
771
+ sessionCompressor.delete(identifier)
772
+ } else {
773
+ sessionSendCodec.set(identifier, connectionCodec)
774
+ sessionCompressor.set(identifier, new AdaptiveCompressor())
775
+ }
776
+ await queueSessionSend(identifier, async () => {
777
+ if (!lifecycle.active || sessionLifecycles.get(identifier) !== lifecycle || sessionSenders.get(identifier) !== sendFrame) return
778
+ await flushBufferedTargetChunksNow(identifier, lifecycle, sendFrame)
779
+ if (detachedTerminalState !== undefined && sessionTargetTerminalState.get(identifier) === detachedTerminalState && sessionSenders.get(identifier) === sendFrame) await sendFrame(detachedTerminalState)
780
+ })
781
+ })()
782
+ }
783
+
784
+ attempt.once("connect", onConnect)
785
+ attempt.once("error", onFailure)
786
+ attempt.once("close", onFailure)
787
+ const connectTimer = setTimeout(() => {
788
+ if (sessionInitialTargetConnectTimers.get(identifier) !== connectTimer ||
789
+ sessionInitialTargetAttempts.get(identifier) !== attempt || acquisitionStopped()) return
790
+ onFailure()
791
+ }, initialTargetConnectTimeoutMs)
792
+ connectTimer.unref?.()
793
+ sessionInitialTargetConnectTimers.set(identifier, connectTimer)
794
+ }
795
+
796
+ attemptTarget()
797
+ }
798
+
799
+ let tunnelFrameQueue = Promise.resolve()
800
+
801
+ socket.on("data", (chunk) => {
802
+ const frameOperation = tunnelFrameQueue.then(async () => {
803
+ let frames
804
+ try {
805
+ frames = decoder.push(chunk)
806
+ } catch {
807
+ const errorFrame = encodeFrame({type: "error", code: "PROTOCOL_VIOLATION", message: "Malformed frame.", retryable: false})
808
+ socket.end(errorFrame)
809
+ return
810
+ }
811
+
812
+ for (const frame of frames) {
813
+ if (frame.wireFormat === "compact" && authenticatedSession && connectionFraming !== 2) {
814
+ sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: "Compact framing version 2 was not negotiated.", retryable: false})
815
+ socket.end()
816
+ return
817
+ }
818
+ if (frame.header.type === "session.create" || frame.header.type === "session.resume") {
819
+ if (authenticatedSession) {
820
+ await sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: "A tunnel can authenticate exactly one session.", retryable: false})
821
+ socket.end()
822
+ return
823
+ }
824
+ const response = handleControlMessage({
825
+ tokens: options.tokens,
826
+ sessions: options.sessions,
827
+ authorizeTarget: options.authorizeTarget,
828
+ supportedCodecs,
829
+ supportedFraming: [2]
830
+ }, frame.header)
831
+ if (response.type !== "session.created" && response.type !== "session.resumed") {
832
+ await sendFrame(response)
833
+ continue
834
+ }
835
+
836
+ authenticatedSession = true
837
+ decoder.disableInputBudget()
838
+ clearHandshakeTimeout()
839
+ connectionCodec = typeof response.compression === "string" ? response.compression : undefined
840
+ connectionFraming = response.framing === 2 ? 2 : undefined
841
+ sessionIdentifier = typeof response.identifier === "string" ? response.identifier : undefined
842
+ if (sessionIdentifier === undefined) {
843
+ await sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: "Session response did not contain an identifier.", retryable: false})
844
+ socket.end()
845
+ return
846
+ }
847
+
848
+ if (frame.header.type === "session.create") {
849
+ const lifecycle = {active: true, announced: false}
850
+ tunnelSessionLifecycle = lifecycle
851
+ sessionLifecycles.set(sessionIdentifier, lifecycle)
852
+ startInitialTargetAcquisition(sessionIdentifier, lifecycle, response)
853
+ continue
854
+ }
855
+
856
+ const lifecycle = sessionLifecycles.get(sessionIdentifier)
857
+ if (lifecycle === undefined || !lifecycle.active || !lifecycle.announced) {
858
+ await sendFrame({type: "error", code: "SESSION_NOT_FOUND", message: "Session target is not available for resume.", retryable: false})
859
+ socket.end()
860
+ return
861
+ }
862
+ tunnelSessionLifecycle = lifecycle
863
+ const sent = await sendFrame(response)
864
+ if (!sent || socket.destroyed || !lifecycle.active || sessionLifecycles.get(sessionIdentifier) !== lifecycle) return
865
+ if (options.sessions.get(sessionIdentifier)?.state === "resuming") {
866
+ options.sessions.transition(sessionIdentifier, "active")
867
+ }
868
+ // Snapshot before publishing the replacement sender. A terminal
869
+ // observed afterward owns a queue entry behind preceding target data.
870
+ const detachedTerminalState = sessionTargetTerminalState.get(sessionIdentifier)
871
+ sessionSenders.set(sessionIdentifier, sendFrame)
872
+ if (connectionCodec === undefined) {
873
+ sessionSendCodec.delete(sessionIdentifier)
874
+ sessionCompressor.delete(sessionIdentifier)
875
+ } else {
876
+ sessionSendCodec.set(sessionIdentifier, connectionCodec)
877
+ sessionCompressor.set(sessionIdentifier, new AdaptiveCompressor())
878
+ }
879
+ const resumedSessionIdentifier = sessionIdentifier
880
+ if (typeof frame.header.targetToClientAck === "number") {
881
+ acknowledgeTargetData(resumedSessionIdentifier, frame.header.targetToClientAck)
882
+ }
883
+ await queueSessionSend(resumedSessionIdentifier, async () => {
884
+ if (!lifecycle.active || sessionLifecycles.get(resumedSessionIdentifier) !== lifecycle || sessionSenders.get(resumedSessionIdentifier) !== sendFrame) return
885
+ await replayUnackedTargetData(resumedSessionIdentifier, lifecycle, sendFrame)
886
+ await flushBufferedTargetChunksNow(resumedSessionIdentifier, lifecycle, sendFrame)
887
+ if (detachedTerminalState !== undefined && sessionTargetTerminalState.get(resumedSessionIdentifier) === detachedTerminalState && lifecycle.active && sessionLifecycles.get(resumedSessionIdentifier) === lifecycle && sessionSenders.get(resumedSessionIdentifier) === sendFrame) {
888
+ await sendFrame(detachedTerminalState)
889
+ }
890
+ })
891
+ continue
892
+ }
893
+
894
+ if (frame.header.type === "stream.data" && frame.header.direction === "client-to-target") {
895
+ if (!authenticatedSession) {
896
+ sendFrame({type: "error", code: "AUTH_REQUIRED", message: "Authentication is required before stream data.", retryable: false})
897
+ continue
898
+ }
899
+
900
+ const lifecycle = sessionIdentifier === undefined ? undefined : sessionLifecycles.get(sessionIdentifier)
901
+ if (lifecycle === undefined || !lifecycle.active || !lifecycle.announced) {
902
+ await sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: "Stream data is forbidden before target readiness.", retryable: false})
903
+ socket.end()
904
+ return
905
+ }
906
+
907
+ if (frame.wireFormat !== "compact") {
908
+ sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: "Length-prefixed stream.data is forbidden after framing version 2 is established.", retryable: false})
909
+ socket.end()
910
+ return
911
+ }
912
+
913
+ if (sessionIdentifier !== undefined && sessionSenders.get(sessionIdentifier) !== sendFrame) {
914
+ continue
915
+ }
916
+
917
+ const sequence = frame.header.sequence
918
+ if (!isValidStreamDataSequence(sequence)) {
919
+ sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: "stream.data sequence must be a positive safe integer.", retryable: false})
920
+ continue
921
+ }
922
+
923
+ // Decompress and verify before any sequence accounting: the contiguity
924
+ // check below is defined over uncompressed bytes, so a lying dataLength
925
+ // must never be able to move the acknowledgement watermark.
926
+ /** @type {Buffer} */
927
+ let dataBytes
928
+ try {
929
+ dataBytes = await decodeStreamPayload(frame.header, frame.payload ?? Buffer.alloc(0), connectionCodec, compressionLimits)
930
+ } catch (error) {
931
+ // Fail closed: a frame we cannot decode is never written to the target,
932
+ // and the connection ends rather than continuing on a desynchronised
933
+ // uncompressed byte stream.
934
+ const detail = error instanceof Error ? error.message : String(error)
935
+ sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: detail, retryable: false})
936
+ socket.end()
937
+ return
938
+ }
939
+
940
+ const currentSession = sessionIdentifier !== undefined ? options.sessions.get(sessionIdentifier) : undefined
941
+ const committedClientSequence = currentSession?.clientSequence ?? 0
942
+ const pendingClientSeq = sessionIdentifier !== undefined ? (sessionPendingClientSequence.get(sessionIdentifier) ?? 0) : 0
943
+ const effectiveClientSequence = Math.max(committedClientSequence, pendingClientSeq)
944
+ const payloadLength = dataBytes.length
945
+ const expectedSequence = effectiveClientSequence + payloadLength
946
+
947
+ if (sequence <= effectiveClientSequence) {
948
+ sendFrame({type: "stream.ack", direction: "client-to-target", ackSequence: committedClientSequence})
949
+ continue
950
+ }
951
+
952
+ if (sequence !== expectedSequence) {
953
+ const detail = sequence < expectedSequence
954
+ ? `overlap (expected contiguous offset ${expectedSequence})`
955
+ : `gap (expected contiguous offset ${expectedSequence})`
956
+ sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: `stream.data sequence ${sequence} creates a ${detail}.`, retryable: false})
957
+ continue
958
+ }
959
+
960
+ const destination = sessionIdentifier === undefined ? undefined : sessionTargets.get(sessionIdentifier)
961
+ if (destination === undefined || destination.destroyed) {
962
+ sendFrame({type: "error", code: "TARGET_CLOSED", message: "Target socket is not available.", retryable: false})
963
+ socket.destroy()
964
+ continue
965
+ }
966
+
967
+ const targetSessionIdentifier = sessionIdentifier
968
+ if (targetSessionIdentifier === undefined) continue
969
+ sessionPendingClientSequence.set(targetSessionIdentifier, sequence)
970
+ destination.write(dataBytes, (error) => {
971
+ if (error || destination.destroyed) {
972
+ const terminalError = {type: "error", code: "TARGET_CLOSED", message: "Target write failed; delivery outcome is uncertain.", retryable: false}
973
+ persistAndQueueTargetTerminal(targetSessionIdentifier, lifecycle, terminalError)
974
+ sendFrame(terminalError)
975
+ socket.destroy()
976
+ return
977
+ }
978
+ options.sessions.updateSequences(targetSessionIdentifier, {clientSequence: sequence})
979
+ const newCommitted = options.sessions.get(targetSessionIdentifier)?.clientSequence ?? 0
980
+ const currentPending = sessionPendingClientSequence.get(targetSessionIdentifier) ?? 0
981
+ sessionPendingClientSequence.set(targetSessionIdentifier, Math.max(currentPending, newCommitted))
982
+ sendFrame({type: "stream.ack", direction: "client-to-target", ackSequence: sequence})
983
+ })
984
+ continue
985
+ }
986
+
987
+ if (frame.header.type === "stream.ack") {
988
+ const lifecycle = sessionIdentifier === undefined ? undefined : sessionLifecycles.get(sessionIdentifier)
989
+ if (authenticatedSession && (lifecycle === undefined || !lifecycle.active || !lifecycle.announced)) {
990
+ await sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: "Stream acknowledgements are forbidden before target readiness.", retryable: false})
991
+ socket.end()
992
+ return
993
+ }
994
+ if (authenticatedSession && frame.wireFormat !== "compact") {
995
+ sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: "Length-prefixed stream.ack is forbidden after framing version 2 is established.", retryable: false})
996
+ socket.end()
997
+ return
998
+ }
999
+ if (frame.header.direction === "target-to-client" && typeof frame.header.ackSequence === "number" && sessionIdentifier !== undefined) {
1000
+ if (sessionSenders.get(sessionIdentifier) !== sendFrame) {
1001
+ continue
1002
+ }
1003
+ acknowledgeTargetData(sessionIdentifier, frame.header.ackSequence)
1004
+ }
1005
+ continue
1006
+ }
1007
+
1008
+ if (frame.header.type === "session.close" && frame.header.direction === "client-to-relay") {
1009
+ if (!authenticatedSession) {
1010
+ sendFrame({type: "error", code: "AUTH_REQUIRED", message: "Authentication is required.", retryable: false})
1011
+ continue
1012
+ }
1013
+ const lifecycle = sessionIdentifier === undefined ? undefined : sessionLifecycles.get(sessionIdentifier)
1014
+ if (sessionIdentifier !== undefined && lifecycle !== undefined && !lifecycle.announced) {
1015
+ const sid = sessionIdentifier
1016
+ deleteUnannouncedSession(sid, lifecycle)
1017
+ await sendFrame({type: "session.close", direction: "target-to-client", acknowledged: true})
1018
+ continue
1019
+ }
1020
+ if (sessionIdentifier !== undefined && sessionSenders.get(sessionIdentifier) !== sendFrame) {
1021
+ continue
1022
+ }
1023
+ if (sessionIdentifier !== undefined) {
1024
+ const sid = sessionIdentifier
1025
+ destroySessionTarget(sid)
1026
+ sessionSenders.delete(sid)
1027
+ try {
1028
+ if (options.sessions.get(sid) !== undefined) {
1029
+ options.sessions.transition(sid, "closed")
1030
+ }
1031
+ } catch {
1032
+ // session already in a terminal state
1033
+ }
1034
+ options.sessions.delete(sid)
1035
+ sendFrame({type: "session.close", direction: "target-to-client", acknowledged: true})
1036
+ sessionIdentifier = undefined
1037
+ tunnelSessionLifecycle = undefined
1038
+ }
1039
+ continue
1040
+ }
1041
+
1042
+ sendFrame({type: "error", code: "PROTOCOL_VIOLATION", message: `Unsupported frame type: ${frame.header.type}`, retryable: false})
1043
+ }
1044
+ })
1045
+ tunnelFrameQueue = frameOperation.catch((error) => {
1046
+ socket.destroy(error instanceof Error ? error : new Error(String(error)))
1047
+ })
1048
+ })
1049
+
1050
+ socket.on("close", () => {
1051
+ clearHandshakeTimeout()
1052
+ tunnels.delete(socket)
1053
+ const settleTunnelClose = () => {
1054
+ const lifecycle = tunnelSessionLifecycle
1055
+ if (sessionIdentifier !== undefined && lifecycle !== undefined &&
1056
+ sessionLifecycles.get(sessionIdentifier) === lifecycle && !lifecycle.announced) {
1057
+ deleteUnannouncedSession(sessionIdentifier, lifecycle)
1058
+ } else if (sessionIdentifier !== undefined && lifecycle !== undefined &&
1059
+ sessionLifecycles.get(sessionIdentifier) === lifecycle &&
1060
+ sessionSenders.get(sessionIdentifier) === sendFrame) {
1061
+ sessionSenders.delete(sessionIdentifier)
1062
+ sessionPendingClientSequence.delete(sessionIdentifier)
1063
+ sessionSendCodec.delete(sessionIdentifier)
1064
+ sessionCompressor.delete(sessionIdentifier)
1065
+ expireSessionTargetLater(sessionIdentifier)
1066
+ }
1067
+ }
1068
+ // A TLS socket can emit close after delivering bytes but before the
1069
+ // promise-backed frame queue has processed them. Settle every frame that
1070
+ // was received first so an intentional session.close wins over the
1071
+ // transport-detach/resume path.
1072
+ const settleAndForget = () => {
1073
+ try {
1074
+ settleTunnelClose()
1075
+ } finally {
1076
+ tunnelCloseSettlements.delete(socket)
1077
+ }
1078
+ }
1079
+ const settlement = tunnelFrameQueue.then(settleAndForget, settleAndForget)
1080
+ tunnelCloseSettlement.promise = settlement
1081
+ })
1082
+ socket.on("error", () => {
1083
+ clearHandshakeTimeout()
1084
+ })
1085
+ }
1086
+
1087
+ await new Promise((resolve, reject) => {
1088
+ server.once("error", reject)
1089
+ server.listen(options.port ?? 0, options.host ?? "127.0.0.1", () => {
1090
+ server.off("error", reject)
1091
+ resolve(undefined)
1092
+ })
1093
+ })
1094
+
1095
+ const address = server.address()
1096
+ if (address === null || typeof address === "string") {
1097
+ throw new Error("Relay did not bind to a TCP port")
1098
+ }
1099
+
1100
+ return {
1101
+ server,
1102
+ port: address.port,
1103
+ status: () => {
1104
+ let announcedSessions = 0
1105
+ for (const lifecycle of sessionLifecycles.values()) {
1106
+ if (lifecycle.announced) announcedSessions += 1
1107
+ }
1108
+ return {
1109
+ activeTunnels: tunnels.size,
1110
+ activeTargets: targets.size,
1111
+ sessions: sessionLifecycles.size,
1112
+ attachedSessions: sessionSenders.size,
1113
+ detachedSessions: Math.max(0, announcedSessions - sessionSenders.size),
1114
+ bufferedTargetBytes: totalProcessBufferBytes,
1115
+ targetConnectionAttempts: sessionInitialTargetAttempts.size,
1116
+ targetRetryTimers: sessionInitialTargetRetryTimers.size
1117
+ }
1118
+ },
1119
+ closeActiveTunnel: async () => {
1120
+ const closing = [...tunnelCloseSettlements].map(async ([tunnel, closeSettlement]) => {
1121
+ if (!tunnel.closed) {
1122
+ await new Promise((resolve) => {
1123
+ tunnel.once("close", resolve)
1124
+ tunnel.destroy()
1125
+ })
1126
+ }
1127
+ if (closeSettlement?.promise === undefined) {
1128
+ throw new Error("Tunnel close settlement was not registered")
1129
+ }
1130
+ await closeSettlement.promise
1131
+ })
1132
+ await Promise.all(closing)
1133
+ },
1134
+ close: async () => {
1135
+ closing = true
1136
+ for (const timer of sessionInitialTargetRetryTimers.values()) clearTimeout(timer)
1137
+ sessionInitialTargetRetryTimers.clear()
1138
+ for (const timer of sessionInitialTargetConnectTimers.values()) clearTimeout(timer)
1139
+ sessionInitialTargetConnectTimers.clear()
1140
+ for (const attempt of sessionInitialTargetAttempts.values()) {
1141
+ attempt.removeAllListeners()
1142
+ attempt.destroy()
1143
+ }
1144
+ sessionInitialTargetAttempts.clear()
1145
+ for (const [identifier, lifecycle] of sessionLifecycles) {
1146
+ if (!lifecycle.announced) options.sessions.delete(identifier)
1147
+ }
1148
+ for (const tunnel of tunnels) {
1149
+ tunnel.destroy()
1150
+ }
1151
+ for (const target of targets) {
1152
+ target.destroy()
1153
+ }
1154
+ sessionTargets.clear()
1155
+ sessionSenders.clear()
1156
+ sessionTargetBuffers.clear()
1157
+ sessionTargetSentChunks.clear()
1158
+ sessionTargetTerminalState.clear()
1159
+ sessionPendingClientSequence.clear()
1160
+ sessionSendCodec.clear()
1161
+ sessionCompressor.clear()
1162
+ sessionSendQueue.clear()
1163
+ sessionQueuedTargetBatch.clear()
1164
+ for (const lifecycle of sessionLifecycles.values()) lifecycle.active = false
1165
+ sessionLifecycles.clear()
1166
+ sessionTargetBufferBytes.clear()
1167
+ totalProcessBufferBytes = 0
1168
+ for (const timer of sessionTargetExpiryTimers.values()) {
1169
+ clearTimeout(timer)
1170
+ }
1171
+ sessionTargetExpiryTimers.clear()
1172
+ await new Promise((resolve, reject) => {
1173
+ server.close((error) => {
1174
+ if (error) {
1175
+ reject(error)
1176
+ return
1177
+ }
1178
+ resolve(undefined)
1179
+ })
1180
+ })
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ /**
1186
+ * @param {unknown} value - Candidate sequence value.
1187
+ * @returns {value is number} - True if valid.
1188
+ */
1189
+ function isValidStreamDataSequence(value) {
1190
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0
1191
+ }