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.
package/.env.example CHANGED
@@ -115,6 +115,9 @@ SOCKETDUCT_TARGET_ALLOWLIST=docker-socket-shim:2375
115
115
  # SOCKETDUCT_REVERSE_GATEWAY_BINDINGS=o2:home-o2,zyxel:home-zyxel,starlink:home-starlink
116
116
  # Authorize exact client username, gateway ID, and home-local target tuples.
117
117
  # SOCKETDUCT_REVERSE_ROUTES=tensorbuzz:o2:docker-server:2375,tensorbuzz:zyxel:docker-server:2375,tensorbuzz:starlink:docker-server:2375
118
+ # Or bind one client physical connection to a route class and resolve each
119
+ # requested target to one exact registered gateway. Ambiguous rows fail closed.
120
+ # SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES=tensorbuzz:starlink:server-a-starlink:docker-a:2375,tensorbuzz:starlink:server-b-starlink:docker-b:2375
118
121
 
119
122
  # Home member: change SOCKETDUCT_MODE to reverse-gateway. Run one process in
120
123
  # each router-pinned namespace and use distinct stable identity/credential data.
@@ -0,0 +1 @@
1
+ Socketduct reverse clients can now share one authenticated physical connection per route class while the hub securely resolves each target stream and isolates backpressured server-local gateways.
@@ -78,6 +78,13 @@ failure retains the spool reservation and fails that member rather than
78
78
  advertising capacity that still exists on disk. These process bounds supplement,
79
79
  rather than replace, member and stream limits.
80
80
 
81
+ For native reverse mode, the set can instead receive `routeClasses`. It creates
82
+ one `ReverseGatewayConnection` and one stable derived connection identity per
83
+ `{process connectionId, routeClass}`. The hub chooses an exact server-local
84
+ gateway independently for each target `OPEN`, so adding target gateways does not
85
+ multiply client physical connections. Allocation metadata is `routeClass`;
86
+ legacy exact-gateway sets keep `gatewayId` and their existing identity derivation.
87
+
81
88
  Replacement recovery is registered before set startup and addresses the
82
89
  original configured gateway ID directly. The set derives the same member
83
90
  identity/spool namespace and delegates recovery to that member before any
@@ -93,14 +100,20 @@ rollback. A relay listener selects v2 or v3 explicitly.
93
100
  The reverse hub is a rendezvous and stream-ID translation boundary on Server3.
94
101
  Authenticated home gateway processes connect outward, register stable IDs, and
95
102
  accept hub-created even stream IDs. Authenticated local application members
96
- select one ID and create odd-ID streams. The hub maps frames without opening
97
- the target or nesting another Socketduct transport. Each home gateway applies
98
- the final local allowlist before connecting to `docker-server:2375`.
103
+ select either one exact ID or a route class and create odd-ID streams. The class
104
+ stays bound to the client physical connection while the exact gateway is
105
+ resolved from the requested target for each `OPEN`. The hub maps frames without
106
+ opening the target or nesting another Socketduct transport. Each home gateway
107
+ applies the final local allowlist before connecting to its private Docker shim.
99
108
 
100
109
  Gateway registration reconnects with generation fencing. Established reverse
101
110
  streams are terminal on transport loss because replaying an ambiguous Docker
102
111
  operation would be unsafe. Live-transport buffers remain bounded; durable
103
112
  spooling and resume continue to apply to ordinary Socketduct sessions only.
113
+ The hub serializes output independently for each physical destination with a
114
+ bounded queue, so one backpressured target gateway cannot stop client frames
115
+ bound for a healthy sibling gateway. Exceeding that destination bound closes
116
+ only the unhealthy physical owner and releases its mapped streams.
104
117
  See [Native reverse gateway](reverse-gateway.md).
105
118
 
106
119
  ### Edge
package/docs/protocol.md CHANGED
@@ -103,11 +103,23 @@ relay's per-stream result rather than being sent again.
103
103
  ### Native reverse-gateway extension
104
104
 
105
105
  Reverse mode reuses the v3 physical transport and stream frames directly. A
