socketduct 0.1.25 → 0.1.27

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