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,44 @@
1
+ import {AdaptiveCompressor, decodeStreamPayload, encodeStreamPayload, ZSTD_CODEC} from "./compression.js"
2
+ import {V3FrameFlag} from "./v3-frame.js"
3
+
4
+ const LENGTH_BYTES = 4
5
+
6
+ /**
7
+ * Encodes one independently decompressible v3 DATA payload.
8
+ * @param {Buffer} data - Raw stream bytes.
9
+ * @param {string | undefined} codec - Negotiated codec.
10
+ * @param {AdaptiveCompressor} adaptive - Per-direction adaptive state.
11
+ * @param {import('./compression.js').CompressionLimits} [limits] - Codec limits.
12
+ * @returns {Promise<{payload: Buffer, flags: number, rawBytes: number, wireBytes: number}>} Encoded v3 payload.
13
+ */
14
+ export async function encodeV3Data(data, codec, adaptive, limits = {}) {
15
+ const encoded = await encodeStreamPayload(data, codec, adaptive, limits)
16
+ if (encoded.encoding === undefined) return {payload: encoded.payload, flags: 0, rawBytes: data.length, wireBytes: data.length}
17
+ const payload = Buffer.allocUnsafe(LENGTH_BYTES + encoded.payload.length)
18
+ payload.writeUInt32BE(data.length, 0)
19
+ encoded.payload.copy(payload, LENGTH_BYTES)
20
+ return {payload, flags: V3FrameFlag.DATA_COMPRESSED, rawBytes: data.length, wireBytes: payload.length}
21
+ }
22
+
23
+ /**
24
+ * Decodes one v3 DATA payload and rejects unnegotiated compression.
25
+ * @param {{flags: number, payload: Buffer}} frame - Incoming DATA frame.
26
+ * @param {string | undefined} codec - Negotiated codec.
27
+ * @param {import('./compression.js').CompressionLimits} [limits] - Codec limits.
28
+ * @returns {Promise<Buffer>} Raw stream bytes.
29
+ */
30
+ export async function decodeV3Data(frame, codec, limits = {}) {
31
+ if (frame.flags === 0) return await decodeStreamPayload({type: "stream.data"}, frame.payload, codec, limits)
32
+ if (frame.flags !== V3FrameFlag.DATA_COMPRESSED) throw new Error("Unsupported V3 DATA flags")
33
+ if (codec !== ZSTD_CODEC) throw new Error("Compressed V3 DATA was not negotiated")
34
+ if (frame.payload.length <= LENGTH_BYTES) throw new Error("Compressed V3 DATA payload is truncated")
35
+ const dataLength = frame.payload.readUInt32BE(0)
36
+ return await decodeStreamPayload(
37
+ {type: "stream.data", encoding: ZSTD_CODEC, dataLength},
38
+ frame.payload.subarray(LENGTH_BYTES),
39
+ codec,
40
+ limits
41
+ )
42
+ }
43
+
44
+ export {AdaptiveCompressor}
@@ -0,0 +1,209 @@
1
+ const MAGIC = Buffer.from("SDV3")
2
+ const VERSION = 3
3
+ const HEADER_BYTES = 24
4
+ const DEFAULT_MAX_FRAME_BYTES = 64 * 1024
5
+ const DEFAULT_MAX_CONTROL_BYTES = 64 * 1024
6
+ const UINT64_MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER)
7
+
8
+ export const V3FrameType = Object.freeze({
9
+ AUTH: 1,
10
+ AUTH_OK: 2,
11
+ AUTH_ERROR: 3,
12
+ PING: 4,
13
+ PONG: 5,
14
+ GOAWAY: 6,
15
+ RESUME: 7,
16
+ RESUMED: 8,
17
+ GATEWAY_REGISTER: 9,
18
+ GATEWAY_REGISTERED: 10,
19
+ GATEWAY_SELECT: 11,
20
+ GATEWAY_SELECTED: 12,
21
+ OPEN: 16,
22
+ OPENED: 17,
23
+ DATA: 18,
24
+ ACK: 19,
25
+ WINDOW: 20,
26
+ END: 21,
27
+ CLOSE: 22,
28
+ RESET: 23
29
+ })
30
+
31
+ export const V3FrameFlag = Object.freeze({
32
+ DATA_COMPRESSED: 1
33
+ })
34
+
35
+ /** @type {Set<number>} */
36
+ const CONNECTION_TYPES = new Set([
37
+ V3FrameType.AUTH,
38
+ V3FrameType.AUTH_OK,
39
+ V3FrameType.AUTH_ERROR,
40
+ V3FrameType.PING,
41
+ V3FrameType.PONG,
42
+ V3FrameType.GOAWAY,
43
+ V3FrameType.RESUME,
44
+ V3FrameType.RESUMED,
45
+ V3FrameType.GATEWAY_REGISTER,
46
+ V3FrameType.GATEWAY_REGISTERED,
47
+ V3FrameType.GATEWAY_SELECT,
48
+ V3FrameType.GATEWAY_SELECTED
49
+ ])
50
+ /** @type {Set<number>} */
51
+ const STREAM_TYPES = new Set([
52
+ V3FrameType.OPEN,
53
+ V3FrameType.OPENED,
54
+ V3FrameType.DATA,
55
+ V3FrameType.ACK,
56
+ V3FrameType.WINDOW,
57
+ V3FrameType.END,
58
+ V3FrameType.CLOSE,
59
+ V3FrameType.RESET
60
+ ])
61
+
62
+ /**
63
+ * @typedef {object} V3FrameHeader
64
+ * @property {number} type - V3FrameType value.
65
+ * @property {number} streamId - Zero for connection control, otherwise a virtual stream ID.
66
+ * @property {number} sequence - Cumulative byte position or control sequence.
67
+ * @property {number} [flags] - Protocol flags.
68
+ * @typedef {V3FrameHeader & {payload: Buffer, flags: number}} V3Frame
69
+ */
70
+
71
+ /**
72
+ * Encodes one protocol-v3 binary frame.
73
+ * @param {V3FrameHeader} header - Validated frame header.
74
+ * @param {Uint8Array} [payload] - Frame payload.
75
+ * @returns {Buffer} Wire bytes.
76
+ */
77
+ export function encodeV3Frame(header, payload = Buffer.alloc(0)) {
78
+ validateHeader(header)
79
+ const body = Buffer.from(payload)
80
+ if (body.length > 0xffff_ffff) throw new RangeError("V3 frame payload length exceeds uint32")
81
+ const wire = Buffer.allocUnsafe(HEADER_BYTES + body.length)
82
+ MAGIC.copy(wire, 0)
83
+ wire[4] = VERSION
84
+ wire[5] = header.type
85
+ wire.writeUInt16BE(header.flags ?? 0, 6)
86
+ wire.writeUInt32BE(header.streamId, 8)
87
+ wire.writeBigUInt64BE(BigInt(header.sequence), 12)
88
+ wire.writeUInt32BE(body.length, 20)
89
+ body.copy(wire, HEADER_BYTES)
90
+ return wire
91
+ }
92
+
93
+ /**
94
+ * Encodes a bounded JSON control object.
95
+ * @param {unknown} value - Plain JSON object.
96
+ * @param {number} [maxBytes] - Encoded size limit.
97
+ * @returns {Buffer} UTF-8 JSON bytes.
98
+ */
99
+ export function encodeV3Control(value, maxBytes = DEFAULT_MAX_CONTROL_BYTES) {
100
+ positiveInteger(maxBytes, "maxBytes")
101
+ if (!isObject(value)) throw new TypeError("V3 control payload must be an object")
102
+ const encoded = Buffer.from(JSON.stringify(value), "utf8")
103
+ if (encoded.length > maxBytes) throw new RangeError(`V3 control payload exceeds ${maxBytes} byte limit`)
104
+ return encoded
105
+ }
106
+
107
+ /**
108
+ * Decodes a bounded JSON control object.
109
+ * @param {Uint8Array} bytes - UTF-8 JSON bytes.
110
+ * @param {number} [maxBytes] - Encoded size limit.
111
+ * @returns {Record<string, unknown>} Parsed object.
112
+ */
113
+ export function decodeV3Control(bytes, maxBytes = DEFAULT_MAX_CONTROL_BYTES) {
114
+ positiveInteger(maxBytes, "maxBytes")
115
+ if (bytes.length > maxBytes) throw new RangeError(`V3 control payload exceeds ${maxBytes} byte limit`)
116
+ let value
117
+ try {
118
+ value = JSON.parse(Buffer.from(bytes).toString("utf8"))
119
+ } catch {
120
+ throw new Error("V3 control payload is not valid JSON")
121
+ }
122
+ if (!isObject(value)) throw new TypeError("V3 control payload must be an object")
123
+ return value
124
+ }
125
+
126
+ export class V3FrameDecoder {
127
+ /** @type {number} */
128
+ #maxFrameBytes
129
+
130
+ /** @type {number} */
131
+ #maxBufferedBytes
132
+
133
+ /** @type {Buffer} */
134
+ #buffer = Buffer.alloc(0)
135
+
136
+ /** @param {{maxFrameBytes?: number, maxBufferedBytes?: number}} [options] - Decoder bounds. */
137
+ constructor(options = {}) {
138
+ this.#maxFrameBytes = positiveInteger(options.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES, "maxFrameBytes")
139
+ this.#maxBufferedBytes = positiveInteger(options.maxBufferedBytes ?? (this.#maxFrameBytes + HEADER_BYTES), "maxBufferedBytes")
140
+ if (this.#maxBufferedBytes < HEADER_BYTES) throw new RangeError("maxBufferedBytes must fit a V3 header")
141
+ }
142
+
143
+ /**
144
+ * @param {Uint8Array} chunk - Next transport bytes.
145
+ * @returns {V3Frame[]} Complete frames.
146
+ */
147
+ push(chunk) {
148
+ if (chunk.length === 0) return []
149
+ if (this.#buffer.length + chunk.length > this.#maxBufferedBytes) {
150
+ throw new RangeError("V3 decoder buffered byte limit exceeded")
151
+ }
152
+ this.#buffer = this.#buffer.length === 0
153
+ ? Buffer.from(chunk)
154
+ : Buffer.concat([this.#buffer, Buffer.from(chunk)])
155
+ /** @type {V3Frame[]} */
156
+ const frames = []
157
+ let offset = 0
158
+ while (this.#buffer.length - offset >= HEADER_BYTES) {
159
+ const header = this.#buffer.subarray(offset, offset + HEADER_BYTES)
160
+ if (!header.subarray(0, 4).equals(MAGIC)) throw new Error("Invalid V3 frame magic")
161
+ if (header[4] !== VERSION) throw new Error(`Unsupported protocol version: ${header[4]}`)
162
+ const type = /** @type {number} */ (header[5])
163
+ const flags = header.readUInt16BE(6)
164
+ const streamId = header.readUInt32BE(8)
165
+ const sequenceValue = header.readBigUInt64BE(12)
166
+ if (sequenceValue > UINT64_MAX_SAFE) throw new RangeError("V3 sequence exceeds JavaScript safe integer range")
167
+ const sequence = Number(sequenceValue)
168
+ const payloadLength = header.readUInt32BE(20)
169
+ if (payloadLength > this.#maxFrameBytes) throw new RangeError(`Invalid V3 frame payload length: ${payloadLength}`)
170
+ const end = offset + HEADER_BYTES + payloadLength
171
+ if (end > this.#buffer.length) break
172
+ validateHeader({type, flags, streamId, sequence})
173
+ frames.push({type, flags, streamId, sequence, payload: Buffer.from(this.#buffer.subarray(offset + HEADER_BYTES, end))})
174
+ offset = end
175
+ }
176
+ if (offset > 0) this.#buffer = Buffer.from(this.#buffer.subarray(offset))
177
+ return frames
178
+ }
179
+ }
180
+
181
+ /** @param {V3FrameHeader} header - Candidate header. */
182
+ function validateHeader(header) {
183
+ if (!CONNECTION_TYPES.has(header.type) && !STREAM_TYPES.has(header.type)) throw new RangeError(`Unknown V3 frame type: ${header.type}`)
184
+ if (!Number.isInteger(header.streamId) || header.streamId < 0 || header.streamId > 0xffff_ffff) throw new RangeError("V3 stream ID must be uint32")
185
+ if (!Number.isSafeInteger(header.sequence) || header.sequence < 0) throw new RangeError("V3 sequence must be a non-negative safe integer")
186
+ const flags = header.flags ?? 0
187
+ if (!Number.isInteger(flags) || flags < 0 || flags > 0xffff) throw new RangeError("V3 flags must be uint16")
188
+ if (flags !== 0 && !(header.type === V3FrameType.DATA && flags === V3FrameFlag.DATA_COMPRESSED)) throw new Error("Unsupported V3 frame flags")
189
+ if (CONNECTION_TYPES.has(header.type) && header.streamId !== 0) throw new Error("V3 connection control frames require stream ID zero")
190
+ if (STREAM_TYPES.has(header.type) && header.streamId === 0) throw new Error("V3 stream ID zero is reserved for connection control")
191
+ }
192
+
193
+ /**
194
+ * @param {unknown} value - Candidate plain object.
195
+ * @returns {value is Record<string, unknown>} Whether the value is an object.
196
+ */
197
+ function isObject(value) {
198
+ return typeof value === "object" && value !== null && !Array.isArray(value)
199
+ }
200
+
201
+ /**
202
+ * @param {number} value - Candidate.
203
+ * @param {string} name - Option name.
204
+ * @returns {number} Validated value.
205
+ */
206
+ function positiveInteger(value, name) {
207
+ if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${name} must be a positive integer`)
208
+ return value
209
+ }