socketduct 0.1.15

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.
Files changed (138) hide show
  1. package/.dockerignore +11 -0
  2. package/.env.example +131 -0
  3. package/AGENTS.md +168 -0
  4. package/Dockerfile +30 -0
  5. package/README.md +134 -0
  6. package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
  7. package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
  8. package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
  9. package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
  10. package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
  11. package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
  12. package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
  13. package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
  14. package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
  15. package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
  16. package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
  17. package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
  18. package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
  19. package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
  20. package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
  21. package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
  22. package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
  23. package/changelog.d/20260902231542-reverse-close-position.md +1 -0
  24. package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
  25. package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
  26. package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
  27. package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
  28. package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
  29. package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
  30. package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
  31. package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
  32. package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
  33. package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
  34. package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
  35. package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
  36. package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
  37. package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
  38. package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
  39. package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
  40. package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
  41. package/docker-compose.dev.yml +19 -0
  42. package/docker-compose.reverse-gateway.yml +36 -0
  43. package/docker-compose.yml +73 -0
  44. package/docs/architecture.md +264 -0
  45. package/docs/docker-topology.md +164 -0
  46. package/docs/docker.md +281 -0
  47. package/docs/logging.md +185 -0
  48. package/docs/plans/initial-implementation.md +205 -0
  49. package/docs/plans/socket-docker-integration.md +221 -0
  50. package/docs/protocol.md +558 -0
  51. package/docs/releasing.md +18 -0
  52. package/docs/reverse-gateway.md +186 -0
  53. package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
  54. package/docs/running.md +215 -0
  55. package/docs/security.md +258 -0
  56. package/docs/session-lifecycle.md +127 -0
  57. package/docs/socket-api.md +448 -0
  58. package/eslint.config.js +36 -0
  59. package/package.json +44 -0
  60. package/scripts/deploy-gateway.js +194 -0
  61. package/scripts/test.js +28 -0
  62. package/src/auth/passwords.js +38 -0
  63. package/src/auth/static-token-store.js +41 -0
  64. package/src/auth/user-store.js +139 -0
  65. package/src/client/connect.js +76 -0
  66. package/src/client/deferred-virtual-socket.js +312 -0
  67. package/src/client/http-agent.js +101 -0
  68. package/src/client/https-agent.js +366 -0
  69. package/src/client/multiplexed-connection.js +1504 -0
  70. package/src/client/multiplexed-transport-set.js +917 -0
  71. package/src/client/multiplexed.js +3 -0
  72. package/src/client/socketduct-socket.js +1174 -0
  73. package/src/client/virtual-socket.js +256 -0
  74. package/src/config/environment.js +400 -0
  75. package/src/edge/tcp-edge.js +82 -0
  76. package/src/index.js +101 -0
  77. package/src/logging/structured-logger.js +745 -0
  78. package/src/protocol/compression.js +325 -0
  79. package/src/protocol/frame.js +341 -0
  80. package/src/protocol/v3-compression.js +44 -0
  81. package/src/protocol/v3-frame.js +209 -0
  82. package/src/proxy/multiplexed-relay.js +798 -0
  83. package/src/proxy/tcp-relay.js +1191 -0
  84. package/src/relay.js +3 -0
  85. package/src/reverse/client.js +721 -0
  86. package/src/reverse/common.js +88 -0
  87. package/src/reverse/gateway.js +663 -0
  88. package/src/reverse/hub.js +563 -0
  89. package/src/reverse/index.js +3 -0
  90. package/src/reverse/runtime.js +78 -0
  91. package/src/server/control-handler.js +170 -0
  92. package/src/server/primary.js +64 -0
  93. package/src/server/relay-runtime.js +124 -0
  94. package/src/server/worker.js +39 -0
  95. package/src/sessions/session.js +212 -0
  96. package/src/spool/spool-file.js +451 -0
  97. package/tensorbuzz.yml +36 -0
  98. package/test/e2e/gateway-process.test.js +262 -0
  99. package/test/e2e/reverse-gateway-process.test.js +285 -0
  100. package/test/fixtures/logging/enospc-matrix.mjs +238 -0
  101. package/test/fixtures/logging/enospc-recovery.mjs +146 -0
  102. package/test/fixtures/logging/process-writer.mjs +20 -0
  103. package/test/helpers/tls-certificates.js +115 -0
  104. package/test/integration/initial-target-recovery.test.js +518 -0
  105. package/test/integration/logging-recovery.test.js +163 -0
  106. package/test/integration/multiplexed-compatibility.test.js +254 -0
  107. package/test/integration/multiplexed-corrections.test.js +551 -0
  108. package/test/integration/multiplexed-failures.test.js +339 -0
  109. package/test/integration/multiplexed-transport-set.test.js +456 -0
  110. package/test/integration/multiplexed-transport.test.js +208 -0
  111. package/test/integration/reverse-gateway.test.js +1293 -0
  112. package/test/integration/socketduct-http-agent.test.js +435 -0
  113. package/test/integration/socketduct-https-agent.test.js +669 -0
  114. package/test/integration/stream-compression.test.js +1267 -0
  115. package/test/integration/tcp-forwarding.test.js +2878 -0
  116. package/test/integration/tls-forwarding.test.js +748 -0
  117. package/test/unit/auth/auth.test.js +64 -0
  118. package/test/unit/auth/static-token-store.test.js +14 -0
  119. package/test/unit/client/connect.test.js +58 -0
  120. package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
  121. package/test/unit/client/multiplexed-transport-set.test.js +906 -0
  122. package/test/unit/client/socketduct-socket.test.js +1124 -0
  123. package/test/unit/client/virtual-socket.test.js +98 -0
  124. package/test/unit/config/docker-gateway-files.test.js +274 -0
  125. package/test/unit/config/environment.test.js +507 -0
  126. package/test/unit/index.test.js +78 -0
  127. package/test/unit/logging/structured-logger.test.js +674 -0
  128. package/test/unit/protocol/compression.test.js +421 -0
  129. package/test/unit/protocol/frame.test.js +336 -0
  130. package/test/unit/protocol/v3-compression.test.js +31 -0
  131. package/test/unit/protocol/v3-frame.test.js +57 -0
  132. package/test/unit/release-configuration.test.js +21 -0
  133. package/test/unit/server/control-handler.test.js +291 -0
  134. package/test/unit/server/primary.test.js +59 -0
  135. package/test/unit/server/relay-runtime.test.js +434 -0
  136. package/test/unit/sessions/session.test.js +84 -0
  137. package/test/unit/spool/spool-file.test.js +295 -0
  138. package/tsconfig.json +20 -0
