socketduct 0.1.16 → 0.1.17

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.
@@ -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,7 +265,7 @@ 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}),
@@ -267,10 +274,10 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
267
274
  aggregateBudget: this.#budget,
268
275
  ...(this.#logger === undefined ? {} : {logger: this.#logger})
269
276
  }))
270
- const connection = /** @type {TransportMemberConnection} */ (createMember(memberOptions, gatewayId))
277
+ const connection = /** @type {TransportMemberConnection} */ (createMember(memberOptions, memberIdentity))
271
278
  validateMember(connection)
272
279
  /** @type {TransportMember} */
273
- const member = {gatewayId, connection, assignments: 0, startupError: undefined, terminalError: undefined, lastReadyGeneration: undefined}
280
+ const member = {gatewayId, routeClass, connection, assignments: 0, startupError: undefined, terminalError: undefined, lastReadyGeneration: undefined}
274
281
  connection.on("stateChange", (state, error) => this.#memberStateChanged(member, state, error))
275
282
  return member
276
283
  })
@@ -321,8 +328,8 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
321
328
  if (selection === undefined) return this.#deferConnection(options, socketOptions, callback)
322
329
  try {
323
330
  /** @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)
331
+ const memberCallback = callback === undefined ? undefined : (error, socket) => callback(error, exposeAllocation(socket, memberAllocation(selection.member)))
332
+ const socket = exposeAllocation(/** @type {import('./virtual-socket.js').VirtualSocket} */ (selection.member.connection.createConnection(options, memberCallback)), memberAllocation(selection.member))
326
333
  selection.member.assignments += 1
327
334
  this.#virtualOpens += 1
328
335
  return socket
@@ -405,9 +412,10 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
405
412
  if (member === undefined) throw new Error(`Unknown gatewayId: ${options.gatewayId}`)
406
413
  const virtualOptions = {...options}
407
414
  delete /** @type {{gatewayId?: string}} */ (virtualOptions).gatewayId
415
+ const allocation = memberAllocation(member)
408
416
  /** @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)
417
+ const memberCallback = callback === undefined ? undefined : (error, socket) => callback(error, exposeAllocation(socket, allocation))
418
+ const socket = exposeAllocation(/** @type {import('./virtual-socket.js').VirtualSocket} */ (member.connection.registerRecoveredConnection({...virtualOptions, recover: true}, memberCallback)), allocation)
411
419
  member.assignments += 1
412
420
  this.#virtualOpens += 1
413
421
  return socket
@@ -425,7 +433,7 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
425
433
  const raw = this.createConnection(virtualOptions)
426
434
  const tlsOptions = omitVirtualOptions(options)
427
435
  const secure = tlsConnect({...tlsOptions, socket: raw})
428
- const exposed = exposeGatewayGetter(secure, () => raw.gatewayId)
436
+ const exposed = exposeAllocationGetters(secure, () => raw.gatewayId, () => raw.routeClass)
429
437
  if (callback !== undefined) installTlsCallback(exposed, callback)
430
438
  return exposed
431
439
  }
@@ -442,12 +450,12 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
442
450
  return this
443
451
  }
444
452
 
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. */
453
+ /** @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
454
  status() {
447
455
  const members = this.#members.map((member) => {
448
456
  const status = member.connection.status()
449
457
  return {
450
- gatewayId: member.gatewayId,
458
+ ...memberAllocation(member),
451
459
  state: status.physicalState,
452
460
  assignments: member.assignments,
453
461
  physicalConnects: status.physicalConnects,
@@ -544,7 +552,7 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
544
552
  if (member !== undefined && typeof state === "string") {
545
553
  const status = member.connection.status()
546
554
  logDiagnostic(this.#logger, "transport_set.member_state", {
547
- gatewayId: member.gatewayId,
555
+ ...memberAllocation(member),
548
556
  state,
549
557
  generation: status.connectionGeneration,
550
558
  physicalConnects: status.physicalConnects,
@@ -553,7 +561,7 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
553
561
  })
554
562
  if (state === "ready" && member.lastReadyGeneration !== undefined && status.connectionGeneration > member.lastReadyGeneration) {
555
563
  logDiagnostic(this.#logger, "transport_set.member_replaced", {
556
- gatewayId: member.gatewayId,
564
+ ...memberAllocation(member),
557
565
  generation: status.connectionGeneration,
558
566
  physicalConnects: status.physicalConnects
559
567
  })
@@ -605,8 +613,9 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
605
613
  /** @type {import('./virtual-socket.js').VirtualSocket | undefined} */
