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.
- package/.dockerignore +11 -0
- package/.env.example +131 -0
- package/AGENTS.md +168 -0
- package/Dockerfile +30 -0
- package/README.md +134 -0
- package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
- package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
- package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
- package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
- package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
- package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
- package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
- package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
- package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
- package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
- package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
- package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
- package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
- package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
- package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
- package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
- package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
- package/changelog.d/20260902231542-reverse-close-position.md +1 -0
- package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
- package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
- package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
- package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
- package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
- package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
- package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
- package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
- package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
- package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
- package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
- package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
- package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
- package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
- package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
- package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
- package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
- package/docker-compose.dev.yml +19 -0
- package/docker-compose.reverse-gateway.yml +36 -0
- package/docker-compose.yml +73 -0
- package/docs/architecture.md +264 -0
- package/docs/docker-topology.md +164 -0
- package/docs/docker.md +281 -0
- package/docs/logging.md +185 -0
- package/docs/plans/initial-implementation.md +205 -0
- package/docs/plans/socket-docker-integration.md +221 -0
- package/docs/protocol.md +558 -0
- package/docs/releasing.md +18 -0
- package/docs/reverse-gateway.md +186 -0
- package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
- package/docs/running.md +215 -0
- package/docs/security.md +258 -0
- package/docs/session-lifecycle.md +127 -0
- package/docs/socket-api.md +448 -0
- package/eslint.config.js +36 -0
- package/package.json +44 -0
- package/scripts/deploy-gateway.js +194 -0
- package/scripts/test.js +28 -0
- package/src/auth/passwords.js +38 -0
- package/src/auth/static-token-store.js +41 -0
- package/src/auth/user-store.js +139 -0
- package/src/client/connect.js +76 -0
- package/src/client/deferred-virtual-socket.js +312 -0
- package/src/client/http-agent.js +101 -0
- package/src/client/https-agent.js +366 -0
- package/src/client/multiplexed-connection.js +1504 -0
- package/src/client/multiplexed-transport-set.js +917 -0
- package/src/client/multiplexed.js +3 -0
- package/src/client/socketduct-socket.js +1174 -0
- package/src/client/virtual-socket.js +256 -0
- package/src/config/environment.js +400 -0
- package/src/edge/tcp-edge.js +82 -0
- package/src/index.js +101 -0
- package/src/logging/structured-logger.js +745 -0
- package/src/protocol/compression.js +325 -0
- package/src/protocol/frame.js +341 -0
- package/src/protocol/v3-compression.js +44 -0
- package/src/protocol/v3-frame.js +209 -0
- package/src/proxy/multiplexed-relay.js +798 -0
- package/src/proxy/tcp-relay.js +1191 -0
- package/src/relay.js +3 -0
- package/src/reverse/client.js +721 -0
- package/src/reverse/common.js +88 -0
- package/src/reverse/gateway.js +663 -0
- package/src/reverse/hub.js +563 -0
- package/src/reverse/index.js +3 -0
- package/src/reverse/runtime.js +78 -0
- package/src/server/control-handler.js +170 -0
- package/src/server/primary.js +64 -0
- package/src/server/relay-runtime.js +124 -0
- package/src/server/worker.js +39 -0
- package/src/sessions/session.js +212 -0
- package/src/spool/spool-file.js +451 -0
- package/tensorbuzz.yml +36 -0
- package/test/e2e/gateway-process.test.js +262 -0
- package/test/e2e/reverse-gateway-process.test.js +285 -0
- package/test/fixtures/logging/enospc-matrix.mjs +238 -0
- package/test/fixtures/logging/enospc-recovery.mjs +146 -0
- package/test/fixtures/logging/process-writer.mjs +20 -0
- package/test/helpers/tls-certificates.js +115 -0
- package/test/integration/initial-target-recovery.test.js +518 -0
- package/test/integration/logging-recovery.test.js +163 -0
- package/test/integration/multiplexed-compatibility.test.js +254 -0
- package/test/integration/multiplexed-corrections.test.js +551 -0
- package/test/integration/multiplexed-failures.test.js +339 -0
- package/test/integration/multiplexed-transport-set.test.js +456 -0
- package/test/integration/multiplexed-transport.test.js +208 -0
- package/test/integration/reverse-gateway.test.js +1293 -0
- package/test/integration/socketduct-http-agent.test.js +435 -0
- package/test/integration/socketduct-https-agent.test.js +669 -0
- package/test/integration/stream-compression.test.js +1267 -0
- package/test/integration/tcp-forwarding.test.js +2878 -0
- package/test/integration/tls-forwarding.test.js +748 -0
- package/test/unit/auth/auth.test.js +64 -0
- package/test/unit/auth/static-token-store.test.js +14 -0
- package/test/unit/client/connect.test.js +58 -0
- package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
- package/test/unit/client/multiplexed-transport-set.test.js +906 -0
- package/test/unit/client/socketduct-socket.test.js +1124 -0
- package/test/unit/client/virtual-socket.test.js +98 -0
- package/test/unit/config/docker-gateway-files.test.js +274 -0
- package/test/unit/config/environment.test.js +507 -0
- package/test/unit/index.test.js +78 -0
- package/test/unit/logging/structured-logger.test.js +674 -0
- package/test/unit/protocol/compression.test.js +421 -0
- package/test/unit/protocol/frame.test.js +336 -0
- package/test/unit/protocol/v3-compression.test.js +31 -0
- package/test/unit/protocol/v3-frame.test.js +57 -0
- package/test/unit/release-configuration.test.js +21 -0
- package/test/unit/server/control-handler.test.js +291 -0
- package/test/unit/server/primary.test.js +59 -0
- package/test/unit/server/relay-runtime.test.js +434 -0
- package/test/unit/sessions/session.test.js +84 -0
- package/test/unit/spool/spool-file.test.js +295 -0
- 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
|
+
}
|