socketduct 0.1.15 → 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.
@@ -0,0 +1,373 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
2
+ import assert from "node:assert/strict"
3
+ import {mkdtemp, rm} from "node:fs/promises"
4
+ import {createServer} from "node:net"
5
+ import {tmpdir} from "node:os"
6
+ import {join} from "node:path"
7
+ import test from "node:test"
8
+
9
+ import {StaticTokenStore} from "../../src/auth/static-token-store.js"
10
+ import {V3FrameType} from "../../src/protocol/v3-frame.js"
11
+ import {OutboundReverseGateway, ReverseGatewayConnection, startReverseGatewayHub} from "../../src/reverse/index.js"
12
+ import {generateTestCertificates} from "../helpers/tls-certificates.js"
13
+
14
+ /** @param {import('node:net').Server} server - Server. */
15
+ async function listen(server) {
16
+ await new Promise((resolve, reject) => {
17
+ server.once("error", reject)
18
+ server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
19
+ })
20
+ const address = server.address()
21
+ assert(address !== null && typeof address !== "string")
22
+ return address.port
23
+ }
24
+
25
+ /** @param {import('node:events').EventEmitter} emitter - Source. @param {string} event - Event. */
26
+ async function onceEvent(emitter, event) {
27
+ await new Promise((resolve, reject) => {
28
+ emitter.once("error", reject)
29
+ emitter.once(event, () => { emitter.off("error", reject); resolve(undefined) })
30
+ })
31
+ }
32
+
33
+ /** @param {import('node:stream').Duplex} socket - Stream. @returns {Promise<Error & {code?: string}>} Next stream error. */
34
+ async function nextError(socket) {
35
+ return await new Promise((resolve) => socket.once("error", (error) => resolve(/** @type {Error & {code?: string}} */ (error))))
36
+ }
37
+
38
+ /** @param {Promise<unknown>} promise - Work. @param {string} label - Timeout label. */
39
+ async function withTimeout(promise, label) {
40
+ let timer
41
+ try {
42
+ return await Promise.race([
43
+ promise,
44
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`Timed out: ${label}`)), 4_000) })
45
+ ])
46
+ } finally {
47
+ if (timer !== undefined) clearTimeout(timer)
48
+ }
49
+ }
50
+
51
+ /** @param {() => boolean} predicate - Completion predicate. @param {string} label - Failure label. */
52
+ async function waitUntil(predicate, label) {
53
+ const deadline = Date.now() + 4_000
54
+ while (!predicate()) {
55
+ if (Date.now() >= deadline) throw new Error(`Timed out: ${label}`)
56
+ await new Promise((resolve) => setTimeout(resolve, 10))
57
+ }
58
+ }
59
+
60
+ /** @param {string} prefix - Echo response prefix. */
61
+ async function createEchoTarget(prefix) {
62
+ const sockets = new Set()
63
+ const server = createServer((socket) => {
64
+ sockets.add(socket)
65
+ socket.once("close", () => sockets.delete(socket))
66
+ socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from(prefix), chunk])))
67
+ })
68
+ const port = await listen(server)
69
+ return {
70
+ host: "127.0.0.1",
71
+ port,
72
+ close: async () => {
73
+ for (const socket of sockets) socket.destroy()
74
+ await new Promise((resolve) => server.close(() => resolve(undefined)))
75
+ }
76
+ }
77
+ }
78
+
79
+ /** @param {import('node:stream').Duplex} socket - Open stream. @param {string} payload - Request. */
80
+ async function exchange(socket, payload) {
81
+ const response = new Promise((resolve, reject) => {
82
+ socket.once("error", reject)
83
+ socket.once("data", (chunk) => resolve(Buffer.from(chunk).toString()))
84
+ })
85
+ socket.write(payload)
86
+ return await withTimeout(response, `response for ${payload}`)
87
+ }
88
+
89
+ test("route-class clients fail closed per OPEN while unaffected routes and legacy exact clients remain usable", async (t) => {
90
+ const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-"))
91
+ t.after(() => rm(root, {recursive: true, force: true}))
92
+ const certificates = generateTestCertificates(root)
93
+ const targetA = await createEchoTarget("a:")
94
+ const targetB = await createEchoTarget("b:")
95
+ t.after(() => targetA.close())
96
+ t.after(() => targetB.close())
97
+
98
+ const tokens = new StaticTokenStore([
99
+ {username: "home-a", token: "gateway-a-token"},
100
+ {username: "home-b", token: "gateway-b-token"},
101
+ {username: "tensorbuzz", token: "client-token"},
102
+ {username: "untrusted", token: "untrusted-token"}
103
+ ])
104
+ let ambiguousTargetA = false
105
+ const hub = await startReverseGatewayHub({
106
+ tokens,
107
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
108
+ knownGatewayIds: ["server-a-starlink", "server-b-starlink"],
109
+ knownRouteClasses: ["starlink"],
110
+ authorizeGateway: ({principal, gatewayId}) => (
111
+ (principal.username === "home-a" && gatewayId === "server-a-starlink")
112
+ || (principal.username === "home-b" && gatewayId === "server-b-starlink")
113
+ ),
114
+ authorizeClientGateway: ({principal, gatewayId}) => principal.username === "tensorbuzz" && gatewayId === "server-a-starlink",
115
+ authorizeClientRouteClass: ({principal}) => principal.username === "tensorbuzz",
116
+ resolveOpenGateways: ({principal, routeClass, target}) => {
117
+ if (principal.username !== "tensorbuzz" || routeClass !== "starlink" || target.host !== "127.0.0.1") return []
118
+ if (target.port === targetA.port) return ambiguousTargetA ? ["server-a-starlink", "server-b-starlink"] : ["server-a-starlink"]
119
+ if (target.port === targetB.port) return ["server-b-starlink"]
120
+ return []
121
+ },
122
+ authorizeOpen: ({principal, gatewayId, target}) => principal.username === "tensorbuzz" && (
123
+ (gatewayId === "server-a-starlink" && target.host === targetA.host && target.port === targetA.port)
124
+ || (gatewayId === "server-b-starlink" && target.host === targetB.host && target.port === targetB.port)
125
+ )
126
+ })
127
+ t.after(() => hub.close())
128
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
129
+ const gatewayA = new OutboundReverseGateway({
130
+ gatewayId: "server-a-starlink",
131
+ connectionId: "route_class_gateway_a",
132
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
133
+ tls,
134
+ authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port,
135
+ reconnectDelayMs: 10_000,
136
+ maxReconnectDelayMs: 10_000
137
+ })
138
+ const gatewayB = new OutboundReverseGateway({
139
+ gatewayId: "server-b-starlink",
140
+ connectionId: "route_class_gateway_b",
141
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-b-token"},
142
+ tls,
143
+ authorizeTarget: (target) => target.host === targetB.host && target.port === targetB.port,
144
+ reconnectDelayMs: 10_000,
145
+ maxReconnectDelayMs: 10_000
146
+ })
147
+ t.after(() => gatewayA.close())
148
+ t.after(() => gatewayB.close())
149
+ await Promise.all([gatewayA.start(), gatewayB.start()])
150
+
151
+ const unknown = new ReverseGatewayConnection({
152
+ routeClass: "missing",
153
+ connectionId: "route_class_unknown_client",
154
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
155
+ tls,
156
+ continuousReconnect: false
157
+ })
158
+ t.after(() => unknown.close())
159
+ await assert.rejects(withTimeout(unknown.connect(), "unknown route class"), (error) => /** @type {{code?: string}} */ (error).code === "UNKNOWN_ROUTE_CLASS")
160
+
161
+ const unauthorized = new ReverseGatewayConnection({
162
+ routeClass: "starlink",
163
+ connectionId: "route_class_unauthorized_client",
164
+ hub: {host: "127.0.0.1", port: hub.port, token: "untrusted-token"},
165
+ tls,
166
+ continuousReconnect: false
167
+ })
168
+ t.after(() => unauthorized.close())
169
+ await assert.rejects(withTimeout(unauthorized.connect(), "unauthorized route class"), (error) => /** @type {{code?: string}} */ (error).code === "ROUTE_CLASS_UNAUTHORIZED")
170
+
171
+ const client = new ReverseGatewayConnection({
172
+ routeClass: "starlink",
173
+ connectionId: "route_class_tensorbuzz_client",
174
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
175
+ tls
176
+ })
177
+ t.after(() => client.close())
178
+ await client.connect()
179
+ assert.equal(client.status().physicalConnects, 1)
180
+ assert.equal(hub.status().clientPhysicalConnections, 1)
181
+
182
+ const denied = client.createConnection({host: "127.0.0.1", port: 65_534})
183
+ assert.equal((await nextError(denied)).code, "TARGET_UNAUTHORIZED")
184
+
185
+ ambiguousTargetA = true
186
+ const ambiguous = client.createConnection({host: targetA.host, port: targetA.port})
187
+ assert.equal((await nextError(ambiguous)).code, "AMBIGUOUS_ROUTE")
188
+ ambiguousTargetA = false
189
+
190
+ const legacy = new ReverseGatewayConnection({
191
+ gatewayId: "server-a-starlink",
192
+ connectionId: "legacy_exact_gateway_client",
193
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
194
+ tls
195
+ })
196
+ t.after(() => legacy.close())
197
+ await legacy.connect()
198
+ const legacyStream = legacy.createConnection({host: targetA.host, port: targetA.port})
199
+ await onceEvent(legacyStream, "connect")
200
+ assert.equal(await exchange(legacyStream, "legacy"), "a:legacy")
201
+ legacyStream.destroy()
202
+ await legacy.close()
203
+
204
+ const streamA = client.createConnection({host: targetA.host, port: targetA.port})
205
+ const streamB = client.createConnection({host: targetB.host, port: targetB.port})
206
+ await Promise.all([onceEvent(streamA, "connect"), onceEvent(streamB, "connect")])
207
+ assert.deepEqual(await Promise.all([exchange(streamA, "one"), exchange(streamB, "two")]), ["a:one", "b:two"])
208
+
209
+ const streamAError = nextError(streamA)
210
+ await hub.closeActiveGateway("server-a-starlink")
211
+ assert.equal((await streamAError).code, "GATEWAY_DISCONNECTED")
212
+ await waitUntil(() => hub.status().healthyGateways === 1, "gateway A disconnect")
213
+
214
+ const disconnected = client.createConnection({host: targetA.host, port: targetA.port})
215
+ assert.equal((await nextError(disconnected)).code, "GATEWAY_UNAVAILABLE")
216
+ assert.equal(await exchange(streamB, "still-open"), "b:still-open")
217
+ const anotherB = client.createConnection({host: targetB.host, port: targetB.port})
218
+ await onceEvent(anotherB, "connect")
219
+ assert.equal(await exchange(anotherB, "new"), "b:new")
220
+
221
+ await gatewayA.close()
222
+ const replacementA = new OutboundReverseGateway({
223
+ gatewayId: "server-a-starlink",
224
+ connectionId: "route_class_gateway_a",
225
+ initialGeneration: gatewayA.status().connectionGeneration,
226
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
227
+ tls,
228
+ authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port
229
+ })
230
+ t.after(() => replacementA.close())
231
+ await replacementA.start()
232
+ const rejoinedA = client.createConnection({host: targetA.host, port: targetA.port})
233
+ await onceEvent(rejoinedA, "connect")
234
+ assert.equal(await exchange(rejoinedA, "rejoined"), "a:rejoined")
235
+ assert.equal(client.status().physicalConnects, 1)
236
+ assert.equal(hub.status().clientPhysicalConnections, 1)
237
+ streamB.destroy()
238
+ anotherB.destroy()
239
+ rejoinedA.destroy()
240
+ })
241
+
242
+ test("a backpressured gateway does not block DATA or a new OPEN for another route-class gateway", async (t) => {
243
+ const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-backpressure-"))
244
+ t.after(() => rm(root, {recursive: true, force: true}))
245
+ const certificates = generateTestCertificates(root)
246
+ const targetA = await createEchoTarget("a:")
247
+ const targetB = await createEchoTarget("b:")
248
+ t.after(() => targetA.close())
249
+ t.after(() => targetB.close())
250
+
251
+ const hub = await startReverseGatewayHub({
252
+ tokens: new StaticTokenStore([
253
+ {username: "home-a", token: "gateway-a-token"},
254
+ {username: "home-b", token: "gateway-b-token"},
255
+ {username: "tensorbuzz", token: "client-token"}
256
+ ]),
257
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
258
+ knownGatewayIds: ["server-a-starlink", "server-b-starlink"],
259
+ knownRouteClasses: ["starlink"],
260
+ authorizeGateway: ({principal, gatewayId}) => (
261
+ (principal.username === "home-a" && gatewayId === "server-a-starlink")
262
+ || (principal.username === "home-b" && gatewayId === "server-b-starlink")
263
+ ),
264
+ authorizeClientGateway: () => false,
265
+ authorizeClientRouteClass: ({principal, routeClass}) => principal.username === "tensorbuzz" && routeClass === "starlink",
266
+ resolveOpenGateways: ({routeClass, target}) => {
267
+ if (routeClass !== "starlink" || target.host !== "127.0.0.1") return []
268
+ if (target.port === targetA.port) return ["server-a-starlink"]
269
+ if (target.port === targetB.port) return ["server-b-starlink"]
270
+ return []
271
+ },
272
+ authorizeOpen: ({gatewayId, target}) => (
273
+ (gatewayId === "server-a-starlink" && target.port === targetA.port)
274
+ || (gatewayId === "server-b-starlink" && target.port === targetB.port)
275
+ ),
276
+ maxOutputBytesPerConnection: 1_024
277
+ })
278
+ t.after(() => hub.close())
279
+ /** @type {import('node:tls').TLSSocket[]} */
280
+ const accepted = []
281
+ hub.server.on("secureConnection", (socket) => accepted.push(socket))
282
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
283
+ const gatewayA = new OutboundReverseGateway({
284
+ gatewayId: "server-a-starlink",
285
+ connectionId: "backpressure_gateway_a",
286
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
287
+ tls,
288
+ authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port,
289
+ reconnectDelayMs: 10_000,
290
+ maxReconnectDelayMs: 10_000
291
+ })
292
+ const gatewayB = new OutboundReverseGateway({
293
+ gatewayId: "server-b-starlink",
294
+ connectionId: "backpressure_gateway_b",
295
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-b-token"},
296
+ tls,
297
+ authorizeTarget: (target) => target.host === targetB.host && target.port === targetB.port,
298
+ reconnectDelayMs: 10_000,
299
+ maxReconnectDelayMs: 10_000
300
+ })
301
+ t.after(() => gatewayA.close())
302
+ t.after(() => gatewayB.close())
303
+ await gatewayA.start()
304
+ await gatewayB.start()
305
+ assert.equal(accepted.length, 2)
306
+
307
+ const client = new ReverseGatewayConnection({
308
+ routeClass: "starlink",
309
+ connectionId: "backpressure_route_client",
310
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
311
+ tls,
312
+ continuousReconnect: false,
313
+ reconnectDelayMs: 10_000,
314
+ maxReconnectDelayMs: 10_000
315
+ })
316
+ t.after(() => client.close())
317
+ await client.connect()
318
+ const streamA = client.createConnection({host: targetA.host, port: targetA.port})
319
+ const streamB = client.createConnection({host: targetB.host, port: targetB.port})
320
+ await Promise.all([onceEvent(streamA, "connect"), onceEvent(streamB, "connect")])
321
+
322
+ const gatewayASocket = /** @type {import('node:tls').TLSSocket} */ (accepted[0])
323
+ const writable = /** @type {{write: (chunk: Buffer) => boolean}} */ (/** @type {unknown} */ (gatewayASocket))
324
+ const originalWrite = writable.write
325
+ let blocked = false
326
+ /** @type {() => void} */
327
+ let markBlocked = () => {}
328
+ const backpressured = new Promise((resolve) => { markBlocked = () => resolve(undefined) })
329
+ writable.write = (chunk) => {
330
+ const acceptedWrite = Reflect.apply(originalWrite, gatewayASocket, [chunk])
331
+ if (!blocked && chunk[5] === V3FrameType.DATA) {
332
+ blocked = true
333
+ markBlocked()
334
+ return false
335
+ }
336
+ return acceptedWrite
337
+ }
338
+ t.after(() => {
339
+ writable.write = originalWrite
340
+ if (blocked) gatewayASocket.emit("drain")
341
+ })
342
+
343
+ streamA.write("blocked")
344
+ await withTimeout(backpressured, "gateway A backpressure")
345
+ const newStreamB = client.createConnection({host: targetB.host, port: targetB.port})
346
+ t.after(() => newStreamB.destroy())
347
+ const newStreamBExchange = (async () => {
348
+ await onceEvent(newStreamB, "connect")
349
+ return await exchange(newStreamB, "new-open")
350
+ })()
351
+ assert.deepEqual(await withTimeout(Promise.all([
352
+ exchange(streamB, "existing"),
353
+ newStreamBExchange
354
+ ]), "healthy gateway progress while gateway A is backpressured"), ["b:existing", "b:new-open"])
355
+ assert.equal(client.status().physicalConnects, 1)
356
+ assert(hub.status().queuedOutputBytes > 0)
357
+ const streamAError = nextError(streamA)
358
+ const queuedAErrors = Array.from({length: 8}, () => {
359
+ const socket = client.createConnection({host: targetA.host, port: targetA.port, sessionName: "a".repeat(256)})
360
+ t.after(() => socket.destroy())
361
+ return nextError(socket)
362
+ })
363
+ assert.equal((await withTimeout(streamAError, "gateway A output limit")).code, "GATEWAY_DISCONNECTED")
364
+ await withTimeout(Promise.all(queuedAErrors), "queued gateway A stream cleanup")
365
+ assert.equal(await exchange(streamB, "after-limit"), "b:after-limit")
366
+ assert.equal(hub.status().healthyGateways, 1)
367
+ assert.equal(hub.status().clientPhysicalConnections, 1)
368
+ assert.equal(client.status().physicalConnects, 1)
369
+ writable.write = originalWrite
370
+ assert.equal(hub.status().queuedOutputBytes, 0)
371
+ streamA.destroy()
372
+ streamB.destroy()
373
+ })
@@ -0,0 +1,170 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param-type, jsdoc/require-returns */
2
+ import assert from "node:assert/strict"
3
+ import test from "node:test"
4
+
5
+ import {DeferredVirtualSocket} from "../../../src/client/deferred-virtual-socket.js"
6
+ import {VirtualSocket} from "../../../src/client/virtual-socket.js"
7
+
8
+ /**
9
+ * @param {{reserveMemoryError?: Error}} [options] - Optional rejection to inject into the byte budget.
10
+ * @returns {{reservedBytes: () => number, socket: import('../../../src/client/deferred-virtual-socket.js').DeferredVirtualSocket}} Deferred socket fixture with reserved-byte accounting.
11
+ */
12
+ function deferredSocket(options = {}) {
13
+ let reservedBytes = 0
14
+ const socket = new DeferredVirtualSocket({host: "127.0.0.1", port: 4793}, {
15
+ budget: {
16
+ reserveMemory(bytes) {
17
+ if (options.reserveMemoryError) throw options.reserveMemoryError
18
+ reservedBytes += bytes
19
+ },
20
+ releaseMemory(bytes) {
21
+ reservedBytes -= bytes
22
+ }
23
+ },
24
+ onCancel: () => {},
25
+ ref: () => {},
26
+ unref: () => {}
27
+ })
28
+ return {reservedBytes: () => reservedBytes, socket}
29
+ }
30
+
31
+ /** Fake member connection that settles write and final callbacks on the next macrotask. */
32
+ function memberConnection() {
33
+ /** @type {Buffer[]} */
34
+ const writes = []
35
+ let ended = 0
36
+ return {
37
+ connection: /** @type {import('../../../src/client/virtual-socket.js').VirtualSocketOwner} */ (/** @type {unknown} */ ({
38
+ /**
39
+ * @param {number} _id @param {Buffer} data @param {(error?: Error | null) => void} callback
40
+ * @param data
41
+ * @param callback
42
+ */
43
+ _writeStream: (_id, data, callback) => {
44
+ writes.push(data)
45
+ setImmediate(callback)
46
+ },
47
+ /**
48
+ * @param {number} _id @param {(error?: Error | null) => void} callback
49
+ * @param callback
50
+ */
51
+ _endStream: (_id, callback) => {
52
+ ended += 1
53
+ callback()
54
+ },
55
+ _destroyStream: () => {},
56
+ _resumeReadable: () => {},
57
+ physicalAddress: () => ({address: "127.0.0.1", port: 43210}),
58
+ ref: () => {},
59
+ unref: () => {}
60
+ })),
61
+ ended: () => ended,
62
+ writes
63
+ }
64
+ }
65
+
66
+ test("DeferredVirtualSocket write ignores non-function callback values in both parameter slots", async () => {
67
+ const first = deferredSocket()
68
+ // @ts-expect-error Deliberate non-function callback value; the socket must ignore it.
69
+ assert.equal(first.socket.write("hello", null), true)
70
+ const closed = new Promise((resolve) => first.socket.once("close", resolve))
71
+ first.socket.destroy()
72
+ await closed
73
+ assert.equal(first.socket.destroyed, true)
74
+
75
+ const second = deferredSocket()
76
+ // @ts-expect-error Deliberate non-function callback value; the socket must ignore it.
77
+ assert.equal(second.socket.write("hello", "utf8", null), true)
78
+ const closedAgain = new Promise((resolve) => second.socket.once("close", resolve))
79
+ assert.doesNotThrow(() => second.socket.destroy())
80
+ await closedAgain
81
+ assert.equal(second.socket.destroyed, true)
82
+ })
83
+
84
+ test("DeferredVirtualSocket write budget failure destroys the socket without crashing on non-function callback values", async () => {
85
+ const failure = new Error("budget exhausted")
86
+ const {socket} = deferredSocket({reserveMemoryError: failure})
87
+ const error = new Promise((resolve) => socket.once("error", resolve))
88
+ // @ts-expect-error Deliberate non-function callback value; the socket must ignore it.
89
+ assert.equal(socket.write("hello", "utf8", null), false)
90
+ assert.equal(await error, failure)
91
+ assert.equal(socket.destroyed, true)
92
+ })
93
+
94
+ test("DeferredVirtualSocket write budget failure invokes the completion callback with the failure", async () => {
95
+ const failure = new Error("budget exhausted")
96
+ const {reservedBytes, socket} = deferredSocket({reserveMemoryError: failure})
97
+ /** @type {Error | null | undefined} */
98
+ let callbackError
99
+ const error = new Promise((resolve) => socket.once("error", resolve))
100
+ assert.equal(socket.write("hello", "utf8", (received) => {
101
+ callbackError = received
102
+ }), false)
103
+ await error
104
+ assert.equal(callbackError, failure)
105
+ assert.equal(reservedBytes(), 0)
106
+ assert.equal(socket.destroyed, true)
107
+ })
108
+
109
+ test("DeferredVirtualSocket write completion after member assignment ignores non-function callback values", async () => {
110
+ const {socket} = deferredSocket()
111
+ const member = memberConnection()
112
+ socket.assign(new VirtualSocket(member.connection, 1, {host: "127.0.0.1", port: 4793}), "gateway-1")
113
+ // @ts-expect-error Deliberate non-function callback value; the socket must ignore it.
114
+ assert.equal(socket.write("hello", "utf8", null), true)
115
+ await new Promise((resolve) => setImmediate(resolve))
116
+ const written = member.writes[0]
117
+ assert(written !== undefined)
118
+ assert.equal(written.toString(), "hello")
119
+ })
120
+
121
+ test("DeferredVirtualSocket end ignores non-function callback values in both parameter slots", async () => {
122
+ const first = deferredSocket()
123
+ assert.doesNotThrow(() => {
124
+ // @ts-expect-error Deliberate non-function callback value; the socket must ignore it.
125
+ first.socket.end("hello", null)
126
+ })
127
+ assert.equal(first.socket.writableEnded, true)
128
+ const closed = new Promise((resolve) => first.socket.once("close", resolve))
129
+ first.socket.destroy()
130
+ await closed
131
+ assert.equal(first.socket.destroyed, true)
132
+
133
+ const second = deferredSocket()
134
+ assert.doesNotThrow(() => {
135
+ // @ts-expect-error Deliberate non-function callback value; the socket must ignore it.
136
+ second.socket.end("hello", "utf8", null)
137
+ })
138
+ assert.equal(second.socket.writableEnded, true)
139
+ const closedAgain = new Promise((resolve) => second.socket.once("close", resolve))
140
+ second.socket.destroy()
141
+ await closedAgain
142
+ assert.equal(second.socket.destroyed, true)
143
+ })
144
+
145
+ test("DeferredVirtualSocket end does not forward non-function callback values to the stream", async () => {
146
+ const {socket} = deferredSocket()
147
+ assert.doesNotThrow(() => {
148
+ // @ts-expect-error Deliberate non-function callback value; the socket must ignore it.
149
+ socket.end("hello", "utf8", 42)
150
+ })
151
+ assert.equal(socket.writableEnded, true)
152
+ const closed = new Promise((resolve) => socket.once("close", resolve))
153
+ socket.destroy()
154
+ await closed
155
+ })
156
+
157
+ test("DeferredVirtualSocket end completion after member assignment invokes the callback on finish", async () => {
158
+ const {socket} = deferredSocket()
159
+ const member = memberConnection()
160
+ socket.assign(new VirtualSocket(member.connection, 1, {host: "127.0.0.1", port: 4793}), "gateway-1")
161
+ let finishedCallback = false
162
+ const finished = new Promise((resolve) => socket.once("finish", resolve))
163
+ socket.end("hello", "utf8", () => {
164
+ finishedCallback = true
165
+ })
166
+ await finished
167
+ assert.equal(finishedCallback, true)
168
+ assert.equal(socket.writableFinished, true)
169
+ assert.equal(member.ended(), 1)
170
+ })
@@ -260,6 +260,48 @@ test("validates stable gateway identities, pinned endpoints, and process limits"
260
260
  }