606
614
  let memberSocket
607
615
  try {
608
- memberSocket = exposeGateway(selection.member.connection.createConnection(pending.options), selection.member.gatewayId)
609
- if (!pending.socket.assign(memberSocket, selection.member.gatewayId)) {
616
+ const allocation = memberAllocation(selection.member)
617
+ memberSocket = exposeAllocation(selection.member.connection.createConnection(pending.options), allocation)
618
+ if (!pending.socket.assign(memberSocket, allocation)) {
610
619
  this.#cursor = selection.previousCursor
611
620
  continue
612
621
  }
@@ -682,9 +691,13 @@ function errorClassification(error, fallback) {
682
691
  function validateTransportSetOptions(options) {
683
692
  if (typeof options !== "object" || options === null) throw new TypeError("Socketduct transport-set options are required")
684
693
  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")
694
+ const hasGateways = options.gateways !== undefined
695
+ const hasRouteClasses = options.routeClasses !== undefined
696
+ if (hasGateways === hasRouteClasses) throw new TypeError("Transport set requires exactly one non-empty gateways or routeClasses array")
697
+ const configuredMembers = hasGateways ? options.gateways : options.routeClasses
698
+ if (!Array.isArray(configuredMembers) || configuredMembers.length === 0) throw new TypeError("Transport-set members must be a non-empty array")
686
699
  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")
700
+ if (!Number.isSafeInteger(minimumReadyMembers) || minimumReadyMembers < 1 || minimumReadyMembers > configuredMembers.length) throw new RangeError("minimumReadyMembers must be between 1 and the configured member count")
688
701
  const maxPendingOpens = positiveLimit(options.maxPendingOpens, DEFAULT_MAX_PENDING_OPENS, "maxPendingOpens")
689
702
  const limits = {
690
703
  maxDataBytes: positiveLimit(options.maxTotalBufferBytes, DEFAULT_TOTAL_BUFFER_BYTES, "maxTotalBufferBytes"),
@@ -693,38 +706,42 @@ function validateTransportSetOptions(options) {
693
706
  }
694
707
  const memberOptions = options.memberOptions === undefined ? {} : options.memberOptions
695
708
  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"]) {
709
+ for (const forbidden of ["relay", "hub", "gatewayId", "routeClass", "tls", "createPhysicalSocket", "connectionId", "spoolDirectory", "continuousReconnect", "aggregateBudget", "logger"]) {
697
710
  if (Object.hasOwn(memberOptions, forbidden)) throw new TypeError(`memberOptions.${forbidden} is owned by the transport set`)
698
711
  }
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")
712
+ /** @type {Array<{gatewayId: string | undefined, routeClass: string | undefined, endpoint: GatewayEndpoint}>} */
713
+ const members = []
714
+ const memberIds = new Set()
715
+ for (const raw of configuredMembers) {
716
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new TypeError("Each transport-set member must be an object")
704
717
  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`)
718
+ const identityKey = hasGateways ? "gatewayId" : "routeClass"
719
+ if (key !== identityKey && key !== "endpoint") throw new TypeError(`Unknown transport-set member field ${key}; identity and endpoint must be explicit`)
706
720
  }
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)
721
+ const member = /** @type {Partial<GatewayConfiguration & RouteClassConfiguration>} */ (raw)
722
+ const memberId = hasGateways ? member.gatewayId : member.routeClass
723
+ const validMemberId = hasGateways ? typeof memberId === "string" && GATEWAY_ID.test(memberId) : typeof memberId === "string" && ROUTE_CLASS.test(memberId)
724
+ 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")
725
+ if (memberIds.has(memberId)) throw new TypeError(`Duplicate ${hasGateways ? "gatewayId" : "routeClass"}: ${memberId}`)
726
+ memberIds.add(memberId)
727
+ if (typeof member.endpoint !== "object" || member.endpoint === null || Array.isArray(member.endpoint)) throw new TypeError(`Transport-set member ${memberId} requires an explicit endpoint`)
728
+ const endpoint = /** @type {Partial<GatewayEndpoint>} */ (member.endpoint)
713
729
  for (const key of Object.keys(endpoint)) {
714
730
  if (key !== "relay" && key !== "reverseHub" && key !== "tls" && key !== "createPhysicalSocket") throw new TypeError(`Unknown endpoint field: ${key}`)
715
731
  }
716
- if ((endpoint.relay === undefined) === (endpoint.reverseHub === undefined)) throw new TypeError(`Gateway ${gateway.gatewayId} requires exactly one relay or reverseHub endpoint`)
732
+ if ((endpoint.relay === undefined) === (endpoint.reverseHub === undefined)) throw new TypeError(`Transport-set member ${memberId} requires exactly one relay or reverseHub endpoint`)
733
+ if (hasRouteClasses && endpoint.reverseHub === undefined) throw new TypeError(`Route class ${memberId} requires a reverseHub endpoint`)
717
734
  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
735
+ 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`)
736
+ 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`)
737
+ if (endpoint.reverseHub !== undefined && endpoint.tls === undefined) throw new TypeError(`Transport-set member ${memberId} reverseHub requires verified TLS settings`)
738
+ if (endpoint.createPhysicalSocket !== undefined && typeof endpoint.createPhysicalSocket !== "function") throw new TypeError(`Transport-set member ${memberId} createPhysicalSocket must be a function`)
739
+ members.push({gatewayId: hasGateways ? memberId : undefined, routeClass: hasRouteClasses ? memberId : undefined, endpoint: /** @type {GatewayEndpoint} */ (endpoint)})
740
+ }
741
+ for (let left = 0; left < members.length; left += 1) {
742
+ for (let right = left + 1; right < members.length; right += 1) {
743
+ const first = /** @type {{endpoint: GatewayEndpoint}} */ (members[left]).endpoint
744
+ const second = /** @type {{endpoint: GatewayEndpoint}} */ (members[right]).endpoint
728
745
  if (first.relay === undefined || second.relay === undefined) continue
729
746
  const sameAddress = first.relay.host === second.relay.host && first.relay.port === second.relay.port
730
747
  const sameFactory = first.createPhysicalSocket === second.createPhysicalSocket
@@ -734,7 +751,7 @@ function validateTransportSetOptions(options) {
734
751
  const defaultSpoolDirectory = join(tmpdir(), `socketduct-${typeof process.getuid === "function" ? process.getuid() : "user"}`, "multiplexed-set")
735
752
  if (options.spoolDirectory !== undefined && (typeof options.spoolDirectory !== "string" || options.spoolDirectory.length === 0)) throw new TypeError("spoolDirectory must be a non-empty string")
736
753
  return {
737
- gateways,
754
+ members,
738
755
  minimumReadyMembers,
739
756
  maxPendingOpens,
740
757
  limits,
@@ -752,6 +769,15 @@ function deriveGatewayIdentity(baseId, gatewayId) {
752
769
  return {connectionId: `gateway_${digest}`, spoolName: `gateway-${digest}`}
753
770
  }
754
771
 
772
+ /**
773
+ * @param {string} baseId - Base identity.
774
+ * @param {string} routeClass - Route-class identity.
775
+ */
776
+ function deriveRouteClassIdentity(baseId, routeClass) {
777
+ const digest = createHash("sha256").update("socketduct-v3-route-class\0").update(baseId).update("\0").update(routeClass).digest("base64url")
778
+ return {connectionId: `route_${digest}`, spoolName: `route-${digest}`}
779
+ }
780
+
755
781
  /**
756
782
  * @param {unknown} value - Raw limit.
757
783
  * @param {number} defaultValue - Default.
@@ -771,8 +797,8 @@ function validateReservation(bytes) {
771
797
  /** @param {unknown} options - Ordinary open options. */
772
798
  function validateOrdinaryOpenOptions(options) {
773
799
  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")
800
+ if (Object.hasOwn(options, "gatewayId") || Object.hasOwn(options, "routeClass") || /** @type {{recover?: unknown}} */ (options).recover === true) {
801
+ throw new TypeError("Transport member selection is set-owned; gateway-bound durable recovery must use recoverConnection before start")
776
802
  }
777
803
  }
778
804
 
@@ -802,28 +828,42 @@ function validateMember(member) {
802
828
  /**
803
829
  * @template {object} T
804
830
  * @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.
831
+ * @param {{gatewayId: string} | {routeClass: string}} allocation - Stable configured member identity.
832
+ * @returns {T & {readonly gatewayId: string | undefined, readonly routeClass: string | undefined}} Socket with immutable public allocation metadata.
807
833
  */
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)
834
+ function exposeAllocation(socket, allocation) {
835
+ const values = "gatewayId" in allocation
836
+ ? {gatewayId: allocation.gatewayId, routeClass: undefined}
837
+ : {gatewayId: undefined, routeClass: allocation.routeClass}
838
+ for (const key of /** @type {const} */ (["gatewayId", "routeClass"])) {
839
+ const existing = Object.getOwnPropertyDescriptor(socket, key)
840
+ if (existing !== undefined) {
841
+ if (/** @type {Record<string, unknown>} */ (socket)[key] !== values[key]) throw new Error("Socketduct member allocation metadata mismatch")
842
+ continue
843
+ }
844
+ Object.defineProperty(socket, key, {value: values[key], enumerable: true, writable: false, configurable: false})
845
+ }
846
+ return /** @type {T & {readonly gatewayId: string | undefined, readonly routeClass: string | undefined}} */ (socket)
816
847
  }
817
848
 
818
849
  /**
819
850
  * @template {object} T
820
851
  * @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.
852
+ * @param {() => string | undefined} gatewayId - Live exact-gateway assignment lookup.
853
+ * @param {() => string | undefined} routeClass - Live route-class assignment lookup.
854
+ * @returns {T & {readonly gatewayId: string | undefined, readonly routeClass: string | undefined}} Socket with immutable public allocation metadata.
823
855
  */
824
- function exposeGatewayGetter(socket, gatewayId) {
856
+ function exposeAllocationGetters(socket, gatewayId, routeClass) {
825
857
  Object.defineProperty(socket, "gatewayId", {get: gatewayId, enumerable: true, configurable: false})
826
- return /** @type {T & {readonly gatewayId: string | undefined}} */ (socket)
858
+ Object.defineProperty(socket, "routeClass", {get: routeClass, enumerable: true, configurable: false})
859
+ return /** @type {T & {readonly gatewayId: string | undefined, readonly routeClass: string | undefined}} */ (socket)
860
+ }
861
+
862
+ /** @param {TransportMember} member - Configured member. @returns {{gatewayId: string} | {routeClass: string}} */
863
+ function memberAllocation(member) {
864
+ if (member.gatewayId !== undefined) return {gatewayId: member.gatewayId}
865
+ if (member.routeClass !== undefined) return {routeClass: member.routeClass}
866
+ throw new Error("Socketduct transport-set member identity is unavailable")
827
867
  }
828
868
 
829
869
  /**
@@ -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.
@@ -20,6 +20,7 @@ import {
20
20
  parseTarget,
21
21
  positiveInteger,
22
22
  resolveVerifiedTls,
23
+ ROUTE_CLASS,
23
24
  STABLE_IDENTIFIER,
24
25
  writeSocket
25
26
  } from "./common.js"
@@ -29,7 +30,8 @@ const DEFAULT_CONNECTION_BUFFER_BYTES = 32 * 1024 * 1024
29
30
 
30
31
  /**
31
32
  * @typedef {object} ReverseGatewayConnectionOptions
32
- * @property {string} gatewayId - Registered home gateway selected by this member.
33
+ * @property {string} [gatewayId] - Registered exact gateway selected by this legacy member.
34
+ * @property {string} [routeClass] - Server-resolved route class selected by this member.
33
35
  * @property {string} [connectionId] - Stable client transport identity.
34
36
  * @property {{host: string, port: number, token: string}} hub - Server3 hub endpoint.
35
37
  * @property {{caFile?: string, certFile?: string, keyFile?: string, servername?: string}} tls - Mandatory verified outer TLS.
@@ -82,7 +84,7 @@ const DEFAULT_CONNECTION_BUFFER_BYTES = 32 * 1024 * 1024
82
84
  * @property {((error?: Error | null) => void) | undefined} endCallback
83
85
  */
84
86
 
85
- /** Server3/TensorBuzz-facing virtual socket connection to one registered gateway. */
87
+ /** Server3/TensorBuzz-facing virtual socket connection to an exact gateway or server-resolved route class. */
86
88
  export class ReverseGatewayConnection extends EventEmitter {
87
89
  /** @type {ReverseGatewayConnectionOptions} */ #options
88
90
  /** @type {string} */ #connectionId
@@ -180,11 +182,11 @@ export class ReverseGatewayConnection extends EventEmitter {
180
182
  logDiagnostic(this.#options.logger, "virtual_stream.opening", {
181
183
  streamId: id,
182
184
  generation: this.#generation,
183
- gatewayId: this.#options.gatewayId,
185
+ ...this.#routeIdentityFields(),
184
186
  recovered: false
185
187
  })
186
188
  if (callback !== undefined) installCallback(socket, callback)
187
- void this.#sendControl(V3FrameType.OPEN, id, 0, {gatewayId: this.#options.gatewayId, identifier, name, target, initialWindow: this.#initialWindow()}).catch((error) => socket._receiveError(asError(error)))
189
+ void this.#sendControl(V3FrameType.OPEN, id, 0, {...this.#routeIdentityFields(), identifier, name, target, initialWindow: this.#initialWindow()}).catch((error) => socket._receiveError(asError(error)))
188
190
  return socket
189
191
  }
190
192
 
@@ -265,7 +267,7 @@ export class ReverseGatewayConnection extends EventEmitter {
265
267
  logDiagnostic(this.#options.logger, error === null ? "virtual_stream.closed" : "virtual_stream.error", {
266
268
  streamId,
267
269
  generation: this.#generation,
268
- gatewayId: this.#options.gatewayId,
270
+ ...this.#routeIdentityFields(),
269
271
  ...(error === null ? {} : errorDiagnostics(error))
270
272
  })
271
273
  this.#retireStream(stream, error ?? new SocketductStreamError("Reverse stream is not writable", "ERR_STREAM_DESTROYED"))
@@ -297,7 +299,7 @@ export class ReverseGatewayConnection extends EventEmitter {
297
299
  logDiagnostic(this.#options.logger, "transport.connecting", {
298
300
  generation,
299
301
  attempt: this.#physicalAttempts,
300
- gatewayId: this.#options.gatewayId,
302
+ ...this.#routeIdentityFields(),
301
303
  reconnect: this.#physicalAttempts > 1
302
304
  })
303
305
  const tls = resolveVerifiedTls(this.#options.tls)
@@ -308,7 +310,7 @@ export class ReverseGatewayConnection extends EventEmitter {
308
310
  logDiagnostic(this.#options.logger, "transport.failed", {
309
311
  generation,
310
312
  attempt: this.#physicalAttempts,
311
- gatewayId: this.#options.gatewayId,
313
+ ...this.#routeIdentityFields(),
312
314
  ...errorDiagnostics(error)
313
315
  })
314
316
  this.#scheduleReconnect(asError(error))
@@ -346,13 +348,14 @@ export class ReverseGatewayConnection extends EventEmitter {
346
348
  authenticated = true
347
349
  this.#codec = ZSTD_CODEC
348
350
  this.#sendWindowLimit = Math.min(this.#initialWindow(), detail.initialWindow)
349
- await writeSocket(physical, encodeV3Frame({type: V3FrameType.GATEWAY_SELECT, streamId: 0, sequence: generation}, encodeV3Control({gatewayId: this.#options.gatewayId})))
351
+ await writeSocket(physical, encodeV3Frame({type: V3FrameType.GATEWAY_SELECT, streamId: 0, sequence: generation}, encodeV3Control(this.#routeIdentityFields())))
350
352
  continue
351
353
  }
352
354
  if (!selected) {
353
355
  if (frame.type !== V3FrameType.GATEWAY_SELECTED) throw fatalError("GATEWAY_SELECTION_FAILED")
354
356
  const detail = decodeV3Control(frame.payload)
355
- if (detail.gatewayId !== this.#options.gatewayId || detail.generation !== generation) throw fatalError("GATEWAY_SELECTION_FAILED")
357
+ const selection = this.#routeIdentityFields()
358
+ if (detail.generation !== generation || ("gatewayId" in selection ? detail.gatewayId !== selection.gatewayId || Object.hasOwn(detail, "routeClass") : detail.routeClass !== selection.routeClass || Object.hasOwn(detail, "gatewayId"))) throw fatalError("GATEWAY_SELECTION_FAILED")
356
359
  selected = true
357
360
  clearTimeout(timer)
358
361
  this.#physicalConnects += 1
@@ -361,7 +364,7 @@ export class ReverseGatewayConnection extends EventEmitter {
361
364
  logDiagnostic(this.#options.logger, "transport.connected", {
362
365
  generation,
363
366
  attempt: this.#physicalAttempts,
364
- gatewayId: this.#options.gatewayId,
367
+ ...this.#routeIdentityFields(),
365
368
  physicalConnects: this.#physicalConnects,
366
369
  reconnect: this.#physicalConnects > 1
367
370
  })
@@ -369,7 +372,7 @@ export class ReverseGatewayConnection extends EventEmitter {
369
372
  logDiagnostic(this.#options.logger, "transport.reconnect_succeeded", {
370
373
  generation,
371
374
  attempt: this.#reconnectAttempts,
372
- gatewayId: this.#options.gatewayId,
375
+ ...this.#routeIdentityFields(),
373
376
  physicalConnects: this.#physicalConnects
374
377
  })
375
378
  }
@@ -397,7 +400,7 @@ export class ReverseGatewayConnection extends EventEmitter {
397
400
  if (!this.#closing) {
398
401
  logDiagnostic(this.#options.logger, "transport.disconnected", {
399
402
  generation,
400
- gatewayId: this.#options.gatewayId,
403
+ ...this.#routeIdentityFields(),
401
404
  virtualStreams: this.#streams.size,
402
405
  ...errorDiagnostics(attemptError ?? lost, "GATEWAY_DISCONNECTED")
403
406
  })
@@ -411,7 +414,7 @@ export class ReverseGatewayConnection extends EventEmitter {
411
414
  logDiagnostic(this.#options.logger, "transport.failed", {
412
415
  generation,
413
416
  attempt: this.#physicalAttempts,
414
- gatewayId: this.#options.gatewayId,
417
+ ...this.#routeIdentityFields(),
415
418
  ...errorDiagnostics(terminalError, "GATEWAY_DISCONNECTED")
416
419
  })
417
420
  this.#rejectConnect?.(terminalError)
@@ -441,7 +444,7 @@ export class ReverseGatewayConnection extends EventEmitter {
441
444
  logDiagnostic(this.#options.logger, "virtual_stream.opened", {
442
445
  streamId: stream.id,
443
446
  generation: this.#generation,
444
- gatewayId: this.#options.gatewayId,
447
+ ...this.#routeIdentityFields(),
445
448
  recovered: false
446
449
  })
447
450
  stream.socket._markConnected()
@@ -474,7 +477,7 @@ export class ReverseGatewayConnection extends EventEmitter {
474
477
  logDiagnostic(this.#options.logger, "virtual_stream.closed", {
475
478
  streamId: stream.id,
476
479
  generation: this.#generation,
477
- gatewayId: this.#options.gatewayId
480
+ ...this.#routeIdentityFields()
478
481
  })
479
482
  this.#retireStream(stream, new SocketductStreamError("Reverse stream closed before pending writes completed", "ERR_STREAM_DESTROYED"))
480
483
  stream.socket._receiveEnd()
@@ -652,7 +655,7 @@ export class ReverseGatewayConnection extends EventEmitter {
652
655
  generation: this.#generation,
653
656
  attempt: this.#reconnectAttempts,
654
657
  delayMs: delay,
655
- gatewayId: this.#options.gatewayId,
658
+ ...this.#routeIdentityFields(),
656
659
  ...errorDiagnostics(error, "GATEWAY_DISCONNECTED")
657
660
  })
658
661
  this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, this.#options.maxReconnectDelayMs ?? 30_000)
@@ -673,13 +676,24 @@ export class ReverseGatewayConnection extends EventEmitter {
673
676
 
674
677
  #settleConnect() { this.#connectPromise = undefined; this.#resolveConnect = undefined; this.#rejectConnect = undefined }
675
678
  #setState(/** @type {'configured' | 'connecting' | 'ready' | 'reconnecting' | 'closed'} */ state, /** @type {Error | undefined} */ error = undefined) { if (this.#state === state) return; this.#state = state; this.emit("stateChange", state, error) }
679
+ /** @returns {{gatewayId: string} | {routeClass: string}} Stable physical route identity. */
680
+ #routeIdentityFields() {
681
+ return this.#options.routeClass === undefined
682
+ ? {gatewayId: /** @type {string} */ (this.#options.gatewayId)}
683
+ : {routeClass: this.#options.routeClass}
684
+ }
676
685
  #initialWindow() { return this.#options.initialWindowBytes ?? DEFAULT_WINDOW }
677
686
  #maxFrameBytes() { return Math.min(this.#options.maxFrameBytes ?? DEFAULT_FRAME_BYTES, getMaxDecompressedBytes(this.#options.compressionLimits), 64 * 1024) }
678
687
  }
679
688
 
680
689
  /** @param {ReverseGatewayConnectionOptions} options - Options. */
681
690
  function validateOptions(options) {
682
- if (typeof options !== "object" || options === null || typeof options.gatewayId !== "string" || !GATEWAY_ID.test(options.gatewayId)) throw new TypeError("Reverse gateway connection requires a valid gatewayId")
691
+ if (typeof options !== "object" || options === null) throw new TypeError("Reverse gateway connection options are required")
692
+ const hasGatewayId = Object.hasOwn(options, "gatewayId")
693
+ const hasRouteClass = Object.hasOwn(options, "routeClass")
694
+ if (hasGatewayId === hasRouteClass) throw new TypeError("Reverse gateway connection requires exactly one gatewayId or routeClass")
695
+ if (hasGatewayId && (typeof options.gatewayId !== "string" || !GATEWAY_ID.test(options.gatewayId))) throw new TypeError("Reverse gateway connection requires a valid gatewayId")
696
+ if (hasRouteClass && (typeof options.routeClass !== "string" || !ROUTE_CLASS.test(options.routeClass))) throw new TypeError("Reverse gateway connection requires a valid routeClass")
683
697
  if (options.connectionId !== undefined && (typeof options.connectionId !== "string" || !STABLE_IDENTIFIER.test(options.connectionId))) throw new TypeError("Reverse gateway connectionId must be 8-128 URL-safe characters")
684
698
  if (typeof options.hub !== "object" || options.hub === null || typeof options.hub.host !== "string" || options.hub.host.length === 0 || !Number.isInteger(options.hub.port) || options.hub.port < 1 || options.hub.port > 65_535 || typeof options.hub.token !== "string" || options.hub.token.length === 0) throw new TypeError("Reverse gateway connection requires hub host, port and token")
685
699
  resolveVerifiedTls(options.tls)
@@ -3,6 +3,7 @@ import {readFileSync} from "node:fs"
3
3
  import {connect as tlsConnect} from "node:tls"
4
4
 
5
5
  export const GATEWAY_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/
6
+ export const ROUTE_CLASS = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/
6
7
  export const STABLE_IDENTIFIER = /^[A-Za-z0-9_-]{8,128}$/
7
8
  export const DEFAULT_WINDOW = 256 * 1024
8
9
  export const DEFAULT_FRAME_BYTES = 16 * 1024