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,721 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-jsdoc, jsdoc/require-param, jsdoc/require-property-description, jsdoc/require-returns */
2
+ import {randomBytes} from "node:crypto"
3
+ import {EventEmitter} from "node:events"
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 {SocketductSessionLostError, SocketductStreamError, VirtualSocket} from "../client/virtual-socket.js"
10
+ import {
11
+ asError,
12
+ createHubSocket,
13
+ DEFAULT_CONNECT_TIMEOUT_MS,
14
+ DEFAULT_FRAME_BYTES,
15
+ DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS,
16
+ DEFAULT_MAX_STREAMS,
17
+ DEFAULT_WINDOW,
18
+ GATEWAY_ID,
19
+ MAX_CONTROL_BYTES,
20
+ parseTarget,
21
+ positiveInteger,
22
+ resolveVerifiedTls,
23
+ STABLE_IDENTIFIER,
24
+ writeSocket
25
+ } from "./common.js"
26
+
27
+ const DEFAULT_STREAM_BUFFER_BYTES = 4 * 1024 * 1024
28
+ const DEFAULT_CONNECTION_BUFFER_BYTES = 32 * 1024 * 1024
29
+
30
+ /**
31
+ * @typedef {object} ReverseGatewayConnectionOptions
32
+ * @property {string} gatewayId - Registered home gateway selected by this member.
33
+ * @property {string} [connectionId] - Stable client transport identity.
34
+ * @property {{host: string, port: number, token: string}} hub - Server3 hub endpoint.
35
+ * @property {{caFile?: string, certFile?: string, keyFile?: string, servername?: string}} tls - Mandatory verified outer TLS.
36
+ * @property {number} [initialWindowBytes] - Flow-control window.
37
+ * @property {number} [maxFrameBytes] - Maximum raw DATA frame.
38
+ * @property {number} [maxStreams] - Concurrent stream limit.
39
+ * @property {number} [maxStreamBufferBytes] - Per-stream client output bound.
40
+ * @property {number} [maxConnectionBufferBytes] - Aggregate client output bound.
41
+ * @property {number} [connectTimeoutMs] - TLS/auth deadline.
42
+ * @property {number} [reconnectDelayMs] - Initial reconnect delay.
43
+ * @property {number} [maxReconnectDelayMs] - Maximum reconnect delay.
44
+ * @property {number} [initialGeneration] - Monotonic seed for controlled replacement; defaults to wall-clock microseconds.
45
+ * @property {boolean} [continuousReconnect] - Keep the member warm after loss, default true.
46
+ * @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - zstd safety bounds.
47
+ * @property {(options: {host: string, port: number, tls: object}) => import('node:tls').TLSSocket} [createPhysicalSocket] - Test transport factory.
48
+ * @property {{reserveData(bytes: number): void, reserveMemory(bytes: number): void, releaseMemory(bytes: number): void, releaseSpool(bytes: number): void, reserveControl(bytes: number): void, releaseControl(bytes: number): void}} [aggregateBudget] - Optional transport-set budget.
49
+ * @property {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional privacy-safe structured diagnostic logger.
50
+ * @typedef {object} ClientChunk
51
+ * @property {number} sequence
52
+ * @property {Buffer} data
53
+ * @property {boolean} sent
54
+ * @typedef {object} PendingClientWrite
55
+ * @property {Buffer} data
56
+ * @property {number} offset
57
+ * @property {(error?: Error | null) => void} callback
58
+ * @typedef {object} ReverseClientStream
59
+ * @property {number} id
60
+ * @property {string} identifier
61
+ * @property {string} name
62
+ * @property {{host: string, port: number}} target
63
+ * @property {VirtualSocket} socket
64
+ * @property {'opening' | 'open' | 'half-closed' | 'closed' | 'reset'} state
65
+ * @property {number} sendSequence
66
+ * @property {number} transmittedSequence
67
+ * @property {number} sendAck
68
+ * @property {number} sendWindow
69
+ * @property {number} sendWindowLimit
70
+ * @property {number} receiveSequence
71
+ * @property {number} receiveAck
72
+ * @property {number | undefined} pendingReceiveAck
73
+ * @property {boolean} remoteEnded
74
+ * @property {number} bufferedBytes
75
+ * @property {ClientChunk[]} replay
76
+ * @property {AdaptiveCompressor} compressor
77
+ * @property {boolean} pumping
78
+ * @property {PendingClientWrite | undefined} pendingWrite
79
+ * @property {boolean} progressingWrite
80
+ * @property {boolean} localEnded
81
+ * @property {boolean} endSent
82
+ * @property {((error?: Error | null) => void) | undefined} endCallback
83
+ */
84
+
85
+ /** Server3/TensorBuzz-facing virtual socket connection to one registered gateway. */
86
+ export class ReverseGatewayConnection extends EventEmitter {
87
+ /** @type {ReverseGatewayConnectionOptions} */ #options
88
+ /** @type {string} */ #connectionId
89
+ /** @type {import('node:tls').TLSSocket | undefined} */ #physical
90
+ /** @type {Map<number, ReverseClientStream>} */ #streams = new Map()
91
+ /** @type {number} */ #nextStreamId = 1
92
+ /** @type {'configured' | 'connecting' | 'ready' | 'reconnecting' | 'closed'} */ #state = "configured"
93
+ /** @type {number} */ #generation
94
+ /** @type {string | undefined} */ #codec
95
+ /** @type {boolean} */ #closing = false
96
+ /** @type {boolean} */ #fatal = false
97
+ /** @type {Error | undefined} */ #fatalError
98
+ /** @type {boolean} */ #referenced = true
99
+ /** @type {boolean} */ #warm = false
100
+ /** @type {ReturnType<typeof setTimeout> | undefined} */ #reconnectTimer
101
+ /** @type {Promise<void> | undefined} */ #connectPromise
102
+ /** @type {(() => void) | undefined} */ #resolveConnect
103
+ /** @type {((error: Error) => void) | undefined} */ #rejectConnect
104
+ /** @type {number} */ #reconnectDelay
105
+ /** @type {number} */ #sendWindowLimit
106
+ /** @type {number} */ #physicalConnects = 0
107
+ /** @type {number} */ #physicalAttempts = 0
108
+ /** @type {number} */ #reconnectAttempts = 0
109
+ /** @type {number} */ #bufferedBytes = 0
110
+ /** @type {number} */ #rawDataBytes = 0
111
+ /** @type {number} */ #wireDataBytes = 0
112
+ /** @type {number} */ #lastProgressedStreamId = 0
113
+
114
+ /** @param {ReverseGatewayConnectionOptions} options - Hub route and safety policy. */
115
+ constructor(options) {
116
+ super()
117
+ validateOptions(options)
118
+ this.#options = options
119
+ this.#connectionId = options.connectionId ?? randomBytes(24).toString("base64url")
120
+ this.#generation = options.initialGeneration ?? Date.now() * 1000
121
+ this.#reconnectDelay = options.reconnectDelayMs ?? 250
122
+ this.#sendWindowLimit = this.#initialWindow()
123
+ this.createConnection = this.createConnection.bind(this)
124
+ }
125
+
126
+ /** Warms the selected reverse member. */
127
+ connect() {
128
+ if (this.#closing) return Promise.reject(closedError())
129
+ if (this.#fatalError !== undefined) return Promise.reject(this.#fatalError)
130
+ if (this.#state === "ready") return Promise.resolve()
131
+ if (this.#connectPromise !== undefined) return this.#connectPromise
132
+ this.#warm = true
133
+ this.#connectPromise = new Promise((resolve, reject) => { this.#resolveConnect = resolve; this.#rejectConnect = reject })
134
+ this.#connectPhysical()
135
+ return this.#connectPromise
136
+ }
137
+
138
+ /** Allocates one stream on the already-warm gateway transport. */
139
+ createConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback = undefined) {
140
+ if (this.#closing) throw closedError()
141
+ if (this.#state !== "ready") throw Object.assign(new Error("Reverse gateway member is not ready"), {code: "SOCKETDUCT_TRANSPORT_SET_NOT_READY"})
142
+ if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) throw new Error("Maximum reverse virtual stream count exceeded")
143
+ const target = parseTarget(options?.target ?? {host: options?.host, port: options?.port})
144
+ if (target === undefined) throw new TypeError("Reverse virtual connection target host and port are required")
145
+ const identifier = options.identifier ?? randomBytes(24).toString("base64url")
146
+ if (typeof identifier !== "string" || !STABLE_IDENTIFIER.test(identifier)) throw new TypeError("Reverse virtual stream identifier must be 8-128 URL-safe characters")
147
+ const id = this.#allocateStreamId()
148
+ const name = options.sessionName ?? `socketduct-reverse-${id}`
149
+ if (typeof name !== "string" || name.length === 0 || name.length > 256) throw new TypeError("Reverse virtual stream name must be 1-256 characters")
150
+ const socket = new VirtualSocket(/** @type {import('../client/multiplexed-connection.js').MultiplexedSocketductConnection} */ (/** @type {unknown} */ (this)), id, {
151
+ ...target,
152
+ ...(options.allowHalfOpen === undefined ? {} : {allowHalfOpen: options.allowHalfOpen}),
153
+ ...(options.readableHighWaterMark === undefined ? {} : {readableHighWaterMark: options.readableHighWaterMark}),
154
+ ...(options.writableHighWaterMark === undefined ? {} : {writableHighWaterMark: options.writableHighWaterMark})
155
+ })
156
+ /** @type {ReverseClientStream} */
157
+ const stream = {
158
+ id, identifier, name, target, socket,
159
+ state: "opening",
160
+ sendSequence: 0,
161
+ transmittedSequence: 0,
162
+ sendAck: 0,
163
+ sendWindow: this.#sendWindowLimit,
164
+ sendWindowLimit: this.#sendWindowLimit,
165
+ receiveSequence: 0,
166
+ receiveAck: 0,
167
+ pendingReceiveAck: undefined,
168
+ remoteEnded: false,
169
+ bufferedBytes: 0,
170
+ replay: [],
171
+ compressor: new AdaptiveCompressor(),
172
+ pumping: false,
173
+ pendingWrite: undefined,
174
+ progressingWrite: false,
175
+ localEnded: false,
176
+ endSent: false,
177
+ endCallback: undefined
178
+ }
179
+ this.#streams.set(id, stream)
180
+ logDiagnostic(this.#options.logger, "virtual_stream.opening", {
181
+ streamId: id,
182
+ generation: this.#generation,
183
+ gatewayId: this.#options.gatewayId,
184
+ recovered: false
185
+ })
186
+ if (callback !== undefined) installCallback(socket, callback)
187
+ void this.#sendControl(V3FrameType.OPEN, id, 0, {gatewayId: this.#options.gatewayId, identifier, name, target, initialWindow: this.#initialWindow()}).catch((error) => socket._receiveError(asError(error)))
188
+ return socket
189
+ }
190
+
191
+ /** Reverse established operations are intentionally not recoverable across process/transport loss. */
192
+ registerRecoveredConnection() {
193
+ throw Object.assign(new Error("Reverse gateway streams cannot be recovered; reconcile the Docker operation before retrying"), {code: "REVERSE_STREAM_RECOVERY_UNSUPPORTED"})
194
+ }
195
+
196
+ status() {
197
+ /** @type {Record<string, number>} */
198
+ const streamStates = {}
199
+ for (const stream of this.#streams.values()) streamStates[stream.state] = (streamStates[stream.state] ?? 0) + 1
200
+ return {
201
+ physicalState: this.#state,
202
+ connectionGeneration: this.#generation,
203
+ virtualStreams: this.#streams.size,
204
+ streamStates,
205
+ queuedBytes: this.#bufferedBytes,
206
+ dataQueuedBytes: this.#bufferedBytes,
207
+ controlQueuedBytes: 0,
208
+ spoolBytes: 0,
209
+ spoolCleanupFailures: 0,
210
+ physicalConnects: this.#physicalConnects,
211
+ reconnectAttempts: this.#reconnectAttempts,
212
+ reconnectSuccesses: Math.max(0, this.#physicalConnects - 1),
213
+ resumeFailures: 0,
214
+ compression: this.#codec ?? null,
215
+ rawDataBytes: this.#rawDataBytes,
216
+ wireDataBytes: this.#wireDataBytes,
217
+ compressionBytesSaved: Math.max(0, this.#rawDataBytes - this.#wireDataBytes)
218
+ }
219
+ }
220
+
221
+ physicalAddress() { return {address: this.#physical?.localAddress, port: this.#physical?.localPort} }
222
+ ref() { this.#referenced = true; this.#physical?.ref(); this.#reconnectTimer?.ref?.(); return this }
223
+ unref() { this.#referenced = false; this.#physical?.unref(); this.#reconnectTimer?.unref?.(); return this }
224
+
225
+ async close() {
226
+ if (this.#closing) return
227
+ this.#closing = true
228
+ if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
229
+ this.#reconnectTimer = undefined
230
+ this.#setState("closed")
231
+ for (const stream of [...this.#streams.values()]) stream.socket.destroy()
232
+ const physical = this.#physical
233
+ this.#physical = undefined
234
+ if (physical !== undefined && !physical.destroyed) {
235
+ await writeSocket(physical, encodeV3Frame({type: V3FrameType.GOAWAY, streamId: 0, sequence: 0}, encodeV3Control({reason: "client_shutdown"}))).catch(() => false)
236
+ physical.destroy()
237
+ }
238
+ this.#rejectConnect?.(closedError())
239
+ this.#settleConnect()
240
+ }
241
+
242
+ /** Detach is terminal because ambiguous reverse streams are never durably replayed. */
243
+ detach() { return this.close() }
244
+
245
+ _writeStream(/** @type {number} */ streamId, /** @type {Buffer} */ data, /** @type {(error?: Error | null) => void} */ callback) {
246
+ const stream = this.#streams.get(streamId)
247
+ if (stream === undefined || stream.localEnded || stream.state === "closed" || stream.state === "reset") { callback(new SocketductStreamError("Reverse stream is not writable", "ERR_STREAM_DESTROYED")); return }
248
+ stream.pendingWrite = {data, offset: 0, callback}
249
+ this.#progressPendingWrites()
250
+ }
251
+
252
+ _endStream(/** @type {number} */ streamId, /** @type {(error?: Error | null) => void} */ callback) {
253
+ const stream = this.#streams.get(streamId)
254
+ if (stream === undefined || stream.localEnded) { callback(); return }
255
+ stream.localEnded = true
256
+ if (stream.state === "closed") { callback(); return }
257
+ stream.endCallback = callback
258
+ void this.#pumpStream(stream).catch((error) => this.#finishEnd(stream, asError(error)))
259
+ }
260
+
261
+ _destroyStream(/** @type {number} */ streamId, /** @type {Error | null} */ error) {
262
+ const stream = this.#streams.get(streamId)
263
+ if (stream === undefined) return
264
+ const wasClosed = stream.state === "closed"
265
+ logDiagnostic(this.#options.logger, error === null ? "virtual_stream.closed" : "virtual_stream.error", {
266
+ streamId,
267
+ generation: this.#generation,
268
+ gatewayId: this.#options.gatewayId,
269
+ ...(error === null ? {} : errorDiagnostics(error))
270
+ })
271
+ this.#retireStream(stream, error ?? new SocketductStreamError("Reverse stream is not writable", "ERR_STREAM_DESTROYED"))
272
+ if (!wasClosed && !this.#closing && this.#state === "ready") void this.#sendControl(V3FrameType.RESET, streamId, 0, {code: error instanceof SocketductStreamError ? error.code : "CLIENT_CANCELLED"}).catch(() => {})
273
+ }
274
+
275
+ _resumeReadable(/** @type {number} */ streamId) {
276
+ const stream = this.#streams.get(streamId)
277
+ if (stream?.pendingReceiveAck === undefined) return
278
+ const sequence = stream.pendingReceiveAck
279
+ stream.pendingReceiveAck = undefined
280
+ this.#ackReceived(stream, sequence)
281
+ }
282
+
283
+ #allocateStreamId() {
284
+ const id = this.#nextStreamId
285
+ if (id > 0xffff_fffd) throw new Error("Reverse v3 stream ID space exhausted")
286
+ this.#nextStreamId += 2
287
+ return id
288
+ }
289
+
290
+ #connectPhysical() {
291
+ if (this.#closing || this.#fatal || this.#physical !== undefined) return
292
+ this.#sendWindowLimit = this.#initialWindow()
293
+ this.#setState(this.#physicalConnects === 0 ? "connecting" : "reconnecting")
294
+ this.#generation += 1
295
+ this.#physicalAttempts += 1
296
+ const generation = this.#generation
297
+ logDiagnostic(this.#options.logger, "transport.connecting", {
298
+ generation,
299
+ attempt: this.#physicalAttempts,
300
+ gatewayId: this.#options.gatewayId,
301
+ reconnect: this.#physicalAttempts > 1
302
+ })
303
+ const tls = resolveVerifiedTls(this.#options.tls)
304
+ let physical
305
+ try {
306
+ physical = (this.#options.createPhysicalSocket ?? createHubSocket)({host: this.#options.hub.host, port: this.#options.hub.port, tls})
307
+ } catch (error) {
308
+ logDiagnostic(this.#options.logger, "transport.failed", {
309
+ generation,
310
+ attempt: this.#physicalAttempts,
311
+ gatewayId: this.#options.gatewayId,
312
+ ...errorDiagnostics(error)
313
+ })
314
+ this.#scheduleReconnect(asError(error))
315
+ return
316
+ }
317
+ this.#physical = physical
318
+ if (!this.#referenced) physical.unref()
319
+ physical.setNoDelay(true)
320
+ physical.setKeepAlive(true, DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS)
321
+ const decoder = new V3FrameDecoder({maxFrameBytes: Math.max(MAX_CONTROL_BYTES, this.#maxFrameBytes() + 4), maxBufferedBytes: Math.max(MAX_CONTROL_BYTES, this.#maxFrameBytes() + 4) * 2})
322
+ let queue = Promise.resolve()
323
+ let authenticated = false
324
+ let selected = false
325
+ /** @type {Error | undefined} */
326
+ let attemptError
327
+ const timer = setTimeout(() => physical.destroy(Object.assign(new Error("Reverse client TLS/auth timeout"), {code: "SOCKETDUCT_AUTH_TIMEOUT"})), this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
328
+ timer.unref?.()
329
+ physical.once("secureConnect", () => {
330
+ const body = encodeV3Control({role: "client", token: this.#options.hub.token, versions: [3], connectionId: this.#connectionId, generation, compression: availableCodecs()})
331
+ void writeSocket(physical, encodeV3Frame({type: V3FrameType.AUTH, streamId: 0, sequence: generation}, body))
332
+ .catch((error) => physical.destroy(asError(error)))
333
+ })
334
+ physical.on("data", (chunk) => {
335
+ physical.pause()
336
+ queue = queue.then(async () => {
337
+ for (const frame of decoder.push(chunk)) {
338
+ if (frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.GOAWAY) {
339
+ const detail = decodeV3Control(frame.payload)
340
+ throw connectionError(typeof detail.code === "string" ? detail.code : "CONNECTION_REJECTED")
341
+ }
342
+ if (!authenticated) {
343
+ if (frame.type !== V3FrameType.AUTH_OK) throw fatalError("AUTH_NEGOTIATION_FAILED")
344
+ const detail = decodeV3Control(frame.payload)
345
+ if (detail.version !== 3 || detail.role !== "client" || detail.connectionId !== this.#connectionId || detail.generation !== generation || detail.compression !== ZSTD_CODEC || !validPeerWindow(detail.initialWindow)) throw fatalError("AUTH_NEGOTIATION_FAILED")
346
+ authenticated = true
347
+ this.#codec = ZSTD_CODEC
348
+ this.#sendWindowLimit = Math.min(this.#initialWindow(), detail.initialWindow)
349
+ await writeSocket(physical, encodeV3Frame({type: V3FrameType.GATEWAY_SELECT, streamId: 0, sequence: generation}, encodeV3Control({gatewayId: this.#options.gatewayId})))
350
+ continue
351
+ }
352
+ if (!selected) {
353
+ if (frame.type !== V3FrameType.GATEWAY_SELECTED) throw fatalError("GATEWAY_SELECTION_FAILED")
354
+ const detail = decodeV3Control(frame.payload)
355
+ if (detail.gatewayId !== this.#options.gatewayId || detail.generation !== generation) throw fatalError("GATEWAY_SELECTION_FAILED")
356
+ selected = true
357
+ clearTimeout(timer)
358
+ this.#physicalConnects += 1
359
+ this.#reconnectDelay = this.#options.reconnectDelayMs ?? 250
360
+ this.#setState("ready")
361
+ logDiagnostic(this.#options.logger, "transport.connected", {
362
+ generation,
363
+ attempt: this.#physicalAttempts,
364
+ gatewayId: this.#options.gatewayId,
365
+ physicalConnects: this.#physicalConnects,
366
+ reconnect: this.#physicalConnects > 1
367
+ })
368
+ if (this.#physicalConnects > 1) {
369
+ logDiagnostic(this.#options.logger, "transport.reconnect_succeeded", {
370
+ generation,
371
+ attempt: this.#reconnectAttempts,
372
+ gatewayId: this.#options.gatewayId,
373
+ physicalConnects: this.#physicalConnects
374
+ })
375
+ }
376
+ this.#resolveConnect?.()
377
+ this.#settleConnect()
378
+ continue
379
+ }
380
+ await this.#handleFrame(frame)
381
+ }
382
+ }).catch((error) => {
383
+ attemptError = asError(error)
384
+ if (/** @type {{fatal?: unknown}} */ (attemptError).fatal === true) this.#fatal = true
385
+ physical.destroy(attemptError)
386
+ }).finally(() => { if (!physical.destroyed) physical.resume() })
387
+ })
388
+ physical.once("error", (error) => { attemptError = error; if (isFatalTransportError(error)) this.#fatal = true })
389
+ physical.once("close", () => {
390
+ clearTimeout(timer)
391
+ void queue.finally(() => {
392
+ if (this.#physical !== physical) return
393
+ this.#physical = undefined
394
+ this.#codec = undefined
395
+ if (!this.#closing && !this.#fatal) this.#setState("reconnecting")
396
+ const lost = new SocketductSessionLostError("Reverse gateway transport was lost; operation outcome may be uncertain", "GATEWAY_DISCONNECTED")
397
+ if (!this.#closing) {
398
+ logDiagnostic(this.#options.logger, "transport.disconnected", {
399
+ generation,
400
+ gatewayId: this.#options.gatewayId,
401
+ virtualStreams: this.#streams.size,
402
+ ...errorDiagnostics(attemptError ?? lost, "GATEWAY_DISCONNECTED")
403
+ })
404
+ }
405
+ for (const stream of [...this.#streams.values()]) stream.socket._receiveError(lost)
406
+ if (this.#closing) return
407
+ if (this.#fatal) {
408
+ const terminalError = attemptError ?? lost
409
+ this.#fatalError = terminalError
410
+ this.#setState("closed", terminalError)
411
+ logDiagnostic(this.#options.logger, "transport.failed", {
412
+ generation,
413
+ attempt: this.#physicalAttempts,
414
+ gatewayId: this.#options.gatewayId,
415
+ ...errorDiagnostics(terminalError, "GATEWAY_DISCONNECTED")
416
+ })
417
+ this.#rejectConnect?.(terminalError)
418
+ this.#settleConnect()
419
+ return
420
+ }
421
+ if (this.#warm || this.#options.continuousReconnect !== false) this.#scheduleReconnect(attemptError)
422
+ })
423
+ })
424
+ }
425
+
426
+ /** @param {import('../protocol/v3-frame.js').V3Frame} frame - Hub frame. */
427
+ async #handleFrame(frame) {
428
+ if (frame.type === V3FrameType.PING) { await this.#sendControl(V3FrameType.PONG, 0, frame.sequence, {}); return }
429
+ if (frame.type === V3FrameType.PONG) return
430
+ const stream = this.#streams.get(frame.streamId)
431
+ if (stream === undefined) {
432
+ if (frame.type !== V3FrameType.CLOSE && frame.type !== V3FrameType.RESET) await this.#sendControl(V3FrameType.RESET, frame.streamId, 0, {code: "UNKNOWN_STREAM"})
433
+ return
434
+ }
435
+ if (frame.type === V3FrameType.OPENED) {
436
+ const detail = decodeV3Control(frame.payload)
437
+ if (stream.state !== "opening" || frame.sequence !== 0 || detail.identifier !== stream.identifier || !validPeerWindow(detail.initialWindow)) throw new Error("Invalid reverse OPENED response")
438
+ stream.sendWindowLimit = Math.min(stream.sendWindowLimit, detail.initialWindow)
439
+ stream.sendWindow = Math.min(stream.sendWindow, stream.sendWindowLimit)
440
+ stream.state = "open"
441
+ logDiagnostic(this.#options.logger, "virtual_stream.opened", {
442
+ streamId: stream.id,
443
+ generation: this.#generation,
444
+ gatewayId: this.#options.gatewayId,
445
+ recovered: false
446
+ })
447
+ stream.socket._markConnected()
448
+ this.#progressPendingWrites()
449
+ await this.#pumpStream(stream)
450
+ return
451
+ }
452
+ if (frame.type === V3FrameType.DATA) {
453
+ if (stream.remoteEnded) throw new Error("Reverse client received DATA after END")
454
+ const data = await decodeV3Data(frame, this.#codec, this.#options.compressionLimits)
455
+ 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 client DATA sequence or window")
456
+ stream.receiveSequence = frame.sequence
457
+ const canContinue = stream.socket._receive(data)
458
+ if (canContinue) this.#ackReceived(stream, frame.sequence)
459
+ else stream.pendingReceiveAck = frame.sequence
460
+ return
461
+ }
462
+ if (frame.type === V3FrameType.ACK) { this.#acknowledge(stream, frame.sequence); return }
463
+ if (frame.type === V3FrameType.WINDOW) { this.#addWindow(stream, frame.sequence); return }
464
+ if (frame.type === V3FrameType.END) {
465
+ if (stream.remoteEnded || frame.sequence !== stream.receiveSequence) throw new Error("Invalid reverse client END")
466
+ stream.remoteEnded = true
467
+ stream.socket._receiveEnd()
468
+ return
469
+ }
470
+ if (frame.type === V3FrameType.CLOSE) {
471
+ if (frame.sequence !== stream.receiveSequence) throw new Error("Invalid reverse client CLOSE")
472
+ await this.#sendControl(V3FrameType.CLOSE, stream.id, stream.transmittedSequence, {})
473
+ stream.state = "closed"
474
+ logDiagnostic(this.#options.logger, "virtual_stream.closed", {
475
+ streamId: stream.id,
476
+ generation: this.#generation,
477
+ gatewayId: this.#options.gatewayId
478
+ })
479
+ this.#retireStream(stream, new SocketductStreamError("Reverse stream closed before pending writes completed", "ERR_STREAM_DESTROYED"))
480
+ stream.socket._receiveEnd()
481
+ if (!stream.socket.writableEnded && !stream.socket.destroyed) stream.socket.end()
482
+ this.#finishEnd(stream)
483
+ return
484
+ }
485
+ if (frame.type === V3FrameType.RESET) {
486
+ const detail = decodeV3Control(frame.payload)
487
+ const code = typeof detail.code === "string" ? detail.code : "STREAM_RESET"
488
+ stream.state = "reset"
489
+ const error = new SocketductStreamError("Reverse gateway stream was reset", code)
490
+ stream.socket._receiveError(error)
491
+ return
492
+ }
493
+ throw new Error("Unsupported reverse client frame")
494
+ }
495
+
496
+ /** @param {ReverseClientStream} stream - Stream. */
497
+ async #pumpStream(stream) {
498
+ if (stream.pumping || stream.state === "opening" || !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), () => { stream.transmittedSequence = chunk.sequence })
509
+ if (!sent) return
510
+ chunk.sent = true
511
+ stream.sendWindow -= chunk.data.length
512
+ }
513
+ if (stream.localEnded && !stream.endSent && stream.replay.every((chunk) => chunk.sent)) {
514
+ stream.endSent = true
515
+ stream.state = "half-closed"
516
+ await this.#sendControl(V3FrameType.END, stream.id, stream.sendSequence, {})
517
+ this.#finishEnd(stream)
518
+ }
519
+ } finally { stream.pumping = false }
520
+ }
521
+
522
+ /** @param {ReverseClientStream} stream - Stream with an active application write. */
523
+ async #progressWrite(stream) {
524
+ const pending = stream.pendingWrite
525
+ if (pending === undefined || stream.progressingWrite || stream.state === "opening") return
526
+ stream.progressingWrite = true
527
+ try {
528
+ if (pending.offset < pending.data.length && this.#streams.get(stream.id) === stream) {
529
+ const available = this.#writeCapacity(stream)
530
+ if (available <= 0) return
531
+ const chunk = Buffer.from(pending.data.subarray(pending.offset, Math.min(pending.data.length, pending.offset + available)))
532
+ try {
533
+ if (this.#options.aggregateBudget?.reserveMemory !== undefined) this.#options.aggregateBudget.reserveMemory(chunk.length)
534
+ else this.#options.aggregateBudget?.reserveData(chunk.length)
535
+ } catch (error) {
536
+ this.#finishWrite(stream, asError(error))
537
+ return
538
+ }
539
+ pending.offset += chunk.length
540
+ stream.sendSequence += chunk.length
541
+ stream.replay.push({sequence: stream.sendSequence, data: chunk, sent: false})
542
+ stream.bufferedBytes += chunk.length
543
+ this.#bufferedBytes += chunk.length
544
+ }
545
+ await this.#pumpStream(stream)
546
+ if (pending.offset === pending.data.length && stream.pendingWrite === pending) this.#finishWrite(stream)
547
+ } catch (error) {
548
+ this.#finishWrite(stream, asError(error))
549
+ } finally {
550
+ stream.progressingWrite = false
551
+ this.#progressPendingWrites()
552
+ }
553
+ }
554
+
555
+ /** @param {ReverseClientStream} stream - Stream. @returns {number} Retained-byte headroom for its next frame. */
556
+ #writeCapacity(stream) {
557
+ return Math.max(0, Math.min(
558
+ this.#maxFrameBytes(),
559
+ stream.sendWindowLimit,
560
+ (this.#options.maxStreamBufferBytes ?? DEFAULT_STREAM_BUFFER_BYTES) - stream.bufferedBytes,
561
+ (this.#options.maxConnectionBufferBytes ?? DEFAULT_CONNECTION_BUFFER_BYTES) - this.#bufferedBytes
562
+ ))
563
+ }
564
+
565
+ /** Starts one bounded pending-write slice using rotating stream priority. */
566
+ #progressPendingWrites() {
567
+ const streams = [...this.#streams.values()]
568
+ if (streams.length === 0) return
569
+ const previousIndex = streams.findIndex((stream) => stream.id === this.#lastProgressedStreamId)
570
+ const startIndex = previousIndex === -1 ? 0 : (previousIndex + 1) % streams.length
571
+ for (let offset = 0; offset < streams.length; offset += 1) {
572
+ const stream = /** @type {ReverseClientStream} */ (streams[(startIndex + offset) % streams.length])
573
+ const pending = stream.pendingWrite
574
+ if (pending === undefined || stream.progressingWrite || stream.state === "opening" || (pending.offset < pending.data.length && this.#writeCapacity(stream) <= 0)) continue
575
+ this.#lastProgressedStreamId = stream.id
576
+ void this.#progressWrite(stream)
577
+ return
578
+ }
579
+ }
580
+
581
+ /** @param {ReverseClientStream} stream - Stream. @param {Error} [error] - Write failure. */
582
+ #finishWrite(stream, error) {
583
+ const pending = stream.pendingWrite
584
+ if (pending === undefined) return
585
+ stream.pendingWrite = undefined
586
+ pending.callback(error)
587
+ }
588
+
589
+ /** @param {ReverseClientStream} stream - Stream. @param {Error} [error] - Terminal send error. */
590
+ #finishEnd(stream, error) {
591
+ const callback = stream.endCallback
592
+ stream.endCallback = undefined
593
+ callback?.(error)
594
+ }
595
+
596
+ /** @param {ReverseClientStream} stream - Terminal stream. @param {Error} [pendingWriteError] - Pending write failure. */
597
+ #retireStream(stream, pendingWriteError = new SocketductStreamError("Reverse stream is not writable", "ERR_STREAM_DESTROYED")) {
598
+ if (!this.#streams.delete(stream.id)) return
599
+ this.#bufferedBytes = Math.max(0, this.#bufferedBytes - stream.bufferedBytes)
600
+ this.#options.aggregateBudget?.releaseMemory(stream.bufferedBytes)
601
+ if (this.#options.aggregateBudget?.reserveMemory === undefined) this.#options.aggregateBudget?.releaseSpool(stream.bufferedBytes)
602
+ stream.replay = []
603
+ stream.bufferedBytes = 0
604
+ this.#finishWrite(stream, pendingWriteError)
605
+ this.#progressPendingWrites()
606
+ }
607
+
608
+ /** @param {ReverseClientStream} stream - Stream. @param {number} sequence - ACK. */
609
+ #acknowledge(stream, sequence) {
610
+ if (!Number.isSafeInteger(sequence) || sequence < stream.sendAck || sequence > stream.sendSequence) throw new Error("Invalid reverse client ACK")
611
+ if (sequence === stream.sendAck) return
612
+ const delta = sequence - stream.sendAck
613
+ stream.sendAck = sequence
614
+ let released = 0
615
+ stream.replay = stream.replay.filter((chunk) => { if (chunk.sequence <= sequence) { released += chunk.data.length; return false } return true })
616
+ stream.bufferedBytes = Math.max(0, stream.bufferedBytes - released)
617
+ this.#bufferedBytes = Math.max(0, this.#bufferedBytes - released)
618
+ this.#options.aggregateBudget?.releaseMemory(released)
619
+ if (this.#options.aggregateBudget?.reserveMemory === undefined) this.#options.aggregateBudget?.releaseSpool(released)
620
+ stream.sendWindow = Math.min(stream.sendWindowLimit, stream.sendWindow + delta)
621
+ this.#progressPendingWrites()
622
+ void this.#pumpStream(stream).catch((error) => stream.socket._receiveError(asError(error)))
623
+ }
624
+
625
+ /** @param {ReverseClientStream} stream - Stream. @param {number} credit - Credit. */
626
+ #addWindow(stream, credit) {
627
+ if (!Number.isSafeInteger(credit) || credit <= 0 || credit > stream.sendWindowLimit) throw new Error("Invalid reverse client WINDOW")
628
+ stream.sendWindow = Math.min(stream.sendWindowLimit, stream.sendWindow + credit)
629
+ void this.#pumpStream(stream).catch((error) => stream.socket._receiveError(asError(error)))
630
+ }
631
+
632
+ /** @param {ReverseClientStream} stream - Stream. @param {number} sequence - Receive ack. */
633
+ #ackReceived(stream, sequence) {
634
+ if (sequence <= stream.receiveAck) return
635
+ stream.receiveAck = sequence
636
+ void this.#sendControl(V3FrameType.ACK, stream.id, sequence, {}).catch(() => {})
637
+ }
638
+
639
+ /** @param {number} type - Type. @param {number} streamId - Stream. @param {number} sequence - Sequence. @param {Record<string, unknown>} body - Body. */
640
+ async #sendControl(type, streamId, sequence, body) {
641
+ const physical = this.#physical
642
+ if (physical === undefined || physical.destroyed) throw new SocketductSessionLostError("Reverse gateway transport is unavailable", "GATEWAY_DISCONNECTED")
643
+ return await writeSocket(physical, encodeV3Frame({type, streamId, sequence}, encodeV3Control(body)))
644
+ }
645
+
646
+ #scheduleReconnect(/** @type {Error | undefined} */ error) {
647
+ if (this.#closing || this.#fatal || this.#reconnectTimer !== undefined) return
648
+ this.#reconnectAttempts += 1
649
+ this.#setState("reconnecting")
650
+ const delay = this.#reconnectDelay
651
+ logDiagnostic(this.#options.logger, "transport.reconnect_scheduled", {
652
+ generation: this.#generation,
653
+ attempt: this.#reconnectAttempts,
654
+ delayMs: delay,
655
+ gatewayId: this.#options.gatewayId,
656
+ ...errorDiagnostics(error, "GATEWAY_DISCONNECTED")
657
+ })
658
+ this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, this.#options.maxReconnectDelayMs ?? 30_000)
659
+ this.#reconnectTimer = setTimeout(() => {
660
+ this.#reconnectTimer = undefined
661
+ if (this.#connectPromise === undefined) {
662
+ this.#connectPromise = new Promise((resolve, reject) => { this.#resolveConnect = resolve; this.#rejectConnect = reject })
663
+ this.#connectPromise.catch(() => {})
664
+ }
665
+ this.#connectPhysical()
666
+ }, delay)
667
+ if (!this.#referenced) this.#reconnectTimer.unref?.()
668
+ if (this.#physicalConnects === 0 && this.#options.continuousReconnect === false) {
669
+ this.#rejectConnect?.(error ?? new Error("Reverse hub connection failed"))
670
+ this.#settleConnect()
671
+ }
672
+ }
673
+
674
+ #settleConnect() { this.#connectPromise = undefined; this.#resolveConnect = undefined; this.#rejectConnect = undefined }
675
+ #setState(/** @type {'configured' | 'connecting' | 'ready' | 'reconnecting' | 'closed'} */ state, /** @type {Error | undefined} */ error = undefined) { if (this.#state === state) return; this.#state = state; this.emit("stateChange", state, error) }
676
+ #initialWindow() { return this.#options.initialWindowBytes ?? DEFAULT_WINDOW }
677
+ #maxFrameBytes() { return Math.min(this.#options.maxFrameBytes ?? DEFAULT_FRAME_BYTES, getMaxDecompressedBytes(this.#options.compressionLimits), 64 * 1024) }
678
+ }
679
+
680
+ /** @param {ReverseGatewayConnectionOptions} options - Options. */
681
+ function validateOptions(options) {
682
+ if (typeof options !== "object" || options === null || typeof options.gatewayId !== "string" || !GATEWAY_ID.test(options.gatewayId)) throw new TypeError("Reverse gateway connection requires a valid gatewayId")
683
+ if (options.connectionId !== undefined && (typeof options.connectionId !== "string" || !STABLE_IDENTIFIER.test(options.connectionId))) throw new TypeError("Reverse gateway connectionId must be 8-128 URL-safe characters")
684
+ if (typeof options.hub !== "object" || options.hub === null || typeof options.hub.host !== "string" || options.hub.host.length === 0 || !Number.isInteger(options.hub.port) || options.hub.port < 1 || options.hub.port > 65_535 || typeof options.hub.token !== "string" || options.hub.token.length === 0) throw new TypeError("Reverse gateway connection requires hub host, port and token")
685
+ resolveVerifiedTls(options.tls)
686
+ if (!zstdAvailable()) throw new Error("Reverse gateway mode requires Node.js zstd support")
687
+ getMaxDecompressedBytes(options.compressionLimits)
688
+ const frame = positiveInteger(options.maxFrameBytes, "maxFrameBytes", DEFAULT_FRAME_BYTES)
689
+ const window = positiveInteger(options.initialWindowBytes, "initialWindowBytes", DEFAULT_WINDOW)
690
+ if (frame > window) throw new RangeError("maxFrameBytes must not exceed initialWindowBytes")
691
+ positiveInteger(options.maxStreams, "maxStreams", DEFAULT_MAX_STREAMS)
692
+ positiveInteger(options.maxStreamBufferBytes, "maxStreamBufferBytes", DEFAULT_STREAM_BUFFER_BYTES)
693
+ positiveInteger(options.maxConnectionBufferBytes, "maxConnectionBufferBytes", DEFAULT_CONNECTION_BUFFER_BYTES)
694
+ if (options.initialGeneration !== undefined && (!Number.isSafeInteger(options.initialGeneration) || options.initialGeneration < 0)) throw new RangeError("initialGeneration must be a non-negative safe integer")
695
+ }
696
+
697
+ /** @param {VirtualSocket} socket - Socket. @param {(error: Error | null, socket: VirtualSocket) => void} callback - Callback. */
698
+ function installCallback(socket, callback) {
699
+ let settled = false
700
+ const cleanup = () => { socket.off("connect", onConnect); socket.off("error", onError) }
701
+ const onConnect = () => { if (settled) return; settled = true; cleanup(); callback(null, socket) }
702
+ const onError = (/** @type {Error} */ error) => { if (settled) return; settled = true; cleanup(); callback(error, socket) }
703
+ socket.once("connect", onConnect)
704
+ socket.once("error", onError)
705
+ }
706
+
707
+ function fatalError(/** @type {string} */ code) { return Object.assign(new Error("Reverse gateway client was rejected by the hub"), {code, fatal: true}) }
708
+ function connectionError(/** @type {string} */ code) {
709
+ if (code === "HUB_SHUTDOWN" || code === "GATEWAY_UNAVAILABLE" || code === "GATEWAY_DISCONNECTED" || code === "GATEWAY_REPLACED") return Object.assign(new Error("Selected reverse gateway is temporarily unavailable"), {code, fatal: false})
710
+ return fatalError(code)
711
+ }
712
+ /** @param {unknown} value - Advertised peer receive window. @returns {value is number} Whether valid. */
713
+ function validPeerWindow(value) { return Number.isSafeInteger(value) && /** @type {number} */ (value) > 0 }
714
+ function closedError() { return Object.assign(new Error("Reverse gateway connection is closed"), {code: "SOCKETDUCT_CONNECTION_CLOSED"}) }
715
+ function isFatalTransportError(/** @type {Error} */ error) {
716
+ const code = /** @type {{code?: unknown}} */ (error).code
717
+ 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"))
718
+ }
719
+
720
+ /** @param {ReverseGatewayConnectionOptions} options - Options. */
721
+ export function createReverseGatewayConnection(options) { return new ReverseGatewayConnection(options) }