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,295 @@
1
+ import assert from "node:assert/strict"
2
+ import {access, mkdtemp, readFile, rm, stat, writeFile} from "node:fs/promises"
3
+ import {tmpdir} from "node:os"
4
+ import {join} from "node:path"
5
+ import test from "node:test"
6
+
7
+ import {
8
+ SessionSpool,
9
+ SpoolQuotaError
10
+ } from "../../../src/spool/spool-file.js"
11
+
12
+ /**
13
+ * @template T
14
+ * @param {(dir: string) => Promise<T>} fn
15
+ * @returns {Promise<T>} Promise resolving with the callback result.
16
+ */
17
+ async function withTempDir(fn) {
18
+ const dir = await mkdtemp(join(tmpdir(), "socketduct-spool-test-"))
19
+ try {
20
+ return await fn(dir)
21
+ } finally {
22
+ await rm(dir, {recursive: true, force: true})
23
+ }
24
+ }
25
+
26
+ test("SessionSpool creates private per-session temp storage with metadata", async () => {
27
+ await withTempDir(async (root) => {
28
+ const spool = new SessionSpool({rootDir: root})
29
+ const session = await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
30
+ const mode = (await stat(session.directory)).mode & 0o777
31
+
32
+ assert.equal(mode, 0o700)
33
+ assert.equal(session.metadata.sessionId, "session_1234567890")
34
+ assert.equal(session.metadata.ownerId, "user-1")
35
+ })
36
+ })
37
+
38
+ test("SessionSpool writes chunks and reads only chunks after a requested sequence", async () => {
39
+ await withTempDir(async (root) => {
40
+ const spool = new SessionSpool({rootDir: root})
41
+ await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
42
+ await spool.appendChunk({sessionId: "session_1234567890", sequence: 1, data: Buffer.from("one")})
43
+ await spool.appendChunk({sessionId: "session_1234567890", sequence: 2, data: Buffer.from([0, 255])})
44
+ await spool.appendChunk({sessionId: "session_1234567890", sequence: 3, data: Buffer.from("three")})
45
+
46
+ const chunks = await spool.readChunksAfter("session_1234567890", 1)
47
+
48
+ assert.deepEqual(chunks.map((chunk) => chunk.sequence), [2, 3])
49
+ assert.deepEqual(chunks[0]?.data, Buffer.from([0, 255]))
50
+ assert.equal(chunks[1]?.data.toString("utf8"), "three")
51
+ })
52
+ })
53
+
54
+ test("SessionSpool truncates acknowledged chunks", async () => {
55
+ await withTempDir(async (root) => {
56
+ const spool = new SessionSpool({rootDir: root})
57
+ await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
58
+ await spool.appendChunk({sessionId: "session_1234567890", sequence: 1, data: Buffer.from("one")})
59
+ await spool.appendChunk({sessionId: "session_1234567890", sequence: 2, data: Buffer.from("two")})
60
+ await spool.truncateAcked("session_1234567890", 1)
61
+
62
+ assert.deepEqual((await spool.readChunksAfter("session_1234567890", 0)).map((chunk) => chunk.sequence), [2])
63
+ })
64
+ })
65
+
66
+ test("SessionSpool rejects path traversal and mismatched metadata", async () => {
67
+ await withTempDir(async (root) => {
68
+ const spool = new SessionSpool({rootDir: root})
69
+
70
+ await assert.rejects(
71
+ spool.create({sessionId: "../escape", ownerId: "user-1"}),
72
+ /invalid session identifier/i
73
+ )
74
+
75
+ await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
76
+ await assert.rejects(
77
+ spool.appendChunk({sessionId: "session_1234567890", ownerId: "other-user", sequence: 1, data: Buffer.from("nope")}),
78
+ /does not match spool metadata/i
79
+ )
80
+ })
81
+ })
82
+
83
+ test("SessionSpool.remove removes all session spool data and rejects subsequent operations", async () => {
84
+ await withTempDir(async (root) => {
85
+ const spool = new SessionSpool({rootDir: root})
86
+ const sessionId = "session_1234567890"
87
+ await spool.create({sessionId, ownerId: "user-1"})
88
+ await spool.appendChunk({sessionId, sequence: 1, data: Buffer.from("hello")})
89
+
90
+ const dir = join(root, sessionId)
91
+ await access(dir)
92
+
93
+ await spool.remove(sessionId)
94
+
95
+ await assert.rejects(
96
+ access(dir),
97
+ /ENOENT|no such file/i
98
+ )
99
+
100
+ await assert.rejects(
101
+ spool.readChunksAfter(sessionId, 0),
102
+ /ENOENT|no such file/i
103
+ )
104
+ })
105
+ })
106
+
107
+ test("SessionSpool.remove is safe when session does not exist", async () => {
108
+ await withTempDir(async (root) => {
109
+ const spool = new SessionSpool({rootDir: root})
110
+
111
+ // does not throw
112
+ await spool.remove("nonexistent_session")
113
+ })
114
+ })
115
+
116
+ test("SessionSpool enforces maxBytes quota on appendChunk", async () => {
117
+ await withTempDir(async (root) => {
118
+ const spool = new SessionSpool({rootDir: root, maxBytes: 100})
119
+ const sessionId = "session_1234567890"
120
+ await spool.create({sessionId, ownerId: "user-1"})
121
+
122
+ await spool.appendChunk({sessionId, sequence: 1, data: Buffer.alloc(50)})
123
+ await spool.appendChunk({sessionId, sequence: 2, data: Buffer.alloc(40)})
124
+
125
+ await assert.rejects(
126
+ spool.appendChunk({sessionId, sequence: 3, data: Buffer.alloc(30)}),
127
+ (err) => {
128
+ if (!(err instanceof SpoolQuotaError)) {
129
+ return false
130
+ }
131
+ assert.equal(err.code, "SPOOL_QUOTA_EXCEEDED")
132
+ assert.ok(err.message.includes("100"))
133
+ return true
134
+ }
135
+ )
136
+ })
137
+ })
138
+
139
+ test("SessionSpool maxBytes quota does not block under-limit appends", async () => {
140
+ await withTempDir(async (root) => {
141
+ const spool = new SessionSpool({rootDir: root, maxBytes: 200})
142
+ const sessionId = "session_1234567890"
143
+ await spool.create({sessionId, ownerId: "user-1"})
144
+
145
+ // exactly at limit
146
+ await spool.appendChunk({sessionId, sequence: 1, data: Buffer.alloc(200)})
147
+
148
+ const chunks = await spool.readChunksAfter(sessionId, 0)
149
+ assert.equal(chunks.length, 1)
150
+ })
151
+ })
152
+
153
+ test("SessionSpool enforces maxAgeMs quota for aged-out sessions", async () => {
154
+ await withTempDir(async (root) => {
155
+ let now = 1_000_000
156
+ const maxAgeMs = 60_000
157
+ const spool = new SessionSpool({rootDir: root, maxAgeMs, now: () => now})
158
+
159
+ const session = await spool.create({sessionId: "session_1234567890", ownerId: "user-1"})
160
+
161
+ now = 1_000_000 + maxAgeMs - 1
162
+ await spool.appendChunk({sessionId: session.metadata.sessionId, sequence: 1, data: Buffer.from("ok")})
163
+
164
+ now = 1_000_000 + maxAgeMs + 1
165
+ await assert.rejects(
166
+ spool.appendChunk({sessionId: session.metadata.sessionId, sequence: 2, data: Buffer.from("x")}),
167
+ (err) => {
168
+ if (!(err instanceof SpoolQuotaError)) {
169
+ return false
170
+ }
171
+ assert.equal(err.code, "SPOOL_AGE_EXCEEDED")
172
+ return true
173
+ }
174
+ )
175
+ })
176
+ })
177
+
178
+ test("SessionSpool synchronously rejects expired durable recovery state", async () => {
179
+ await withTempDir(async (root) => {
180
+ let now = 1_000_000
181
+ const spool = new SessionSpool({rootDir: root, maxAgeMs: 60_000, now: () => now})
182
+ const sessionId = "session_recovery_expired"
183
+ await spool.createNew({sessionId, ownerId: "edge-v3"})
184
+ await spool.writeRecoveryState(sessionId, {version: 3})
185
+
186
+ now += 60_001
187
+ assert.throws(
188
+ () => spool.readRecoveryStateSync(sessionId, "edge-v3"),
189
+ (error) => error instanceof SpoolQuotaError && error.code === "SPOOL_AGE_EXCEEDED"
190
+ )
191
+ })
192
+ })
193
+
194
+ test("SessionSpool cleans expired abandoned entries so their stable IDs can be reused", async () => {
195
+ await withTempDir(async (root) => {
196
+ let now = 1_000_000
197
+ const sessionId = "session_abandoned_expired"
198
+ const first = new SessionSpool({rootDir: root, maxAgeMs: 60_000, now: () => now})
199
+ await first.createNew({sessionId, ownerId: "edge-v3"})
200
+
201
+ now += 60_001
202
+ const replacement = new SessionSpool({rootDir: root, maxAgeMs: 60_000, now: () => now})
203
+ await replacement.createNew({sessionId, ownerId: "edge-v3"})
204
+ await access(join(root, sessionId))
205
+ })
206
+ })
207
+
208
+ test("SessionSpool appendChunk appends lines without rewriting whole file", async () => {
209
+ await withTempDir(async (root) => {
210
+ const spool = new SessionSpool({rootDir: root})
211
+ const sessionId = "session_1234567890"
212
+ await spool.create({sessionId, ownerId: "user-1"})
213
+
214
+ await spool.appendChunk({sessionId, sequence: 1, data: Buffer.from("first")})
215
+ await spool.appendChunk({sessionId, sequence: 2, data: Buffer.from("second")})
216
+ await spool.appendChunk({sessionId, sequence: 3, data: Buffer.from("third")})
217
+
218
+ const raw = await readFile(join(root, sessionId, "chunks.jsonl"), "utf8")
219
+ const lines = raw.trimEnd().split("\n")
220
+
221
+ assert.ok(lines.length >= 3)
222
+ const chunk1 = JSON.parse(/** @type {string} */ (lines[0]))
223
+ const chunk2 = JSON.parse(/** @type {string} */ (lines[1]))
224
+ const chunk3 = JSON.parse(/** @type {string} */ (lines[2]))
225
+ assert.equal(chunk1.sequence, 1)
226
+ assert.equal(chunk2.sequence, 2)
227
+ assert.equal(chunk3.sequence, 3)
228
+ })
229
+ })
230
+
231
+ test("SessionSpool getUsedBytes tracks per-session spool byte usage", async () => {
232
+ await withTempDir(async (root) => {
233
+ const spool = new SessionSpool({rootDir: root, maxBytes: 1000})
234
+ const sessionId = "session_1234567890"
235
+ await spool.create({sessionId, ownerId: "user-1"})
236
+
237
+ assert.equal(spool.getUsedBytes(sessionId), 0)
238
+
239
+ await spool.appendChunk({sessionId, sequence: 1, data: Buffer.alloc(50)})
240
+ assert.equal(spool.getUsedBytes(sessionId), 50)
241
+
242
+ await spool.appendChunk({sessionId, sequence: 2, data: Buffer.alloc(30)})
243
+ assert.equal(spool.getUsedBytes(sessionId), 80)
244
+
245
+ await spool.truncateAcked(sessionId, 1)
246
+ assert.equal(spool.getUsedBytes(sessionId), 30)
247
+ })
248
+ })
249
+
250
+ test("SessionSpool getUsedBytes returns undefined for non-existent session", () => {
251
+ const spool = new SessionSpool({rootDir: "/tmp"})
252
+ assert.equal(spool.getUsedBytes("nonexistent_session"), undefined)
253
+ })
254
+
255
+ test("SessionSpool reopened with existing data enforces maxBytes quota from durable usage", async () => {
256
+ await withTempDir(async (root) => {
257
+ const sessionId = "session_1234567890"
258
+
259
+ const spool1 = new SessionSpool({rootDir: root, maxBytes: 100})
260
+ await spool1.create({sessionId, ownerId: "user-1"})
261
+ await spool1.appendChunk({sessionId, sequence: 1, data: Buffer.alloc(60)})
262
+
263
+ const spool2 = new SessionSpool({rootDir: root, maxBytes: 100})
264
+ await spool2.appendChunk({sessionId, sequence: 2, data: Buffer.alloc(30)})
265
+
266
+ await assert.rejects(
267
+ spool2.appendChunk({sessionId, sequence: 3, data: Buffer.alloc(20)}),
268
+ (err) => err instanceof SpoolQuotaError && err.code === "SPOOL_QUOTA_EXCEEDED"
269
+ )
270
+
271
+ const chunks = await spool2.readChunksAfter(sessionId, 0)
272
+ assert.equal(chunks.length, 2)
273
+ assert.equal(chunks[0]?.sequence, 1)
274
+ assert.equal(chunks[1]?.sequence, 2)
275
+ })
276
+ })
277
+
278
+ test("SessionSpool reconciles a stale usage sidecar from durable chunks when reopened", async () => {
279
+ await withTempDir(async (root) => {
280
+ const sessionId = "session_1234567890"
281
+ const spool1 = new SessionSpool({rootDir: root, maxBytes: 20})
282
+ await spool1.create({sessionId, ownerId: "user-1"})
283
+ await spool1.appendChunk({sessionId, sequence: 1, data: Buffer.alloc(10)})
284
+ await writeFile(join(root, sessionId, "usage.json"), JSON.stringify({totalBytes: 20}) + "\n")
285
+
286
+ const spool2 = new SessionSpool({rootDir: root, maxBytes: 20})
287
+ await spool2.appendChunk({sessionId, sequence: 2, data: Buffer.alloc(10)})
288
+
289
+ await assert.rejects(
290
+ spool2.appendChunk({sessionId, sequence: 3, data: Buffer.alloc(1)}),
291
+ (err) => err instanceof SpoolQuotaError && err.code === "SPOOL_QUOTA_EXCEEDED"
292
+ )
293
+ assert.deepEqual((await spool2.readChunksAfter(sessionId, 0)).map((chunk) => chunk.data.length), [10, 10])
294
+ })
295
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "allowJs": true,
7
+ "checkJs": true,
8
+ "noEmit": true,
9
+ "lib": ["ES2023"],
10
+ "types": ["node"],
11
+ "strict": true,
12
+ "noUncheckedIndexedAccess": true,
13
+ "exactOptionalPropertyTypes": true,
14
+ "noImplicitOverride": true,
15
+ "noFallthroughCasesInSwitch": true,
16
+ "forceConsistentCasingInFileNames": true,
17
+ "skipLibCheck": true
18
+ },
19
+ "include": ["src/**/*.js", "test/**/*.js"]
20
+ }