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.
@@ -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-gateway v3 API)
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
- Every entry must have a stable 2-64 character `gatewayId` and an explicit
137
- `endpoint.relay`. An optional, gateway-specific `endpoint.createPhysicalSocket`
138
- can pin an interface, routing table or external transport. Repeating the same
139
- relay address without distinct pinned factories is rejected: assigning three
140
- names to one load-balanced endpoint is not path diversity. Socketduct cannot
141
- inspect arbitrary DNS or factory routing, so callers remain responsible for
142
- making each declared endpoint/factory genuinely gateway-pinned. Blank,
143
- duplicate and path-unsafe IDs and malformed endpoint entries fail synchronously.
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. `gatewayId`
163
- is `undefined` until one ready member accepts the open, then becomes the stable,
164
- immutable configured ID for that socket. Destroying the socket first removes
165
- the pending open, so later readiness cannot leak a member stream. A member
166
- selected immediately before its path drops retains the stream and performs
167
- member-local v3 recovery; the set never speculatively migrates it.
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
- persist its immutable value after connection so a replacement can recover the
194
- stream on its original member.
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 `gatewayId`, so new opens
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()` or durable replay; a transport loss is a typed
374
- terminal result requiring application reconciliation.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socketduct",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Resilient authenticated tunnel/proxy for keeping socket streams alive across flaky links.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -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} gatewayId - Stable selected gateway ID.
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, gatewayId) {
246
- if (this.#gatewayId !== undefined) throw new Error("Deferred Socketduct socket gateway is already assigned")
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, gatewayId: string) => TransportMemberConnection} [createMember] - Narrow test dependency.
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) => "gatewayId" in memberOptions
248
+ const createMember = dependencies.createMember ?? ((memberOptions) => "hub" in memberOptions
243
249
  ? new ReverseGatewayConnection(memberOptions)
244
250
  : new MultiplexedSocketductConnection(memberOptions))
245
- this.#members = validated.gateways.map(({gatewayId, endpoint}) => {
246
- const identity = deriveGatewayIdentity(options.connectionId, gatewayId)
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, gatewayId))
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, exposeGateway(socket, selection.member.gatewayId))
325
- const socket = exposeGateway(/** @type {import('./virtual-socket.js').VirtualSocket} */ (selection.member.connection.createConnection(options, memberCallback)), selection.member.gatewayId)
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, exposeGateway(socket, member.gatewayId))
410
- const socket = exposeGateway(/** @type {import('./virtual-socket.js').VirtualSocket} */ (member.connection.registerRecoveredConnection({...virtualOptions, recover: true}, memberCallback)), member.gatewayId)
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 = exposeGatewayGetter(secure, () => raw.gatewayId)
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: 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. */
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
- gatewayId: member.gatewayId,
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
- gatewayId: member.gatewayId,
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
- gatewayId: member.gatewayId,
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
- memberSocket = exposeGateway(selection.member.connection.createConnection(pending.options), selection.member.gatewayId)
609
- if (!pending.socket.assign(memberSocket, selection.member.gatewayId)) {
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
- if (!Array.isArray(options.gateways) || options.gateways.length === 0) throw new TypeError("Transport-set gateways must be a non-empty array")
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 > options.gateways.length) throw new RangeError("minimumReadyMembers must be between 1 and the configured gateway count")
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 {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")
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
- if (key !== "gatewayId" && key !== "endpoint") throw new TypeError(`Unknown gateway field ${key}; gateway relay and factory settings belong under endpoint`)
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 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)
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(`Gateway ${gateway.gatewayId} requires exactly one relay or reverseHub endpoint`)
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(`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
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
- gateways,
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("Gateway-bound durable recovery must use recoverConnection before start")
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} gatewayId - Stable configured gateway ID.
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 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)
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 immutable assignment lookup.
822
- * @returns {T & {readonly gatewayId: string | undefined}} Socket with immutable public allocation metadata.
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 exposeGatewayGetter(socket, gatewayId) {
857
+ function exposeAllocationGetters(socket, gatewayId, routeClass) {
825
858
  Object.defineProperty(socket, "gatewayId", {get: gatewayId, enumerable: true, configurable: false})
826
- return /** @type {T & {readonly gatewayId: string | undefined}} */ (socket)
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.