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,917 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-returns, jsdoc/require-returns-description */
2
+ import {createHash} from "node:crypto"
3
+ import {EventEmitter} from "node:events"
4
+ import {tmpdir} from "node:os"
5
+ import {join, resolve} from "node:path"
6
+ import {connect as tlsConnect} from "node:tls"
7
+
8
+ import {errorDiagnostics, logDiagnostic} from "../logging/structured-logger.js"
9
+ import {ReverseGatewayConnection} from "../reverse/client.js"
10
+ import {DeferredVirtualSocket} from "./deferred-virtual-socket.js"
11
+ import {MultiplexedSocketductConnection} from "./multiplexed-connection.js"
12
+
13
+ const DEFAULT_TOTAL_BUFFER_BYTES = 32 * 1024 * 1024
14
+ const DEFAULT_TOTAL_SPOOL_BYTES = 32 * 1024 * 1024
15
+ const DEFAULT_TOTAL_QUEUED_BYTES = 33 * 1024 * 1024
16
+ const DEFAULT_MAX_PENDING_OPENS = 1_024
17
+ const STABLE_IDENTIFIER = /^[A-Za-z0-9_-]{8,128}$/
18
+ const GATEWAY_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/
19
+
20
+ /**
21
+ * @typedef {object} GatewayEndpoint
22
+ * @property {{host: string, port: number, token: string}} [relay] - Gateway-pinned ordinary relay endpoint.
23
+ * @property {{host: string, port: number, token: string}} [reverseHub] - Shared reverse hub endpoint; gatewayId selects its registered member.
24
+ * @property {{caFile?: string, certFile?: string, keyFile?: string, servername?: string, rejectUnauthorized?: boolean}} [tls] - Outer TLS settings.
25
+ * @property {(options: {host: string, port: number, tls?: Record<string, unknown>}) => import('node:net').Socket} [createPhysicalSocket] - Gateway-pinned physical socket factory.
26
+ * @typedef {object} GatewayConfiguration
27
+ * @property {string} gatewayId - Stable, privacy-safe gateway identity.
28
+ * @property {GatewayEndpoint} endpoint - Explicit gateway-pinned endpoint/factory.
29
+ * @typedef {object} TransportSetOptions
30
+ * @property {string} connectionId - Stable process/base connection identity.
31
+ * @property {string} [spoolDirectory] - Base directory for derived gateway spool namespaces.
32
+ * @property {GatewayConfiguration[]} gateways - Explicit configured gateways.
33
+ * @property {number} [minimumReadyMembers] - Required ready members for start(), default 1.
34
+ * @property {number} [maxTotalBufferBytes] - Set-wide retained data limit.
35
+ * @property {number} [maxTotalSpoolBytes] - Set-wide durable spool data limit.
36
+ * @property {number} [maxTotalQueuedBytes] - Set-wide retained data plus control queue limit.
37
+ * @property {number} [maxPendingOpens] - Maximum sockets awaiting an eligible ready member, default 1024.
38
+ * @property {Record<string, unknown>} [memberOptions] - Shared single-member v3 options other than identity and endpoint.
39
+ * @property {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional privacy-safe structured logger shared by the set and its members.
40
+ * @typedef {object} MemberConnectionStatus
41
+ * @property {string} physicalState - Member lifecycle.
42
+ * @property {number} connectionGeneration - Current authenticated generation.
43
+ * @property {number} physicalConnects - Authenticated physical generations.
44
+ * @property {number} reconnectAttempts - Physical reconnect attempts.
45
+ * @property {number} reconnectSuccesses - Successful resumptions.
46
+ * @property {number} virtualStreams - Current virtual streams.
47
+ * @property {number} queuedBytes - Current retained and control bytes.
48
+ * @property {number} spoolBytes - Current durable spool bytes.
49
+ * @property {number} spoolCleanupFailures - Durable cleanup failures retained for visibility.
50
+ * @typedef {object} TransportMemberConnection
51
+ * @property {() => Promise<void>} connect - Warm the member.
52
+ * @property {(options: Record<string, unknown>, callback?: (error: Error | null, socket: import('./virtual-socket.js').VirtualSocket) => void) => import('./virtual-socket.js').VirtualSocket} createConnection - Open a virtual stream.
53
+ * @property {(options: Record<string, unknown>, callback?: (error: Error | null, socket: import('./virtual-socket.js').VirtualSocket) => void) => import('./virtual-socket.js').VirtualSocket} registerRecoveredConnection - Cold-register durable recovery.
54
+ * @property {() => MemberConnectionStatus} status - Privacy-safe status.
55
+ * @property {() => Promise<void>} close - Close the member.
56
+ * @property {() => Promise<void>} detach - Preserve durable streams for process-replacement recovery.
57
+ * @property {(eventName: string, listener: (...args: unknown[]) => void) => unknown} on - Subscribe to lifecycle changes.
58
+ * @property {() => unknown} [ref] - Reference resources.
59
+ * @property {() => unknown} [unref] - Unreference resources.
60
+ * @typedef {object} TransportSetDependencies
61
+ * @property {(options: import('./multiplexed-connection.js').MultiplexedConnectionOptions | import('../reverse/client.js').ReverseGatewayConnectionOptions, gatewayId: string) => TransportMemberConnection} [createMember] - Narrow test dependency.
62
+ * @typedef {object} TransportMember
63
+ * @property {string} gatewayId - Stable gateway ID.
64
+ * @property {TransportMemberConnection} connection - Single-lane owner.
65
+ * @property {number} assignments - Virtual sockets assigned for life.
66
+ * @property {Error | undefined} startupError - Terminal startup error.
67
+ * @property {Error | undefined} terminalError - Terminal member error after readiness.
68
+ * @property {number | undefined} lastReadyGeneration - Last generation that reached readiness.
69
+ * @typedef {object} PendingOpen
70
+ * @property {import('./multiplexed-connection.js').VirtualConnectionOptions} options - Validated caller options.
71
+ * @property {DeferredVirtualSocket} socket - Immediate app-facing socket.
72
+ * @typedef {import('./virtual-socket.js').VirtualSocket & {readonly gatewayId: string | undefined}} GatewayVirtualSocket
73
+ * @typedef {import('node:tls').TLSSocket & {readonly gatewayId: string | undefined}} GatewayTlsSocket
74
+ */
75
+
76
+ /** No configured gateway is currently safe for a new virtual stream. */
77
+ export class SocketductTransportSetNotReadyError extends Error {
78
+ /** @param {string} [message] - Privacy-safe detail. */
79
+ constructor(message = "No Socketduct gateway member is currently ready") {
80
+ super(message)
81
+ this.name = "SocketductTransportSetNotReadyError"
82
+ this.code = "SOCKETDUCT_TRANSPORT_SET_NOT_READY"
83
+ }
84
+ }
85
+
86
+ /** Set-level resource exhaustion before bytes enter a member. */
87
+ export class SocketductTransportSetLimitError extends Error {
88
+ /**
89
+ * @param {string} message - Privacy-safe detail.
90
+ * @param {string} code - Stable code.
91
+ */
92
+ constructor(message, code) {
93
+ super(message)
94
+ this.name = "SocketductTransportSetLimitError"
95
+ this.code = code
96
+ }
97
+ }
98
+
99
+ /** Every configured member became terminal after the set reached readiness. */
100
+ export class SocketductTransportSetTerminalError extends AggregateError {
101
+ /** @param {Error[]} errors - Member errors in configured gateway order. */
102
+ constructor(errors) {
103
+ super(errors, "All Socketduct transport-set members closed")
104
+ this.name = "SocketductTransportSetTerminalError"
105
+ this.code = "SOCKETDUCT_TRANSPORT_SET_TERMINAL"
106
+ }
107
+ }
108
+
109
+ /** Shared retained-byte reservations across every configured member. */
110
+ class AggregateTransportBudget {
111
+ #memoryBytes = 0
112
+ #spoolBytes = 0
113
+ #controlBytes = 0
114
+
115
+ /** @param {{maxDataBytes: number, maxSpoolBytes: number, maxQueuedBytes: number}} limits - Process limits. */
116
+ constructor(limits) {
117
+ this.maxDataBytes = limits.maxDataBytes
118
+ this.maxSpoolBytes = limits.maxSpoolBytes
119
+ this.maxQueuedBytes = limits.maxQueuedBytes
120
+ }
121
+
122
+ /** @param {number} bytes - Retained and durably spooled data bytes. */
123
+ reserveData(bytes) {
124
+ validateReservation(bytes)
125
+ if (this.#memoryBytes + bytes > this.maxDataBytes) throw new SocketductTransportSetLimitError("Socketduct transport-set data buffer limit exceeded", "SOCKETDUCT_SET_BUFFER_LIMIT")
126
+ if (this.#spoolBytes + bytes > this.maxSpoolBytes) throw new SocketductTransportSetLimitError("Socketduct transport-set spool limit exceeded", "SOCKETDUCT_SET_SPOOL_LIMIT")
127
+ if (this.#memoryBytes + this.#controlBytes + bytes > this.maxQueuedBytes) throw new SocketductTransportSetLimitError("Socketduct transport-set queued-byte limit exceeded", "SOCKETDUCT_SET_QUEUE_LIMIT")
128
+ this.#memoryBytes += bytes
129
+ this.#spoolBytes += bytes
130
+ }
131
+
132
+ /** @param {number} bytes - Retained non-durable reverse-stream bytes. */
133
+ reserveMemory(bytes) {
134
+ validateReservation(bytes)
135
+ if (this.#memoryBytes + bytes > this.maxDataBytes) throw new SocketductTransportSetLimitError("Socketduct transport-set data buffer limit exceeded", "SOCKETDUCT_SET_BUFFER_LIMIT")
136
+ if (this.#memoryBytes + this.#controlBytes + bytes > this.maxQueuedBytes) throw new SocketductTransportSetLimitError("Socketduct transport-set queued-byte limit exceeded", "SOCKETDUCT_SET_QUEUE_LIMIT")
137
+ this.#memoryBytes += bytes
138
+ }
139
+
140
+ /** @param {number} bytes - Released in-memory data bytes. */
141
+ releaseMemory(bytes) {
142
+ validateReservation(bytes)
143
+ if (bytes > this.#memoryBytes) throw new Error("Socketduct transport-set memory budget release underflow")
144
+ this.#memoryBytes -= bytes
145
+ }
146
+
147
+ /** @param {number} bytes - Bytes successfully removed from durable spool storage. */
148
+ releaseSpool(bytes) {
149
+ validateReservation(bytes)
150
+ if (bytes > this.#spoolBytes) throw new Error("Socketduct transport-set spool budget release underflow")
151
+ this.#spoolBytes -= bytes
152
+ }
153
+
154
+ /** @param {number} bytes - Queued control bytes. */
155
+ reserveControl(bytes) {
156
+ validateReservation(bytes)
157
+ if (this.#memoryBytes + this.#controlBytes + bytes > this.maxQueuedBytes) throw new SocketductTransportSetLimitError("Socketduct transport-set queued-byte limit exceeded", "SOCKETDUCT_SET_QUEUE_LIMIT")
158
+ this.#controlBytes += bytes
159
+ }
160
+
161
+ /** @param {number} bytes - Released control bytes. */
162
+ releaseControl(bytes) {
163
+ validateReservation(bytes)
164
+ if (bytes > this.#controlBytes) throw new Error("Socketduct transport-set control budget release underflow")
165
+ this.#controlBytes -= bytes
166
+ }
167
+
168
+ /** @returns {{dataBytes: number, spoolBytes: number, queuedBytes: number, maxDataBytes: number, maxSpoolBytes: number, maxQueuedBytes: number}} */
169
+ status() {
170
+ return {
171
+ dataBytes: this.#memoryBytes,
172
+ spoolBytes: this.#spoolBytes,
173
+ queuedBytes: this.#memoryBytes + this.#controlBytes,
174
+ maxDataBytes: this.maxDataBytes,
175
+ maxSpoolBytes: this.maxSpoolBytes,
176
+ maxQueuedBytes: this.maxQueuedBytes
177
+ }
178
+ }
179
+ }
180
+
181
+ /** One process-wide set of explicit gateway-pinned v3 member connections. */
182
+ export class MultiplexedSocketductTransportSet extends EventEmitter {
183
+ /** @type {TransportMember[]} */
184
+ #members
185
+
186
+ /** @type {AggregateTransportBudget} */
187
+ #budget
188
+
189
+ /** @type {number} */
190
+ #minimumReadyMembers
191
+
192
+ /** @type {number} */
193
+ #maxPendingOpens
194
+
195
+ /** @type {PendingOpen[]} */
196
+ #pendingOpens = []
197
+
198
+ /** @type {number} */
199
+ #cursor = 0
200
+
201
+ /** @type {number} */
202
+ #virtualOpens = 0
203
+
204
+ /** @type {boolean} */
205
+ #started = false
206
+
207
+ /** @type {boolean} */
208
+ #startupResolved = false
209
+
210
+ /** @type {Error | undefined} */
211
+ #startupError
212
+
213
+ /** @type {boolean} */
214
+ #closed = false
215
+
216
+ /** @type {Promise<void> | undefined} */
217
+ #startPromise
218
+
219
+ /** @type {(() => void) | undefined} */
220
+ #resolveStart
221
+
222
+ /** @type {((error: Error) => void) | undefined} */
223
+ #rejectStart
224
+
225
+ /** @type {Promise<void> | undefined} */
226
+ #closePromise
227
+
228
+ /** @type {import('../logging/structured-logger.js').DiagnosticLogger | undefined} */
229
+ #logger
230
+
231
+ /**
232
+ * @param {TransportSetOptions} options - Explicit set and gateway policy.
233
+ * @param {TransportSetDependencies} [dependencies] - Narrow test dependencies.
234
+ */
235
+ constructor(options, dependencies = {}) {
236
+ super()
237
+ const validated = validateTransportSetOptions(options)
238
+ this.#minimumReadyMembers = validated.minimumReadyMembers
239
+ this.#maxPendingOpens = validated.maxPendingOpens
240
+ this.#budget = new AggregateTransportBudget(validated.limits)
241
+ this.#logger = options.logger
242
+ const createMember = dependencies.createMember ?? ((memberOptions) => "gatewayId" in memberOptions
243
+ ? new ReverseGatewayConnection(memberOptions)
244
+ : new MultiplexedSocketductConnection(memberOptions))
245
+ this.#members = validated.gateways.map(({gatewayId, endpoint}) => {
246
+ const identity = deriveGatewayIdentity(options.connectionId, gatewayId)
247
+ const memberOptions = endpoint.reverseHub === undefined
248
+ ? /** @type {import('./multiplexed-connection.js').MultiplexedConnectionOptions} */ ({
249
+ ...validated.memberOptions,
250
+ relay: endpoint.relay,
251
+ ...(endpoint.tls === undefined ? {} : {tls: endpoint.tls}),
252
+ ...(endpoint.createPhysicalSocket === undefined ? {} : {createPhysicalSocket: endpoint.createPhysicalSocket}),
253
+ connectionId: identity.connectionId,
254
+ spoolDirectory: join(validated.spoolDirectory, identity.spoolName),
255
+ continuousReconnect: true,
256
+ aggregateBudget: this.#budget,
257
+ ...(this.#logger === undefined ? {} : {logger: this.#logger})
258
+ })
259
+ : /** @type {import('../reverse/client.js').ReverseGatewayConnectionOptions} */ (/** @type {unknown} */ ({
260
+ ...validated.memberOptions,
261
+ gatewayId,
262
+ hub: endpoint.reverseHub,
263
+ tls: endpoint.tls,
264
+ ...(endpoint.createPhysicalSocket === undefined ? {} : {createPhysicalSocket: endpoint.createPhysicalSocket}),
265
+ connectionId: identity.connectionId,
266
+ continuousReconnect: true,
267
+ aggregateBudget: this.#budget,
268
+ ...(this.#logger === undefined ? {} : {logger: this.#logger})
269
+ }))
270
+ const connection = /** @type {TransportMemberConnection} */ (createMember(memberOptions, gatewayId))
271
+ validateMember(connection)
272
+ /** @type {TransportMember} */
273
+ const member = {gatewayId, connection, assignments: 0, startupError: undefined, terminalError: undefined, lastReadyGeneration: undefined}
274
+ connection.on("stateChange", (state, error) => this.#memberStateChanged(member, state, error))
275
+ return member
276
+ })
277
+ this.createConnection = this.createConnection.bind(this)
278
+ this.recoverConnection = this.recoverConnection.bind(this)
279
+ this.createTlsConnection = this.createTlsConnection.bind(this)
280
+ }
281
+
282
+ /** Starts every configured member concurrently and waits for minimum readiness. @returns {Promise<void>} */
283
+ start() {
284
+ if (this.#startPromise !== undefined) return this.#startPromise
285
+ if (this.#closed) return Promise.reject(closedError())
286
+ this.#started = true
287
+ logDiagnostic(this.#logger, "transport_set.started", {
288
+ configuredMembers: this.#members.length,
289
+ minimumReadyMembers: this.#minimumReadyMembers
290
+ })
291
+ this.#startPromise = new Promise((resolve, reject) => {
292
+ this.#resolveStart = resolve
293
+ this.#rejectStart = reject
294
+ })
295
+ for (const member of this.#members) {
296
+ try {
297
+ Promise.resolve(member.connection.connect()).catch((error) => {
298
+ member.startupError = asError(error)
299
+ this.#memberStateChanged()
300
+ })
301
+ } catch (error) {
302
+ member.startupError = asError(error)
303
+ }
304
+ }
305
+ this.#evaluateStartup()
306
+ return this.#startPromise
307
+ }
308
+
309
+ /**
310
+ * Assigns one new virtual socket to the next ready member.
311
+ * @param {import('./multiplexed-connection.js').VirtualConnectionOptions} options - Target/socket options.
312
+ * @param {(error: Error | null, socket: GatewayVirtualSocket) => void} [callback] - Existing callback convention.
313
+ * @returns {GatewayVirtualSocket} Sticky member-owned virtual socket with stable gateway metadata.
314
+ */
315
+ createConnection(options, callback) {
316
+ validateOrdinaryOpenOptions(options)
317
+ if (this.#closed) throw closedError()
318
+ const socketOptions = normalizeVirtualSocketOptions(options)
319
+ if (this.#startupError !== undefined) return this.#failedSocket(socketOptions, this.#startupError, callback)
320
+ const selection = this.#pendingOpens.length === 0 && this.#startupResolved ? this.#selectReadyMember() : undefined
321
+ if (selection === undefined) return this.#deferConnection(options, socketOptions, callback)
322
+ try {
323
+ /** @type {((error: Error | null, socket: import('./virtual-socket.js').VirtualSocket) => void) | undefined} */
324
+ const memberCallback = callback === undefined ? undefined : (error, socket) => callback(error, exposeGateway(socket, selection.member.gatewayId))
325
+ const socket = exposeGateway(/** @type {import('./virtual-socket.js').VirtualSocket} */ (selection.member.connection.createConnection(options, memberCallback)), selection.member.gatewayId)
326
+ selection.member.assignments += 1
327
+ this.#virtualOpens += 1
328
+ return socket
329
+ } catch (error) {
330
+ this.#cursor = selection.previousCursor
331
+ throw error
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Returns an app-facing socket while the set owns readiness and assignment.
337
+ * @param {import('./multiplexed-connection.js').VirtualConnectionOptions} options - Validated member open options.
338
+ * @param {{host: string, port: number, allowHalfOpen?: boolean, readableHighWaterMark?: number, writableHighWaterMark?: number}} socketOptions - Normal socket metadata.
339
+ * @param {(error: Error | null, socket: GatewayVirtualSocket) => void} [callback] - Existing callback convention.
340
+ * @returns {GatewayVirtualSocket} Deferred socket.
341
+ */
342
+ #deferConnection(options, socketOptions, callback) {
343
+ if (this.#pendingOpens.length >= this.#maxPendingOpens) {
344
+ throw new SocketductTransportSetLimitError("Socketduct transport-set pending-open limit exceeded", "SOCKETDUCT_SET_PENDING_OPEN_LIMIT")
345
+ }
346
+ /** @type {PendingOpen | undefined} */
347
+ let pending
348
+ const socket = new DeferredVirtualSocket(socketOptions, {
349
+ budget: this.#budget,
350
+ onCancel: () => {
351
+ if (pending === undefined) return
352
+ const index = this.#pendingOpens.indexOf(pending)
353
+ if (index !== -1) this.#pendingOpens.splice(index, 1)
354
+ },
355
+ ref: () => this.ref(),
356
+ unref: () => this.unref()
357
+ })
358
+ if (callback !== undefined) installConnectCallback(/** @type {GatewayVirtualSocket} */ (socket), callback)
359
+ pending = {
360
+ options: {
361
+ ...options,
362
+ ...(options.target === undefined || options.target === null ? {} : {target: {...options.target}})
363
+ },
364
+ socket
365
+ }
366
+ this.#pendingOpens.push(pending)
367
+ if (!this.#started) {
368
+ this.start().catch((error) => this.#recordStartupFailure(asError(error)))
369
+ }
370
+ this.#drainPendingOpens()
371
+ return /** @type {GatewayVirtualSocket} */ (socket)
372
+ }
373
+
374
+ /**
375
+ * Creates a socket that asynchronously reports the already-terminal startup failure.
376
+ * @param {{host: string, port: number, allowHalfOpen?: boolean, readableHighWaterMark?: number, writableHighWaterMark?: number}} socketOptions - Normal socket metadata.
377
+ * @param {Error} error - Terminal startup failure.
378
+ * @param {(error: Error | null, socket: GatewayVirtualSocket) => void} [callback] - Existing callback convention.
379
+ * @returns {GatewayVirtualSocket} Failed socket facade.
380
+ */
381
+ #failedSocket(socketOptions, error, callback) {
382
+ const socket = new DeferredVirtualSocket(socketOptions, {
383
+ budget: this.#budget,
384
+ onCancel: () => {},
385
+ ref: () => this.ref(),
386
+ unref: () => this.unref()
387
+ })
388
+ if (callback !== undefined) installConnectCallback(/** @type {GatewayVirtualSocket} */ (socket), callback)
389
+ queueMicrotask(() => socket._receiveError(error))
390
+ return /** @type {GatewayVirtualSocket} */ (socket)
391
+ }
392
+
393
+ /**
394
+ * Registers a durable stream on its original gateway before any member starts.
395
+ * @param {import('./multiplexed-connection.js').VirtualConnectionOptions & {gatewayId: string}} options - Original gateway and recovery identity.
396
+ * @param {(error: Error | null, socket: GatewayVirtualSocket) => void} [callback] - Existing callback convention.
397
+ * @returns {GatewayVirtualSocket} Original member-owned virtual socket with stable gateway metadata.
398
+ */
399
+ recoverConnection(options, callback) {
400
+ if (this.#closed) throw closedError()
401
+ if (this.#started) throw new Error("Recovered streams must be registered before start()")
402
+ if (typeof options !== "object" || options === null || typeof options.gatewayId !== "string" || !GATEWAY_ID.test(options.gatewayId)) throw new TypeError("recoverConnection requires a valid configured gatewayId")
403
+ if (Object.hasOwn(options, "recover")) throw new TypeError("recoverConnection owns the recover option")
404
+ const member = this.#members.find((candidate) => candidate.gatewayId === options.gatewayId)
405
+ if (member === undefined) throw new Error(`Unknown gatewayId: ${options.gatewayId}`)
406
+ const virtualOptions = {...options}
407
+ delete /** @type {{gatewayId?: string}} */ (virtualOptions).gatewayId
408
+ /** @type {((error: Error | null, socket: import('./virtual-socket.js').VirtualSocket) => void) | undefined} */
409
+ const memberCallback = callback === undefined ? undefined : (error, socket) => callback(error, exposeGateway(socket, member.gatewayId))
410
+ const socket = exposeGateway(/** @type {import('./virtual-socket.js').VirtualSocket} */ (member.connection.registerRecoveredConnection({...virtualOptions, recover: true}, memberCallback)), member.gatewayId)
411
+ member.assignments += 1
412
+ this.#virtualOpens += 1
413
+ return socket
414
+ }
415
+
416
+ /**
417
+ * Opens a sticky virtual socket and wraps it with an ordinary target TLS socket.
418
+ * @param {Record<string, unknown>} options - Virtual target plus tls.connect options.
419
+ * @param {(error: Error | null, socket: GatewayTlsSocket) => void} [callback] - Optional callback.
420
+ * @returns {GatewayTlsSocket} Target TLS socket with stable gateway metadata.
421
+ */
422
+ createTlsConnection(options, callback) {
423
+ validateOrdinaryOpenOptions(options)
424
+ const virtualOptions = pickVirtualOptions(options)
425
+ const raw = this.createConnection(virtualOptions)
426
+ const tlsOptions = omitVirtualOptions(options)
427
+ const secure = tlsConnect({...tlsOptions, socket: raw})
428
+ const exposed = exposeGatewayGetter(secure, () => raw.gatewayId)
429
+ if (callback !== undefined) installTlsCallback(exposed, callback)
430
+ return exposed
431
+ }
432
+
433
+ /** @returns {this} References every member. */
434
+ ref() {
435
+ for (const member of this.#members) member.connection.ref?.()
436
+ return this
437
+ }
438
+
439
+ /** @returns {this} Unreferences every member. */
440
+ unref() {
441
+ for (const member of this.#members) member.connection.unref?.()
442
+ return this
443
+ }
444
+
445
+ /** @returns {{state: string, configuredMembers: number, connectingMembers: number, readyMembers: number, degradedMembers: number, closedMembers: number, minimumReadyMembers: number, pendingOpens: number, maxPendingOpens: number, virtualOpens: number, physicalConnects: number, reconnectAttempts: number, budgets: {dataBytes: number, spoolBytes: number, queuedBytes: number, maxDataBytes: number, maxSpoolBytes: number, maxQueuedBytes: number}, members: Array<{gatewayId: string, state: string, assignments: number, physicalConnects: number, reconnectAttempts: number, reconnectSuccesses: number, virtualStreams: number, queuedBytes: number, spoolBytes: number, spoolCleanupFailures: number, terminalErrorCode: string | null}>}} Privacy-safe aggregate and per-gateway status. */
446
+ status() {
447
+ const members = this.#members.map((member) => {
448
+ const status = member.connection.status()
449
+ return {
450
+ gatewayId: member.gatewayId,
451
+ state: status.physicalState,
452
+ assignments: member.assignments,
453
+ physicalConnects: status.physicalConnects,
454
+ reconnectAttempts: status.reconnectAttempts,
455
+ reconnectSuccesses: status.reconnectSuccesses,
456
+ virtualStreams: status.virtualStreams,
457
+ queuedBytes: status.queuedBytes,
458
+ spoolBytes: status.spoolBytes,
459
+ spoolCleanupFailures: status.spoolCleanupFailures,
460
+ terminalErrorCode: publicErrorCode(member.terminalError ?? member.startupError)
461
+ }
462
+ })
463
+ const readyMembers = members.filter((member) => member.state === "ready").length
464
+ const connectingMembers = members.filter((member) => member.state === "connecting").length
465
+ const closedMembers = members.filter((member) => member.state === "closed").length
466
+ const degradedMembers = members.length - readyMembers - connectingMembers - closedMembers
467
+ let state = "configured"
468
+ if (this.#closed) state = "closed"
469
+ else if (this.#startupError !== undefined) state = "failed"
470
+ else if (this.#started && readyMembers === members.length) state = "ready"
471
+ else if (this.#started && (this.#startupResolved || readyMembers > 0)) state = "degraded"
472
+ else if (this.#started) state = "starting"
473
+ return {
474
+ state,
475
+ configuredMembers: members.length,
476
+ connectingMembers,
477
+ readyMembers,
478
+ degradedMembers,
479
+ closedMembers,
480
+ minimumReadyMembers: this.#minimumReadyMembers,
481
+ pendingOpens: this.#pendingOpens.length,
482
+ maxPendingOpens: this.#maxPendingOpens,
483
+ virtualOpens: this.#virtualOpens,
484
+ physicalConnects: members.reduce((sum, member) => sum + member.physicalConnects, 0),
485
+ reconnectAttempts: members.reduce((sum, member) => sum + member.reconnectAttempts, 0),
486
+ budgets: this.#budget.status(),
487
+ members
488
+ }
489
+ }
490
+
491
+ /** Closes every configured member once and preserves ordered failures. @returns {Promise<void>} */
492
+ close() {
493
+ return this.#shutdown("close")
494
+ }
495
+
496
+ /** Preserves member spools for a replacement set and closes every physical member once. @returns {Promise<void>} */
497
+ detach() {
498
+ return this.#shutdown("detach")
499
+ }
500
+
501
+ /** @param {"close" | "detach"} method - Member shutdown operation. @returns {Promise<void>} */
502
+ #shutdown(method) {
503
+ if (this.#closePromise !== undefined) return this.#closePromise
504
+ this.#closed = true
505
+ const error = closedError()
506
+ this.#rejectStart?.(error)
507
+ this.#failPendingOpens(error)
508
+ this.emit("stateChange", this.status())
509
+ this.#closePromise = Promise.allSettled(this.#members.map((member) => {
510
+ try {
511
+ return member.connection[method]()
512
+ } catch (error) {
513
+ return Promise.reject(error)
514
+ }
515
+ })).then((results) => {
516
+ const errors = results.flatMap((result) => result.status === "rejected" ? [asError(result.reason)] : [])
517
+ if (errors.length > 0) throw new AggregateError(errors, `One or more Socketduct gateway members failed to ${method}`)
518
+ })
519
+ return this.#closePromise
520
+ }
521
+
522
+ /** @returns {{member: TransportMember, previousCursor: number} | undefined} */
523
+ #selectReadyMember() {
524
+ if (this.#closed) throw closedError()
525
+ const previousCursor = this.#cursor
526
+ for (let offset = 0; offset < this.#members.length; offset += 1) {
527
+ const index = (this.#cursor + offset) % this.#members.length
528
+ const member = /** @type {TransportMember} */ (this.#members[index])
529
+ if (member.connection.status().physicalState !== "ready") continue
530
+ this.#cursor = (index + 1) % this.#members.length
531
+ return {member, previousCursor}
532
+ }
533
+ return undefined
534
+ }
535
+
536
+ /**
537
+ * @param {TransportMember} [member] - Member reporting the state.
538
+ * @param {unknown} [state] - Reported state.
539
+ * @param {unknown} [error] - Terminal error.
540
+ * @returns {void}
541
+ */
542
+ #memberStateChanged(member, state, error) {
543
+ if (member !== undefined && state === "closed" && error instanceof Error) member.terminalError = error
544
+ if (member !== undefined && typeof state === "string") {
545
+ const status = member.connection.status()
546
+ logDiagnostic(this.#logger, "transport_set.member_state", {
547
+ gatewayId: member.gatewayId,
548
+ state,
549
+ generation: status.connectionGeneration,
550
+ physicalConnects: status.physicalConnects,
551
+ reconnectAttempts: status.reconnectAttempts,
552
+ ...(error === undefined ? {} : errorClassification(error))
553
+ })
554
+ if (state === "ready" && member.lastReadyGeneration !== undefined && status.connectionGeneration > member.lastReadyGeneration) {
555
+ logDiagnostic(this.#logger, "transport_set.member_replaced", {
556
+ gatewayId: member.gatewayId,
557
+ generation: status.connectionGeneration,
558
+ physicalConnects: status.physicalConnects
559
+ })
560
+ }
561
+ if (state === "ready") member.lastReadyGeneration = status.connectionGeneration
562
+ }
563
+ this.#evaluateStartup()
564
+ this.#evaluateTerminalMemberLoss()
565
+ this.#drainPendingOpens()
566
+ this.emit("stateChange", this.status())
567
+ }
568
+
569
+ /** @returns {void} */
570
+ #evaluateStartup() {
571
+ if (this.#closed || this.#startPromise === undefined || this.#startupResolved) return
572
+ const ready = this.#members.filter((member) => member.connection.status().physicalState === "ready").length
573
+ if (ready >= this.#minimumReadyMembers) {
574
+ this.#startupResolved = true
575
+ logDiagnostic(this.#logger, "transport_set.ready", {
576
+ configuredMembers: this.#members.length,
577
+ readyMembers: ready,
578
+ minimumReadyMembers: this.#minimumReadyMembers
579
+ })
580
+ this.#resolveStart?.()
581
+ this.#resolveStart = undefined
582
+ this.#rejectStart = undefined
583
+ this.#drainPendingOpens()
584
+ return
585
+ }
586
+ const available = this.#members.filter((member) => member.connection.status().physicalState !== "closed" || member.startupError === undefined).length
587
+ if (available < this.#minimumReadyMembers) {
588
+ const errors = this.#members.flatMap((member) => member.startupError === undefined ? [] : [member.startupError])
589
+ this.#recordStartupFailure(new AggregateError(errors, "Socketduct transport set cannot satisfy minimumReadyMembers"))
590
+ }
591
+ }
592
+
593
+ /** Assigns pending sockets FIFO while eligible members are ready. */
594
+ #drainPendingOpens() {
595
+ if (!this.#startupResolved || this.#closed || this.#startupError !== undefined) return
596
+ while (this.#pendingOpens.length > 0) {
597
+ const selection = this.#selectReadyMember()
598
+ if (selection === undefined) return
599
+ const pending = this.#pendingOpens.shift()
600
+ if (pending === undefined) return
601
+ if (pending.socket.destroyed) {
602
+ this.#cursor = selection.previousCursor
603
+ continue
604
+ }
605
+ /** @type {import('./virtual-socket.js').VirtualSocket | undefined} */
606
+ let memberSocket
607
+ try {
608
+ memberSocket = exposeGateway(selection.member.connection.createConnection(pending.options), selection.member.gatewayId)
609
+ if (!pending.socket.assign(memberSocket, selection.member.gatewayId)) {
610
+ this.#cursor = selection.previousCursor
611
+ continue
612
+ }
613
+ selection.member.assignments += 1
614
+ this.#virtualOpens += 1
615
+ } catch (error) {
616
+ this.#cursor = selection.previousCursor
617
+ const failure = asError(error)
618
+ if (memberSocket !== undefined && !memberSocket.destroyed) memberSocket.destroy(failure)
619
+ pending.socket._receiveError(failure)
620
+ }
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Records the one terminal initial-start error and fails every waiting socket.
626
+ * @param {Error} error - Terminal error.
627
+ */
628
+ #recordStartupFailure(error) {
629
+ if (this.#startupResolved || this.#startupError !== undefined || this.#closed) return
630
+ this.#startupError = error
631
+ logDiagnostic(this.#logger, "transport_set.terminal_failure", {
632
+ configuredMembers: this.#members.length,
633
+ closedMembers: this.#members.filter((member) => member.connection.status().physicalState === "closed").length,
634
+ ...errorClassification(error, "SOCKETDUCT_TRANSPORT_SET_TERMINAL")
635
+ })
636
+ this.#rejectStart?.(error)
637
+ this.#resolveStart = undefined
638
+ this.#rejectStart = undefined
639
+ this.#failPendingOpens(error)
640
+ }
641
+
642
+ /** Fails queued sockets if every authenticated member becomes terminal after startup. */
643
+ #evaluateTerminalMemberLoss() {
644
+ if (!this.#startupResolved || this.#closed || this.#startupError !== undefined) return
645
+ if (!this.#members.every((member) => member.connection.status().physicalState === "closed")) return
646
+ const errors = this.#members.map((member) => member.terminalError ?? member.startupError ?? memberTerminalError())
647
+ const error = new SocketductTransportSetTerminalError(errors)
648
+ this.#startupError = error
649
+ logDiagnostic(this.#logger, "transport_set.terminal_failure", {
650
+ configuredMembers: this.#members.length,
651
+ closedMembers: this.#members.length,
652
+ ...errorClassification(error, "SOCKETDUCT_TRANSPORT_SET_TERMINAL")
653
+ })
654
+ this.#failPendingOpens(error)
655
+ }
656
+
657
+ /** @param {Error} error - Terminal failure delivered through normal socket errors. */
658
+ #failPendingOpens(error) {
659
+ const pending = this.#pendingOpens
660
+ this.#pendingOpens = []
661
+ for (const open of pending) open.socket._receiveError(error)
662
+ }
663
+ }
664
+
665
+ /** @param {TransportSetOptions} options - Set policy. @returns {MultiplexedSocketductTransportSet} */
666
+ export function createMultiplexedSocketductTransportSet(options) {
667
+ return new MultiplexedSocketductTransportSet(options)
668
+ }
669
+
670
+ /**
671
+ * Transport-set lifecycle events must not expose arbitrary member error text.
672
+ * @param {unknown} error - Member or aggregate failure.
673
+ * @param {string} [fallback] - Known fallback code.
674
+ * @returns {{errorCode: string, errorClass: string}} Safe error classification.
675
+ */
676
+ function errorClassification(error, fallback) {
677
+ const {errorCode, errorClass} = errorDiagnostics(error, fallback)
678
+ return {errorCode, errorClass}
679
+ }
680
+
681
+ /** @param {TransportSetOptions} options - Raw policy. */
682
+ function validateTransportSetOptions(options) {
683
+ if (typeof options !== "object" || options === null) throw new TypeError("Socketduct transport-set options are required")
684
+ if (typeof options.connectionId !== "string" || !STABLE_IDENTIFIER.test(options.connectionId)) throw new TypeError("Transport-set connectionId must be 8-128 URL-safe opaque characters")
685
+ if (!Array.isArray(options.gateways) || options.gateways.length === 0) throw new TypeError("Transport-set gateways must be a non-empty array")
686
+ const minimumReadyMembers = options.minimumReadyMembers === undefined ? 1 : options.minimumReadyMembers
687
+ if (!Number.isSafeInteger(minimumReadyMembers) || minimumReadyMembers < 1 || minimumReadyMembers > options.gateways.length) throw new RangeError("minimumReadyMembers must be between 1 and the configured gateway count")
688
+ const maxPendingOpens = positiveLimit(options.maxPendingOpens, DEFAULT_MAX_PENDING_OPENS, "maxPendingOpens")
689
+ const limits = {
690
+ maxDataBytes: positiveLimit(options.maxTotalBufferBytes, DEFAULT_TOTAL_BUFFER_BYTES, "maxTotalBufferBytes"),
691
+ maxSpoolBytes: positiveLimit(options.maxTotalSpoolBytes, DEFAULT_TOTAL_SPOOL_BYTES, "maxTotalSpoolBytes"),
692
+ maxQueuedBytes: positiveLimit(options.maxTotalQueuedBytes, DEFAULT_TOTAL_QUEUED_BYTES, "maxTotalQueuedBytes")
693
+ }
694
+ const memberOptions = options.memberOptions === undefined ? {} : options.memberOptions
695
+ if (typeof memberOptions !== "object" || memberOptions === null || Array.isArray(memberOptions)) throw new TypeError("memberOptions must be an object")
696
+ for (const forbidden of ["relay", "hub", "gatewayId", "tls", "createPhysicalSocket", "connectionId", "spoolDirectory", "continuousReconnect", "aggregateBudget", "logger"]) {
697
+ if (Object.hasOwn(memberOptions, forbidden)) throw new TypeError(`memberOptions.${forbidden} is owned by the transport set`)
698
+ }
699
+ /** @type {GatewayConfiguration[]} */
700
+ const gateways = []
701
+ const gatewayIds = new Set()
702
+ for (const raw of options.gateways) {
703
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new TypeError("Each gateway entry must be an object")
704
+ for (const key of Object.keys(raw)) {
705
+ if (key !== "gatewayId" && key !== "endpoint") throw new TypeError(`Unknown gateway field ${key}; gateway relay and factory settings belong under endpoint`)
706
+ }
707
+ const gateway = /** @type {Partial<GatewayConfiguration>} */ (raw)
708
+ if (typeof gateway.gatewayId !== "string" || !GATEWAY_ID.test(gateway.gatewayId)) throw new TypeError("gatewayId must be 2-64 URL-safe characters and cannot contain path separators")
709
+ if (gatewayIds.has(gateway.gatewayId)) throw new TypeError(`Duplicate gatewayId: ${gateway.gatewayId}`)
710
+ gatewayIds.add(gateway.gatewayId)
711
+ if (typeof gateway.endpoint !== "object" || gateway.endpoint === null || Array.isArray(gateway.endpoint)) throw new TypeError(`Gateway ${gateway.gatewayId} requires an explicit endpoint`)
712
+ const endpoint = /** @type {Partial<GatewayEndpoint>} */ (gateway.endpoint)
713
+ for (const key of Object.keys(endpoint)) {
714
+ if (key !== "relay" && key !== "reverseHub" && key !== "tls" && key !== "createPhysicalSocket") throw new TypeError(`Unknown endpoint field: ${key}`)
715
+ }
716
+ if ((endpoint.relay === undefined) === (endpoint.reverseHub === undefined)) throw new TypeError(`Gateway ${gateway.gatewayId} requires exactly one relay or reverseHub endpoint`)
717
+ const selectedEndpoint = endpoint.relay ?? endpoint.reverseHub
718
+ if (typeof selectedEndpoint !== "object" || selectedEndpoint === null || typeof selectedEndpoint.host !== "string" || selectedEndpoint.host.length === 0 || !validPort(selectedEndpoint.port) || typeof selectedEndpoint.token !== "string" || selectedEndpoint.token.length === 0) throw new TypeError(`Gateway ${gateway.gatewayId} endpoint host, port, and token are required`)
719
+ if (endpoint.tls !== undefined && (typeof endpoint.tls !== "object" || endpoint.tls === null || Array.isArray(endpoint.tls))) throw new TypeError(`Gateway ${gateway.gatewayId} tls must be an object`)
720
+ if (endpoint.reverseHub !== undefined && endpoint.tls === undefined) throw new TypeError(`Gateway ${gateway.gatewayId} reverseHub requires verified TLS settings`)
721
+ if (endpoint.createPhysicalSocket !== undefined && typeof endpoint.createPhysicalSocket !== "function") throw new TypeError(`Gateway ${gateway.gatewayId} createPhysicalSocket must be a function`)
722
+ gateways.push(/** @type {GatewayConfiguration} */ ({gatewayId: gateway.gatewayId, endpoint}))
723
+ }
724
+ for (let left = 0; left < gateways.length; left += 1) {
725
+ for (let right = left + 1; right < gateways.length; right += 1) {
726
+ const first = /** @type {GatewayConfiguration} */ (gateways[left]).endpoint
727
+ const second = /** @type {GatewayConfiguration} */ (gateways[right]).endpoint
728
+ if (first.relay === undefined || second.relay === undefined) continue
729
+ const sameAddress = first.relay.host === second.relay.host && first.relay.port === second.relay.port
730
+ const sameFactory = first.createPhysicalSocket === second.createPhysicalSocket
731
+ if (sameAddress && sameFactory) throw new TypeError("Multiple gateways cannot use the same unpinned endpoint/factory")
732
+ }
733
+ }
734
+ const defaultSpoolDirectory = join(tmpdir(), `socketduct-${typeof process.getuid === "function" ? process.getuid() : "user"}`, "multiplexed-set")
735
+ if (options.spoolDirectory !== undefined && (typeof options.spoolDirectory !== "string" || options.spoolDirectory.length === 0)) throw new TypeError("spoolDirectory must be a non-empty string")
736
+ return {
737
+ gateways,
738
+ minimumReadyMembers,
739
+ maxPendingOpens,
740
+ limits,
741
+ memberOptions,
742
+ spoolDirectory: resolve(options.spoolDirectory === undefined ? defaultSpoolDirectory : options.spoolDirectory)
743
+ }
744
+ }
745
+
746
+ /**
747
+ * @param {string} baseId - Base identity.
748
+ * @param {string} gatewayId - Gateway identity.
749
+ */
750
+ function deriveGatewayIdentity(baseId, gatewayId) {
751
+ const digest = createHash("sha256").update("socketduct-v3-transport-set\0").update(baseId).update("\0").update(gatewayId).digest("base64url")
752
+ return {connectionId: `gateway_${digest}`, spoolName: `gateway-${digest}`}
753
+ }
754
+
755
+ /**
756
+ * @param {unknown} value - Raw limit.
757
+ * @param {number} defaultValue - Default.
758
+ * @param {string} name - Option name.
759
+ */
760
+ function positiveLimit(value, defaultValue, name) {
761
+ if (value === undefined) return defaultValue
762
+ if (!Number.isSafeInteger(value) || /** @type {number} */ (value) <= 0) throw new RangeError(`${name} must be a positive integer`)
763
+ return /** @type {number} */ (value)
764
+ }
765
+
766
+ /** @param {unknown} bytes - Reservation. */
767
+ function validateReservation(bytes) {
768
+ if (!Number.isSafeInteger(bytes) || /** @type {number} */ (bytes) < 0) throw new RangeError("Socketduct transport-set budget reservations must be non-negative integers")
769
+ }
770
+
771
+ /** @param {unknown} options - Ordinary open options. */
772
+ function validateOrdinaryOpenOptions(options) {
773
+ if (typeof options !== "object" || options === null) throw new TypeError("Virtual connection options are required")
774
+ if (Object.hasOwn(options, "gatewayId") || /** @type {{recover?: unknown}} */ (options).recover === true) {
775
+ throw new TypeError("Gateway-bound durable recovery must use recoverConnection before start")
776
+ }
777
+ }
778
+
779
+ /**
780
+ * @param {import('./multiplexed-connection.js').VirtualConnectionOptions} options - Validated open options.
781
+ * @returns {{host: string, port: number, allowHalfOpen?: boolean, readableHighWaterMark?: number, writableHighWaterMark?: number}} Socket facade metadata.
782
+ */
783
+ function normalizeVirtualSocketOptions(options) {
784
+ const target = options.target ?? {host: options.host, port: options.port}
785
+ if (typeof target.host !== "string" || target.host.length === 0 || target.host.length > 253 || !validPort(target.port)) throw new TypeError("Virtual connection target host and port are required")
786
+ return {
787
+ host: target.host,
788
+ port: target.port,
789
+ ...(options.allowHalfOpen === undefined ? {} : {allowHalfOpen: options.allowHalfOpen}),
790
+ ...(options.readableHighWaterMark === undefined ? {} : {readableHighWaterMark: options.readableHighWaterMark}),
791
+ ...(options.writableHighWaterMark === undefined ? {} : {writableHighWaterMark: options.writableHighWaterMark})
792
+ }
793
+ }
794
+
795
+ /** @param {unknown} member - Constructed member. */
796
+ function validateMember(member) {
797
+ if (typeof member !== "object" || member === null) throw new TypeError("Transport-set member factory returned an invalid member")
798
+ const candidate = /** @type {Record<string, unknown>} */ (member)
799
+ if (typeof candidate.connect !== "function" || typeof candidate.createConnection !== "function" || typeof candidate.registerRecoveredConnection !== "function" || typeof candidate.status !== "function" || typeof candidate.close !== "function" || typeof candidate.detach !== "function" || typeof candidate.on !== "function") throw new TypeError("Transport-set member factory returned an invalid member")
800
+ }
801
+
802
+ /**
803
+ * @template {object} T
804
+ * @param {T} socket - Selected member socket.
805
+ * @param {string} gatewayId - Stable configured gateway ID.
806
+ * @returns {T & {readonly gatewayId: string}} Socket with immutable public allocation metadata.
807
+ */
808
+ function exposeGateway(socket, gatewayId) {
809
+ const existing = Object.getOwnPropertyDescriptor(socket, "gatewayId")
810
+ if (existing !== undefined) {
811
+ if (existing.value !== gatewayId) throw new Error("Socketduct gateway allocation metadata mismatch")
812
+ return /** @type {T & {readonly gatewayId: string}} */ (socket)
813
+ }
814
+ Object.defineProperty(socket, "gatewayId", {value: gatewayId, enumerable: true, writable: false, configurable: false})
815
+ return /** @type {T & {readonly gatewayId: string}} */ (socket)
816
+ }
817
+
818
+ /**
819
+ * @template {object} T
820
+ * @param {T} socket - Socket whose assignment is owned by another socket.
821
+ * @param {() => string | undefined} gatewayId - Live immutable assignment lookup.
822
+ * @returns {T & {readonly gatewayId: string | undefined}} Socket with immutable public allocation metadata.
823
+ */
824
+ function exposeGatewayGetter(socket, gatewayId) {
825
+ Object.defineProperty(socket, "gatewayId", {get: gatewayId, enumerable: true, configurable: false})
826
+ return /** @type {T & {readonly gatewayId: string | undefined}} */ (socket)
827
+ }
828
+
829
+ /**
830
+ * @param {GatewayVirtualSocket} socket - Deferred or selected virtual socket.
831
+ * @param {(error: Error | null, socket: GatewayVirtualSocket) => void} callback - Completion.
832
+ */
833
+ function installConnectCallback(socket, callback) {
834
+ let settled = false
835
+ const cleanup = () => {
836
+ socket.off("connect", onConnect)
837
+ socket.off("error", onError)
838
+ }
839
+ const onConnect = () => {
840
+ if (settled) return
841
+ settled = true
842
+ cleanup()
843
+ callback(null, socket)
844
+ }
845
+ /** @param {Error} error - Connection error. */
846
+ const onError = (error) => {
847
+ if (settled) return
848
+ settled = true
849
+ cleanup()
850
+ callback(error, socket)
851
+ }
852
+ socket.once("connect", onConnect)
853
+ socket.once("error", onError)
854
+ }
855
+
856
+ /** @param {Record<string, unknown>} options - Combined options. */
857
+ function pickVirtualOptions(options) {
858
+ const keys = ["host", "port", "target", "sessionName", "identifier", "recover", "allowHalfOpen", "readableHighWaterMark", "writableHighWaterMark"]
859
+ return Object.fromEntries(keys.flatMap((key) => Object.hasOwn(options, key) ? [[key, options[key]]] : []))
860
+ }
861
+
862
+ /** @param {Record<string, unknown>} options - Combined options. */
863
+ function omitVirtualOptions(options) {
864
+ const tlsOptions = {...options}
865
+ for (const key of ["host", "port", "target", "sessionName", "identifier", "recover", "allowHalfOpen", "readableHighWaterMark", "writableHighWaterMark"]) delete tlsOptions[key]
866
+ return tlsOptions
867
+ }
868
+
869
+ /**
870
+ * @param {GatewayTlsSocket} socket - TLS socket.
871
+ * @param {(error: Error | null, socket: GatewayTlsSocket) => void} callback - Completion.
872
+ */
873
+ function installTlsCallback(socket, callback) {
874
+ let settled = false
875
+ const cleanup = () => {
876
+ socket.off("secureConnect", onConnect)
877
+ socket.off("error", onError)
878
+ }
879
+ const onConnect = () => {
880
+ if (settled) return
881
+ settled = true
882
+ cleanup()
883
+ callback(null, socket)
884
+ }
885
+ /** @param {Error} error - Connection error. */
886
+ const onError = (error) => {
887
+ if (settled) return
888
+ settled = true
889
+ cleanup()
890
+ callback(error, socket)
891
+ }
892
+ socket.once("secureConnect", onConnect)
893
+ socket.once("error", onError)
894
+ }
895
+
896
+ /** @returns {Error & {code: string}} */
897
+ function closedError() {
898
+ return Object.assign(new Error("Multiplexed Socketduct transport set is closed"), {code: "SOCKETDUCT_TRANSPORT_SET_CLOSED"})
899
+ }
900
+
901
+ /** @returns {Error} Privacy-safe fallback for a member that closed without a cause. */
902
+ function memberTerminalError() {
903
+ return Object.assign(new Error("Socketduct gateway member closed without a terminal cause"), {code: "SOCKETDUCT_MEMBER_TERMINAL"})
904
+ }
905
+
906
+ /** @param {Error | undefined} error - Internal terminal error. @returns {string | null} Stable privacy-safe code. */
907
+ function publicErrorCode(error) {
908
+ if (error === undefined) return null
909
+ const code = /** @type {{code?: unknown}} */ (error).code
910
+ return typeof code === "string" && code.length <= 128 && /^[A-Z][A-Z0-9_]*$/.test(code) ? code : "SOCKETDUCT_MEMBER_TERMINAL"
911
+ }
912
+
913
+ /** @param {unknown} value - Thrown value. @returns {Error} */
914
+ function asError(value) { return value instanceof Error ? value : new Error(String(value)) }
915
+
916
+ /** @param {unknown} port - Raw port. @returns {port is number} */
917
+ function validPort(port) { return typeof port === "number" && Number.isInteger(port) && port >= 1 && port <= 65_535 }