socketduct 0.1.30 → 0.1.32
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,5 @@
|
|
|
1
|
+
## Node.js 24 LTS
|
|
2
|
+
|
|
3
|
+
The package, its test suite and the reverse-gateway integration tests are validated on Node.js 24 (current LTS). `engines.node` is now pinned to `^24.0.0`.
|
|
4
|
+
|
|
5
|
+
Why: on this development host the suite is also run under Node.js 26, where the compressed reverse-gateway round-trip tests fail with corrupted payloads (16 failures, `Buffer wasn't equal to Buffer`). The same suite passes 42/42 under Node.js 24, so the corruption tracks the runtime, not the wire format. Pinning to LTS keeps the validated runtime and the declared support range identical.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
`ReverseGatewayConnection` gains `drain()`, matching the multiplexed connection: it refuses new virtual streams with `SOCKETDUCT_TRANSPORT_SET_DRAINING`, waits for the open ones to finish, and only then releases the hub transport, so tearing down a reverse-mode transport set no longer resets work that is still in flight; `close()` keeps its immediate-termination behaviour and now logs how many live streams it terminated.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "socketduct",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.32",
|
|
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",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"./relay": "./src/relay.js"
|
|
17
17
|
},
|
|
18
18
|
"engines": {
|
|
19
|
-
"node": "
|
|
19
|
+
"node": "^24.0.0"
|
|
20
20
|
},
|
|
21
21
|
"scripts": {
|
|
22
22
|
"gateway:deploy": "node scripts/deploy-gateway.js",
|
package/src/reverse/client.js
CHANGED
|
@@ -144,6 +144,9 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
144
144
|
/** @type {number} */ #generation
|
|
145
145
|
/** @type {string | undefined} */ #codec
|
|
146
146
|
/** @type {boolean} */ #closing = false
|
|
147
|
+
/** @type {boolean} */ #draining = false
|
|
148
|
+
/** @type {Promise<void> | undefined} */ #closePromise
|
|
149
|
+
/** @type {Array<() => void>} */ #drainWaiters = []
|
|
147
150
|
/** @type {boolean} */ #detached = false
|
|
148
151
|
/** @type {boolean} */ #preserveSpools = false
|
|
149
152
|
/** @type {boolean} */ #fatal = false
|
|
@@ -247,6 +250,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
247
250
|
/** Creates a fresh or synchronously recovered reverse stream. */
|
|
248
251
|
#createConnection(/** @type {import('../client/multiplexed-connection.js').VirtualConnectionOptions} */ options, /** @type {((error: Error | null, socket: VirtualSocket) => void) | undefined} */ callback, /** @type {boolean} */ recovering) {
|
|
249
252
|
if (this.#closing) throw closedError()
|
|
253
|
+
if (this.#draining) throw Object.assign(new Error("Reverse gateway member is draining"), {code: "SOCKETDUCT_TRANSPORT_SET_DRAINING"})
|
|
250
254
|
if (options?.manualReceiveAck !== undefined && typeof options.manualReceiveAck !== "boolean") throw new TypeError("manualReceiveAck must be a boolean")
|
|
251
255
|
if (!recovering && this.#state !== "ready") throw Object.assign(new Error("Reverse gateway member is not ready"), {code: "SOCKETDUCT_TRANSPORT_SET_NOT_READY"})
|
|
252
256
|
if (this.#streams.size >= (this.#options.maxStreams ?? DEFAULT_MAX_STREAMS)) throw new Error("Maximum reverse virtual stream count exceeded")
|
|
@@ -487,8 +491,44 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
487
491
|
ref() { this.#referenced = true; this.#physical?.ref(); this.#reconnectTimer?.ref?.(); return this }
|
|
488
492
|
unref() { this.#referenced = false; this.#physical?.unref(); this.#reconnectTimer?.unref?.(); return this }
|
|
489
493
|
|
|
494
|
+
/**
|
|
495
|
+
* Graceful counterpart of `close()`: refuses new virtual streams, lets the open
|
|
496
|
+
* ones finish on their own, then releases the shared transport. Transport sets
|
|
497
|
+
* that are torn down while they carry work must use this instead of `close()`,
|
|
498
|
+
* otherwise live streams are reset at the relay mid-transfer.
|
|
499
|
+
* @returns {Promise<void>}
|
|
500
|
+
*/
|
|
501
|
+
async drain() {
|
|
502
|
+
if (this.#closePromise !== undefined) return this.#closePromise
|
|
503
|
+
this.#draining = true
|
|
504
|
+
this.#closePromise = (async () => {
|
|
505
|
+
logDiagnostic(this.#options.logger, "reverse_client.draining", {virtualStreams: this.#streams.size})
|
|
506
|
+
await this.#waitForStreamsToDrain()
|
|
507
|
+
await this.close()
|
|
508
|
+
})()
|
|
509
|
+
|
|
510
|
+
return this.#closePromise
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** @returns {Promise<void>} */
|
|
514
|
+
#waitForStreamsToDrain() {
|
|
515
|
+
if (this.#streams.size === 0) return Promise.resolve()
|
|
516
|
+
|
|
517
|
+
return new Promise((resolve) => { this.#drainWaiters.push(resolve) })
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** @returns {void} */
|
|
521
|
+
#releaseDrainWaiters() {
|
|
522
|
+
if (this.#drainWaiters.length === 0) return
|
|
523
|
+
const waiters = this.#drainWaiters.splice(0)
|
|
524
|
+
for (const resolve of waiters) resolve()
|
|
525
|
+
}
|
|
526
|
+
|
|
490
527
|
async close() {
|
|
491
528
|
if (this.#closing) return
|
|
529
|
+
if (this.#streams.size > 0 && !this.#draining) {
|
|
530
|
+
logDiagnostic(this.#options.logger, "reverse_client.closed_with_live_streams", {streamsKilled: this.#streams.size})
|
|
531
|
+
}
|
|
492
532
|
this.#closing = true
|
|
493
533
|
if (this.#reconnectTimer !== undefined) clearTimeout(this.#reconnectTimer)
|
|
494
534
|
this.#reconnectTimer = undefined
|
|
@@ -496,6 +536,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
496
536
|
this.#gatewayProbeTimer = undefined
|
|
497
537
|
this.#setState("closed")
|
|
498
538
|
for (const stream of [...this.#streams.values()]) stream.socket.destroy()
|
|
539
|
+
this.#releaseDrainWaiters()
|
|
499
540
|
const physical = this.#physical
|
|
500
541
|
this.#physical = undefined
|
|
501
542
|
if (physical !== undefined && !physical.destroyed) {
|
|
@@ -1203,6 +1244,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
1203
1244
|
while (stream.pendingWrites.length > 0) this.#finishWrite(stream, pendingWriteError)
|
|
1204
1245
|
if (!this.#preserveSpools) this.#trackSpoolCleanup(stream)
|
|
1205
1246
|
this.#progressPendingWrites()
|
|
1247
|
+
if (this.#streams.size === 0) this.#releaseDrainWaiters()
|
|
1206
1248
|
}
|
|
1207
1249
|
|
|
1208
1250
|
/** @param {ReverseClientStream} stream - Stream. @param {number} sequence - ACK. */
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
|
|
2
|
+
import {describe, expect, it, withTestCleanup} from "@velocious/testing"
|
|
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
|
+
|
|
8
|
+
import {StaticTokenStore} from "../../src/auth/static-token-store.js"
|
|
9
|
+
import {OutboundReverseGateway, ReverseGatewayConnection, startReverseGatewayHub} from "../../src/reverse/index.js"
|
|
10
|
+
import {generateTestCertificates} from "../helpers/tls-certificates.js"
|
|
11
|
+
|
|
12
|
+
/** @param {import('node:net').Server} server - Server. */
|
|
13
|
+
async function listen(server) {
|
|
14
|
+
await new Promise((resolve, reject) => {
|
|
15
|
+
server.once("error", reject)
|
|
16
|
+
server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
|
|
17
|
+
})
|
|
18
|
+
const address = server.address()
|
|
19
|
+
if (address === null || typeof address === "string") throw new Error("Server did not bind to a TCP port")
|
|
20
|
+
return address.port
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @param {string} prefix - Echo prefix. */
|
|
24
|
+
async function createEchoTarget(prefix) {
|
|
25
|
+
const server = createServer((socket) => {
|
|
26
|
+
socket.on("data", (chunk) => socket.write(`${prefix}${chunk.toString("utf8")}`))
|
|
27
|
+
})
|
|
28
|
+
const port = await listen(server)
|
|
29
|
+
return {host: "127.0.0.1", port, close: () => new Promise((resolve) => server.close(resolve))}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @param {import('node:events').EventEmitter} emitter - Source. @param {string} event - Event. */
|
|
33
|
+
async function onceEvent(emitter, event) {
|
|
34
|
+
await new Promise((resolve, reject) => {
|
|
35
|
+
emitter.once("error", reject)
|
|
36
|
+
emitter.once(event, () => { emitter.off("error", reject); resolve(undefined) })
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {import('node:stream').Duplex} socket - Stream. @param {string} message - Payload. */
|
|
41
|
+
async function exchange(socket, message) {
|
|
42
|
+
return await new Promise((resolve) => {
|
|
43
|
+
socket.once("data", (chunk) => resolve(chunk.toString("utf8")))
|
|
44
|
+
socket.write(message)
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @param {() => boolean} predicate - Completion predicate. @param {string} label - Failure label. */
|
|
49
|
+
async function waitUntil(predicate, label) {
|
|
50
|
+
const deadline = Date.now() + 4_000
|
|
51
|
+
while (!predicate()) {
|
|
52
|
+
if (Date.now() >= deadline) throw new Error(`Timed out: ${label}`)
|
|
53
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("reverse gateway drain", () => {
|
|
58
|
+
it("refuses new streams, lets open streams finish, then releases the transport", async () => withTestCleanup(async (after) => {
|
|
59
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-drain-"))
|
|
60
|
+
after(() => rm(root, {recursive: true, force: true}))
|
|
61
|
+
const certificates = generateTestCertificates(root)
|
|
62
|
+
const target = await createEchoTarget("t:")
|
|
63
|
+
after(() => target.close())
|
|
64
|
+
|
|
65
|
+
const tokens = new StaticTokenStore([
|
|
66
|
+
{username: "home-a", token: "gateway-a-token"},
|
|
67
|
+
{username: "tensorbuzz", token: "client-token"}
|
|
68
|
+
])
|
|
69
|
+
const hub = await startReverseGatewayHub({
|
|
70
|
+
tokens,
|
|
71
|
+
tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
|
|
72
|
+
knownGatewayIds: ["server-a-starlink"],
|
|
73
|
+
knownRouteClasses: ["starlink"],
|
|
74
|
+
authorizeGateway: ({gatewayId}) => gatewayId === "server-a-starlink",
|
|
75
|
+
authorizeClientGateway: ({gatewayId}) => gatewayId === "server-a-starlink",
|
|
76
|
+
authorizeClientRouteClass: () => true,
|
|
77
|
+
resolveOpenGateways: ({routeClass, target: requestedTarget}) => (
|
|
78
|
+
routeClass === "starlink" && requestedTarget.port === target.port ? ["server-a-starlink"] : []
|
|
79
|
+
),
|
|
80
|
+
authorizeOpen: ({target: requestedTarget}) => requestedTarget.host === target.host && requestedTarget.port === target.port
|
|
81
|
+
})
|
|
82
|
+
after(() => hub.close())
|
|
83
|
+
const tls = {caFile: certificates.caCertFile, servername: "localhost"}
|
|
84
|
+
const gateway = new OutboundReverseGateway({
|
|
85
|
+
gatewayId: "server-a-starlink",
|
|
86
|
+
connectionId: "drain_gateway_a",
|
|
87
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
|
|
88
|
+
tls,
|
|
89
|
+
authorizeTarget: (requestedTarget) => requestedTarget.host === target.host && requestedTarget.port === target.port,
|
|
90
|
+
reconnectDelayMs: 10,
|
|
91
|
+
maxReconnectDelayMs: 20
|
|
92
|
+
})
|
|
93
|
+
after(() => gateway.close())
|
|
94
|
+
await gateway.start()
|
|
95
|
+
|
|
96
|
+
const client = new ReverseGatewayConnection({
|
|
97
|
+
routeClass: "starlink",
|
|
98
|
+
connectionId: "drain_client",
|
|
99
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
|
|
100
|
+
tls
|
|
101
|
+
})
|
|
102
|
+
await client.connect()
|
|
103
|
+
expect(hub.status().clientPhysicalConnections).toBe(1)
|
|
104
|
+
|
|
105
|
+
const stream = client.createConnection({host: target.host, port: target.port})
|
|
106
|
+
await onceEvent(stream, "connect")
|
|
107
|
+
expect(await exchange(stream, "before")).toBe("t:before")
|
|
108
|
+
expect(client.status().virtualStreams).toBe(1)
|
|
109
|
+
|
|
110
|
+
expect(typeof client.drain).toBe("function")
|
|
111
|
+
|
|
112
|
+
let drained = false
|
|
113
|
+
const drainPromise = client.drain().then(() => { drained = true })
|
|
114
|
+
|
|
115
|
+
// New work is refused while the set drains.
|
|
116
|
+
/** @type {Error & {code?: string} | undefined} */
|
|
117
|
+
let refused
|
|
118
|
+
try {
|
|
119
|
+
client.createConnection({host: target.host, port: target.port})
|
|
120
|
+
} catch (caught) {
|
|
121
|
+
refused = /** @type {Error & {code?: string}} */ (caught)
|
|
122
|
+
}
|
|
123
|
+
expect(refused?.code).toBe("SOCKETDUCT_TRANSPORT_SET_DRAINING")
|
|
124
|
+
|
|
125
|
+
// The transport stays up while the open stream is still usable.
|
|
126
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
127
|
+
expect(drained).toBe(false)
|
|
128
|
+
expect(await exchange(stream, "during")).toBe("t:during")
|
|
129
|
+
expect(hub.status().clientPhysicalConnections).toBe(1)
|
|
130
|
+
|
|
131
|
+
// Finishing the last stream lets the drain complete and release the transport.
|
|
132
|
+
stream.destroy()
|
|
133
|
+
await drainPromise
|
|
134
|
+
expect(drained).toBe(true)
|
|
135
|
+
await waitUntil(() => hub.status().clientPhysicalConnections === 0, "hub releases the drained member")
|
|
136
|
+
expect(client.status().physicalState).toBe("closed")
|
|
137
|
+
}))
|
|
138
|
+
})
|