@@ -0,0 +1,1124 @@
1
+ import assert from "node:assert/strict"
2
+ import {mkdtemp, readdir, rm} from "node:fs/promises"
3
+ import {tmpdir} from "node:os"
4
+ import {join} from "node:path"
5
+ import {createServer} from "node:net"
6
+ import {Duplex} from "node:stream"
7
+ import {EventEmitter} from "node:events"
8
+ import test from "node:test"
9
+
10
+ import {InMemoryUserStore, login, TokenStore} from "../../../src/auth/user-store.js"
11
+ import {connectSocketduct, SocketductSocket} from "../../../src/client/socketduct-socket.js"
12
+ import {encodeCompactData, encodeFrame, FrameDecoder} from "../../../src/protocol/frame.js"
13
+ import {startTcpRelay} from "../../../src/proxy/tcp-relay.js"
14
+ import {SessionStore} from "../../../src/sessions/session.js"
15
+ import {SessionSpool} from "../../../src/spool/spool-file.js"
16
+
17
+ /**
18
+ * @returns {Promise<{rootDir: string, spool: SessionSpool}>} - Test spool.
19
+ */
20
+ async function createTestSpool() {
21
+ const rootDir = await mkdtemp(join(tmpdir(), "socketduct-socket-test-"))
22
+ return {rootDir, spool: new SessionSpool({rootDir})}
23
+ }
24
+
25
+ /**
26
+ * @param {import('node:net').Server} server - Server to bind.
27
+ * @returns {Promise<number>} - Bound port.
28
+ */
29
+ async function listen(server) {
30
+ await new Promise((resolve, reject) => {
31
+ server.once("error", reject)
32
+ server.listen(0, "127.0.0.1", () => {
33
+ server.off("error", reject)
34
+ resolve(undefined)
35
+ })
36
+ })
37
+ const address = server.address()
38
+ if (address === null || typeof address === "string") {
39
+ throw new Error("Server did not bind to a TCP port")
40
+ }
41
+ return /** @type {import('node:net').AddressInfo} */ (address).port
42
+ }
43
+
44
+ /**
45
+ * @param {() => boolean} predicate - Completion predicate.
46
+ * @param {number} [timeoutMs] - Maximum wait.
47
+ */
48
+ async function waitUntil(predicate, timeoutMs = 1_000) {
49
+ const deadline = Date.now() + timeoutMs
50
+ while (!predicate()) {
51
+ if (Date.now() >= deadline) throw new Error("timed out waiting for condition")
52
+ await new Promise((resolve) => setTimeout(resolve, 2))
53
+ }
54
+ }
55
+
56
+ /**
57
+ * @template T
58
+ * @param {Promise<T>} promise - Operation to bound.
59
+ * @param {string} message - Timeout error.
60
+ * @returns {Promise<T>} Bounded operation.
61
+ */
62
+ function withDeadline(promise, message) {
63
+ return new Promise((resolve, reject) => {
64
+ const timer = setTimeout(() => reject(new Error(message)), 250)
65
+ promise.then(
66
+ (value) => {
67
+ clearTimeout(timer)
68
+ resolve(value)
69
+ },
70
+ (error) => {
71
+ clearTimeout(timer)
72
+ reject(error)
73
+ }
74
+ )
75
+ })
76
+ }
77
+
78
+ /**
79
+ * @param {SocketductSocket} socket - Socket to read from.
80
+ * @returns {Promise<Buffer>} - Next chunk.
81
+ */
82
+ async function readOnce(socket) {
83
+ return new Promise((resolve) => {
84
+ socket.once("data", (chunk) => resolve(chunk))
85
+ })
86
+ }
87
+
88
+ class FakeRelaySocket extends EventEmitter {
89
+ constructor() {
90
+ super()
91
+ this.destroyed = false
92
+ this.destroyCount = 0
93
+ this.localAddress = "127.0.0.1"
94
+ this.localPort = 43100
95
+ /** @type {Buffer[]} */
96
+ this.writes = []
97
+ }
98
+
99
+ /** @returns {this} - Destroyed fake socket. */
100
+ destroy() {
101
+ this.destroyCount += 1
102
+ this.destroyed = true
103
+ return this
104
+ }
105
+
106
+ /**
107
+ * @param {Buffer} chunk - Bytes written to the fake socket.
108
+ * @returns {boolean} - Always writable.
109
+ */
110
+ write(chunk) {
111
+ this.writes.push(chunk)
112
+ return true
113
+ }
114
+
115
+ resume() {}
116
+
117
+ pause() {}
118
+
119
+ ref() {}
120
+
121
+ unref() {}
122
+ }
123
+
124
+ /** @returns {Promise<void>} Allows queued relay-frame work to settle. */
125
+ async function settleRelayFrames() {
126
+ await new Promise((resolve) => setImmediate(resolve))
127
+ await new Promise((resolve) => setImmediate(resolve))
128
+ }
129
+
130
+ /** @param {SocketductSocket} socket - Socket to destroy and await. */
131
+ async function destroyAndWait(socket) {
132
+ if (socket.closed) return
133
+ const closed = new Promise((resolve) => socket.once("close", resolve))
134
+ socket.destroy()
135
+ await closed
136
+ }
137
+
138
+ /**
139
+ * @param {FakeRelaySocket} relaySocket - Fake relay whose writes should be decoded.
140
+ * @returns {import('../../../src/protocol/frame.js').FrameHeader[]} Decoded control headers.
141
+ */
142
+ function writtenHeaders(relaySocket) {
143
+ const decoder = new FrameDecoder({compactDirection: "client-to-target"})
144
+ return relaySocket.writes.flatMap((write) => decoder.push(write)).map((frame) => frame.header)
145
+ }
146
+
147
+ class FakeTimers {
148
+ constructor() {
149
+ /** @type {Array<{active: boolean, callback: () => void, delay: number}>} */
150
+ this.timers = []
151
+ }
152
+
153
+ /**
154
+ * @param {() => void} callback - Timer callback.
155
+ * @param {number} delay - Timer delay.
156
+ * @returns {{active: boolean, callback: () => void, delay: number}} - Fake timer.
157
+ */
158
+ setTimeout(callback, delay) {
159
+ const timer = {active: true, callback, delay}
160
+
161
+ this.timers.push(timer)
162
+ return timer
163
+ }
164
+
165
+ /** @param {{active: boolean}} timer - Fake timer to clear. */
166
+ clearTimeout(timer) {
167
+ timer.active = false
168
+ }
169
+
170
+ /**
171
+ * @param {number} delay - Timer delay to select.
172
+ * @returns {{active: boolean, callback: () => void, delay: number} | undefined} - Active matching timer.
173
+ */
174
+ activeTimer(delay) {
175
+ return this.timers.find((timer) => timer.active && timer.delay === delay)
176
+ }
177
+
178
+ /**
179
+ * @param {{active: boolean, callback: () => void}} timer - Active timer to run.
180
+ * @returns {void}
181
+ */
182
+ run(timer) {
183
+ assert.equal(timer.active, true, "expected timer to be active")
184
+ timer.active = false
185
+ timer.callback()
186
+ }
187
+
188
+ /** @returns {void} - Runs the active relay connect timer. */
189
+ runConnectTimeout() {
190
+ const timer = this.timers.find((candidate) => candidate.active && candidate.delay === 10_000)
191
+
192
+ assert.ok(timer, "expected the relay connect timeout to be armed")
193
+ timer.active = false
194
+ timer.callback()
195
+ }
196
+
197
+ /** @returns {number} - Active reconnect timer count. */
198
+ activeReconnectTimers() {
199
+ return this.timers.filter((timer) => timer.active && timer.delay === 60_000).length
200
+ }
201
+ }
202
+
203
+ /**
204
+ * @param {{tls?: boolean}} [options]
205
+ * @returns {Promise<{fakeRelaySocket: FakeRelaySocket, rootDir: string, socket: SocketductSocket, timers: FakeTimers}>} - Fake client and controls.
206
+ */
207
+ async function createFakeRelaySocketClient({tls = false} = {}) {
208
+ const {rootDir, spool} = await createTestSpool()
209
+ const fakeRelaySocket = new FakeRelaySocket()
210
+ const timers = new FakeTimers()
211
+ const socket = new SocketductSocket({
212
+ relayHost: "relay.invalid",
213
+ relayPort: 3100,
214
+ token: "token",
215
+ sessionName: tls ? "tls timeout" : "plain timeout",
216
+ target: {host: "docker-api", port: 2375},
217
+ spool,
218
+ reconnectDelayMs: 60_000,
219
+ ...(tls ? {tls: {rejectUnauthorized: false}} : {})
220
+ }, {
221
+ clearTimeout: (timer) => timers.clearTimeout(/** @type {{active: boolean}} */ (timer)),
222
+ createRelaySocket: () => /** @type {import("node:net").Socket} */ (/** @type {unknown} */ (fakeRelaySocket)),
223
+ setTimeout: (callback, delay) => timers.setTimeout(callback, delay)
224
+ })
225
+
226
+ return {fakeRelaySocket, rootDir, socket, timers}
227
+ }
228
+
229
+ test("SocketductSocket is a Duplex-compatible socket facade", async () => {
230
+ const {rootDir, spool} = await createTestSpool()
231
+ const socket = new SocketductSocket({
232
+ relayHost: "127.0.0.1",
233
+ relayPort: 3100,
234
+ token: "token",
235
+ sessionName: "unit socket",
236
+ target: {host: "docker-api", port: 2375},
237
+ spool
238
+ })
239
+
240
+ assert.equal(socket instanceof Duplex, true)
241
+ assert.equal(socket.connecting, false)
242
+ assert.equal(socket.pending, true)
243
+ assert.equal(socket.remoteAddress, "docker-api")
244
+ assert.equal(socket.remotePort, 2375)
245
+ assert.equal(socket.bytesRead, 0)
246
+ assert.equal(socket.bytesWritten, 0)
247
+ assert.equal(typeof socket.connect, "function")
248
+ assert.equal(socket.setNoDelay(), socket)
249
+ assert.equal(socket.setKeepAlive(), socket)
250
+ assert.equal(socket.setTimeout(0), socket)
251
+ assert.equal(socket.ref(), socket)
252
+ assert.equal(socket.unref(), socket)
253
+ assert.deepEqual(socket.address(), {address: "0.0.0.0", family: "IPv4", port: 0})
254
+
255
+ socket.destroy()
256
+ await rm(rootDir, {recursive: true, force: true})
257
+ })
258
+
259
+ test("SocketductSocket and relay reject invalid compression chunk limits", async () => {
260
+ const {rootDir, spool} = await createTestSpool()
261
+ try {
262
+ assert.throws(
263
+ () => new SocketductSocket({
264
+ relayHost: "127.0.0.1",
265
+ relayPort: 3100,
266
+ token: "token",
267
+ sessionName: "invalid compression limit",
268
+ target: {host: "docker-api", port: 2375},
269
+ spool,
270
+ compressionLimits: {maxDecompressedBytes: 0}
271
+ }),
272
+ /compressionLimits\.maxDecompressedBytes must be a positive safe integer/
273
+ )
274
+
275
+ await assert.rejects(
276
+ () => startTcpRelay({
277
+ authorizeTarget: () => false,
278
+ compressionLimits: {maxDecompressedBytes: 1.5},
279
+ tokens: new TokenStore({now: () => 1000, tokenTtlMs: 60_000}),
280
+ sessions: new SessionStore({now: () => 1000})
281
+ }),
282
+ /compressionLimits\.maxDecompressedBytes must be a positive safe integer/
283
+ )
284
+ } finally {
285
+ await rm(rootDir, {recursive: true, force: true})
286
+ }
287
+ })
288
+
289
+ test("SocketductSocket connect returns the socket", async () => {
290
+ const {rootDir, spool} = await createTestSpool()
291
+ const socket = new SocketductSocket({
292
+ relayHost: "127.0.0.1",
293
+ relayPort: 9,
294
+ token: "token",
295
+ sessionName: "unit socket",
296
+ target: {host: "docker-api", port: 2375},
297
+ spool,
298
+ reconnectDelayMs: 60_000
299
+ })
300
+
301
+ assert.equal(socket.connect(), socket)
302
+ assert.equal(socket.connecting, true)
303
+ socket.destroy()
304
+ await rm(rootDir, {recursive: true, force: true})
305
+ })
306
+
307
+ test("SocketductSocket idle timeout resets on write and read activity before firing when idle", async () => {
308
+ const {fakeRelaySocket, rootDir, socket, timers} = await createFakeRelaySocketClient()
309
+ let timeoutEvents = 0
310
+ socket.on("timeout", () => { timeoutEvents += 1 })
311
+
312
+ try {
313
+ socket.connect()
314
+ fakeRelaySocket.emit("connect")
315
+ const connected = new Promise((resolve) => socket.once("connect", resolve))
316
+ fakeRelaySocket.emit("data", encodeFrame({type: "session.created", identifier: "idle-timeout", framing: 2}))
317
+ await connected
318
+
319
+ socket.setTimeout(50)
320
+ const initialTimer = timers.activeTimer(50)
321
+ assert.ok(initialTimer, "setting an idle timeout must arm the injected scheduler")
322
+
323
+ await new Promise((resolve, reject) => {
324
+ socket.write("ping", (error) => error ? reject(error) : resolve(undefined))
325
+ })
326
+ const afterWriteTimer = timers.activeTimer(50)
327
+ assert.ok(afterWriteTimer, "write activity must leave one idle timer armed")
328
+ assert.notEqual(afterWriteTimer, initialTimer)
329
+ assert.equal(initialTimer.active, false)
330
+ assert.equal(timeoutEvents, 0)
331
+
332
+ const received = readOnce(socket)
333
+ fakeRelaySocket.emit("data", encodeCompactData({sequence: 4, payload: Buffer.from("pong")}))
334
+ assert.equal((await received).toString("utf8"), "pong")
335
+ const afterReadTimer = timers.activeTimer(50)
336
+ assert.ok(afterReadTimer, "read activity must leave one idle timer armed")
337
+ assert.notEqual(afterReadTimer, afterWriteTimer)
338
+ assert.equal(afterWriteTimer.active, false)
339
+ assert.equal(timeoutEvents, 0)
340
+
341
+ initialTimer.callback()
342
+ afterWriteTimer.callback()
343
+ assert.equal(timeoutEvents, 0, "cleared idle timer callbacks already queued before activity must be stale")
344
+
345
+ timers.run(afterReadTimer)
346
+ assert.equal(timeoutEvents, 1, "the current idle timer must fire without later activity")
347
+ socket.setTimeout(0)
348
+ assert.equal(timers.activeTimer(50), undefined)
349
+ } finally {
350
+ socket.setTimeout(0)
351
+ const closed = new Promise((resolve) => socket.once("close", resolve))
352
+ socket.destroy()
353
+ await settleRelayFrames()
354
+ fakeRelaySocket.emit("data", encodeFrame({type: "session.close", direction: "target-to-client", acknowledged: true}))
355
+ await closed
356
+ await rm(rootDir, {recursive: true, force: true})
357
+ }
358
+ })
359
+
360
+ test("SocketductSocket destroys a timed-out plain relay socket once and ignores late events", async () => {
361
+ const {fakeRelaySocket, rootDir, socket, timers} = await createFakeRelaySocketClient()
362
+ let connectEvents = 0
363
+ let readyEvents = 0
364
+
365
+ socket.on("connect", () => { connectEvents += 1 })
366
+ socket.on("ready", () => { readyEvents += 1 })
367
+ socket.connect()
368
+ timers.runConnectTimeout()
369
+
370
+ assert.equal(fakeRelaySocket.destroyCount, 1)
371
+ assert.equal(timers.activeReconnectTimers(), 1)
372
+
373
+ fakeRelaySocket.emit("connect")
374
+ fakeRelaySocket.emit("error", new Error("late connect error"))
375
+ fakeRelaySocket.emit("close")
376
+
377
+ assert.equal(fakeRelaySocket.destroyCount, 1)
378
+ assert.equal(fakeRelaySocket.writes.length, 0)
379
+ assert.equal(connectEvents, 0)
380
+ assert.equal(readyEvents, 0)
381
+ assert.equal(socket.connecting, true)
382
+ assert.equal(socket.pending, true)
383
+ assert.equal(timers.activeReconnectTimers(), 1)
384
+
385
+ socket.destroy()
386
+ await rm(rootDir, {recursive: true, force: true})
387
+ })
388
+
389
+ test("SocketductSocket destroys a timed-out TLS relay socket once and ignores late secureConnect events", async () => {
390
+ const {fakeRelaySocket, rootDir, socket, timers} = await createFakeRelaySocketClient({tls: true})
391
+ let connectEvents = 0
392
+ let readyEvents = 0
393
+
394
+ socket.on("connect", () => { connectEvents += 1 })
395
+ socket.on("ready", () => { readyEvents += 1 })
396
+ socket.connect()
397
+ timers.runConnectTimeout()
398
+
399
+ assert.equal(fakeRelaySocket.destroyCount, 1)
400
+ assert.equal(timers.activeReconnectTimers(), 1)
401
+
402
+ fakeRelaySocket.emit("secureConnect")
403
+ fakeRelaySocket.emit("error", new Error("late TLS error"))
404
+ fakeRelaySocket.emit("close")
405
+
406
+ assert.equal(fakeRelaySocket.destroyCount, 1)
407
+ assert.equal(fakeRelaySocket.writes.length, 0)
408
+ assert.equal(connectEvents, 0)
409
+ assert.equal(readyEvents, 0)
410
+ assert.equal(socket.connecting, true)
411
+ assert.equal(socket.pending, true)
412
+ assert.equal(timers.activeReconnectTimers(), 1)
413
+
414
+ socket.destroy()
415
+ await rm(rootDir, {recursive: true, force: true})
416
+ })
417
+
418
+ test("SocketductSocket rejects a legacy session handshake without framing v2", async () => {
419
+ const {rootDir, spool} = await createTestSpool()
420
+ const relay = createServer((peer) => {
421
+ const decoder = new FrameDecoder()
422
+ peer.on("data", (chunk) => {
423
+ const frames = decoder.push(chunk)
424
+ if (frames.some((frame) => frame.header.type === "session.create")) {
425
+ peer.write(encodeFrame({type: "session.created", identifier: "legacy-session"}))
426
+ }
427
+ })
428
+ })
429
+ const relayPort = await listen(relay)
430
+ const socket = new SocketductSocket({
431
+ relayHost: "127.0.0.1",
432
+ relayPort,
433
+ token: "token",
434
+ sessionName: "legacy handshake",
435
+ target: {host: "docker-api", port: 2375},
436
+ spool,
437
+ reconnectDelayMs: 60_000
438
+ })
439
+
440
+ try {
441
+ let connected = false
442
+ socket.on("connect", () => { connected = true })
443
+ const error = new Promise((resolve) => socket.once("error", resolve))
444
+ socket.connect()
445
+ const handshakeError = await error
446
+ assert.match(String(handshakeError), /required framing version 2/)
447
+ assert.equal(connected, false)
448
+ assert.deepEqual(await readdir(rootDir), [])
449
+ } finally {
450
+ socket.destroy()
451
+ await new Promise((resolve, reject) => relay.close((closeError) => closeError ? reject(closeError) : resolve(undefined)))
452
+ await rm(rootDir, {recursive: true, force: true})
453
+ }
454
+ })
455
+
456
+ test("SocketductSocket relays target traffic directly", async () => {
457
+ const {rootDir, spool} = await createTestSpool()
458
+ const echoServer = createServer((socket) => {
459
+ socket.on("data", (chunk) => socket.write(Buffer.from(`echo:${chunk.toString("utf8")}`)))
460
+ })
461
+ const targetPort = await listen(echoServer)
462
+
463
+ const users = new InMemoryUserStore()
464
+ const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
465
+ await users.createUser({username: "kasper", password: "correct horse battery staple"})
466
+ const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
467
+ const relay = await startTcpRelay({
468
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
469
+ tokens,
470
+ sessions: new SessionStore({now: () => 1000})
471
+ })
472
+
473
+ const socket = new SocketductSocket({
474
+ relayHost: "127.0.0.1",
475
+ relayPort: relay.port,
476
+ token: token.value,
477
+ sessionName: "direct socket integration",
478
+ target: {host: "127.0.0.1", port: targetPort},
479
+ spool,
480
+ reconnectDelayMs: 10
481
+ })
482
+
483
+ socket.connect()
484
+ await new Promise((resolve, reject) => {
485
+ socket.once("error", reject)
486
+ socket.once("connect", () => {
487
+ socket.off("error", reject)
488
+ resolve(undefined)
489
+ })
490
+ })
491
+ socket.write("hello")
492
+ assert.equal((await readOnce(socket)).toString("utf8"), "echo:hello")
493
+ assert.equal(socket.bytesWritten, 5)
494
+ assert.equal(socket.bytesRead, 10)
495
+
496
+ socket.destroy()
497
+ await relay.close()
498
+ await new Promise((resolve, reject) => {
499
+ echoServer.close((error) => {
500
+ if (error) {
501
+ reject(error)
502
+ return
503
+ }
504
+ resolve(undefined)
505
+ })
506
+ })
507
+ await rm(rootDir, {recursive: true, force: true})
508
+ })
509
+
510
+ test("SocketductSocket end does not reopen the relay tunnel", async () => {
511
+ const {rootDir, spool} = await createTestSpool()
512
+ const echoServer = createServer((socket) => {
513
+ socket.on("data", (chunk) => socket.write(Buffer.from(`ended:${chunk.toString("utf8")}`)))
514
+ })
515
+ const targetPort = await listen(echoServer)
516
+
517
+ const users = new InMemoryUserStore()
518
+ const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
519
+ await users.createUser({username: "kasper", password: "correct horse battery staple"})
520
+ const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
521
+ const relay = await startTcpRelay({
522
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
523
+ tokens,
524
+ sessions: new SessionStore({now: () => 1000})
525
+ })
526
+ let relayConnections = 0
527
+ relay.server.on("connection", () => {
528
+ relayConnections += 1
529
+ })
530
+
531
+ const socket = new SocketductSocket({
532
+ relayHost: "127.0.0.1",
533
+ relayPort: relay.port,
534
+ token: token.value,
535
+ sessionName: "intentional end",
536
+ target: {host: "127.0.0.1", port: targetPort},
537
+ spool,
538
+ reconnectDelayMs: 10
539
+ })
540
+
541
+ socket.connect()
542
+ await new Promise((resolve, reject) => {
543
+ socket.once("error", reject)
544
+ socket.once("connect", () => {
545
+ socket.off("error", reject)
546
+ resolve(undefined)
547
+ })
548
+ })
549
+ socket.write("bye")
550
+ assert.equal((await readOnce(socket)).toString("utf8"), "ended:bye")
551
+ socket.end()
552
+ await new Promise((resolve) => { setTimeout(resolve, 50) })
553
+ assert.equal(relayConnections, 1)
554
+
555
+ socket.destroy()
556
+ await relay.close()
557
+ await new Promise((resolve, reject) => {
558
+ echoServer.close((error) => {
559
+ if (error) {
560
+ reject(error)
561
+ return
562
+ }
563
+ resolve(undefined)
564
+ })
565
+ })
566
+ await rm(rootDir, {recursive: true, force: true})
567
+ })
568
+
569
+ test("SocketductSocket reconnect replays writes without re-emitting connect", async () => {
570
+ const {rootDir, spool} = await createTestSpool()
571
+ const echoServer = createServer((socket) => {
572
+ socket.on("data", (chunk) => socket.write(Buffer.from(`again:${chunk.toString("utf8")}`)))
573
+ })
574
+ const targetPort = await listen(echoServer)
575
+
576
+ const users = new InMemoryUserStore()
577
+ const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
578
+ await users.createUser({username: "kasper", password: "correct horse battery staple"})
579
+ const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
580
+ const relay = await startTcpRelay({
581
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
582
+ tokens,
583
+ sessions: new SessionStore({now: () => 1000}),
584
+ resumeWindowMs: 1_000
585
+ })
586
+
587
+ const socket = new SocketductSocket({
588
+ relayHost: "127.0.0.1",
589
+ relayPort: relay.port,
590
+ token: token.value,
591
+ sessionName: "socket reconnect",
592
+ target: {host: "127.0.0.1", port: targetPort},
593
+ spool,
594
+ reconnectDelayMs: 10
595
+ })
596
+ try {
597
+ let connectEvents = 0
598
+ let readyEvents = 0
599
+ socket.on("connect", () => { connectEvents += 1 })
600
+ socket.on("ready", () => { readyEvents += 1 })
601
+
602
+ socket.connect()
603
+ await new Promise((resolve, reject) => {
604
+ socket.once("error", reject)
605
+ socket.once("connect", () => {
606
+ socket.off("error", reject)
607
+ resolve(undefined)
608
+ })
609
+ })
610
+ socket.write("first")
611
+ assert.equal((await readOnce(socket)).toString("utf8"), "again:first")
612
+
613
+ const resumed = new Promise((resolve, reject) => {
614
+ socket.once("error", reject)
615
+ socket.once("ready", () => {
616
+ socket.off("error", reject)
617
+ resolve(undefined)
618
+ })
619
+ })
620
+ relay.closeActiveTunnel()
621
+ socket.write("second")
622
+ await resumed
623
+ assert.equal((await readOnce(socket)).toString("utf8"), "again:second")
624
+ assert.equal(connectEvents, 1)
625
+ assert.equal(readyEvents >= 2, true)
626
+ } finally {
627
+ socket.destroy()
628
+ await relay.close()
629
+ await new Promise((resolve, reject) => {
630
+ echoServer.close((error) => {
631
+ if (error) {
632
+ reject(error)
633
+ return
634
+ }
635
+ resolve(undefined)
636
+ })
637
+ })
638
+ await rm(rootDir, {recursive: true, force: true})
639
+ }
640
+ })
641
+
642
+ test("SocketductSocket defers an intentional close until an in-flight reconnect is authenticated and resumed", async () => {
643
+ const {rootDir, spool} = await createTestSpool()
644
+ const firstRelay = new FakeRelaySocket()
645
+ const secondRelay = new FakeRelaySocket()
646
+ const relaySockets = [firstRelay, secondRelay]
647
+ const timers = new FakeTimers()
648
+ const socket = new SocketductSocket({
649
+ relayHost: "relay.invalid",
650
+ relayPort: 3100,
651
+ token: "token",
652
+ sessionName: "close during reconnect",
653
+ target: {host: "docker-api", port: 2375},
654
+ spool,
655
+ reconnectDelayMs: 60_000
656
+ }, {
657
+ clearTimeout: (timer) => timers.clearTimeout(/** @type {{active: boolean}} */ (timer)),
658
+ createRelaySocket: () => /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (relaySockets.shift())),
659
+ setTimeout: (callback, delay) => timers.setTimeout(callback, delay)
660
+ })
661
+
662
+ try {
663
+ socket.connect()
664
+ firstRelay.emit("connect")
665
+ const connected = new Promise((resolve) => socket.once("connect", resolve))
666
+ firstRelay.emit("data", encodeFrame({type: "session.created", identifier: "close-reconnect", framing: 2}))
667
+ await connected
668
+
669
+ firstRelay.destroyed = true
670
+ firstRelay.emit("close")
671
+ const reconnectTimer = timers.timers.find((timer) => timer.active && timer.delay === 60_000)
672
+ assert.ok(reconnectTimer)
673
+ reconnectTimer.active = false
674
+ reconnectTimer.callback()
675
+
676
+ const closeEvent = new Promise((resolve) => socket.once("close", resolve))
677
+ socket.destroy()
678
+ secondRelay.emit("connect")
679
+ await settleRelayFrames()
680
+ assert.deepEqual(writtenHeaders(secondRelay).map((header) => header.type), ["session.resume"])
681
+
682
+ secondRelay.emit("data", encodeFrame({type: "session.resumed", identifier: "close-reconnect", framing: 2}))
683
+ await settleRelayFrames()
684
+ assert.deepEqual(writtenHeaders(secondRelay).map((header) => header.type), ["session.resume", "session.close"])
685
+ secondRelay.emit("data", encodeFrame({type: "session.close", direction: "target-to-client", acknowledged: true}))
686
+ await closeEvent
687
+ assert.equal(relaySockets.length, 0)
688
+ assert.equal(timers.timers.filter((timer) => timer.active).length, 0)
689
+ assert.equal(secondRelay.destroyCount, 1)
690
+ assert.equal(secondRelay.listenerCount("connect"), 0)
691
+ assert.equal(secondRelay.listenerCount("data"), 0)
692
+ assert.equal(secondRelay.listenerCount("close"), 0)
693
+ assert.deepEqual(await readdir(rootDir), [])
694
+ } finally {
695
+ await destroyAndWait(socket)
696
+ await rm(rootDir, {recursive: true, force: true})
697
+ }
698
+ })
699
+
700
+ test("SocketductSocket reconnects immediately to close an announced session between attempts", async () => {
701
+ const {rootDir, spool} = await createTestSpool()
702
+ const firstRelay = new FakeRelaySocket()
703
+ const secondRelay = new FakeRelaySocket()
704
+ const relaySockets = [firstRelay, secondRelay]
705
+ const timers = new FakeTimers()
706
+ const socket = new SocketductSocket({
707
+ relayHost: "relay.invalid",
708
+ relayPort: 3100,
709
+ token: "token",
710
+ sessionName: "close between reconnects",
711
+ target: {host: "docker-api", port: 2375},
712
+ spool,
713
+ reconnectDelayMs: 60_000
714
+ }, {
715
+ clearTimeout: (timer) => timers.clearTimeout(/** @type {{active: boolean}} */ (timer)),
716
+ createRelaySocket: () => /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (relaySockets.shift())),
717
+ setTimeout: (callback, delay) => timers.setTimeout(callback, delay)
718
+ })
719
+
720
+ try {
721
+ socket.connect()
722
+ firstRelay.emit("connect")
723
+ const connected = new Promise((resolve) => socket.once("connect", resolve))
724
+ firstRelay.emit("data", encodeFrame({type: "session.created", identifier: "close-between", framing: 2}))
725
+ await connected
726
+ firstRelay.destroyed = true
727
+ firstRelay.emit("close")
728
+ assert.equal(timers.activeReconnectTimers(), 1)
729
+
730
+ const closeEvent = new Promise((resolve) => socket.once("close", resolve))
731
+ socket.destroy()
732
+ assert.equal(timers.activeReconnectTimers(), 0)
733
+ assert.equal(relaySockets.length, 0, "logical close should start its own authenticated reconnect immediately")
734
+ secondRelay.emit("connect")
735
+ await settleRelayFrames()
736
+ assert.deepEqual(writtenHeaders(secondRelay).map((header) => header.type), ["session.resume"])
737
+ secondRelay.emit("data", encodeFrame({type: "session.resumed", identifier: "close-between", framing: 2}))
738
+ await settleRelayFrames()
739
+ assert.deepEqual(writtenHeaders(secondRelay).map((header) => header.type), ["session.resume", "session.close"])
740
+ secondRelay.emit("data", encodeFrame({type: "session.close", direction: "target-to-client", acknowledged: true}))
741
+ await closeEvent
742
+ assert.equal(timers.timers.filter((timer) => timer.active).length, 0)
743
+ assert.equal(secondRelay.destroyCount, 1)
744
+ assert.equal(secondRelay.listenerCount("connect"), 0)
745
+ assert.equal(secondRelay.listenerCount("data"), 0)
746
+ assert.equal(secondRelay.listenerCount("close"), 0)
747
+ assert.deepEqual(await readdir(rootDir), [])
748
+ } finally {
749
+ await destroyAndWait(socket)
750
+ await rm(rootDir, {recursive: true, force: true})
751
+ }
752
+ })
753
+
754
+ test("SocketductSocket destroy while detached promptly removes the resumable relay session", async () => {
755
+ const {rootDir, spool} = await createTestSpool()
756
+ const targetServer = createServer((socket) => socket.on("data", (chunk) => socket.write(chunk)))
757
+ const targetPort = await listen(targetServer)
758
+ const users = new InMemoryUserStore()
759
+ const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
760
+ await users.createUser({username: "kasper", password: "correct horse battery staple"})
761
+ const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
762
+ const relay = await startTcpRelay({
763
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
764
+ resumeWindowMs: 10_000,
765
+ sessions: new SessionStore({now: () => 1000}),
766
+ tokens
767
+ })
768
+ const socket = new SocketductSocket({
769
+ relayHost: "127.0.0.1",
770
+ relayPort: relay.port,
771
+ token: token.value,
772
+ sessionName: "destroy while detached",
773
+ target: {host: "127.0.0.1", port: targetPort},
774
+ spool,
775
+ reconnectDelayMs: 60_000
776
+ })
777
+
778
+ try {
779
+ const connected = new Promise((resolve) => socket.once("connect", resolve))
780
+ socket.connect()
781
+ await connected
782
+ await relay.closeActiveTunnel()
783
+ await waitUntil(() => relay.status().detachedSessions === 1)
784
+
785
+ await destroyAndWait(socket)
786
+ await waitUntil(() => relay.status().sessions === 0)
787
+ assert.deepEqual(relay.status(), {
788
+ activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
789
+ detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
790
+ })
791
+ assert.deepEqual(await readdir(rootDir), [])
792
+ } finally {
793
+ await destroyAndWait(socket)
794
+ await relay.close()
795
+ await new Promise((resolve, reject) => targetServer.close((error) => error ? reject(error) : resolve(undefined)))
796
+ await rm(rootDir, {recursive: true, force: true})
797
+ }
798
+ })
799
+
800
+ test("SocketductSocket ignores queued target data after target EOF", async () => {
801
+ const {rootDir, spool} = await createTestSpool()
802
+ const relay = new FakeRelaySocket()
803
+ const socket = new SocketductSocket({
804
+ relayHost: "relay.invalid",
805
+ relayPort: 3100,
806
+ token: "token",
807
+ sessionName: "queued data after eof",
808
+ target: {host: "docker-api", port: 2375},
809
+ spool,
810
+ reconnectDelayMs: 60_000
811
+ }, {
812
+ createRelaySocket: () => /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (relay))
813
+ })
814
+ /** @type {Error[]} */
815
+ const errors = []
816
+ /** @type {Buffer[]} */
817
+ const chunks = []
818
+ socket.on("error", (error) => errors.push(error))
819
+ socket.on("data", (chunk) => chunks.push(chunk))
820
+
821
+ try {
822
+ socket.connect()
823
+ relay.emit("connect")
824
+ const connected = new Promise((resolve) => socket.once("connect", resolve))
825
+ relay.emit("data", encodeFrame({type: "session.created", identifier: "queued-after-eof", framing: 2}))
826
+ await connected
827
+ relay.emit("data", Buffer.concat([
828
+ encodeFrame({type: "session.close", direction: "target-to-client"}),
829
+ encodeCompactData({sequence: 4, payload: Buffer.from("late")})
830
+ ]))
831
+ await settleRelayFrames()
832
+
833
+ assert.deepEqual(chunks, [])
834
+ assert.deepEqual(errors, [])
835
+ assert.equal(socket.bytesRead, 0)
836
+ assert.equal(socket.destroyed, false)
837
+ assert.equal(socket.readable, false)
838
+ const closed = new Promise((resolve) => socket.once("close", resolve))
839
+ socket.destroy()
840
+ await settleRelayFrames()
841
+ assert.equal(writtenHeaders(relay).at(-1)?.type, "session.close")
842
+ relay.emit("data", encodeFrame({type: "session.close", direction: "target-to-client", acknowledged: true}))
843
+ await withDeadline(closed, "timed out waiting for logical close acknowledgement")
844
+ } finally {
845
+ await destroyAndWait(socket)
846
+ await rm(rootDir, {recursive: true, force: true})
847
+ }
848
+ })
849
+
850
+ test("connectSocketduct creates and connects the public socket option shape", async () => {
851
+ const {rootDir} = await createTestSpool()
852
+ const echoServer = createServer((socket) => {
853
+ socket.on("data", (chunk) => socket.write(Buffer.from(`factory:${chunk.toString("utf8")}`)))
854
+ })
855
+ const targetPort = await listen(echoServer)
856
+
857
+ const users = new InMemoryUserStore()
858
+ const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
859
+ await users.createUser({username: "kasper", password: "correct horse battery staple"})
860
+ const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
861
+ const relay = await startTcpRelay({
862
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
863
+ tokens,
864
+ sessions: new SessionStore({now: () => 1000})
865
+ })
866
+
867
+ const socket = connectSocketduct({
868
+ relay: {host: "127.0.0.1", port: relay.port, token: token.value},
869
+ sessionName: "public socket options",
870
+ target: {host: "127.0.0.1", port: targetPort},
871
+ spoolDirectory: rootDir,
872
+ reconnect: {initialDelayMs: 10}
873
+ })
874
+
875
+ await new Promise((resolve, reject) => {
876
+ socket.once("error", reject)
877
+ socket.once("connect", () => {
878
+ socket.off("error", reject)
879
+ resolve(undefined)
880
+ })
881
+ })
882
+ socket.write("hello")
883
+ assert.equal((await readOnce(socket)).toString("utf8"), "factory:hello")
884
+
885
+ socket.destroy()
886
+ await relay.close()
887
+ await new Promise((resolve, reject) => {
888
+ echoServer.close((error) => {
889
+ if (error) {
890
+ reject(error)
891
+ return
892
+ }
893
+ resolve(undefined)
894
+ })
895
+ })
896
+ await rm(rootDir, {recursive: true, force: true})
897
+ })
898
+
899
+ test("SocketductSocket cleans up spool directory on destroy", async () => {
900
+ const {rootDir, spool} = await createTestSpool()
901
+ const echoServer = createServer((socket) => {
902
+ socket.on("data", (chunk) => socket.write(Buffer.from(`cleanup:${chunk.toString("utf8")}`)))
903
+ })
904
+ const targetPort = await listen(echoServer)
905
+
906
+ const users = new InMemoryUserStore()
907
+ const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
908
+ await users.createUser({username: "kasper", password: "correct horse battery staple"})
909
+ const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
910
+ const relay = await startTcpRelay({
911
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
912
+ tokens,
913
+ sessions: new SessionStore({now: () => 1000})
914
+ })
915
+
916
+ const socket = new SocketductSocket({
917
+ relayHost: "127.0.0.1",
918
+ relayPort: relay.port,
919
+ token: token.value,
920
+ sessionName: "spool cleanup on destroy",
921
+ target: {host: "127.0.0.1", port: targetPort},
922
+ spool,
923
+ reconnectDelayMs: 10
924
+ })
925
+
926
+ socket.connect()
927
+ await new Promise((resolve, reject) => {
928
+ socket.once("error", reject)
929
+ socket.once("connect", () => {
930
+ socket.off("error", reject)
931
+ resolve(undefined)
932
+ })
933
+ })
934
+ socket.write("hello")
935
+ assert.equal((await readOnce(socket)).toString("utf8"), "cleanup:hello")
936
+
937
+ const closePromise = new Promise((resolve, hookReject) => {
938
+ socket.on("error", hookReject)
939
+ socket.on("close", () => {
940
+ socket.off("error", hookReject)
941
+ resolve(undefined)
942
+ })
943
+ })
944
+ socket.destroy()
945
+ await closePromise
946
+
947
+ const {readdir} = await import("node:fs/promises")
948
+ const entries = await readdir(rootDir)
949
+ assert.deepEqual(entries, [])
950
+
951
+ await relay.close()
952
+ await new Promise((resolve, reject) => {
953
+ echoServer.close((error) => {
954
+ if (error) {
955
+ reject(error)
956
+ return
957
+ }
958
+ resolve(undefined)
959
+ })
960
+ })
961
+ await rm(rootDir, {recursive: true, force: true})
962
+ })
963
+
964
+ test("SocketductSocket end cleans up spool directory before finish event", async () => {
965
+ const {rootDir, spool} = await createTestSpool()
966
+ const echoServer = createServer((socket) => {
967
+ socket.on("data", (chunk) => socket.write(Buffer.from(`endclean:${chunk.toString("utf8")}`)))
968
+ })
969
+ const targetPort = await listen(echoServer)
970
+
971
+ const users = new InMemoryUserStore()
972
+ const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
973
+ await users.createUser({username: "kasper", password: "correct horse battery staple"})
974
+ const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
975
+ const relay = await startTcpRelay({
976
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
977
+ tokens,
978
+ sessions: new SessionStore({now: () => 1000})
979
+ })
980
+
981
+ const socket = new SocketductSocket({
982
+ relayHost: "127.0.0.1",
983
+ relayPort: relay.port,
984
+ token: token.value,
985
+ sessionName: "spool cleanup on end",
986
+ target: {host: "127.0.0.1", port: targetPort},
987
+ spool,
988
+ reconnectDelayMs: 10
989
+ })
990
+
991
+ socket.connect()
992
+ await new Promise((resolve, reject) => {
993
+ socket.once("error", reject)
994
+ socket.once("connect", () => {
995
+ socket.off("error", reject)
996
+ resolve(undefined)
997
+ })
998
+ })
999
+ socket.write("endtest")
1000
+
1001
+ const finishPromise = new Promise((resolve, hookReject) => {
1002
+ socket.on("error", hookReject)
1003
+ socket.on("finish", () => {
1004
+ socket.off("error", hookReject)
1005
+ resolve(undefined)
1006
+ })
1007
+ })
1008
+ socket.end()
1009
+ await finishPromise
1010
+
1011
+ const {readdir: readdir2} = await import("node:fs/promises")
1012
+ const entries2 = await readdir2(rootDir)
1013
+ assert.deepEqual(entries2, [])
1014
+
1015
+ socket.destroy()
1016
+ await relay.close()
1017
+ await new Promise((resolve, reject) => {
1018
+ echoServer.close((error) => {
1019
+ if (error) {
1020
+ reject(error)
1021
+ return
1022
+ }
1023
+ resolve(undefined)
1024
+ })
1025
+ })
1026
+ await rm(rootDir, {recursive: true, force: true})
1027
+ })
1028
+
1029
+ test("connectSocketduct forwards maxBytes to spool storage", async () => {
1030
+ const rootDir2 = await mkdtemp(join(tmpdir(), "socketduct-socket-test-"))
1031
+ const echoServer = createServer((socket) => {
1032
+ socket.on("data", (chunk) => socket.write(Buffer.from(`maxbytes:${chunk.toString("utf8")}`)))
1033
+ })
1034
+ const targetPort = await listen(echoServer)
1035
+
1036
+ const users = new InMemoryUserStore()
1037
+ const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
1038
+ await users.createUser({username: "kasper", password: "correct horse battery staple"})
1039
+ const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
1040
+ const relay = await startTcpRelay({
1041
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
1042
+ tokens,
1043
+ sessions: new SessionStore({now: () => 1000})
1044
+ })
1045
+
1046
+ const socket = connectSocketduct({
1047
+ relay: {host: "127.0.0.1", port: relay.port, token: token.value},
1048
+ sessionName: "public maxBytes",
1049
+ target: {host: "127.0.0.1", port: targetPort},
1050
+ spoolDirectory: rootDir2,
1051
+ maxBytes: 50,
1052
+ reconnect: {initialDelayMs: 10}
1053
+ })
1054
+
1055
+ await new Promise((resolve, reject) => {
1056
+ socket.once("error", reject)
1057
+ socket.once("connect", () => {
1058
+ socket.off("error", reject)
1059
+ resolve(undefined)
1060
+ })
1061
+ })
1062
+
1063
+ socket.write(Buffer.alloc(60))
1064
+ const errorPromise = new Promise((resolve) => {
1065
+ socket.once("error", (err) => resolve(err))
1066
+ })
1067
+ const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 500))
1068
+ const result = await Promise.race([errorPromise, timeout])
1069
+ assert.ok(result instanceof Error, "Expected spool quota error to surface as an error event")
1070
+
1071
+ socket.destroy()
1072
+ await relay.close()
1073
+ await new Promise((resolve, reject) => {
1074
+ echoServer.close((error) => {
1075
+ if (error) {
1076
+ reject(error)
1077
+ return
1078
+ }
1079
+ resolve(undefined)
1080
+ })
1081
+ })
1082
+ await rm(rootDir2, {recursive: true, force: true})
1083
+ })
1084
+
1085
+ test("createSocketductConnectionFactory forwards maxAgeMs to factory options", async () => {
1086
+ const {createSocketductConnectionFactory} = await import("../../../src/client/connect.js")
1087
+
1088
+ const spoolDir = await mkdtemp(join(tmpdir(), "socketduct-factory-test-"))
1089
+ try {
1090
+ const factory = createSocketductConnectionFactory({
1091
+ relay: {host: "127.0.0.1", port: 9999, token: "test-token"},
1092
+ spoolDirectory: spoolDir,
1093
+ maxAgeMs: 1
1094
+ })
1095
+
1096
+ const socket = factory({host: "127.0.0.1", port: 2375})
1097
+ assert.ok(socket instanceof SocketductSocket)
1098
+
1099
+ socket.destroy()
1100
+ } finally {
1101
+ await rm(spoolDir, {recursive: true, force: true})
1102
+ }
1103
+ })
1104
+
1105
+ test("SocketductHttpAgent forwards maxBytes to createConnection sockets", async () => {
1106
+ const {SocketductHttpAgent} = await import("../../../src/client/http-agent.js")
1107
+
1108
+ const spoolDir = await mkdtemp(join(tmpdir(), "socketduct-agent-test-"))
1109
+ try {
1110
+ const agent = new SocketductHttpAgent({
1111
+ relay: {host: "127.0.0.1", port: 9999, token: "test-token"},
1112
+ target: {host: "127.0.0.1", port: 2375},
1113
+ spoolDirectory: spoolDir,
1114
+ maxBytes: 200
1115
+ })
1116
+
1117
+ const socket = agent.createConnection({})
1118
+ assert.ok(socket instanceof SocketductSocket)
1119
+
1120
+ socket.destroy()
1121
+ } finally {
1122
+ await rm(spoolDir, {recursive: true, force: true})
1123
+ }
1124
+ })