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.
- package/.dockerignore +11 -0
- package/.env.example +131 -0
- package/AGENTS.md +168 -0
- package/Dockerfile +30 -0
- package/README.md +134 -0
- package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
- package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
- package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
- package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
- package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
- package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
- package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
- package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
- package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
- package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
- package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
- package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
- package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
- package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
- package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
- package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
- package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
- package/changelog.d/20260902231542-reverse-close-position.md +1 -0
- package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
- package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
- package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
- package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
- package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
- package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
- package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
- package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
- package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
- package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
- package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
- package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
- package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
- package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
- package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
- package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
- package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
- package/docker-compose.dev.yml +19 -0
- package/docker-compose.reverse-gateway.yml +36 -0
- package/docker-compose.yml +73 -0
- package/docs/architecture.md +264 -0
- package/docs/docker-topology.md +164 -0
- package/docs/docker.md +281 -0
- package/docs/logging.md +185 -0
- package/docs/plans/initial-implementation.md +205 -0
- package/docs/plans/socket-docker-integration.md +221 -0
- package/docs/protocol.md +558 -0
- package/docs/releasing.md +18 -0
- package/docs/reverse-gateway.md +186 -0
- package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
- package/docs/running.md +215 -0
- package/docs/security.md +258 -0
- package/docs/session-lifecycle.md +127 -0
- package/docs/socket-api.md +448 -0
- package/eslint.config.js +36 -0
- package/package.json +44 -0
- package/scripts/deploy-gateway.js +194 -0
- package/scripts/test.js +28 -0
- package/src/auth/passwords.js +38 -0
- package/src/auth/static-token-store.js +41 -0
- package/src/auth/user-store.js +139 -0
- package/src/client/connect.js +76 -0
- package/src/client/deferred-virtual-socket.js +312 -0
- package/src/client/http-agent.js +101 -0
- package/src/client/https-agent.js +366 -0
- package/src/client/multiplexed-connection.js +1504 -0
- package/src/client/multiplexed-transport-set.js +917 -0
- package/src/client/multiplexed.js +3 -0
- package/src/client/socketduct-socket.js +1174 -0
- package/src/client/virtual-socket.js +256 -0
- package/src/config/environment.js +400 -0
- package/src/edge/tcp-edge.js +82 -0
- package/src/index.js +101 -0
- package/src/logging/structured-logger.js +745 -0
- package/src/protocol/compression.js +325 -0
- package/src/protocol/frame.js +341 -0
- package/src/protocol/v3-compression.js +44 -0
- package/src/protocol/v3-frame.js +209 -0
- package/src/proxy/multiplexed-relay.js +798 -0
- package/src/proxy/tcp-relay.js +1191 -0
- package/src/relay.js +3 -0
- package/src/reverse/client.js +721 -0
- package/src/reverse/common.js +88 -0
- package/src/reverse/gateway.js +663 -0
- package/src/reverse/hub.js +563 -0
- package/src/reverse/index.js +3 -0
- package/src/reverse/runtime.js +78 -0
- package/src/server/control-handler.js +170 -0
- package/src/server/primary.js +64 -0
- package/src/server/relay-runtime.js +124 -0
- package/src/server/worker.js +39 -0
- package/src/sessions/session.js +212 -0
- package/src/spool/spool-file.js +451 -0
- package/tensorbuzz.yml +36 -0
- package/test/e2e/gateway-process.test.js +262 -0
- package/test/e2e/reverse-gateway-process.test.js +285 -0
- package/test/fixtures/logging/enospc-matrix.mjs +238 -0
- package/test/fixtures/logging/enospc-recovery.mjs +146 -0
- package/test/fixtures/logging/process-writer.mjs +20 -0
- package/test/helpers/tls-certificates.js +115 -0
- package/test/integration/initial-target-recovery.test.js +518 -0
- package/test/integration/logging-recovery.test.js +163 -0
- package/test/integration/multiplexed-compatibility.test.js +254 -0
- package/test/integration/multiplexed-corrections.test.js +551 -0
- package/test/integration/multiplexed-failures.test.js +339 -0
- package/test/integration/multiplexed-transport-set.test.js +456 -0
- package/test/integration/multiplexed-transport.test.js +208 -0
- package/test/integration/reverse-gateway.test.js +1293 -0
- package/test/integration/socketduct-http-agent.test.js +435 -0
- package/test/integration/socketduct-https-agent.test.js +669 -0
- package/test/integration/stream-compression.test.js +1267 -0
- package/test/integration/tcp-forwarding.test.js +2878 -0
- package/test/integration/tls-forwarding.test.js +748 -0
- package/test/unit/auth/auth.test.js +64 -0
- package/test/unit/auth/static-token-store.test.js +14 -0
- package/test/unit/client/connect.test.js +58 -0
- package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
- package/test/unit/client/multiplexed-transport-set.test.js +906 -0
- package/test/unit/client/socketduct-socket.test.js +1124 -0
- package/test/unit/client/virtual-socket.test.js +98 -0
- package/test/unit/config/docker-gateway-files.test.js +274 -0
- package/test/unit/config/environment.test.js +507 -0
- package/test/unit/index.test.js +78 -0
- package/test/unit/logging/structured-logger.test.js +674 -0
- package/test/unit/protocol/compression.test.js +421 -0
- package/test/unit/protocol/frame.test.js +336 -0
- package/test/unit/protocol/v3-compression.test.js +31 -0
- package/test/unit/protocol/v3-frame.test.js +57 -0
- package/test/unit/release-configuration.test.js +21 -0
- package/test/unit/server/control-handler.test.js +291 -0
- package/test/unit/server/primary.test.js +59 -0
- package/test/unit/server/relay-runtime.test.js +434 -0
- package/test/unit/sessions/session.test.js +84 -0
- package/test/unit/spool/spool-file.test.js +295 -0
- 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 }
|