106
- home gateway registers outbound; a Server3 client selects it. Client streams
107
- are odd and the hub maps them to even gateway-side IDs. The hub authorizes the
108
- principal/gateway/target tuple and the gateway separately authorizes its local
109
- target. Registration reconnect uses a higher generation, but any established
110
- stream is reset on gateway loss and never replayed.
106
+ home gateway registers outbound. A Server3 client selects exactly one legacy
107
+ `gatewayId` or one first-class `routeClass` in `GATEWAY_SELECT`; the response
108
+ echoes the same selector. Client streams are odd and the hub maps them to even
109
+ gateway-side IDs. For a class-bound connection, every `OPEN` repeats the bound
110
+ class and the hub resolves `{authenticated principal, routeClass, target}` to
111
+ exactly one current gateway. The client never supplies that gateway ID. The hub
112
+ then authorizes the exact principal/gateway/target tuple and the gateway
113
+ separately authorizes its local target. Zero, multiple, stale, disconnected, or
114
+ mismatched resolutions reset the stream without weakening sibling routes.
115
+ Registration reconnect uses a higher generation, but any established stream on
116
+ the lost gateway is reset and never replayed.
117
+
118
+ Hub output has one ordered owner per physical destination. Frames for a
119
+ backpressured gateway remain in that gateway's bounded queue while frames for
120
+ other gateways continue independently; a destination that exceeds its output
121
+ bound is disconnected and its streams are reset without blocking healthy
122
+ destinations. Ordering within each destination and stream is preserved.
111
123
 
112
124
  Stream IDs must increase monotonically on each physical connection. END and
113
125
  CLOSE positions must match the sender's accepted raw-byte sequence; a
@@ -10,26 +10,29 @@ The roles are deliberately separate:
10
10
 
11
11
  ```text
12
12
  TensorBuzz / local application on Server3
13
- -> ReverseGatewayConnection (verified TLS client, one selected gateway ID)
13
+ -> ReverseGatewayConnection (verified TLS client, one selected route class)
14
14
  -> reverse hub on Server3
15
- <- OutboundReverseGateway (verified TLS client from the home Docker host)
16
- -> docker-server:2375 on a private Compose network
15
+ <- OutboundReverseGateway A -> target A on its private Compose network
16
+ <- OutboundReverseGateway B -> target B on its private Compose network
17
17
  ```
18
18
 
19
19
  - `startReverseGatewayHub(options)` is the reachable Server3 broker. It
20
20
  authenticates both kinds of physical connection, binds gateway IDs to
21
- principals, applies `authorizeClientGateway` before selection, authorizes
22
- every client/gateway/target tuple, and never connects to a target itself.
21
+ principals, authorizes exact-gateway or route-class selection, resolves each
22
+ route-class `OPEN` to exactly one current gateway, authorizes the resulting
23
+ client/gateway/target tuple, and never connects to a target itself.
23
24
  - `OutboundReverseGateway` is one home member. It makes one persistent outbound
24
25
  TLS connection, registers one stable `gatewayId`, applies its own exact local
25
26
  target policy, and multiplexes all accepted target streams on that transport.
26
- - `ReverseGatewayConnection` is the local application API. It selects one
27
- registered gateway during connection readiness and returns ordinary
28
- `VirtualSocket` objects from `createConnection()`.
29
- - `MultiplexedSocketductTransportSet` accepts `endpoint.reverseHub` members, so
30
- TensorBuzz can keep its existing health-aware ordered round-robin behavior.
31
- A three-member set points all three members at the same hub but selects
32
- `o2`, `zyxel`, and `starlink` independently.
27
+ - `ReverseGatewayConnection` is the local application API. Supplying
28
+ `routeClass` binds one authenticated physical connection to that class; each
29
+ `createConnection()` is routed from its requested target. Supplying the
30
+ legacy `gatewayId` instead retains exact-gateway behavior unchanged.
31
+ - `MultiplexedSocketductTransportSet` accepts a `routeClasses` array of
32
+ `endpoint.reverseHub` members. A three-member WAN set therefore owns exactly
33
+ one client physical connection for each class such as `o2`, `zyxel`, and
34
+ `starlink`, independent of how many server-local gateways those classes can
35
+ reach.
33
36
 
34
37
  Import these APIs from `socketduct/reverse`. The ordinary v2 and v3 edge/relay
35
38
  APIs and wire handshakes remain unchanged.
@@ -42,11 +45,17 @@ frames authenticate registration and selection:
42
45
 
43
46
  - `GATEWAY_REGISTER` / `GATEWAY_REGISTERED` bind the authenticated outbound
44
47
  transport to a configured gateway ID and monotonically increasing generation.
45
- - `GATEWAY_SELECT` / `GATEWAY_SELECTED` make client readiness depend on that
46
- gateway currently being registered.
48
+ - `GATEWAY_SELECT` / `GATEWAY_SELECTED` carry exactly one `gatewayId` or
49
+ `routeClass`. Exact selection still makes readiness depend on that gateway;
50
+ route-class readiness authenticates the class independently of individual
51
+ gateway availability.
47
52
  - Client-created streams use odd IDs. The hub maps each one to a hub-created