261
261
  })
262
262
 
263
+ test("route-class members are first-class reverse transports with route-class allocation metadata", async () => {
264
+ /** @type {FakeMember[]} */
265
+ const members = []
266
+ const set = new MultiplexedSocketductTransportSet(/** @type {import('../../../src/client/multiplexed-transport-set.js').TransportSetOptions} */ (/** @type {unknown} */ ({
267
+ connectionId: "route_class_process_identity",
268
+ minimumReadyMembers: 1,
269
+ routeClasses: [
270
+ {routeClass: "starlink", endpoint: {reverseHub: {host: "hub.example", port: 3100, token: "starlink-token"}, tls: {servername: "hub.example"}}},
271
+ {routeClass: "zyxel", endpoint: {reverseHub: {host: "hub.example", port: 3100, token: "zyxel-token"}, tls: {servername: "hub.example"}}}
272
+ ]
273
+ })), {
274
+ createMember(memberOptions, routeClass) {
275
+ const member = new FakeMember(memberOptions, routeClass, undefined)
276
+ members.push(member)
277
+ return member
278
+ }
279
+ })
280
+
281
+ assert.deepEqual(members.map((member) => {
282
+ const memberOptions = /** @type {import('../../../src/reverse/client.js').ReverseGatewayConnectionOptions} */ (member.options)
283
+ return {gatewayId: memberOptions.gatewayId, routeClass: memberOptions.routeClass}
284
+ }), [
285
+ {gatewayId: undefined, routeClass: "starlink"},
286
+ {gatewayId: undefined, routeClass: "zyxel"}
287
+ ])
288
+ const connectionIds = members.map((member) => member.options.connectionId)
289
+ assert(connectionIds.every((connectionId) => typeof connectionId === "string"))
290
+ assert.equal(new Set(connectionIds).size, 2)
291
+
292
+ const started = set.start()
293
+ at(members, 0).ready()
294
+ await started
295
+ const socket = set.createConnection({host: "target.internal", port: 443})
296
+ assert.equal(/** @type {{gatewayId?: string}} */ (socket).gatewayId, undefined)
297
+ assert.equal(/** @type {{routeClass?: string}} */ (socket).routeClass, "starlink")
298
+ assert.deepEqual(set.status().members.map((member) => ({gatewayId: member.gatewayId, routeClass: member.routeClass})), [
299
+ {gatewayId: undefined, routeClass: "starlink"},
300
+ {gatewayId: undefined, routeClass: "zyxel"}
301
+ ])
302
+ await set.close()
303
+ })
304
+
263
305
  test("logs transport-set startup, generation replacement, and terminal failure without error text", async () => {
264
306
  /** @type {Array<{event: string} & Record<string, unknown>>} */
265
307
  const entries = []
@@ -41,7 +41,9 @@ test("TensorBuzz exclusively owns verification and container build validation",
41
41
  assert.match(tensorbuzz, /privileged: true/)
42
42
  assert.match(tensorbuzz, /docker build --tag socketduct-ci \./)
43
43
  assert.doesNotMatch(tensorbuzz, /docker (?:login|push)/)
44
- assert.equal(packageMetadata.private, true)
44
+ // release-patch refuses to publish a package marked "private": true, so the
45
+ // published UNLICENSED npm release requires the flag to be absent or false.
46
+ assert.notEqual(packageMetadata.private, true)
45
47
  assert.equal(packageMetadata.license, "UNLICENSED")
46
48
  })
47
49
 
@@ -254,12 +254,13 @@ test("isLoopbackHost rejects non-loopback addresses", () => {
254
254
  assert.equal(isLoopbackHost("::ffff:127.0.0.1"), false)
255
255
  })
256
256
 
257
- test("loadEnvironmentConfig parses reverse hub identity bindings and exact routes", () => {
257
+ test("loadEnvironmentConfig parses reverse hub identity bindings, exact routes, and route-class routes", () => {
258
258
  const config = loadEnvironmentConfig({
259
259
  SOCKETDUCT_MODE: "reverse-hub",
260
260
  SOCKETDUCT_AUTH_TOKENS: "home-o2:gateway-token,tensorbuzz:client-token",
261
261
  SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "o2:home-o2,zyxel:home-zyxel",
262
262
  SOCKETDUCT_REVERSE_ROUTES: "tensorbuzz:o2:docker-server:2375,tensorbuzz:zyxel:docker-server:2375",
263
+ SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES: "tensorbuzz:starlink:o2:docker-a:2375,tensorbuzz:starlink:zyxel:docker-b:2375",
263
264
  SOCKETDUCT_TLS_CERT_FILE: "/certs/hub.crt",
264
265
  SOCKETDUCT_TLS_KEY_FILE: "/certs/hub.key"
265
266
  })
@@ -269,6 +270,10 @@ test("loadEnvironmentConfig parses reverse hub identity bindings and exact route
269
270
  {username: "tensorbuzz", gatewayId: "o2", host: "docker-server", port: 2375},
270
271
  {username: "tensorbuzz", gatewayId: "zyxel", host: "docker-server", port: 2375}
271
272
  ])
273
+ assert.deepEqual(config.reverseRouteClassRoutes, [
274
+ {username: "tensorbuzz", routeClass: "starlink", gatewayId: "o2", host: "docker-a", port: 2375},
275
+ {username: "tensorbuzz", routeClass: "starlink", gatewayId: "zyxel", host: "docker-b", port: 2375}
276
+ ])
272
277
  })
273
278
 
274
279
  test("loadEnvironmentConfig parses outbound reverse gateway verified TLS settings", () => {
@@ -296,6 +301,7 @@ test("loadEnvironmentConfig rejects malformed reverse mode policy and credential
296
301
  assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "unknown"}), /Unsupported SOCKETDUCT_MODE/)
297
302
  assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-hub", SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "missing-user"}), /gatewayId:username/)
298
303
  assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-hub", SOCKETDUCT_REVERSE_ROUTES: "too:few:fields"}), /username:gatewayId:host:port/)
304
+ assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-hub", SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES: "too:few:fields"}), /username:routeClass:gatewayId:host:port/)
299
305
  assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-gateway", SOCKETDUCT_REVERSE_TOKEN: "replace-me"}), /placeholder/)
300
306
  assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-gateway", SOCKETDUCT_REVERSE_HUB_CLIENT_CERT_FILE: "/certs/client.crt"}), /must both be set/)
301
307
  })