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,663 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-jsdoc, jsdoc/require-param, jsdoc/require-property-description, jsdoc/require-returns */
2
+ import {EventEmitter} from "node:events"
3
+ import {createConnection as netConnect} from "node:net"
4
+
5
+ import {availableCodecs, getMaxDecompressedBytes, ZSTD_CODEC, zstdAvailable} from "../protocol/compression.js"
6
+ import {errorDiagnostics, logDiagnostic} from "../logging/structured-logger.js"
7
+ import {decodeV3Data, encodeV3Data, AdaptiveCompressor} from "../protocol/v3-compression.js"
8
+ import {decodeV3Control, encodeV3Control, encodeV3Frame, V3FrameDecoder, V3FrameType} from "../protocol/v3-frame.js"
9
+ import {
10
+ asError,
11
+ createHubSocket,
12
+ DEFAULT_CONNECT_TIMEOUT_MS,
13
+ DEFAULT_FRAME_BYTES,
14
+ DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS,
15
+ DEFAULT_MAX_STREAMS,
16
+ DEFAULT_WINDOW,
17
+ GATEWAY_ID,
18
+ MAX_CONTROL_BYTES,
19
+ parseTarget,
20
+ positiveInteger,
21
+ resolveVerifiedTls,
22
+ STABLE_IDENTIFIER,
23
+ writeSocket
24
+ } from "./common.js"
25
+
26
+ const DEFAULT_STREAM_BUFFER_BYTES = 4 * 1024 * 1024
27
+ const DEFAULT_TOTAL_BUFFER_BYTES = 32 * 1024 * 1024
28
+
29
+ /**
30
+ * @typedef {object} OutboundReverseGatewayOptions
31
+ * @property {string} gatewayId - Stable configured gateway identity.
32
+ * @property {string} connectionId - Stable process identity retained across reconnects.
33
+ * @property {{host: string, port: number, token: string}} hub - Server3 hub endpoint and bearer credential.
34
+ * @property {{caFile?: string, certFile?: string, keyFile?: string, servername?: string}} tls - Mandatory verified outer TLS.
35
+ * @property {(target: {host: string, port: number}) => boolean} authorizeTarget - Local deny-by-default target policy.
36
+ * @property {(target: {host: string, port: number}) => import('node:net').Socket} [createTargetConnection] - Local target socket factory.
37
+ * @property {number} [initialWindowBytes] - Per-stream flow-control window.
38
+ * @property {number} [maxFrameBytes] - Maximum uncompressed DATA frame.
39
+ * @property {number} [maxStreams] - Concurrent local target bound.
40
+ * @property {number} [maxStreamBufferBytes] - Per-stream unacknowledged target output bound.
41
+ * @property {number} [maxTotalBufferBytes] - Gateway-wide unacknowledged target output bound.
42
+ * @property {number} [connectTimeoutMs] - TLS/auth/register deadline.
43
+ * @property {number} [targetConnectTimeoutMs] - Local target connection deadline.
44
+ * @property {number} [reconnectDelayMs] - Initial reconnect delay.
45
+ * @property {number} [maxReconnectDelayMs] - Maximum reconnect delay.
46
+ * @property {number} [initialGeneration] - Monotonic seed for controlled replacement; defaults to wall-clock microseconds.
47
+ * @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - zstd safety bounds.
48
+ * @property {(options: {host: string, port: number, tls: object}) => import('node:tls').TLSSocket} [createPhysicalSocket] - Test transport factory.
49
+ * @property {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional privacy-safe structured diagnostic logger.
50
+ * @typedef {object} GatewayReplay
51
+ * @property {number} sequence
52
+ * @property {Buffer} data
53
+ * @property {boolean} sent
54
+ * @typedef {object} GatewayStream
55
+ * @property {number} id
56
+ * @property {string} identifier
57
+ * @property {{host: string, port: number}} target
58
+ * @property {import('node:net').Socket} targetSocket
59
+ * @property {'opening' | 'open' | 'half-closed'} state
60
+ * @property {number} receiveSequence
61
+ * @property {number} receiveAck
62
+ * @property {boolean} remoteEnded
63
+ * @property {number} sendSequence
64
+ * @property {number} sendAck
65
+ * @property {number} sendWindow
66
+ * @property {number} sendWindowLimit
67
+ * @property {number} bufferedBytes
68
+ * @property {GatewayReplay[]} replay
69
+ * @property {AdaptiveCompressor} compressor
70
+ * @property {Promise<void>} targetQueue
71
+ * @property {boolean} pumping
72
+ * @property {boolean} targetEnded
73
+ * @property {boolean} targetClosed
74
+ * @property {string | undefined} targetResetCode
75
+ * @property {boolean} endSent
76
+ * @property {ReturnType<typeof setTimeout> | undefined} openTimer
77
+ * @property {boolean} targetOpened
78
+ * @property {boolean} targetTerminalLogged
79
+ */
80
+
81
+ /** One outbound home member carrying many hub-opened local target streams. */
82
+ export class OutboundReverseGateway extends EventEmitter {
83
+ /** @type {OutboundReverseGatewayOptions} */ #options
84
+ /** @type {import('node:tls').TLSSocket | undefined} */ #physical
85
+ /** @type {Map<number, GatewayStream>} */ #streams = new Map()
86
+ /** @type {number} */ #lastStreamId = 0
87
+ /** @type {'configured' | 'connecting' | 'registering' | 'ready' | 'reconnecting' | 'closed'} */ #state = "configured"
88
+ /** @type {number} */ #generation
89
+ /** @type {string | undefined} */ #codec
90
+ /** @type {boolean} */ #closing = false
91
+ /** @type {boolean} */ #fatal = false
92
+ /** @type {boolean} */ #referenced = true
93
+ /** @type {ReturnType<typeof setTimeout> | undefined} */ #reconnectTimer
94
+ /** @type {Promise<void> | undefined} */ #startPromise
95
+ /** @type {(() => void) | undefined} */ #resolveStart
96
+ /** @type {((error: Error) => void) | undefined} */ #rejectStart
97
+ /** @type {number} */ #reconnectDelay
98
+ /** @type {number} */ #sendWindowLimit
99
+ /** @type {number} */ #physicalConnects = 0
100
+ /** @type {number} */ #physicalAttempts = 0
101
+ /** @type {number} */ #reconnectAttempts = 0
102
+ /** @type {number} */ #targetOpens = 0
103
+ /** @type {number} */ #targetOpenFailures = 0
104
+ /** @type {number} */ #bufferedBytes = 0
105
+ /** @type {number} */ #rawDataBytes = 0
106
+ /** @type {number} */ #wireDataBytes = 0
107
+
108
+ /** @param {OutboundReverseGatewayOptions} options - Identity, hub, TLS and local policy. */
109
+ constructor(options) {
110
+ super()
111
+ validateOptions(options)
112
+ this.#options = options
113
+ this.#generation = options.initialGeneration ?? Date.now() * 1000
114
+ this.#reconnectDelay = options.reconnectDelayMs ?? 250
115
+ this.#sendWindowLimit = this.#initialWindow()
116
+ }
117
+
118
+ /** Starts and resolves after authenticated registration. */
119
+ start() {
120
+ if (this.#startPromise !== undefined) return this.#startPromise
121
+ if (this.#closing) return Promise.reject(closedError())
122
+ this.#startPromise = new Promise((resolve, reject) => { this.#resolveStart = resolve; this.#rejectStart = reject })
123
+ this.#connect()
124
+ return this.#startPromise
125
+ }
126
+
127
+ /** Alias for lifecycle symmetry with client transports. */
128
+ connect() { return this.start() }
129
+
130
+ /** Privacy-safe runtime state and compression accounting. */
131
+ status() {
132
+ return {
133
+ physicalState: this.#state,
134
+ connectionGeneration: this.#generation,
135
+ physicalConnects: this.#physicalConnects,
136
+ reconnectAttempts: this.#reconnectAttempts,
137
+ reconnectSuccesses: Math.max(0, this.#physicalConnects - 1),
138
+ virtualStreams: this.#streams.size,
139
+ activeTargets: [...this.#streams.values()].filter((stream) => stream.state !== "opening").length,
140
+ targetOpens: this.#targetOpens,
141
+ targetOpenFailures: this.#targetOpenFailures,
142
+ queuedBytes: this.#bufferedBytes,
143
+ spoolBytes: 0,
144
+ spoolCleanupFailures: 0,
145
+ compression: this.#codec ?? null,
146
+ rawDataBytes: this.#rawDataBytes,
147
+ wireDataBytes: this.#wireDataBytes,
148
+ compressionBytesSaved: Math.max(0, this.#rawDataBytes - this.#wireDataBytes)
149
+ }
150
+ }
151
+
152
+ /** Current local TLS address for socket-shaped metadata. */
153
+ physicalAddress() {
154
+ return {address: this.#physical?.localAddress, port: this.#physical?.localPort}
155
+ }
156
+
157
+ ref() { this.#referenced = true; this.#physical?.ref(); this.#reconnectTimer?.ref?.(); return this }
158
+ unref() { this.#referenced = false; this.#physical?.unref(); this.#reconnectTimer?.unref?.(); return this }
159
+
160
+ /** Gracefully retires this member and every local target. */
161
+ async close() {
162
+ if (this.#closing) return
163
+ this.#closing = true
164
+ if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
165
+ this.#reconnectTimer = undefined
166
+ this.#setState("closed")
167
+ for (const stream of [...this.#streams.values()]) this.#destroyStream(stream)
168
+ const physical = this.#physical
169
+ this.#physical = undefined
170
+ if (physical !== undefined && !physical.destroyed) {
171
+ await writeSocket(physical, encodeV3Frame({type: V3FrameType.GOAWAY, streamId: 0, sequence: 0}, encodeV3Control({reason: "gateway_shutdown"}))).catch(() => false)
172
+ physical.destroy()
173
+ }
174
+ this.#rejectStart?.(closedError())
175
+ this.#settleStart()
176
+ }
177
+
178
+ #connect() {
179
+ if (this.#closing || this.#fatal) return
180
+ this.#sendWindowLimit = this.#initialWindow()
181
+ this.#setState(this.#physicalConnects === 0 ? "connecting" : "reconnecting")
182
+ this.#generation += 1
183
+ this.#physicalAttempts += 1
184
+ const generation = this.#generation
185
+ logDiagnostic(this.#options.logger, "transport.connecting", {
186
+ generation,
187
+ attempt: this.#physicalAttempts,
188
+ gatewayId: this.#options.gatewayId,
189
+ reconnect: this.#physicalAttempts > 1
190
+ })
191
+ const tls = resolveVerifiedTls(this.#options.tls)
192
+ let physical
193
+ try {
194
+ physical = (this.#options.createPhysicalSocket ?? createHubSocket)({host: this.#options.hub.host, port: this.#options.hub.port, tls})
195
+ } catch (error) {
196
+ logDiagnostic(this.#options.logger, "transport.failed", {
197
+ generation,
198
+ attempt: this.#physicalAttempts,
199
+ gatewayId: this.#options.gatewayId,
200
+ ...errorDiagnostics(error)
201
+ })
202
+ this.#scheduleReconnect(error)
203
+ return
204
+ }
205
+ this.#physical = physical
206
+ if (!this.#referenced) physical.unref()
207
+ physical.setNoDelay(true)
208
+ physical.setKeepAlive(true, DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS)
209
+ const decoder = new V3FrameDecoder({maxFrameBytes: Math.max(MAX_CONTROL_BYTES, this.#maxFrameBytes() + 4), maxBufferedBytes: Math.max(MAX_CONTROL_BYTES, this.#maxFrameBytes() + 4) * 2})
210
+ let queue = Promise.resolve()
211
+ /** @type {Error | undefined} */
212
+ let attemptError
213
+ let authenticated = false
214
+ let registered = false
215
+ const timer = setTimeout(() => physical.destroy(Object.assign(new Error("Reverse gateway connect/auth/register timeout"), {code: "SOCKETDUCT_AUTH_TIMEOUT"})), this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
216
+ timer.unref?.()
217
+ physical.once("secureConnect", () => {
218
+ if (this.#physical !== physical || physical.destroyed) return
219
+ const auth = encodeV3Control({
220
+ role: "gateway",
221
+ token: this.#options.hub.token,
222
+ versions: [3],
223
+ connectionId: this.#options.connectionId,
224
+ generation,
225
+ compression: availableCodecs()
226
+ })
227
+ void writeSocket(physical, encodeV3Frame({type: V3FrameType.AUTH, streamId: 0, sequence: generation}, auth))
228
+ })
229
+ physical.on("data", (chunk) => {
230
+ physical.pause()
231
+ queue = queue.then(async () => {
232
+ for (const frame of decoder.push(chunk)) {
233
+ if (frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.GOAWAY) {
234
+ const detail = decodeV3Control(frame.payload)
235
+ throw connectionError(typeof detail.code === "string" ? detail.code : "CONNECTION_REJECTED")
236
+ }
237
+ if (!authenticated) {
238
+ if (frame.type !== V3FrameType.AUTH_OK) throw fatalError("AUTH_NEGOTIATION_FAILED")
239
+ const detail = decodeV3Control(frame.payload)
240
+ if (detail.version !== 3 || detail.role !== "gateway" || detail.connectionId !== this.#options.connectionId || detail.generation !== generation || detail.compression !== ZSTD_CODEC || !validPeerWindow(detail.initialWindow)) throw fatalError("AUTH_NEGOTIATION_FAILED")
241
+ authenticated = true
242
+ this.#codec = ZSTD_CODEC
243
+ this.#sendWindowLimit = Math.min(this.#initialWindow(), detail.initialWindow)
244
+ this.#setState("registering")
245
+ await this.#sendControl(V3FrameType.GATEWAY_REGISTER, 0, generation, {gatewayId: this.#options.gatewayId})
246
+ continue
247
+ }
248
+ if (!registered) {
249
+ if (frame.type !== V3FrameType.GATEWAY_REGISTERED) throw fatalError("GATEWAY_REGISTRATION_FAILED")
250
+ const detail = decodeV3Control(frame.payload)
251
+ if (detail.gatewayId !== this.#options.gatewayId || detail.generation !== generation) throw fatalError("GATEWAY_REGISTRATION_FAILED")
252
+ registered = true
253
+ clearTimeout(timer)
254
+ this.#physicalConnects += 1
255
+ this.#reconnectDelay = this.#options.reconnectDelayMs ?? 250
256
+ this.#setState("ready")
257
+ logDiagnostic(this.#options.logger, "transport.connected", {
258
+ generation,
259
+ attempt: this.#physicalAttempts,
260
+ gatewayId: this.#options.gatewayId,
261
+ physicalConnects: this.#physicalConnects,
262
+ reconnect: this.#physicalConnects > 1
263
+ })
264
+ if (this.#physicalConnects > 1) {
265
+ logDiagnostic(this.#options.logger, "transport.reconnect_succeeded", {
266
+ generation,
267
+ attempt: this.#reconnectAttempts,
268
+ gatewayId: this.#options.gatewayId,
269
+ physicalConnects: this.#physicalConnects
270
+ })
271
+ }
272
+ this.#resolveStart?.()
273
+ this.#settleStart()
274
+ continue
275
+ }
276
+ await this.#handleFrame(frame)
277
+ }
278
+ }).catch((error) => {
279
+ attemptError = asError(error)
280
+ if (/** @type {{fatal?: unknown}} */ (attemptError).fatal === true) this.#fatal = true
281
+ physical.destroy(attemptError)
282
+ }).finally(() => {
283
+ if (!physical.destroyed) physical.resume()
284
+ })
285
+ })
286
+ physical.once("error", (error) => { attemptError = error; if (isFatalTransportError(error)) this.#fatal = true })
287
+ physical.once("close", () => {
288
+ clearTimeout(timer)
289
+ void queue.finally(() => {
290
+ if (this.#physical !== physical) return
291
+ this.#physical = undefined
292
+ this.#codec = undefined
293
+ if (!this.#closing && !this.#fatal) this.#setState("reconnecting")
294
+ if (!this.#closing) {
295
+ logDiagnostic(this.#options.logger, "transport.disconnected", {
296
+ generation,
297
+ gatewayId: this.#options.gatewayId,
298
+ virtualStreams: this.#streams.size,
299
+ ...errorDiagnostics(attemptError, "TRANSPORT_CLOSED")
300
+ })
301
+ }
302
+ for (const stream of [...this.#streams.values()]) this.#destroyStream(stream, "GATEWAY_DISCONNECTED")
303
+ this.#lastStreamId = 0
304
+ if (this.#closing) return
305
+ if (this.#fatal) {
306
+ this.#setState("closed")
307
+ const failure = attemptError ?? new Error("Reverse gateway registration failed")
308
+ logDiagnostic(this.#options.logger, "transport.failed", {
309
+ generation,
310
+ attempt: this.#physicalAttempts,
311
+ gatewayId: this.#options.gatewayId,
312
+ ...errorDiagnostics(failure, "GATEWAY_REGISTRATION_FAILED")
313
+ })
314
+ this.#rejectStart?.(failure)
315
+ this.#settleStart()
316
+ return
317
+ }
318
+ this.#scheduleReconnect(attemptError)
319
+ })
320
+ })
321
+ }
322
+
323
+ /** @param {import('../protocol/v3-frame.js').V3Frame} frame - Hub frame. */
324
+ async #handleFrame(frame) {
325
+ if (frame.type === V3FrameType.PING) { await this.#sendControl(V3FrameType.PONG, 0, frame.sequence, {}); return }
326
+ if (frame.type === V3FrameType.PONG) return
327
+ if (frame.type === V3FrameType.OPEN) { await this.#openStream(frame); return }
328
+ const stream = this.#streams.get(frame.streamId)
329
+ if (stream === undefined) {
330
+ if (frame.type !== V3FrameType.CLOSE && frame.type !== V3FrameType.RESET) await this.#sendControl(V3FrameType.RESET, frame.streamId, 0, {code: "UNKNOWN_STREAM"})
331
+ return
332
+ }
333
+ if (stream.state === "opening" && frame.type !== V3FrameType.RESET) throw new Error("Reverse stream received data before target readiness")
334
+ if (frame.type === V3FrameType.DATA) {
335
+ if (stream.remoteEnded) throw new Error("Reverse stream received DATA after END")
336
+ const data = await decodeV3Data(frame, this.#codec, this.#options.compressionLimits)
337
+ if (data.length === 0 || data.length > this.#maxFrameBytes() || frame.sequence !== stream.receiveSequence + data.length || frame.sequence - stream.receiveAck > this.#initialWindow()) throw new Error("Invalid reverse stream DATA sequence or window")
338
+ try {
339
+ await writeTarget(stream.targetSocket, data)
340
+ } catch {
341
+ this.#resetStream(stream, "TARGET_CLOSED")
342
+ return
343
+ }
344
+ stream.receiveSequence = frame.sequence
345
+ stream.receiveAck = frame.sequence
346
+ await this.#sendControl(V3FrameType.ACK, stream.id, frame.sequence, {})
347
+ return
348
+ }
349
+ if (frame.type === V3FrameType.ACK) { this.#acknowledge(stream, frame.sequence); return }
350
+ if (frame.type === V3FrameType.WINDOW) { this.#addWindow(stream, frame.sequence); return }
351
+ if (frame.type === V3FrameType.END) {
352
+ if (stream.remoteEnded || frame.sequence !== stream.receiveSequence) throw new Error("Invalid reverse stream END")
353
+ stream.remoteEnded = true
354
+ stream.state = "half-closed"
355
+ stream.targetSocket.end()
356
+ return
357
+ }
358
+ if (frame.type === V3FrameType.CLOSE || frame.type === V3FrameType.RESET) { this.#destroyStream(stream); return }
359
+ throw new Error("Unsupported reverse gateway stream frame")
360
+ }
361
+
362
+ /** @param {import('../protocol/v3-frame.js').V3Frame} frame - Hub OPEN. */
363
+ async #openStream(frame) {
364
+ if (frame.sequence !== 0 || frame.streamId % 2 !== 0 || frame.streamId <= this.#lastStreamId) throw new Error("Invalid reverse gateway stream ID")
365
+ this.#lastStreamId = frame.streamId
366
+ if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) {
367
+ logDiagnostic(this.#options.logger, "reverse_gateway.target_open_failed", {
368
+ streamId: frame.streamId,
369
+ generation: this.#generation,
370
+ gatewayId: this.#options.gatewayId,
371
+ ...errorDiagnostics({code: "GATEWAY_STREAM_LIMIT"})
372
+ })
373
+ await this.#sendControl(V3FrameType.RESET, frame.streamId, 0, {code: "GATEWAY_STREAM_LIMIT"})
374
+ return
375
+ }
376
+ const request = decodeV3Control(frame.payload)
377
+ const target = parseTarget(request.target)
378
+ if (target === undefined || typeof request.identifier !== "string" || !STABLE_IDENTIFIER.test(request.identifier) || typeof request.name !== "string" || request.name.length === 0 || request.name.length > 256 || !validPeerWindow(request.initialWindow) || this.#options.authorizeTarget(target) !== true) {
379
+ const code = target === undefined ? "INVALID_STREAM_OPEN" : "TARGET_UNAUTHORIZED"
380
+ logDiagnostic(this.#options.logger, "reverse_gateway.target_open_failed", {
381
+ streamId: frame.streamId,
382
+ generation: this.#generation,
383
+ gatewayId: this.#options.gatewayId,
384
+ ...errorDiagnostics({code})
385
+ })
386
+ await this.#sendControl(V3FrameType.RESET, frame.streamId, 0, {code})
387
+ return
388
+ }
389
+ let targetSocket
390
+ try {
391
+ targetSocket = (this.#options.createTargetConnection ?? ((endpoint) => netConnect(endpoint)))(target)
392
+ } catch (error) {
393
+ this.#targetOpenFailures += 1
394
+ logDiagnostic(this.#options.logger, "reverse_gateway.target_open_failed", {
395
+ streamId: frame.streamId,
396
+ generation: this.#generation,
397
+ gatewayId: this.#options.gatewayId,
398
+ ...errorDiagnostics(error, "TARGET_OPEN_FAILED")
399
+ })
400
+ await this.#sendControl(V3FrameType.RESET, frame.streamId, 0, {code: "TARGET_OPEN_FAILED"})
401
+ return
402
+ }
403
+ /** @type {GatewayStream} */
404
+ const stream = {
405
+ id: frame.streamId,
406
+ identifier: request.identifier,
407
+ target,
408
+ targetSocket,
409
+ state: "opening",
410
+ receiveSequence: 0,
411
+ receiveAck: 0,
412
+ remoteEnded: false,
413
+ sendSequence: 0,
414
+ sendAck: 0,
415
+ sendWindow: Math.min(this.#sendWindowLimit, request.initialWindow),
416
+ sendWindowLimit: Math.min(this.#sendWindowLimit, request.initialWindow),
417
+ bufferedBytes: 0,
418
+ replay: [],
419
+ compressor: new AdaptiveCompressor(),
420
+ targetQueue: Promise.resolve(),
421
+ pumping: false,
422
+ targetEnded: false,
423
+ targetClosed: false,
424
+ targetResetCode: undefined,
425
+ endSent: false,
426
+ openTimer: undefined,
427
+ targetOpened: false,
428
+ targetTerminalLogged: false
429
+ }
430
+ this.#streams.set(stream.id, stream)
431
+ stream.openTimer = setTimeout(() => this.#resetStream(stream, "TARGET_OPEN_TIMEOUT"), this.#options.targetConnectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
432
+ stream.openTimer.unref?.()
433
+ targetSocket.once("connect", () => {
434
+ if (!this.#streams.has(stream.id)) return
435
+ if (stream.openTimer !== undefined) clearTimeout(stream.openTimer)
436
+ stream.openTimer = undefined
437
+ stream.state = "open"
438
+ stream.targetOpened = true
439
+ this.#targetOpens += 1
440
+ logDiagnostic(this.#options.logger, "reverse_gateway.target_opened", {
441
+ streamId: stream.id,
442
+ generation: this.#generation,
443
+ gatewayId: this.#options.gatewayId
444
+ })
445
+ void this.#sendControl(V3FrameType.OPENED, stream.id, 0, {identifier: stream.identifier, initialWindow: this.#initialWindow()})
446
+ })
447
+ targetSocket.on("data", (chunk) => {
448
+ targetSocket.pause()
449
+ stream.targetQueue = stream.targetQueue.then(() => this.#retainTargetData(stream, chunk)).catch(() => this.#resetStream(stream, "TARGET_READ_FAILED"))
450
+ })
451
+ targetSocket.once("end", () => {
452
+ stream.targetQueue = stream.targetQueue.then(async () => {
453
+ if (!this.#streams.has(stream.id)) return
454
+ stream.targetEnded = true
455
+ await this.#pumpStream(stream)
456
+ }).catch(() => this.#resetStream(stream, "TARGET_READ_FAILED"))
457
+ })
458
+ targetSocket.once("error", () => {
459
+ if (!this.#streams.has(stream.id)) return
460
+ const code = stream.state === "opening" ? "TARGET_OPEN_FAILED" : "TARGET_CLOSED"
461
+ this.#targetOpenFailures += stream.state === "opening" ? 1 : 0
462
+ this.#logTargetTerminal(stream, code)
463
+ stream.targetQueue = stream.targetQueue.then(async () => {
464
+ if (!this.#streams.has(stream.id)) return
465
+ stream.targetResetCode = code
466
+ await this.#pumpStream(stream)
467
+ }).catch(() => this.#resetStream(stream, "TARGET_READ_FAILED"))
468
+ })
469
+ targetSocket.once("close", () => {
470
+ this.#logTargetTerminal(stream, "TARGET_CLOSED")
471
+ stream.targetQueue = stream.targetQueue.then(async () => {
472
+ if (!this.#streams.has(stream.id)) return
473
+ stream.targetClosed = true
474
+ await this.#pumpStream(stream)
475
+ }).catch(() => this.#resetStream(stream, "TARGET_READ_FAILED"))
476
+ })
477
+ }
478
+
479
+ /** @param {GatewayStream} stream - Stream. @param {Buffer} chunk - Target output. */
480
+ async #retainTargetData(stream, chunk) {
481
+ if (!this.#streams.has(stream.id) || stream.state === "opening") return
482
+ for (let offset = 0; offset < chunk.length; offset += this.#maxFrameBytes()) {
483
+ const data = Buffer.from(chunk.subarray(offset, Math.min(chunk.length, offset + this.#maxFrameBytes())))
484
+ if (stream.bufferedBytes + data.length > (this.#options.maxStreamBufferBytes ?? DEFAULT_STREAM_BUFFER_BYTES) || this.#bufferedBytes + data.length > (this.#options.maxTotalBufferBytes ?? DEFAULT_TOTAL_BUFFER_BYTES)) {
485
+ this.#resetStream(stream, "BUFFER_LIMIT")
486
+ return
487
+ }
488
+ stream.sendSequence += data.length
489
+ stream.replay.push({sequence: stream.sendSequence, data, sent: false})
490
+ stream.bufferedBytes += data.length
491
+ this.#bufferedBytes += data.length
492
+ }
493
+ await this.#pumpStream(stream)
494
+ }
495
+
496
+ /** @param {GatewayStream} stream - Stream. */
497
+ async #pumpStream(stream) {
498
+ if (stream.pumping || !this.#streams.has(stream.id) || this.#state !== "ready") return
499
+ stream.pumping = true
500
+ try {
501
+ for (const chunk of stream.replay) {
502
+ if (chunk.sent) continue
503
+ if (chunk.data.length > stream.sendWindow) break
504
+ const encoded = await encodeV3Data(chunk.data, this.#codec, stream.compressor, this.#options.compressionLimits)
505
+ if (!this.#streams.has(stream.id) || this.#state !== "ready") return
506
+ this.#rawDataBytes += encoded.rawBytes
507
+ this.#wireDataBytes += encoded.wireBytes
508
+ const sent = await writeSocket(/** @type {import('node:tls').TLSSocket} */ (this.#physical), encodeV3Frame({type: V3FrameType.DATA, streamId: stream.id, sequence: chunk.sequence, flags: encoded.flags}, encoded.payload))
509
+ if (!sent) return
510
+ chunk.sent = true
511
+ stream.sendWindow -= chunk.data.length
512
+ }
513
+ if (!stream.replay.some((chunk) => !chunk.sent)) {
514
+ if (stream.targetResetCode !== undefined) {
515
+ const code = stream.targetResetCode
516
+ stream.targetResetCode = undefined
517
+ await this.#sendControl(V3FrameType.RESET, stream.id, 0, {code})
518
+ this.#destroyStream(stream, code)
519
+ return
520
+ }
521
+ if (stream.targetEnded && !stream.endSent) {
522
+ stream.endSent = true
523
+ await this.#sendControl(V3FrameType.END, stream.id, stream.sendSequence, {})
524
+ }
525
+ if (stream.targetClosed && (!stream.targetEnded || stream.endSent)) {
526
+ await this.#sendControl(V3FrameType.CLOSE, stream.id, stream.sendSequence, {})
527
+ this.#destroyStream(stream)
528
+ }
529
+ }
530
+ } finally {
531
+ stream.pumping = false
532
+ if (this.#streams.has(stream.id) && !stream.replay.some((chunk) => !chunk.sent) && stream.bufferedBytes < (this.#options.maxStreamBufferBytes ?? DEFAULT_STREAM_BUFFER_BYTES) && this.#bufferedBytes < (this.#options.maxTotalBufferBytes ?? DEFAULT_TOTAL_BUFFER_BYTES)) stream.targetSocket.resume()
533
+ }
534
+ }
535
+
536
+ /** @param {GatewayStream} stream - Stream. @param {number} sequence - Cumulative ack. */
537
+ #acknowledge(stream, sequence) {
538
+ if (!Number.isSafeInteger(sequence) || sequence < stream.sendAck || sequence > stream.sendSequence) throw new Error("Invalid reverse gateway ACK")
539
+ if (sequence === stream.sendAck) return
540
+ const delta = sequence - stream.sendAck
541
+ stream.sendAck = sequence
542
+ let released = 0
543
+ stream.replay = stream.replay.filter((chunk) => { if (chunk.sequence <= sequence) { released += chunk.data.length; return false } return true })
544
+ stream.bufferedBytes = Math.max(0, stream.bufferedBytes - released)
545
+ this.#bufferedBytes = Math.max(0, this.#bufferedBytes - released)
546
+ stream.sendWindow = Math.min(stream.sendWindowLimit, stream.sendWindow + delta)
547
+ void this.#pumpStream(stream).catch(() => this.#resetStream(stream, "TARGET_READ_FAILED"))
548
+ }
549
+
550
+ /** @param {GatewayStream} stream - Stream. @param {number} credit - Window credit. */
551
+ #addWindow(stream, credit) {
552
+ if (!Number.isSafeInteger(credit) || credit <= 0 || credit > stream.sendWindowLimit) throw new Error("Invalid reverse gateway WINDOW")
553
+ stream.sendWindow = Math.min(stream.sendWindowLimit, stream.sendWindow + credit)
554
+ void this.#pumpStream(stream).catch(() => this.#resetStream(stream, "TARGET_READ_FAILED"))
555
+ }
556
+
557
+ /** @param {GatewayStream} stream - Stream. @param {string} code - Reset code. */
558
+ #resetStream(stream, code) {
559
+ if (!this.#streams.has(stream.id)) return
560
+ this.#logTargetTerminal(stream, code)
561
+ void this.#sendControl(V3FrameType.RESET, stream.id, 0, {code})
562
+ this.#destroyStream(stream)
563
+ }
564
+
565
+ /** @param {GatewayStream} stream - Stream. */
566
+ #destroyStream(stream, code = "TARGET_CLOSED") {
567
+ if (!this.#streams.delete(stream.id)) return
568
+ this.#logTargetTerminal(stream, code)
569
+ if (stream.openTimer !== undefined) clearTimeout(stream.openTimer)
570
+ this.#bufferedBytes = Math.max(0, this.#bufferedBytes - stream.bufferedBytes)
571
+ stream.replay = []
572
+ stream.bufferedBytes = 0
573
+ stream.targetSocket.destroy()
574
+ }
575
+
576
+ /** @param {GatewayStream} stream - Stream. @param {string} code - Terminal classification. */
577
+ #logTargetTerminal(stream, code) {
578
+ if (stream.targetTerminalLogged) return
579
+ stream.targetTerminalLogged = true
580
+ logDiagnostic(this.#options.logger, stream.targetOpened ? "reverse_gateway.target_closed" : "reverse_gateway.target_open_failed", {
581
+ streamId: stream.id,
582
+ generation: this.#generation,
583
+ gatewayId: this.#options.gatewayId,
584
+ ...errorDiagnostics({code}, stream.targetOpened ? "TARGET_CLOSED" : "TARGET_OPEN_FAILED")
585
+ })
586
+ }
587
+
588
+ /** @param {number} type - Type. @param {number} streamId - Stream. @param {number} sequence - Sequence. @param {Record<string, unknown>} body - Body. */
589
+ async #sendControl(type, streamId, sequence, body) {
590
+ const physical = this.#physical
591
+ if (physical === undefined || physical.destroyed) return false
592
+ return await writeSocket(physical, encodeV3Frame({type, streamId, sequence}, encodeV3Control(body)))
593
+ }
594
+
595
+ #scheduleReconnect(/** @type {unknown} */ error = undefined) {
596
+ if (this.#closing || this.#fatal || this.#reconnectTimer !== undefined) return
597
+ this.#reconnectAttempts += 1
598
+ this.#setState("reconnecting")
599
+ const delay = this.#reconnectDelay
600
+ logDiagnostic(this.#options.logger, "transport.reconnect_scheduled", {
601
+ generation: this.#generation,
602
+ attempt: this.#reconnectAttempts,
603
+ delayMs: delay,
604
+ gatewayId: this.#options.gatewayId,
605
+ ...errorDiagnostics(error, "TRANSPORT_CLOSED")
606
+ })
607
+ this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, this.#options.maxReconnectDelayMs ?? 30_000)
608
+ this.#reconnectTimer = setTimeout(() => { this.#reconnectTimer = undefined; this.#connect() }, delay)
609
+ if (!this.#referenced) this.#reconnectTimer.unref?.()
610
+ }
611
+
612
+ #settleStart() { this.#resolveStart = undefined; this.#rejectStart = undefined }
613
+ #setState(/** @type {'configured' | 'connecting' | 'registering' | 'ready' | 'reconnecting' | 'closed'} */ state) { if (this.#state === state) return; this.#state = state; this.emit("stateChange", state) }
614
+ #initialWindow() { return this.#options.initialWindowBytes ?? DEFAULT_WINDOW }
615
+ #maxFrameBytes() { return Math.min(this.#options.maxFrameBytes ?? DEFAULT_FRAME_BYTES, getMaxDecompressedBytes(this.#options.compressionLimits), 64 * 1024) }
616
+ }
617
+
618
+ /** @param {OutboundReverseGatewayOptions} options - Options. */
619
+ function validateOptions(options) {
620
+ if (typeof options !== "object" || options === null || typeof options.gatewayId !== "string" || !GATEWAY_ID.test(options.gatewayId)) throw new TypeError("Outbound reverse gateway requires a valid gatewayId")
621
+ if (typeof options.connectionId !== "string" || !STABLE_IDENTIFIER.test(options.connectionId)) throw new TypeError("Outbound reverse gateway requires a stable connectionId")
622
+ if (typeof options.hub !== "object" || options.hub === null || typeof options.hub.host !== "string" || options.hub.host.length === 0 || !Number.isInteger(options.hub.port) || options.hub.port < 1 || options.hub.port > 65_535 || typeof options.hub.token !== "string" || options.hub.token.length === 0) throw new TypeError("Outbound reverse gateway requires hub host, port and token")
623
+ if (typeof options.authorizeTarget !== "function") throw new TypeError("Outbound reverse gateway requires a local target authorization policy")
624
+ resolveVerifiedTls(options.tls)
625
+ if (!zstdAvailable()) throw new Error("Reverse gateway mode requires Node.js zstd support")
626
+ getMaxDecompressedBytes(options.compressionLimits)
627
+ const frame = positiveInteger(options.maxFrameBytes, "maxFrameBytes", DEFAULT_FRAME_BYTES)
628
+ const window = positiveInteger(options.initialWindowBytes, "initialWindowBytes", DEFAULT_WINDOW)
629
+ if (frame > window) throw new RangeError("maxFrameBytes must not exceed initialWindowBytes")
630
+ positiveInteger(options.maxStreams, "maxStreams", DEFAULT_MAX_STREAMS)
631
+ positiveInteger(options.maxStreamBufferBytes, "maxStreamBufferBytes", DEFAULT_STREAM_BUFFER_BYTES)
632
+ positiveInteger(options.maxTotalBufferBytes, "maxTotalBufferBytes", DEFAULT_TOTAL_BUFFER_BYTES)
633
+ if (options.initialGeneration !== undefined && (!Number.isSafeInteger(options.initialGeneration) || options.initialGeneration < 0)) throw new RangeError("initialGeneration must be a non-negative safe integer")
634
+ }
635
+
636
+ /** @param {import('node:net').Socket} socket - Local target. @param {Buffer} data - Bytes. */
637
+ async function writeTarget(socket, data) {
638
+ if (socket.destroyed || !socket.writable) throw new Error("Local target is unavailable")
639
+ await new Promise((resolve, reject) => socket.write(data, (error) => error ? reject(error) : resolve(undefined)))
640
+ }
641
+
642
+ function fatalError(/** @type {string} */ code) {
643
+ const error = /** @type {Error & {code: string, fatal: boolean}} */ (Object.assign(new Error("Reverse gateway was rejected by the hub"), {code, fatal: true}))
644
+ return error
645
+ }
646
+
647
+ function connectionError(/** @type {string} */ code) {
648
+ if (code === "HUB_SHUTDOWN") return Object.assign(new Error("Reverse gateway hub is temporarily unavailable"), {code, fatal: false})
649
+ return fatalError(code)
650
+ }
651
+
652
+ /** @param {unknown} value - Advertised peer receive window. @returns {value is number} Whether valid. */
653
+ function validPeerWindow(value) { return Number.isSafeInteger(value) && /** @type {number} */ (value) > 0 }
654
+
655
+ function closedError() { return Object.assign(new Error("Outbound reverse gateway is closed"), {code: "SOCKETDUCT_GATEWAY_CLOSED"}) }
656
+
657
+ function isFatalTransportError(/** @type {Error} */ error) {
658
+ const code = /** @type {{code?: unknown}} */ (error).code
659
+ return typeof code === "string" && (code.startsWith("ERR_TLS_") || code.startsWith("ERR_SSL_") || code.includes("CERT") || code.includes("VERIFY") || code.includes("SELF_SIGNED") || code.includes("ISSUER"))
660
+ }
661
+
662
+ /** @param {OutboundReverseGatewayOptions} options - Options. */
663
+ export function createOutboundReverseGateway(options) { return new OutboundReverseGateway(options) }