48
- even ID on the selected gateway transport. `OPEN`, `OPENED`, `DATA`, `ACK`,
49
- `WINDOW`, `END`, `CLOSE`, and `RESET` then retain their v3 meanings.
53
+ even ID on the exact gateway transport selected for that `OPEN`. A class-bound
54
+ `OPEN` repeats its authenticated `routeClass` and never supplies a gateway ID;
55
+ the hub resolves `{principal, routeClass, target}` to exactly one registered
56
+ gateway before applying the existing exact route authorization. `OPEN`,
57
+ `OPENED`, `DATA`, `ACK`, `WINDOW`, `END`, `CLOSE`, and `RESET` then retain their
58
+ v3 meanings.
50
59
  Gateway-originated `CLOSE` is acknowledged by a client `CLOSE`, after which the
51
60
  hub releases both mapped IDs; `RESET` remains immediately terminal.
52
61
  Valid `ACK` and `WINDOW` frames already in flight when that close starts are
@@ -58,7 +67,10 @@ malformed frames, unauthorized routes, and configured limits. A higher
58
67
  generation from the same stable connection identity fences its prior transport.
59
68
  At the configured identity bound, the oldest disconnected client identity is
60
69
  evicted; active owners are never evicted and retain generation fencing.
61
- The home gateway independently rejects any target outside its local allowlist.
70
+ Zero eligible gateways denies the target, and multiple eligible gateways deny
71
+ the stream as ambiguous. Unknown classes, class mismatches, stale registrations,
72
+ and disconnected resolved gateways also fail closed. The home gateway
73
+ independently rejects any target outside its local allowlist.
62
74
 
63
75
  Reverse DATA compression is negotiated as `zstd`. The one DATA-only flag means
64
76
  the payload starts with a four-byte uncompressed length followed by one
@@ -74,6 +86,14 @@ senders use the smaller local and peer window for stream credit. A graceful hub
74
86
  shutdown is transient and reconnects the client and gateway registrations, while
75
87
  authentication, authorization, and stale-generation rejections remain terminal.
76
88
 
89
+ The hub owns a separate ordered output queue for every client and gateway
90
+ physical connection. Each queue defaults to a 32 MiB bound and can be set with
91
+ the programmatic `maxOutputBytesPerConnection` hub option. Backpressure on one
92
+ gateway therefore cannot pause client input for a healthy gateway in the same
93
+ route class. If one destination exceeds its bound, the hub closes that physical
94
+ connection and its mapped streams through the normal detach path; sibling
95
+ gateways and the route-class client connection remain usable.
96
+
77
97
  An application write may be larger than the configured reverse-stream buffer.
78
98
  The client admits it in bounded slices no larger than the negotiated peer window
79
99
  as acknowledgements release capacity, holding Node's Writable callback while
@@ -92,7 +112,9 @@ in-memory gateway registry across independent cluster workers.
92
112
  Gateway registration reconnects with bounded exponential delay and a higher
93
113
  generation. An established Docker stream does **not** survive that transport
94
114
  loss: the hub resets it with `GATEWAY_DISCONNECTED`, both endpoints release the
95
- stream, and the gateway destroys its local Docker socket. This is intentional.
115
+ stream, and the gateway destroys its local Docker socket. A route-class client
116
+ physical connection stays authenticated, so streams through other gateways in
117
+ the class and later opens to those targets remain usable. This is intentional.
96
118
  A Docker request whose write reached dockerd but whose response did not reach
97
119
  TensorBuzz has an ambiguous outcome and must never be replayed automatically.
98
120
  TensorBuzz must reconcile that Docker operation and then create a new stream.
@@ -118,40 +140,44 @@ SOCKETDUCT_RELAY_HOST=0.0.0.0 \
118
140
  SOCKETDUCT_RELAY_PORT=3100 \
119
141
  SOCKETDUCT_TLS_CERT_FILE=/certs/hub.crt \
120
142
  SOCKETDUCT_TLS_KEY_FILE=/certs/hub.key \
