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,336 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
|
|
4
|
+
import {FrameDecoder, encodeCompactAck, encodeCompactData, encodeFrame} from "../../../src/protocol/frame.js"
|
|
5
|
+
|
|
6
|
+
test("encodeFrame and FrameDecoder preserve header fields and payload bytes", () => {
|
|
7
|
+
const payload = Buffer.from([0, 1, 2, 253, 254, 255])
|
|
8
|
+
const encoded = encodeFrame({type: "stream.data", sessionId: "session-1", sequence: 42}, payload)
|
|
9
|
+
|
|
10
|
+
const decoder = new FrameDecoder()
|
|
11
|
+
const frames = decoder.push(encoded)
|
|
12
|
+
|
|
13
|
+
assert.equal(frames.length, 1)
|
|
14
|
+
assert.deepEqual(frames[0]?.header, {
|
|
15
|
+
type: "stream.data",
|
|
16
|
+
sessionId: "session-1",
|
|
17
|
+
sequence: 42
|
|
18
|
+
})
|
|
19
|
+
assert.deepEqual(frames[0]?.payload, payload)
|
|
20
|
+
assert.equal(frames[0]?.wireFormat, "envelope")
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("FrameDecoder decodes frames split across multiple TCP chunks", () => {
|
|
24
|
+
const first = encodeFrame({type: "auth.request", requestId: "a"}, Buffer.from("one"))
|
|
25
|
+
const second = encodeFrame({type: "session.create", requestId: "b"}, Buffer.from("two"))
|
|
26
|
+
const combined = Buffer.concat([first, second])
|
|
27
|
+
const decoder = new FrameDecoder()
|
|
28
|
+
|
|
29
|
+
assert.deepEqual(decoder.push(combined.subarray(0, 2)), [])
|
|
30
|
+
assert.deepEqual(decoder.push(combined.subarray(2, 9)), [])
|
|
31
|
+
const frames = decoder.push(combined.subarray(9))
|
|
32
|
+
|
|
33
|
+
assert.equal(frames.length, 2)
|
|
34
|
+
assert.equal(frames[0]?.header.type, "auth.request")
|
|
35
|
+
assert.equal(frames[0]?.payload.toString("utf8"), "one")
|
|
36
|
+
assert.equal(frames[1]?.header.type, "session.create")
|
|
37
|
+
assert.equal(frames[1]?.payload.toString("utf8"), "two")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("FrameDecoder rejects invalid header lengths before allocating payload buffers", () => {
|
|
41
|
+
const decoder = new FrameDecoder({maxHeaderBytes: 8})
|
|
42
|
+
const invalid = Buffer.alloc(4)
|
|
43
|
+
invalid.writeUInt32BE(9, 0)
|
|
44
|
+
|
|
45
|
+
assert.throws(() => decoder.push(invalid), /header length/i)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test("FrameDecoder rejects payloads larger than the configured limit", () => {
|
|
49
|
+
const decoder = new FrameDecoder({maxPayloadBytes: 2})
|
|
50
|
+
const encoded = encodeFrame({type: "stream.data"}, Buffer.from("too-large"))
|
|
51
|
+
|
|
52
|
+
assert.throws(() => decoder.push(encoded), /payload length/i)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("FrameDecoder handles empty chunk without error", () => {
|
|
56
|
+
const decoder = new FrameDecoder()
|
|
57
|
+
const frames = decoder.push(Buffer.alloc(0))
|
|
58
|
+
assert.deepEqual(frames, [])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("compact v2 uncompressed data and ACK records round-trip beside envelope controls", () => {
|
|
62
|
+
const decoder = new FrameDecoder({compactDirection: "target-to-client"})
|
|
63
|
+
const frames = decoder.push(Buffer.concat([
|
|
64
|
+
encodeFrame({type: "session.resumed", identifier: "s1", framing: 2}),
|
|
65
|
+
encodeCompactData({sequence: 300, payload: Buffer.from("raw")}),
|
|
66
|
+
encodeCompactAck(257),
|
|
67
|
+
encodeFrame({type: "session.close", direction: "target-to-client"})
|
|
68
|
+
]))
|
|
69
|
+
assert.deepEqual(frames.map(({header, payload}) => ({header, payload: payload.toString()})), [
|
|
70
|
+
{header: {type: "session.resumed", identifier: "s1", framing: 2}, payload: ""},
|
|
71
|
+
{header: {type: "stream.data", direction: "target-to-client", sequence: 300, compact: true}, payload: "raw"},
|
|
72
|
+
{header: {type: "stream.ack", direction: "client-to-target", ackSequence: 257, compact: true}, payload: ""},
|
|
73
|
+
{header: {type: "session.close", direction: "target-to-client"}, payload: ""}
|
|
74
|
+
])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test("compact v2 zstd records normalize compression metadata", () => {
|
|
78
|
+
const decoder = new FrameDecoder({compactDirection: "client-to-target"})
|
|
79
|
+
const [frame] = decoder.push(encodeCompactData({sequence: Number.MAX_SAFE_INTEGER, dataLength: 4096, payload: Buffer.from([1, 2, 3])}))
|
|
80
|
+
assert.deepEqual(frame?.header, {type: "stream.data", direction: "client-to-target", sequence: Number.MAX_SAFE_INTEGER, compact: true, encoding: "zstd", dataLength: 4096})
|
|
81
|
+
assert.deepEqual(frame?.payload, Buffer.from([1, 2, 3]))
|
|
82
|
+
assert.equal(frame?.wireFormat, "compact")
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test("compact decoder waits for fragmented records", () => {
|
|
86
|
+
const encoded = encodeCompactData({sequence: 129, payload: Buffer.from("fragmented")})
|
|
87
|
+
const decoder = new FrameDecoder({compactDirection: "client-to-target"})
|
|
88
|
+
assert.deepEqual(decoder.push(encoded.subarray(0, 3)), [])
|
|
89
|
+
assert.equal(decoder.push(encoded.subarray(3))[0]?.payload.toString(), "fragmented")
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test("compact decoder rejects non-canonical, overflowing, and oversized varints", () => {
|
|
93
|
+
const options = /** @type {const} */ ({compactDirection: "client-to-target"})
|
|
94
|
+
assert.throws(() => new FrameDecoder(options).push(Buffer.from([0x82, 0x80, 0x00])), /canonical/)
|
|
95
|
+
assert.throws(() => new FrameDecoder(options).push(Buffer.from([0x82, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f])), /safe integer/)
|
|
96
|
+
assert.throws(() => new FrameDecoder({...options, maxPayloadBytes: 2}).push(encodeCompactData({sequence: 3, payload: Buffer.from("abc")})), /payload length/)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test("FrameDecoder handles partial uint32 header length", () => {
|
|
100
|
+
const decoder = new FrameDecoder()
|
|
101
|
+
const partial = Buffer.alloc(2)
|
|
102
|
+
partial.writeUInt16BE(42, 0)
|
|
103
|
+
|
|
104
|
+
assert.deepEqual(decoder.push(partial), [])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test("FrameDecoder handles partial header bytes after header length", () => {
|
|
108
|
+
const decoder = new FrameDecoder()
|
|
109
|
+
const buf = Buffer.alloc(6)
|
|
110
|
+
buf.writeUInt32BE(10, 0)
|
|
111
|
+
|
|
112
|
+
assert.deepEqual(decoder.push(buf), [])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test("FrameDecoder handles partial payload length uint32 after header", () => {
|
|
116
|
+
const header = JSON.stringify({type: "test"})
|
|
117
|
+
const buf = Buffer.alloc(4 + header.length + 2)
|
|
118
|
+
buf.writeUInt32BE(header.length, 0)
|
|
119
|
+
buf.write(header, 4, "utf8")
|
|
120
|
+
|
|
121
|
+
const decoder = new FrameDecoder()
|
|
122
|
+
assert.deepEqual(decoder.push(buf), [])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test("FrameDecoder handles partial payload bytes after full header and payload length", () => {
|
|
126
|
+
const header = JSON.stringify({type: "test"})
|
|
127
|
+
const buf = Buffer.alloc(4 + header.length + 4 + 3)
|
|
128
|
+
buf.writeUInt32BE(header.length, 0)
|
|
129
|
+
buf.write(header, 4, "utf8")
|
|
130
|
+
buf.writeUInt32BE(10, 4 + header.length)
|
|
131
|
+
|
|
132
|
+
const decoder = new FrameDecoder()
|
|
133
|
+
assert.deepEqual(decoder.push(buf), [])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test("FrameDecoder decodes one frame from a chunk that arrives byte-by-byte (uint32 split)", () => {
|
|
137
|
+
const payload = Buffer.from("hello")
|
|
138
|
+
const encoded = encodeFrame({type: "ping"}, payload)
|
|
139
|
+
const decoder = new FrameDecoder()
|
|
140
|
+
|
|
141
|
+
for (let i = 0; i < encoded.length; i++) {
|
|
142
|
+
const result = decoder.push(encoded.subarray(i, i + 1))
|
|
143
|
+
if (i < encoded.length - 1) {
|
|
144
|
+
assert.deepEqual(result, [])
|
|
145
|
+
} else {
|
|
146
|
+
assert.equal(result.length, 1)
|
|
147
|
+
assert.equal(result[0]?.header.type, "ping")
|
|
148
|
+
assert.deepEqual(result[0]?.payload, payload)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test("FrameDecoder decodes multiple complete frames from a single push", () => {
|
|
154
|
+
const frame1 = encodeFrame({type: "alpha"}, Buffer.from("a"))
|
|
155
|
+
const frame2 = encodeFrame({type: "beta"}, Buffer.from("bb"))
|
|
156
|
+
const frame3 = encodeFrame({type: "gamma"}, Buffer.from("ccc"))
|
|
157
|
+
const combined = Buffer.concat([frame1, frame2, frame3])
|
|
158
|
+
|
|
159
|
+
const decoder = new FrameDecoder()
|
|
160
|
+
const frames = decoder.push(combined)
|
|
161
|
+
|
|
162
|
+
assert.equal(frames.length, 3)
|
|
163
|
+
assert.equal(frames[0]?.header.type, "alpha")
|
|
164
|
+
assert.equal(frames[0]?.payload.toString(), "a")
|
|
165
|
+
assert.equal(frames[1]?.header.type, "beta")
|
|
166
|
+
assert.equal(frames[1]?.payload.toString(), "bb")
|
|
167
|
+
assert.equal(frames[2]?.header.type, "gamma")
|
|
168
|
+
assert.equal(frames[2]?.payload.toString(), "ccc")
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test("FrameDecoder with maxInputBytes rejects over-budget input", () => {
|
|
172
|
+
const decoder = new FrameDecoder({maxInputBytes: 10})
|
|
173
|
+
const frame = encodeFrame({type: "test"}, Buffer.from("hello"))
|
|
174
|
+
|
|
175
|
+
assert.throws(() => decoder.push(frame), /input byte budget/i)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test("FrameDecoder with maxInputBytes allows input within budget and rejects on overflow", () => {
|
|
179
|
+
const decoder = new FrameDecoder({maxInputBytes: 100})
|
|
180
|
+
|
|
181
|
+
const header = JSON.stringify({type: "small"})
|
|
182
|
+
const smallFrame = Buffer.alloc(4 + header.length + 4 + 5)
|
|
183
|
+
smallFrame.writeUInt32BE(header.length, 0)
|
|
184
|
+
smallFrame.write(header, 4, "utf8")
|
|
185
|
+
smallFrame.writeUInt32BE(5, 4 + header.length)
|
|
186
|
+
smallFrame.write("hello", 4 + header.length + 4, "utf8")
|
|
187
|
+
|
|
188
|
+
const frames = decoder.push(smallFrame.subarray(0, 7))
|
|
189
|
+
assert.deepEqual(frames, [])
|
|
190
|
+
|
|
191
|
+
const frames2 = decoder.push(smallFrame.subarray(7))
|
|
192
|
+
assert.equal(frames2.length, 1)
|
|
193
|
+
assert.equal(frames2[0]?.header.type, "small")
|
|
194
|
+
|
|
195
|
+
const big = Buffer.alloc(2000)
|
|
196
|
+
big.writeUInt32BE(5, 0)
|
|
197
|
+
assert.throws(() => decoder.push(big), /input byte budget/i)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test("FrameDecoder with zero maxInputBytes disables budget check", () => {
|
|
201
|
+
const decoder = new FrameDecoder({maxInputBytes: 0, maxHeaderBytes: 200_000})
|
|
202
|
+
|
|
203
|
+
const smallChunk = Buffer.alloc(100_001)
|
|
204
|
+
smallChunk.writeUInt32BE(100_000, 0)
|
|
205
|
+
|
|
206
|
+
assert.deepEqual(decoder.push(smallChunk), [])
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test("FrameDecoder byte-by-byte input exercises cursor path: one frame", () => {
|
|
210
|
+
const payload = Buffer.from("cursor")
|
|
211
|
+
const encoded = encodeFrame({type: "cursor-test"}, payload)
|
|
212
|
+
const decoder = new FrameDecoder()
|
|
213
|
+
|
|
214
|
+
for (let i = 0; i < encoded.length; i++) {
|
|
215
|
+
const result = decoder.push(encoded.subarray(i, i + 1))
|
|
216
|
+
if (i < encoded.length - 1) {
|
|
217
|
+
assert.deepEqual(result, [])
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const frames = decoder.push(Buffer.alloc(0))
|
|
222
|
+
|
|
223
|
+
assert.deepEqual(frames, [])
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test("FrameDecoder byte-by-byte input exercises cursor path: multiple queued frames", () => {
|
|
227
|
+
const frame1 = encodeFrame({type: "alpha"}, Buffer.from("a"))
|
|
228
|
+
const frame2 = encodeFrame({type: "beta"}, Buffer.from("bb"))
|
|
229
|
+
const frame3 = encodeFrame({type: "gamma"}, Buffer.from("ccc"))
|
|
230
|
+
const combined = Buffer.concat([frame1, frame2, frame3])
|
|
231
|
+
const decoder = new FrameDecoder()
|
|
232
|
+
|
|
233
|
+
/** @type {import('../../../src/protocol/frame.js').Frame[]} */
|
|
234
|
+
const allFrames = []
|
|
235
|
+
for (let i = 0; i < combined.length; i++) {
|
|
236
|
+
const result = decoder.push(combined.subarray(i, i + 1))
|
|
237
|
+
for (const f of result) {
|
|
238
|
+
allFrames.push(f)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const f of decoder.push(Buffer.alloc(0))) {
|
|
243
|
+
allFrames.push(f)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
assert.equal(allFrames.length, 3)
|
|
247
|
+
assert.equal(allFrames[0]?.header.type, "alpha")
|
|
248
|
+
assert.equal(allFrames[0]?.payload.toString(), "a")
|
|
249
|
+
assert.equal(allFrames[1]?.header.type, "beta")
|
|
250
|
+
assert.equal(allFrames[1]?.payload.toString(), "bb")
|
|
251
|
+
assert.equal(allFrames[2]?.header.type, "gamma")
|
|
252
|
+
assert.equal(allFrames[2]?.payload.toString(), "ccc")
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
test("FrameDecoder discards header read from garbage bytes and throws on oversized header", () => {
|
|
256
|
+
const decoder = new FrameDecoder()
|
|
257
|
+
|
|
258
|
+
const garbage = Buffer.alloc(5000, 0xFF)
|
|
259
|
+
assert.throws(() => decoder.push(garbage), /header length/i)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test("FrameDecoder compaction path: readPos shifts after partial consumption triggers compact", () => {
|
|
263
|
+
const frame1 = encodeFrame({type: "first"}, Buffer.from("data1"))
|
|
264
|
+
const frame2 = encodeFrame({type: "second"}, Buffer.from("data2"))
|
|
265
|
+
const combined = Buffer.concat([frame1, frame2])
|
|
266
|
+
const decoder = new FrameDecoder()
|
|
267
|
+
|
|
268
|
+
/** @type {import('../../../src/protocol/frame.js').Frame[]} */
|
|
269
|
+
const allFrames = []
|
|
270
|
+
for (const f of decoder.push(combined.subarray(0, frame1.length + 2))) {
|
|
271
|
+
allFrames.push(f)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
for (const f of decoder.push(combined.subarray(frame1.length + 2))) {
|
|
275
|
+
allFrames.push(f)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
for (const f of decoder.push(Buffer.alloc(0))) {
|
|
279
|
+
allFrames.push(f)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
assert.equal(allFrames.length, 2)
|
|
283
|
+
assert.equal(allFrames[0]?.header.type, "first")
|
|
284
|
+
assert.equal(allFrames[1]?.header.type, "second")
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test("FrameDecoder verifies the decoder is reusable across many byte-by-byte deliveries", () => {
|
|
288
|
+
const payload = Buffer.from("large-frame")
|
|
289
|
+
const encoded = encodeFrame({type: "reuse"}, payload)
|
|
290
|
+
const decoder = new FrameDecoder()
|
|
291
|
+
|
|
292
|
+
let totalFrames = 0
|
|
293
|
+
for (let round = 0; round < 5; round++) {
|
|
294
|
+
for (let i = 0; i < encoded.length; i++) {
|
|
295
|
+
const result = decoder.push(encoded.subarray(i, i + 1))
|
|
296
|
+
totalFrames += result.length
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
totalFrames += decoder.push(Buffer.alloc(0)).length
|
|
301
|
+
|
|
302
|
+
assert.equal(totalFrames, 5)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
test("FrameDecoder disableInputBudget allows exceeding original budget", () => {
|
|
306
|
+
const decoder = new FrameDecoder({maxInputBytes: 100})
|
|
307
|
+
const smallFrame = encodeFrame({type: "small"}, Buffer.from("hello"))
|
|
308
|
+
decoder.push(smallFrame)
|
|
309
|
+
|
|
310
|
+
decoder.disableInputBudget()
|
|
311
|
+
|
|
312
|
+
const largeFrame = encodeFrame({type: "large"}, Buffer.allocUnsafe(200_000))
|
|
313
|
+
const after = decoder.push(largeFrame)
|
|
314
|
+
assert.equal(after.length, 1)
|
|
315
|
+
assert.equal(after[0]?.header.type, "large")
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
test("FrameDecoder compaction reuses existing capacity instead of unbounded growth", () => {
|
|
319
|
+
const decoder = new FrameDecoder()
|
|
320
|
+
|
|
321
|
+
const manyBytes = Buffer.alloc(1000, 0x41)
|
|
322
|
+
const bigFrame = encodeFrame({type: "big"}, manyBytes)
|
|
323
|
+
|
|
324
|
+
const part1 = bigFrame.subarray(0, 800)
|
|
325
|
+
const part2 = bigFrame.subarray(800)
|
|
326
|
+
decoder.push(part1)
|
|
327
|
+
const extracted = decoder.push(part2)
|
|
328
|
+
assert.equal(extracted.length, 1)
|
|
329
|
+
|
|
330
|
+
for (let round = 0; round < 20; round++) {
|
|
331
|
+
const inner = encodeFrame({type: "small"}, Buffer.from("x"))
|
|
332
|
+
const result = decoder.push(inner)
|
|
333
|
+
assert.equal(result.length, 1)
|
|
334
|
+
assert.equal(result[0]?.header.type, "small")
|
|
335
|
+
}
|
|
336
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
|
|
4
|
+
import {ZSTD_CODEC, zstdAvailable} from "../../../src/protocol/compression.js"
|
|
5
|
+
import {AdaptiveCompressor, decodeV3Data, encodeV3Data} from "../../../src/protocol/v3-compression.js"
|
|
6
|
+
import {V3FrameFlag} from "../../../src/protocol/v3-frame.js"
|
|
7
|
+
|
|
8
|
+
test("v3 reverse DATA compression is independent, bounded, and negotiated", async (t) => {
|
|
9
|
+
if (!zstdAvailable()) {
|
|
10
|
+
t.skip("Node runtime has no zstd binding")
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
const raw = Buffer.from(Array.from({length: 4096}, (_, index) => `{"dockerEvent":${index},"status":"running"}\n`).join(""))
|
|
14
|
+
const encoded = await encodeV3Data(raw, ZSTD_CODEC, new AdaptiveCompressor())
|
|
15
|
+
assert.equal(encoded.flags, V3FrameFlag.DATA_COMPRESSED)
|
|
16
|
+
assert(encoded.wireBytes < encoded.rawBytes)
|
|
17
|
+
assert.deepEqual(await decodeV3Data(encoded, ZSTD_CODEC), raw)
|
|
18
|
+
await assert.rejects(decodeV3Data(encoded, undefined), /not negotiated/i)
|
|
19
|
+
|
|
20
|
+
const corrupt = {...encoded, payload: Buffer.from(encoded.payload)}
|
|
21
|
+
const last = corrupt.payload.length - 1
|
|
22
|
+
corrupt.payload[last] = /** @type {number} */ (corrupt.payload[last]) ^ 0xff
|
|
23
|
+
await assert.rejects(decodeV3Data(corrupt, ZSTD_CODEC), /decompress/i)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("v3 reverse DATA remains raw when adaptive compression cannot save bytes", async () => {
|
|
27
|
+
const raw = Buffer.from("small")
|
|
28
|
+
const encoded = await encodeV3Data(raw, ZSTD_CODEC, new AdaptiveCompressor())
|
|
29
|
+
assert.equal(encoded.flags, 0)
|
|
30
|
+
assert.deepEqual(await decodeV3Data(encoded, ZSTD_CODEC), raw)
|
|
31
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
decodeV3Control,
|
|
6
|
+
encodeV3Control,
|
|
7
|
+
encodeV3Frame,
|
|
8
|
+
V3FrameFlag,
|
|
9
|
+
V3FrameDecoder,
|
|
10
|
+
V3FrameType
|
|
11
|
+
} from "../../../src/protocol/v3-frame.js"
|
|
12
|
+
|
|
13
|
+
test("v3 decoder parses fragmented and coalesced binary frames", () => {
|
|
14
|
+
const first = encodeV3Frame({type: V3FrameType.PING, streamId: 0, sequence: 7})
|
|
15
|
+
const second = encodeV3Frame({type: V3FrameType.DATA, streamId: 1, sequence: 3}, Buffer.from("abc"))
|
|
16
|
+
const wire = Buffer.concat([first, second])
|
|
17
|
+
const decoder = new V3FrameDecoder({maxFrameBytes: 64})
|
|
18
|
+
|
|
19
|
+
assert.deepEqual(decoder.push(wire.subarray(0, 9)), [])
|
|
20
|
+
const frames = decoder.push(wire.subarray(9))
|
|
21
|
+
assert.deepEqual(frames, [
|
|
22
|
+
{type: V3FrameType.PING, flags: 0, streamId: 0, sequence: 7, payload: Buffer.alloc(0)},
|
|
23
|
+
{type: V3FrameType.DATA, flags: 0, streamId: 1, sequence: 3, payload: Buffer.from("abc")}
|
|
24
|
+
])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("v3 framing rejects control/data stream confusion and oversized payloads", () => {
|
|
28
|
+
assert.throws(
|
|
29
|
+
() => encodeV3Frame({type: V3FrameType.DATA, streamId: 0, sequence: 1}, Buffer.from("x")),
|
|
30
|
+
/stream ID zero/i
|
|
31
|
+
)
|
|
32
|
+
assert.throws(
|
|
33
|
+
() => encodeV3Frame({type: V3FrameType.AUTH, streamId: 1, sequence: 0}, Buffer.from("x")),
|
|
34
|
+
/connection control/i
|
|
35
|
+
)
|
|
36
|
+
assert.throws(() => encodeV3Frame({type: V3FrameType.PING, streamId: 0, sequence: 0, flags: 1}), /flags/i)
|
|
37
|
+
|
|
38
|
+
const decoder = new V3FrameDecoder({maxFrameBytes: 2, maxBufferedBytes: 64})
|
|
39
|
+
assert.throws(
|
|
40
|
+
() => decoder.push(encodeV3Frame({type: V3FrameType.DATA, streamId: 1, sequence: 3}, Buffer.from("abc"))),
|
|
41
|
+
/payload length/i
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test("v3 control payloads are bounded strict JSON objects", () => {
|
|
46
|
+
const encoded = encodeV3Control({token: "secret", versions: [3]}, 128)
|
|
47
|
+
assert.deepEqual(decodeV3Control(encoded, 128), {token: "secret", versions: [3]})
|
|
48
|
+
assert.throws(() => encodeV3Control(["not-an-object"], 128), /object/i)
|
|
49
|
+
assert.throws(() => decodeV3Control(Buffer.from("[]"), 128), /object/i)
|
|
50
|
+
assert.throws(() => encodeV3Control({padding: "x".repeat(129)}, 128), /limit/i)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("v3 reserves one negotiated compression flag for DATA only", () => {
|
|
54
|
+
const wire = encodeV3Frame({type: V3FrameType.DATA, streamId: 2, sequence: 10, flags: V3FrameFlag.DATA_COMPRESSED}, Buffer.from("payload"))
|
|
55
|
+
assert.equal(new V3FrameDecoder().push(wire)[0]?.flags, V3FrameFlag.DATA_COMPRESSED)
|
|
56
|
+
assert.throws(() => encodeV3Frame({type: V3FrameType.GATEWAY_REGISTER, streamId: 0, sequence: 1, flags: V3FrameFlag.DATA_COMPRESSED}), /flags/i)
|
|
57
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict"
|
|
4
|
+
import {readFile} from "node:fs/promises"
|
|
5
|
+
import {fileURLToPath} from "node:url"
|
|
6
|
+
import test from "node:test"
|
|
7
|
+
|
|
8
|
+
const packageJsonPath = fileURLToPath(new URL("../../package.json", import.meta.url))
|
|
9
|
+
|
|
10
|
+
test("patch releases use release-patch", async () => {
|
|
11
|
+
const packageJson = /** @type {{devDependencies: Record<string, string>, private?: boolean, scripts: Record<string, string>}} */ (JSON.parse(await readFile(packageJsonPath, "utf8")))
|
|
12
|
+
|
|
13
|
+
assert.equal(packageJson.private, undefined)
|
|
14
|
+
assert.equal(packageJson.devDependencies["release-patch"], "^1.0.8")
|
|
15
|
+
const check = packageJson.scripts.check
|
|
16
|
+
const releasePatch = packageJson.scripts["release:patch"]
|
|
17
|
+
assert.ok(check)
|
|
18
|
+
assert.ok(releasePatch)
|
|
19
|
+
assert.deepEqual(check.split(" && "), ["npm run lint", "npm run typecheck", "npm test"])
|
|
20
|
+
assert.deepEqual(releasePatch.split(" && "), ["npm run check", "release-patch"])
|
|
21
|
+
})
|