socketduct 0.1.16 → 0.1.18
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/.env.example +3 -0
- package/changelog.d/20260924120000-wan-route-classes.md +1 -0
- package/changelog.d/20260925120000-reverse-stream-transport-resume.md +1 -0
- package/docs/architecture.md +28 -8
- package/docs/protocol.md +59 -15
- package/docs/reverse-gateway.md +121 -55
- package/docs/security.md +28 -5
- package/docs/session-lifecycle.md +21 -10
- package/docs/socket-api.md +59 -23
- package/package.json +1 -1
- package/src/client/deferred-virtual-socket.js +16 -4
- package/src/client/multiplexed-transport-set.js +106 -65
- package/src/config/environment.js +24 -1
- package/src/reverse/client.js +283 -50
- package/src/reverse/common.js +1 -0
- package/src/reverse/gateway.js +265 -38
- package/src/reverse/hub.js +464 -52
- package/src/reverse/runtime.js +32 -6
- package/test/e2e/reverse-gateway-process.test.js +93 -0
- package/test/integration/reverse-gateway.test.js +576 -28
- package/test/integration/reverse-route-class.test.js +377 -0
- package/test/unit/client/multiplexed-transport-set.test.js +42 -0
- package/test/unit/config/environment.test.js +7 -1
package/docs/socket-api.md
CHANGED
|
@@ -104,7 +104,7 @@ responsible for durably reconciling application state after uncertain results.
|
|
|
104
104
|
`detach()` requires an explicit `connectionId`, preserves active spools and
|
|
105
105
|
does not send stream resets. `close()` remains terminal and removes spools.
|
|
106
106
|
|
|
107
|
-
### `MultiplexedSocketductTransportSet` (multi-
|
|
107
|
+
### `MultiplexedSocketductTransportSet` (multi-route v3 API)
|
|
108
108
|
|
|
109
109
|
Use one transport set per Node process when the process has several genuinely
|
|
110
110
|
distinct gateway paths:
|
|
@@ -133,14 +133,38 @@ agent.createConnection = set.createConnection
|
|
|
133
133
|
await starting
|
|
134
134
|
```
|
|
135
135
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
136
|
+
For native reverse hubs, configure first-class route classes instead of exact
|
|
137
|
+
gateways. Exactly one of `gateways` or `routeClasses` is accepted:
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
const set = new MultiplexedSocketductTransportSet({
|
|
141
|
+
connectionId: "runner_01_stable_identity",
|
|
142
|
+
minimumReadyMembers: 1,
|
|
143
|
+
routeClasses: ["starlink", "zyxel", "o2"].map((routeClass) => ({
|
|
144
|
+
routeClass,
|
|
145
|
+
endpoint: {
|
|
146
|
+
reverseHub: {host: "hub.example.net", port: 3100, token},
|
|
147
|
+
tls: {caFile: "/certs/hub-ca.crt", servername: "hub.example.net"}
|
|
148
|
+
}
|
|
149
|
+
}))
|
|
150
|
+
})
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Each route-class member owns one physical connection and stable identity for
|
|
154
|
+
the `{connectionId, routeClass}` pair. The hub resolves every requested target
|
|
155
|
+
to one exact registered gateway, so the physical count is independent of the
|
|
156
|
+
number of server-local target gateways. Route classes require a verified
|
|
157
|
+
`reverseHub` endpoint; they cannot wrap an ordinary relay.
|
|
158
|
+
|
|
159
|
+
For the exact-gateway relay form, every entry must have a stable 2-64 character
|
|
160
|
+
`gatewayId` and an explicit `endpoint.relay`. An optional, gateway-specific
|
|
161
|
+
`endpoint.createPhysicalSocket` can pin an interface, routing table or external
|
|
162
|
+
transport. Repeating the same relay address without distinct pinned factories
|
|
163
|
+
is rejected: assigning three names to one load-balanced endpoint is not path
|
|
164
|
+
diversity. Socketduct cannot inspect arbitrary DNS or factory routing, so callers
|
|
165
|
+
remain responsible for making each declared endpoint/factory genuinely
|
|
166
|
+
gateway-pinned. Blank, duplicate and path-unsafe IDs and malformed endpoint
|
|
167
|
+
entries fail synchronously.
|
|
144
168
|
|
|
145
169
|
`start()` constructs no new members: the constructor creates exactly one
|
|
146
170
|
`MultiplexedSocketductConnection` per gateway and `start()` warms all of them
|
|
@@ -159,12 +183,16 @@ its normal `connecting`/`pending` state. Opens wait FIFO until initial
|
|
|
159
183
|
member may resume the queue. New calls never wait behind a reconnecting route
|
|
160
184
|
when a healthy sibling is ready.
|
|
161
185
|
|
|
162
|
-
Waiting does not assign an anonymous or merely connecting member.
|
|
163
|
-
is `undefined` until one ready member accepts the open, then
|
|
164
|
-
immutable configured ID
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
member
|
|
186
|
+
Waiting does not assign an anonymous or merely connecting member. For an exact
|
|
187
|
+
set, `gatewayId` is `undefined` until one ready member accepts the open, then
|
|
188
|
+
becomes the stable, immutable configured ID. For a route-class set,
|
|
189
|
+
`routeClass` behaves the same way and `gatewayId` remains undefined. Destroying
|
|
190
|
+
the socket first removes the pending open, so later readiness cannot leak a
|
|
191
|
+
member stream. An exact relay member selected immediately before its path drops
|
|
192
|
+
retains the stream and performs member-local v3 recovery; the set never
|
|
193
|
+
speculatively migrates it. A reverse route-class stream likewise remains on its
|
|
194
|
+
resolved target gateway while Socketduct replaces and resumes the physical
|
|
195
|
+
client/gateway transports underneath it.
|
|
168
196
|
|
|
169
197
|
`createConnection(options, callback)` is pre-bound and invokes its callback once
|
|
170
198
|
on `connect` or the terminal pre-connect error; the socket emits `connect` before
|
|
@@ -189,9 +217,13 @@ socket with ordinary `tls.connect` target options such as `servername`, `ca`,
|
|
|
189
217
|
`cert` and `key`. Node TLS owns the handshake, `secureConnect`, TLS errors and
|
|
190
218
|
the callback, which runs exactly once only after `secureConnect` or failure.
|
|
191
219
|
Both methods count only completed member assignments and preserve stream
|
|
192
|
-
stickiness. Every returned raw or TLS socket exposes enumerable `gatewayId
|
|
193
|
-
|
|
194
|
-
|
|
220
|
+
stickiness. Every returned raw or TLS socket exposes enumerable `gatewayId` and
|
|
221
|
+
`routeClass` allocation metadata. Only the configured identity is set. Persist
|
|
222
|
+
an exact set's immutable `gatewayId` after connection so a replacement can
|
|
223
|
+
recover the stream on its original member. Reverse route-class streams are not
|
|
224
|
+
recoverable across process replacement, but they do recover transparently from
|
|
225
|
+
a transient physical transport loss while the original objects and target
|
|
226
|
+
socket remain alive.
|
|
195
227
|
|
|
196
228
|
Process-replacement recovery is deliberately separate from ordinary selection.
|
|
197
229
|
First detach the old set when an orderly handoff is possible. Construct the
|
|
@@ -217,8 +249,8 @@ await replacement.start()
|
|
|
217
249
|
gateway ID, an explicit `recover` option, or registration after `start()` has
|
|
218
250
|
begun. It bypasses round-robin only to reach that configured member; it never
|
|
219
251
|
migrates or replays the stream on a sibling. Ordinary `createConnection()` and
|
|
220
|
-
`createTlsConnection()` reject `recover: true` and `
|
|
221
|
-
cannot force a path. `detach()` coalesces with concurrent shutdown calls,
|
|
252
|
+
`createTlsConnection()` reject `recover: true`, `gatewayId`, and `routeClass`,
|
|
253
|
+
so new opens cannot force a path. `detach()` coalesces with concurrent shutdown calls,
|
|
222
254
|
preserves active member spools, and retires empty warmed lanes; `close()` remains
|
|
223
255
|
terminal and removes their spools.
|
|
224
256
|
|
|
@@ -239,7 +271,7 @@ capacity occupied and fails the affected member visibly.
|
|
|
239
271
|
|
|
240
272
|
`status()` returns only safe aggregate counts/bytes, `pendingOpens`, its maximum,
|
|
241
273
|
and an ordered member list:
|
|
242
|
-
gateway ID, lifecycle state, assignments, physical connects/reconnects, virtual
|
|
274
|
+
gateway ID or route class, lifecycle state, assignments, physical connects/reconnects, virtual
|
|
243
275
|
stream count and queued/spool bytes. It excludes relays, targets, tokens,
|
|
244
276
|
credentials, names, payloads and spool contents. Concurrent `close()` calls
|
|
245
277
|
share one promise. Every member is attempted once in configured order; multiple
|
|
@@ -370,8 +402,12 @@ It selects exactly one registered gateway during `connect()`. The existing
|
|
|
370
402
|
`MultiplexedSocketductTransportSet` accepts `endpoint.reverseHub`, allowing
|
|
371
403
|
three ready members to retain deterministic health-aware round-robin behavior.
|
|
372
404
|
Reverse established operations deliberately do not support
|
|
373
|
-
`registerRecoveredConnection()`
|
|
374
|
-
|
|
405
|
+
`registerRecoveredConnection()` across process replacement. They do durably
|
|
406
|
+
spool unacknowledged send chunks during the lifetime of the client and gateway
|
|
407
|
+
objects, then use paired v3 resume manifests to survive physical transport
|
|
408
|
+
replacement without reopening the target or replaying an application request
|
|
409
|
+
as a new logical stream. Target loss, resume expiry, identity mismatch, or a
|
|
410
|
+
configured bound remains terminal and requires application reconciliation.
|
|
375
411
|
|
|
376
412
|
Reverse virtual sockets accept individual writes larger than their configured
|
|
377
413
|
retained-byte limit. The connection progresses those writes in slices bounded by
|
package/package.json
CHANGED
|
@@ -212,6 +212,9 @@ export class DeferredVirtualSocket extends VirtualSocket {
|
|
|
212
212
|
/** @type {string | undefined} */
|
|
213
213
|
#gatewayId
|
|
214
214
|
|
|
215
|
+
/** @type {string | undefined} */
|
|
216
|
+
#routeClass
|
|
217
|
+
|
|
215
218
|
/**
|
|
216
219
|
* @param {{host: string, port: number, allowHalfOpen?: boolean, readableHighWaterMark?: number, writableHighWaterMark?: number}} socketOptions - Target/socket options.
|
|
217
220
|
* @param {DeferredVirtualSocketOptions} deferredOptions - Set-owned wait policy.
|
|
@@ -222,6 +225,7 @@ export class DeferredVirtualSocket extends VirtualSocket {
|
|
|
222
225
|
this.#owner = owner
|
|
223
226
|
owner.attach(this)
|
|
224
227
|
Object.defineProperty(this, "gatewayId", {get: () => this.#gatewayId, enumerable: true, configurable: false})
|
|
228
|
+
Object.defineProperty(this, "routeClass", {get: () => this.#routeClass, enumerable: true, configurable: false})
|
|
225
229
|
}
|
|
226
230
|
|
|
227
231
|
/** @returns {string | undefined} Immutable assignment selected only after readiness. */
|
|
@@ -229,6 +233,11 @@ export class DeferredVirtualSocket extends VirtualSocket {
|
|
|
229
233
|
return this.#gatewayId
|
|
230
234
|
}
|
|
231
235
|
|
|
236
|
+
/** @returns {string | undefined} Immutable route-class assignment selected only after readiness. */
|
|
237
|
+
get routeClass() {
|
|
238
|
+
return this.#routeClass
|
|
239
|
+
}
|
|
240
|
+
|
|
232
241
|
/**
|
|
233
242
|
* @override
|
|
234
243
|
* @returns {number} Assigned protocol stream ID, or zero while unassigned.
|
|
@@ -239,13 +248,16 @@ export class DeferredVirtualSocket extends VirtualSocket {
|
|
|
239
248
|
|
|
240
249
|
/**
|
|
241
250
|
* @param {VirtualSocket} socket - Selected ready-member socket.
|
|
242
|
-
* @param {string
|
|
251
|
+
* @param {string | {gatewayId?: string, routeClass?: string}} selection - Stable selected member identity.
|
|
243
252
|
* @returns {boolean} Whether assignment succeeded before cancellation.
|
|
244
253
|
*/
|
|
245
|
-
assign(socket,
|
|
246
|
-
if (this.#gatewayId !== undefined) throw new Error("Deferred Socketduct socket
|
|
254
|
+
assign(socket, selection) {
|
|
255
|
+
if (this.#gatewayId !== undefined || this.#routeClass !== undefined) throw new Error("Deferred Socketduct socket member is already assigned")
|
|
256
|
+
const allocation = typeof selection === "string" ? {gatewayId: selection} : selection
|
|
257
|
+
if ((typeof allocation.gatewayId === "string") === (typeof allocation.routeClass === "string")) throw new Error("Deferred Socketduct socket requires exactly one gatewayId or routeClass assignment")
|
|
247
258
|
if (!this.#owner.assign(socket)) return false
|
|
248
|
-
this.#gatewayId = gatewayId
|
|
259
|
+
this.#gatewayId = allocation.gatewayId
|
|
260
|
+
this.#routeClass = allocation.routeClass
|
|
249
261
|
return true
|
|
250
262
|
}
|
|
251
263
|
|
|
@@ -7,6 +7,7 @@ import {connect as tlsConnect} from "node:tls"
|
|
|
7
7
|
|
|
8
8
|
import {errorDiagnostics, logDiagnostic} from "../logging/structured-logger.js"
|
|
9
9
|
import {ReverseGatewayConnection} from "../reverse/client.js"
|
|
10
|
+
import {ROUTE_CLASS} from "../reverse/common.js"
|
|
10
11
|
import {DeferredVirtualSocket} from "./deferred-virtual-socket.js"
|
|
11
12
|
import {MultiplexedSocketductConnection} from "./multiplexed-connection.js"
|
|
12
13
|
|
|
@@ -26,10 +27,14 @@ const GATEWAY_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/
|
|
|
26
27
|
* @typedef {object} GatewayConfiguration
|
|
27
28
|
* @property {string} gatewayId - Stable, privacy-safe gateway identity.
|
|
28
29
|
* @property {GatewayEndpoint} endpoint - Explicit gateway-pinned endpoint/factory.
|
|
30
|
+
* @typedef {object} RouteClassConfiguration
|
|
31
|
+
* @property {string} routeClass - Stable WAN route-class identity.
|
|
32
|
+
* @property {GatewayEndpoint} endpoint - Shared reverse-hub endpoint/factory.
|
|
29
33
|
* @typedef {object} TransportSetOptions
|
|
30
34
|
* @property {string} connectionId - Stable process/base connection identity.
|
|
31
35
|
* @property {string} [spoolDirectory] - Base directory for derived gateway spool namespaces.
|
|
32
|
-
* @property {GatewayConfiguration[]} gateways - Explicit configured gateways.
|
|
36
|
+
* @property {GatewayConfiguration[]} [gateways] - Explicit configured exact gateways.
|
|
37
|
+
* @property {RouteClassConfiguration[]} [routeClasses] - Explicit configured route-class members.
|
|
33
38
|
* @property {number} [minimumReadyMembers] - Required ready members for start(), default 1.
|
|
34
39
|
* @property {number} [maxTotalBufferBytes] - Set-wide retained data limit.
|
|
35
40
|
* @property {number} [maxTotalSpoolBytes] - Set-wide durable spool data limit.
|
|
@@ -58,9 +63,10 @@ const GATEWAY_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/
|
|
|
58
63
|
* @property {() => unknown} [ref] - Reference resources.
|
|
59
64
|
* @property {() => unknown} [unref] - Unreference resources.
|
|
60
65
|
* @typedef {object} TransportSetDependencies
|
|
61
|
-
* @property {(options: import('./multiplexed-connection.js').MultiplexedConnectionOptions | import('../reverse/client.js').ReverseGatewayConnectionOptions,
|
|
66
|
+
* @property {(options: import('./multiplexed-connection.js').MultiplexedConnectionOptions | import('../reverse/client.js').ReverseGatewayConnectionOptions, memberIdentity: string) => TransportMemberConnection} [createMember] - Narrow test dependency.
|
|
62
67
|
* @typedef {object} TransportMember
|
|
63
|
-
* @property {string} gatewayId - Stable gateway ID.
|
|
68
|
+
* @property {string | undefined} gatewayId - Stable exact gateway ID.
|
|
69
|
+
* @property {string | undefined} routeClass - Stable route-class identity.
|
|
64
70
|
* @property {TransportMemberConnection} connection - Single-lane owner.
|
|
65
71
|
* @property {number} assignments - Virtual sockets assigned for life.
|
|
66
72
|
* @property {Error | undefined} startupError - Terminal startup error.
|
|
@@ -69,8 +75,8 @@ const GATEWAY_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/
|
|
|
69
75
|
* @typedef {object} PendingOpen
|
|
70
76
|
* @property {import('./multiplexed-connection.js').VirtualConnectionOptions} options - Validated caller options.
|
|
71
77
|
* @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
|
|
78
|
+
* @typedef {import('./virtual-socket.js').VirtualSocket & {readonly gatewayId: string | undefined, readonly routeClass: string | undefined}} GatewayVirtualSocket
|
|
79
|
+
* @typedef {import('node:tls').TLSSocket & {readonly gatewayId: string | undefined, readonly routeClass: string | undefined}} GatewayTlsSocket
|
|
74
80
|
*/
|
|
75
81
|
|
|
76
82
|
/** No configured gateway is currently safe for a new virtual stream. */
|
|
@@ -239,11 +245,12 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
239
245
|
this.#maxPendingOpens = validated.maxPendingOpens
|
|
240
246
|
this.#budget = new AggregateTransportBudget(validated.limits)
|
|
241
247
|
this.#logger = options.logger
|
|
242
|
-
const createMember = dependencies.createMember ?? ((memberOptions) => "
|
|
248
|
+
const createMember = dependencies.createMember ?? ((memberOptions) => "hub" in memberOptions
|
|
243
249
|
? new ReverseGatewayConnection(memberOptions)
|
|
244
250
|
: new MultiplexedSocketductConnection(memberOptions))
|
|
245
|
-
this.#members = validated.
|
|
246
|
-
const
|
|
251
|
+
this.#members = validated.members.map(({gatewayId, routeClass, endpoint}) => {
|
|
252
|
+
const memberIdentity = /** @type {string} */ (gatewayId ?? routeClass)
|
|
253
|
+
const identity = gatewayId === undefined ? deriveRouteClassIdentity(options.connectionId, memberIdentity) : deriveGatewayIdentity(options.connectionId, gatewayId)
|
|
247
254
|
const memberOptions = endpoint.reverseHub === undefined
|
|
248
255
|
? /** @type {import('./multiplexed-connection.js').MultiplexedConnectionOptions} */ ({
|
|
249
256
|
...validated.memberOptions,
|
|
@@ -258,19 +265,20 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
258
265
|
})
|
|
259
266
|
: /** @type {import('../reverse/client.js').ReverseGatewayConnectionOptions} */ (/** @type {unknown} */ ({
|
|
260
267
|
...validated.memberOptions,
|
|
261
|
-
gatewayId,
|
|
268
|
+
...(gatewayId === undefined ? {routeClass} : {gatewayId}),
|
|
262
269
|
hub: endpoint.reverseHub,
|
|
263
270
|
tls: endpoint.tls,
|
|
264
271
|
...(endpoint.createPhysicalSocket === undefined ? {} : {createPhysicalSocket: endpoint.createPhysicalSocket}),
|
|
265
272
|
connectionId: identity.connectionId,
|
|
273
|
+
spoolDirectory: join(validated.spoolDirectory, identity.spoolName),
|
|
266
274
|
continuousReconnect: true,
|
|
267
275
|
aggregateBudget: this.#budget,
|
|
268
276
|
...(this.#logger === undefined ? {} : {logger: this.#logger})
|
|
269
277
|
}))
|
|
270
|
-
const connection = /** @type {TransportMemberConnection} */ (createMember(memberOptions,
|
|
278
|
+
const connection = /** @type {TransportMemberConnection} */ (createMember(memberOptions, memberIdentity))
|
|
271
279
|
validateMember(connection)
|
|
272
280
|
/** @type {TransportMember} */
|
|
273
|
-
const member = {gatewayId, connection, assignments: 0, startupError: undefined, terminalError: undefined, lastReadyGeneration: undefined}
|
|
281
|
+
const member = {gatewayId, routeClass, connection, assignments: 0, startupError: undefined, terminalError: undefined, lastReadyGeneration: undefined}
|
|
274
282
|
connection.on("stateChange", (state, error) => this.#memberStateChanged(member, state, error))
|
|
275
283
|
return member
|
|
276
284
|
})
|
|
@@ -321,8 +329,8 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
321
329
|
if (selection === undefined) return this.#deferConnection(options, socketOptions, callback)
|
|
322
330
|
try {
|
|
323
331
|
/** @type {((error: Error | null, socket: import('./virtual-socket.js').VirtualSocket) => void) | undefined} */
|
|
324
|
-
const memberCallback = callback === undefined ? undefined : (error, socket) => callback(error,
|
|
325
|
-
const socket =
|
|
332
|
+
const memberCallback = callback === undefined ? undefined : (error, socket) => callback(error, exposeAllocation(socket, memberAllocation(selection.member)))
|
|
333
|
+
const socket = exposeAllocation(/** @type {import('./virtual-socket.js').VirtualSocket} */ (selection.member.connection.createConnection(options, memberCallback)), memberAllocation(selection.member))
|
|
326
334
|
selection.member.assignments += 1
|
|
327
335
|
this.#virtualOpens += 1
|
|
328
336
|
return socket
|
|
@@ -405,9 +413,10 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
405
413
|
if (member === undefined) throw new Error(`Unknown gatewayId: ${options.gatewayId}`)
|
|
406
414
|
const virtualOptions = {...options}
|
|
407
415
|
delete /** @type {{gatewayId?: string}} */ (virtualOptions).gatewayId
|
|
416
|
+
const allocation = memberAllocation(member)
|
|
408
417
|
/** @type {((error: Error | null, socket: import('./virtual-socket.js').VirtualSocket) => void) | undefined} */
|
|
409
|
-
const memberCallback = callback === undefined ? undefined : (error, socket) => callback(error,
|
|
410
|
-
const socket =
|
|
418
|
+
const memberCallback = callback === undefined ? undefined : (error, socket) => callback(error, exposeAllocation(socket, allocation))
|
|
419
|
+
const socket = exposeAllocation(/** @type {import('./virtual-socket.js').VirtualSocket} */ (member.connection.registerRecoveredConnection({...virtualOptions, recover: true}, memberCallback)), allocation)
|
|
411
420
|
member.assignments += 1
|
|
412
421
|
this.#virtualOpens += 1
|
|
413
422
|
return socket
|
|
@@ -425,7 +434,7 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
425
434
|
const raw = this.createConnection(virtualOptions)
|
|
426
435
|
const tlsOptions = omitVirtualOptions(options)
|
|
427
436
|
const secure = tlsConnect({...tlsOptions, socket: raw})
|
|
428
|
-
const exposed =
|
|
437
|
+
const exposed = exposeAllocationGetters(secure, () => raw.gatewayId, () => raw.routeClass)
|
|
429
438
|
if (callback !== undefined) installTlsCallback(exposed, callback)
|
|
430
439
|
return exposed
|
|
431
440
|
}
|
|
@@ -442,12 +451,12 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
442
451
|
return this
|
|
443
452
|
}
|
|
444
453
|
|
|
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
|
|
454
|
+
/** @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, routeClass?: 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-member status. */
|
|
446
455
|
status() {
|
|
447
456
|
const members = this.#members.map((member) => {
|
|
448
457
|
const status = member.connection.status()
|
|
449
458
|
return {
|
|
450
|
-
|
|
459
|
+
...memberAllocation(member),
|
|
451
460
|
state: status.physicalState,
|
|
452
461
|
assignments: member.assignments,
|
|
453
462
|
physicalConnects: status.physicalConnects,
|
|
@@ -544,7 +553,7 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
544
553
|
if (member !== undefined && typeof state === "string") {
|
|
545
554
|
const status = member.connection.status()
|
|
546
555
|
logDiagnostic(this.#logger, "transport_set.member_state", {
|
|
547
|
-
|
|
556
|
+
...memberAllocation(member),
|
|
548
557
|
state,
|
|
549
558
|
generation: status.connectionGeneration,
|
|
550
559
|
physicalConnects: status.physicalConnects,
|
|
@@ -553,7 +562,7 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
553
562
|
})
|
|
554
563
|
if (state === "ready" && member.lastReadyGeneration !== undefined && status.connectionGeneration > member.lastReadyGeneration) {
|
|
555
564
|
logDiagnostic(this.#logger, "transport_set.member_replaced", {
|
|
556
|
-
|
|
565
|
+
...memberAllocation(member),
|
|
557
566
|
generation: status.connectionGeneration,
|
|
558
567
|
physicalConnects: status.physicalConnects
|
|
559
568
|
})
|
|
@@ -605,8 +614,9 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
605
614
|
/** @type {import('./virtual-socket.js').VirtualSocket | undefined} */
|
|
606
615
|
let memberSocket
|
|
607
616
|
try {
|
|
608
|
-
|
|
609
|
-
|
|
617
|
+
const allocation = memberAllocation(selection.member)
|
|
618
|
+
memberSocket = exposeAllocation(selection.member.connection.createConnection(pending.options), allocation)
|
|
619
|
+
if (!pending.socket.assign(memberSocket, allocation)) {
|
|
610
620
|
this.#cursor = selection.previousCursor
|
|
611
621
|
continue
|
|
612
622
|
}
|
|
@@ -682,9 +692,13 @@ function errorClassification(error, fallback) {
|
|
|
682
692
|
function validateTransportSetOptions(options) {
|
|
683
693
|
if (typeof options !== "object" || options === null) throw new TypeError("Socketduct transport-set options are required")
|
|
684
694
|
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
|
-
|
|
695
|
+
const hasGateways = options.gateways !== undefined
|
|
696
|
+
const hasRouteClasses = options.routeClasses !== undefined
|
|
697
|
+
if (hasGateways === hasRouteClasses) throw new TypeError("Transport set requires exactly one non-empty gateways or routeClasses array")
|
|
698
|
+
const configuredMembers = hasGateways ? options.gateways : options.routeClasses
|
|
699
|
+
if (!Array.isArray(configuredMembers) || configuredMembers.length === 0) throw new TypeError("Transport-set members must be a non-empty array")
|
|
686
700
|
const minimumReadyMembers = options.minimumReadyMembers === undefined ? 1 : options.minimumReadyMembers
|
|
687
|
-
if (!Number.isSafeInteger(minimumReadyMembers) || minimumReadyMembers < 1 || minimumReadyMembers >
|
|
701
|
+
if (!Number.isSafeInteger(minimumReadyMembers) || minimumReadyMembers < 1 || minimumReadyMembers > configuredMembers.length) throw new RangeError("minimumReadyMembers must be between 1 and the configured member count")
|
|
688
702
|
const maxPendingOpens = positiveLimit(options.maxPendingOpens, DEFAULT_MAX_PENDING_OPENS, "maxPendingOpens")
|
|
689
703
|
const limits = {
|
|
690
704
|
maxDataBytes: positiveLimit(options.maxTotalBufferBytes, DEFAULT_TOTAL_BUFFER_BYTES, "maxTotalBufferBytes"),
|
|
@@ -693,38 +707,42 @@ function validateTransportSetOptions(options) {
|
|
|
693
707
|
}
|
|
694
708
|
const memberOptions = options.memberOptions === undefined ? {} : options.memberOptions
|
|
695
709
|
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"]) {
|
|
710
|
+
for (const forbidden of ["relay", "hub", "gatewayId", "routeClass", "tls", "createPhysicalSocket", "connectionId", "spoolDirectory", "continuousReconnect", "aggregateBudget", "logger"]) {
|
|
697
711
|
if (Object.hasOwn(memberOptions, forbidden)) throw new TypeError(`memberOptions.${forbidden} is owned by the transport set`)
|
|
698
712
|
}
|
|
699
|
-
/** @type {
|
|
700
|
-
const
|
|
701
|
-
const
|
|
702
|
-
for (const raw of
|
|
703
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new TypeError("Each
|
|
713
|
+
/** @type {Array<{gatewayId: string | undefined, routeClass: string | undefined, endpoint: GatewayEndpoint}>} */
|
|
714
|
+
const members = []
|
|
715
|
+
const memberIds = new Set()
|
|
716
|
+
for (const raw of configuredMembers) {
|
|
717
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new TypeError("Each transport-set member must be an object")
|
|
704
718
|
for (const key of Object.keys(raw)) {
|
|
705
|
-
|
|
719
|
+
const identityKey = hasGateways ? "gatewayId" : "routeClass"
|
|
720
|
+
if (key !== identityKey && key !== "endpoint") throw new TypeError(`Unknown transport-set member field ${key}; identity and endpoint must be explicit`)
|
|
706
721
|
}
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
if (
|
|
712
|
-
|
|
722
|
+
const member = /** @type {Partial<GatewayConfiguration & RouteClassConfiguration>} */ (raw)
|
|
723
|
+
const memberId = hasGateways ? member.gatewayId : member.routeClass
|
|
724
|
+
const validMemberId = hasGateways ? typeof memberId === "string" && GATEWAY_ID.test(memberId) : typeof memberId === "string" && ROUTE_CLASS.test(memberId)
|
|
725
|
+
if (!validMemberId) throw new TypeError(hasGateways ? "gatewayId must be 2-64 URL-safe characters and cannot contain path separators" : "routeClass must be 1-64 URL-safe characters and cannot contain path separators")
|
|
726
|
+
if (memberIds.has(memberId)) throw new TypeError(`Duplicate ${hasGateways ? "gatewayId" : "routeClass"}: ${memberId}`)
|
|
727
|
+
memberIds.add(memberId)
|
|
728
|
+
if (typeof member.endpoint !== "object" || member.endpoint === null || Array.isArray(member.endpoint)) throw new TypeError(`Transport-set member ${memberId} requires an explicit endpoint`)
|
|
729
|
+
const endpoint = /** @type {Partial<GatewayEndpoint>} */ (member.endpoint)
|
|
713
730
|
for (const key of Object.keys(endpoint)) {
|
|
714
731
|
if (key !== "relay" && key !== "reverseHub" && key !== "tls" && key !== "createPhysicalSocket") throw new TypeError(`Unknown endpoint field: ${key}`)
|
|
715
732
|
}
|
|
716
|
-
if ((endpoint.relay === undefined) === (endpoint.reverseHub === undefined)) throw new TypeError(`
|
|
733
|
+
if ((endpoint.relay === undefined) === (endpoint.reverseHub === undefined)) throw new TypeError(`Transport-set member ${memberId} requires exactly one relay or reverseHub endpoint`)
|
|
734
|
+
if (hasRouteClasses && endpoint.reverseHub === undefined) throw new TypeError(`Route class ${memberId} requires a reverseHub endpoint`)
|
|
717
735
|
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(`
|
|
719
|
-
if (endpoint.tls !== undefined && (typeof endpoint.tls !== "object" || endpoint.tls === null || Array.isArray(endpoint.tls))) throw new TypeError(`
|
|
720
|
-
if (endpoint.reverseHub !== undefined && endpoint.tls === undefined) throw new TypeError(`
|
|
721
|
-
if (endpoint.createPhysicalSocket !== undefined && typeof endpoint.createPhysicalSocket !== "function") throw new TypeError(`
|
|
722
|
-
|
|
723
|
-
}
|
|
724
|
-
for (let left = 0; left <
|
|
725
|
-
for (let right = left + 1; right <
|
|
726
|
-
const first = /** @type {
|
|
727
|
-
const second = /** @type {
|
|
736
|
+
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(`Transport-set member ${memberId} endpoint host, port, and token are required`)
|
|
737
|
+
if (endpoint.tls !== undefined && (typeof endpoint.tls !== "object" || endpoint.tls === null || Array.isArray(endpoint.tls))) throw new TypeError(`Transport-set member ${memberId} tls must be an object`)
|
|
738
|
+
if (endpoint.reverseHub !== undefined && endpoint.tls === undefined) throw new TypeError(`Transport-set member ${memberId} reverseHub requires verified TLS settings`)
|
|
739
|
+
if (endpoint.createPhysicalSocket !== undefined && typeof endpoint.createPhysicalSocket !== "function") throw new TypeError(`Transport-set member ${memberId} createPhysicalSocket must be a function`)
|
|
740
|
+
members.push({gatewayId: hasGateways ? memberId : undefined, routeClass: hasRouteClasses ? memberId : undefined, endpoint: /** @type {GatewayEndpoint} */ (endpoint)})
|
|
741
|
+
}
|
|
742
|
+
for (let left = 0; left < members.length; left += 1) {
|
|
743
|
+
for (let right = left + 1; right < members.length; right += 1) {
|
|
744
|
+
const first = /** @type {{endpoint: GatewayEndpoint}} */ (members[left]).endpoint
|
|
745
|
+
const second = /** @type {{endpoint: GatewayEndpoint}} */ (members[right]).endpoint
|
|
728
746
|
if (first.relay === undefined || second.relay === undefined) continue
|
|
729
747
|
const sameAddress = first.relay.host === second.relay.host && first.relay.port === second.relay.port
|
|
730
748
|
const sameFactory = first.createPhysicalSocket === second.createPhysicalSocket
|
|
@@ -734,7 +752,7 @@ function validateTransportSetOptions(options) {
|
|
|
734
752
|
const defaultSpoolDirectory = join(tmpdir(), `socketduct-${typeof process.getuid === "function" ? process.getuid() : "user"}`, "multiplexed-set")
|
|
735
753
|
if (options.spoolDirectory !== undefined && (typeof options.spoolDirectory !== "string" || options.spoolDirectory.length === 0)) throw new TypeError("spoolDirectory must be a non-empty string")
|
|
736
754
|
return {
|
|
737
|
-
|
|
755
|
+
members,
|
|
738
756
|
minimumReadyMembers,
|
|
739
757
|
maxPendingOpens,
|
|
740
758
|
limits,
|
|
@@ -752,6 +770,15 @@ function deriveGatewayIdentity(baseId, gatewayId) {
|
|
|
752
770
|
return {connectionId: `gateway_${digest}`, spoolName: `gateway-${digest}`}
|
|
753
771
|
}
|
|
754
772
|
|
|
773
|
+
/**
|
|
774
|
+
* @param {string} baseId - Base identity.
|
|
775
|
+
* @param {string} routeClass - Route-class identity.
|
|
776
|
+
*/
|
|
777
|
+
function deriveRouteClassIdentity(baseId, routeClass) {
|
|
778
|
+
const digest = createHash("sha256").update("socketduct-v3-route-class\0").update(baseId).update("\0").update(routeClass).digest("base64url")
|
|
779
|
+
return {connectionId: `route_${digest}`, spoolName: `route-${digest}`}
|
|
780
|
+
}
|
|
781
|
+
|
|
755
782
|
/**
|
|
756
783
|
* @param {unknown} value - Raw limit.
|
|
757
784
|
* @param {number} defaultValue - Default.
|
|
@@ -771,8 +798,8 @@ function validateReservation(bytes) {
|
|
|
771
798
|
/** @param {unknown} options - Ordinary open options. */
|
|
772
799
|
function validateOrdinaryOpenOptions(options) {
|
|
773
800
|
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("
|
|
801
|
+
if (Object.hasOwn(options, "gatewayId") || Object.hasOwn(options, "routeClass") || /** @type {{recover?: unknown}} */ (options).recover === true) {
|
|
802
|
+
throw new TypeError("Transport member selection is set-owned; gateway-bound durable recovery must use recoverConnection before start")
|
|
776
803
|
}
|
|
777
804
|
}
|
|
778
805
|
|
|
@@ -802,28 +829,42 @@ function validateMember(member) {
|
|
|
802
829
|
/**
|
|
803
830
|
* @template {object} T
|
|
804
831
|
* @param {T} socket - Selected member socket.
|
|
805
|
-
* @param {string}
|
|
806
|
-
* @returns {T & {readonly gatewayId: string}} Socket with immutable public allocation metadata.
|
|
832
|
+
* @param {{gatewayId: string} | {routeClass: string}} allocation - Stable configured member identity.
|
|
833
|
+
* @returns {T & {readonly gatewayId: string | undefined, readonly routeClass: string | undefined}} Socket with immutable public allocation metadata.
|
|
807
834
|
*/
|
|
808
|
-
function
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
835
|
+
function exposeAllocation(socket, allocation) {
|
|
836
|
+
const values = "gatewayId" in allocation
|
|
837
|
+
? {gatewayId: allocation.gatewayId, routeClass: undefined}
|
|
838
|
+
: {gatewayId: undefined, routeClass: allocation.routeClass}
|
|
839
|
+
for (const key of /** @type {const} */ (["gatewayId", "routeClass"])) {
|
|
840
|
+
const existing = Object.getOwnPropertyDescriptor(socket, key)
|
|
841
|
+
if (existing !== undefined) {
|
|
842
|
+
if (/** @type {Record<string, unknown>} */ (socket)[key] !== values[key]) throw new Error("Socketduct member allocation metadata mismatch")
|
|
843
|
+
continue
|
|
844
|
+
}
|
|
845
|
+
Object.defineProperty(socket, key, {value: values[key], enumerable: true, writable: false, configurable: false})
|
|
846
|
+
}
|
|
847
|
+
return /** @type {T & {readonly gatewayId: string | undefined, readonly routeClass: string | undefined}} */ (socket)
|
|
816
848
|
}
|
|
817
849
|
|
|
818
850
|
/**
|
|
819
851
|
* @template {object} T
|
|
820
852
|
* @param {T} socket - Socket whose assignment is owned by another socket.
|
|
821
|
-
* @param {() => string | undefined} gatewayId - Live
|
|
822
|
-
* @
|
|
853
|
+
* @param {() => string | undefined} gatewayId - Live exact-gateway assignment lookup.
|
|
854
|
+
* @param {() => string | undefined} routeClass - Live route-class assignment lookup.
|
|
855
|
+
* @returns {T & {readonly gatewayId: string | undefined, readonly routeClass: string | undefined}} Socket with immutable public allocation metadata.
|
|
823
856
|
*/
|
|
824
|
-
function
|
|
857
|
+
function exposeAllocationGetters(socket, gatewayId, routeClass) {
|
|
825
858
|
Object.defineProperty(socket, "gatewayId", {get: gatewayId, enumerable: true, configurable: false})
|
|
826
|
-
|
|
859
|
+
Object.defineProperty(socket, "routeClass", {get: routeClass, enumerable: true, configurable: false})
|
|
860
|
+
return /** @type {T & {readonly gatewayId: string | undefined, readonly routeClass: string | undefined}} */ (socket)
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** @param {TransportMember} member - Configured member. @returns {{gatewayId: string} | {routeClass: string}} */
|
|
864
|
+
function memberAllocation(member) {
|
|
865
|
+
if (member.gatewayId !== undefined) return {gatewayId: member.gatewayId}
|
|
866
|
+
if (member.routeClass !== undefined) return {routeClass: member.routeClass}
|
|
867
|
+
throw new Error("Socketduct transport-set member identity is unavailable")
|
|
827
868
|
}
|
|
828
869
|
|
|
829
870
|
/**
|
|
@@ -64,6 +64,7 @@ const SUPPORTED_PROTOCOL_VERSIONS = new Set([2, 3])
|
|
|
64
64
|
* @property {RelayTlsConfig} [relayTls] - Optional TLS configuration.
|
|
65
65
|
* @property {Array<{gatewayId: string, username: string}>} [reverseGatewayBindings] - Hub gateway identity bindings.
|
|
66
66
|
* @property {Array<{username: string, gatewayId: string, host: string, port: number}>} [reverseRoutes] - Hub client route policy.
|
|
67
|
+
* @property {Array<{username: string, routeClass: string, gatewayId: string, host: string, port: number}>} [reverseRouteClassRoutes] - Hub route-class resolution policy.
|
|
67
68
|
* @property {string} [reverseGatewayId] - Outbound gateway stable ID.
|
|
68
69
|
* @property {string} [reverseConnectionId] - Outbound gateway stable transport identity.
|
|
69
70
|
* @property {{host: string, port: number, token: string}} [reverseHub] - Outbound hub endpoint.
|
|
@@ -206,7 +207,8 @@ export function loadEnvironmentConfig(env = process.env) {
|
|
|
206
207
|
...(relayTls !== undefined ? {relayTls} : {}),
|
|
207
208
|
...(mode === "reverse-hub" ? {
|
|
208
209
|
reverseGatewayBindings: parseReverseGatewayBindings(env.SOCKETDUCT_REVERSE_GATEWAY_BINDINGS ?? ""),
|
|
209
|
-
reverseRoutes: parseReverseRoutes(env.SOCKETDUCT_REVERSE_ROUTES ?? "")
|
|
210
|
+
reverseRoutes: parseReverseRoutes(env.SOCKETDUCT_REVERSE_ROUTES ?? ""),
|
|
211
|
+
reverseRouteClassRoutes: parseReverseRouteClassRoutes(env.SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES ?? "")
|
|
210
212
|
} : {}),
|
|
211
213
|
...(mode === "reverse-gateway" ? {
|
|
212
214
|
reverseGatewayId: env.SOCKETDUCT_REVERSE_GATEWAY_ID?.trim() ?? "",
|
|
@@ -252,6 +254,27 @@ function parseReverseRoutes(value) {
|
|
|
252
254
|
})
|
|
253
255
|
}
|
|
254
256
|
|
|
257
|
+
/**
|
|
258
|
+
* @param {string} value - Comma-separated username:routeClass:gatewayId:host:port routes.
|
|
259
|
+
* @returns {Array<{username: string, routeClass: string, gatewayId: string, host: string, port: number}>} Route-class routes.
|
|
260
|
+
*/
|
|
261
|
+
function parseReverseRouteClassRoutes(value) {
|
|
262
|
+
return splitList(value).map((entry) => {
|
|
263
|
+
const first = entry.indexOf(":")
|
|
264
|
+
const second = entry.indexOf(":", first + 1)
|
|
265
|
+
const third = entry.indexOf(":", second + 1)
|
|
266
|
+
const last = entry.lastIndexOf(":")
|
|
267
|
+
if (first <= 0 || second <= first + 1 || third <= second + 1 || last <= third + 1 || last === entry.length - 1) throw new Error("SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES entries must use username:routeClass:gatewayId:host:port")
|
|
268
|
+
return {
|
|
269
|
+
username: entry.slice(0, first),
|
|
270
|
+
routeClass: entry.slice(first + 1, second),
|
|
271
|
+
gatewayId: entry.slice(second + 1, third),
|
|
272
|
+
host: entry.slice(third + 1, last),
|
|
273
|
+
port: parsePort(entry.slice(last + 1), "SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES port", DEFAULT_RELAY_PORT)
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
255
278
|
/**
|
|
256
279
|
* @param {string | undefined} value - Raw version.
|
|
257
280
|
* @returns {2 | 3} Selected protocol version.
|