121
- SOCKETDUCT_AUTH_TOKENS='home-o2:<secret>,home-zyxel:<secret>,home-starlink:<secret>,tensorbuzz:<secret>' \
122
- SOCKETDUCT_REVERSE_GATEWAY_BINDINGS='o2:home-o2,zyxel:home-zyxel,starlink:home-starlink' \
123
- SOCKETDUCT_REVERSE_ROUTES='tensorbuzz:o2:docker-server:2375,tensorbuzz:zyxel:docker-server:2375,tensorbuzz:starlink:docker-server:2375' \
143
+ SOCKETDUCT_AUTH_TOKENS='server-a-starlink:<secret>,server-b-starlink:<secret>,tensorbuzz:<secret>' \
144
+ SOCKETDUCT_REVERSE_GATEWAY_BINDINGS='server-a-starlink:server-a-starlink,server-b-starlink:server-b-starlink' \
145
+ SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES='tensorbuzz:starlink:server-a-starlink:docker-a:2375,tensorbuzz:starlink:server-b-starlink:docker-b:2375' \
124
146
  node src/index.js
125
147
  ```
126
148
 
127
149
  Values shown as `<secret>` are placeholders for secret-manager input, not
128
150
  literal values. The runtime refuses to start without TLS, credentials,
129
- bindings, and routes.
151
+ bindings, and either exact or route-class routes. Exact legacy rows remain
152
+ available as `SOCKETDUCT_REVERSE_ROUTES=username:gatewayId:host:port`.
153
+ Route-class rows use `username:routeClass:gatewayId:host:port`. If two rows make
154
+ different gateways eligible for the same authenticated class and target, that
155
+ `OPEN` is rejected as ambiguous rather than choosing one implicitly.
130
156
 
131
- ## One outbound member per router
157
+ ## One outbound member per target and route class
132
158
 
133
- Run this once in each existing router-pinned network namespace, changing the
134
- two stable IDs and credential for the member:
159
+ Run this once in each target server's route-pinned network namespace, changing
160
+ the stable IDs, credential, and private target name for the member:
135
161
 
136
162
  ```sh
137
163
  SOCKETDUCT_MODE=reverse-gateway \
138
- SOCKETDUCT_REVERSE_GATEWAY_ID=o2 \
139
- SOCKETDUCT_REVERSE_CONNECTION_ID=home_o2_gateway_01 \
164
+ SOCKETDUCT_REVERSE_GATEWAY_ID=server-a-starlink \
165
+ SOCKETDUCT_REVERSE_CONNECTION_ID=server_a_starlink_gateway_01 \
140
166
  SOCKETDUCT_REVERSE_HUB_HOST=server3.example.net \
141
167
  SOCKETDUCT_REVERSE_HUB_PORT=3100 \
142
168
  SOCKETDUCT_REVERSE_TOKEN='<secret>' \
143
169
  SOCKETDUCT_REVERSE_HUB_CA_FILE=/certs/hub-ca.crt \
144
170
  SOCKETDUCT_REVERSE_HUB_SERVERNAME=server3.example.net \
145
- SOCKETDUCT_TARGET_ALLOWLIST=docker-server:2375 \
171
+ SOCKETDUCT_TARGET_ALLOWLIST=docker-a:2375 \
146
172
  node src/index.js
147
173
  ```
148
174
 
149
- The namespace/default-route configuration remains outside Socketduct. Pin the
150
- three processes to the routes whose next hops are O2 `192.168.86.1`, Zyxel
151
- `192.168.86.3`, and Starlink `192.168.86.25`. Socketduct needs no inbound rule
152
- on any home router. Do not publish port 2375: the gateway resolves
153
- `docker-server` and reaches plaintext Docker TCP only on their shared private
154
- Compose network.
175
+ The namespace/default-route configuration remains outside Socketduct. Each
176
+ gateway registration represents one exact target server and WAN class;
177
+ Socketduct does not aggregate away either identity. Socketduct needs no inbound
178
+ rule on the WAN router. Do not publish port 2375: the gateway resolves only its
179
+ allowlisted Docker shim and reaches plaintext Docker TCP on that server's
180
+ private Compose network.
155
181
 
156
182
  ## TensorBuzz transport set
157
183
 
@@ -160,8 +186,8 @@ import {MultiplexedSocketductTransportSet} from "socketduct/multiplexed"
160
186
 
161
187
  const set = new MultiplexedSocketductTransportSet({
162
188
  connectionId: "tensorbuzz_docker_transport_01",
163
- gateways: ["o2", "zyxel", "starlink"].map((gatewayId) => ({
164
- gatewayId,
189
+ routeClasses: ["o2", "zyxel", "starlink"].map((routeClass) => ({
190
+ routeClass,
165
191
  endpoint: {
166
192
  reverseHub: {
167
193
  host: "127.0.0.1",
@@ -174,10 +200,15 @@ const set = new MultiplexedSocketductTransportSet({
174
200
  })
175
201
 
176
202
  const starting = set.start()
177
- const socket = set.createConnection({host: "docker-server", port: 2375})
203
+ const socket = set.createConnection({host: "docker-a", port: 2375})
178
204
  await starting
179
205
  ```
180
206
 
207
+ Route-class sockets expose immutable `routeClass` allocation metadata and an
208
+ undefined `gatewayId`. Exact-gateway sets retain immutable `gatewayId` metadata.
209
+ Callers cannot override either selection per open. Reverse streams remain
210
+ non-recoverable after an uncertain transport loss.
211
+
181
212
  The socket is returned immediately and waits inside Socketduct until the set
182
213
  reaches minimum readiness. New sockets rotate equally across ready members and
183
214
  stay on their assigned member. If all members are reconnecting, new sockets wait
package/docs/security.md CHANGED
@@ -153,6 +153,19 @@ Reverse transports require verified TLS and negotiated zstd. There is no
153
153
  plaintext or certificate-verification opt-out. Docker port 2375 is permitted
154
154
  only as a private `docker-server:2375` target and is never host-published.
155
155
 
156
+ A route-class client authenticates one explicit class on its physical
157
+ connection. It cannot name a gateway per stream. For every `OPEN`, the hub uses
158
+ the authenticated principal, bound class, and exact requested target to obtain
159
+ the eligible gateway IDs and proceeds only when there is exactly one. Unknown
160
+ or unauthorized classes, missing routes, duplicate eligible routes, class
161
+ mismatches, stale owners, and disconnected gateways fail closed. The resolved
162
+ gateway still passes the existing hub authorization and its own server-local
163
+ target allowlist; route classes introduce no wildcard authorization or fallback.
164
+ Hub output is ordered and byte-bounded per physical destination. A gateway that
165
+ stops reading cannot grow an unbounded queue or stall authorization, DATA, or
166
+ OPEN processing for a healthy sibling; exceeding the bound disconnects only
167
+ that destination and resets its owned streams.
168
+
156
169
  Reverse registration can reconnect; established Docker streams cannot resume
157
170
  or spool across loss. They fail with an explicit uncertain-operation error so
158
171
  the caller must reconcile before retrying. This prevents privileged Docker API
@@ -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,15 @@ 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 fails terminally on an
194
+ uncertain transport loss and is never replayed.
168
195
 
169
196
  `createConnection(options, callback)` is pre-bound and invokes its callback once
170
197
  on `connect` or the terminal pre-connect error; the socket emits `connect` before
@@ -189,9 +216,11 @@ socket with ordinary `tls.connect` target options such as `servername`, `ca`,
189
216
  `cert` and `key`. Node TLS owns the handshake, `secureConnect`, TLS errors and
190
217
  the callback, which runs exactly once only after `secureConnect` or failure.
191
218
  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.
219
+ stickiness. Every returned raw or TLS socket exposes enumerable `gatewayId` and
220
+ `routeClass` allocation metadata. Only the configured identity is set. Persist
221
+ an exact set's immutable `gatewayId` after connection so a replacement can
222
+ recover the stream on its original member. Reverse route-class streams are not
223
+ recoverable after an uncertain transport loss.
195
224
 
196
225
  Process-replacement recovery is deliberately separate from ordinary selection.
197
226
  First detach the old set when an orderly handoff is possible. Construct the
@@ -217,8 +246,8 @@ await replacement.start()
217
246
  gateway ID, an explicit `recover` option, or registration after `start()` has
218
247
  begun. It bypasses round-robin only to reach that configured member; it never
219
248
  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,
249
+ `createTlsConnection()` reject `recover: true`, `gatewayId`, and `routeClass`,
250
+ so new opens cannot force a path. `detach()` coalesces with concurrent shutdown calls,
222
251
  preserves active member spools, and retires empty warmed lanes; `close()` remains
223
252
  terminal and removes their spools.
224
253
 
@@ -239,7 +268,7 @@ capacity occupied and fails the affected member visibly.
239
268
 
240
269
  `status()` returns only safe aggregate counts/bytes, `pendingOpens`, its maximum,
241
270
  and an ordered member list:
242
- gateway ID, lifecycle state, assignments, physical connects/reconnects, virtual
271
+ gateway ID or route class, lifecycle state, assignments, physical connects/reconnects, virtual
243
272
  stream count and queued/spool bytes. It excludes relays, targets, tokens,
244
273
  credentials, names, payloads and spool contents. Concurrent `close()` calls
245
274
  share one promise. Every member is attempted once in configured order; multiple
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socketduct",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
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