socketduct 0.1.19 → 0.1.21

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 (59) hide show
  1. package/.env.example +17 -5
  2. package/README.md +3 -3
  3. package/changelog.d/20260925170000-disconnect-attribution-compressed-logs.md +1 -0
  4. package/changelog.d/20260925180000-manual-ack-durable-admission.md +1 -0
  5. package/docs/docker.md +5 -4
  6. package/docs/logging.md +131 -159
  7. package/docs/protocol.md +48 -7
  8. package/docs/reverse-gateway.md +83 -8
  9. package/docs/running.md +17 -13
  10. package/docs/security.md +7 -3
  11. package/docs/session-lifecycle.md +22 -0
  12. package/docs/socket-api.md +22 -17
  13. package/package.json +5 -1
  14. package/scripts/verify-zstd-interoperability.mjs +41 -0
  15. package/src/cli/socketduct.js +143 -0
  16. package/src/client/deferred-virtual-socket.js +42 -1
  17. package/src/client/multiplexed-connection.js +17 -0
  18. package/src/client/multiplexed-transport-set.js +43 -8
  19. package/src/client/multiplexed.js +1 -1
  20. package/src/client/virtual-socket.js +119 -4
  21. package/src/config/environment.js +56 -7
  22. package/src/index.js +65 -27
  23. package/src/logging/compressed-log-reader.js +256 -0
  24. package/src/logging/compressed-log-worker.js +430 -0
  25. package/src/logging/compressed-log-writer.js +538 -0
  26. package/src/logging/log-batch.js +72 -0
  27. package/src/logging/log-retention.js +241 -0
  28. package/src/logging/log-segment-index.js +176 -0
  29. package/src/logging/structured-logger.js +136 -30
  30. package/src/logging/zstd-frame-codec.js +73 -0
  31. package/src/reverse/client.js +245 -34
  32. package/src/reverse/disconnect-attribution.js +191 -0
  33. package/src/reverse/gateway.js +68 -9
  34. package/src/reverse/hub.js +215 -32
  35. package/src/reverse/runtime.js +5 -3
  36. package/src/spool/spool-file.js +92 -6
  37. package/tensorbuzz.yml +2 -1
  38. package/test/e2e/logger-shutdown-process.test.js +86 -0
  39. package/test/e2e/reverse-client-recovery-process.test.js +3 -0
  40. package/test/e2e/reverse-gateway-process.test.js +15 -14
  41. package/test/fixtures/reverse-client-recovery.mjs +14 -2
  42. package/test/integration/logging-cli.test.js +168 -0
  43. package/test/integration/logging-compressed-recovery.test.js +186 -0
  44. package/test/integration/multiplexed-transport-set.test.js +1 -5
  45. package/test/integration/reverse-gateway.test.js +334 -1
  46. package/test/integration/reverse-route-class.test.js +6 -1
  47. package/test/unit/client/multiplexed-transport-set.test.js +50 -3
  48. package/test/unit/client/virtual-socket.test.js +123 -1
  49. package/test/unit/config/docker-gateway-files.test.js +2 -0
  50. package/test/unit/config/environment.test.js +54 -14
  51. package/test/unit/index.test.js +5 -0
  52. package/test/unit/logging/compressed-log-worker.test.js +29 -0
  53. package/test/unit/logging/compressed-log-writer.test.js +183 -0
  54. package/test/unit/logging/log-retention.test.js +86 -0
  55. package/test/unit/logging/log-segment-index.test.js +104 -0
  56. package/test/unit/logging/structured-logger.test.js +14 -1
  57. package/test/unit/logging/zstd-frame-codec.test.js +24 -0
  58. package/test/unit/reverse/disconnect-attribution.test.js +82 -0
  59. package/test/unit/spool/spool-file.test.js +58 -0
@@ -0,0 +1,256 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param-type, jsdoc/require-returns */
2
+ import {createHash} from "node:crypto"
3
+ import {constants} from "node:fs"
4
+ import {lstat, open, readdir, stat} from "node:fs/promises"
5
+ import {join} from "node:path"
6
+
7
+ import {inspectLogBatch} from "./log-batch.js"
8
+ import {INDEX_ENTRY_BYTES, INDEX_HEADER_BYTES, parseIndexEntry, parseIndexHeader, uuidBytes} from "./log-segment-index.js"
9
+ import {decompressLogFrame} from "./zstd-frame-codec.js"
10
+
11
+ const SEGMENT_NAME = /^([a-z][a-z0-9-]{0,63})-([A-Za-z0-9_-]{22})-([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})\.jsonl\.zst$/
12
+ const DEFAULT_MAX_DECODED_FRAME_BYTES = 16 * 1024 * 1024
13
+
14
+ /** Incremental durable-index reader for long-running tail consumers. */
15
+ export class CompressedLogTailReader {
16
+ /** @type {string} */ #directory
17
+ /** @type {{filters?: Record<string, unknown>, maxDecodedFrameBytes?: number}} */ #options
18
+ /** @type {Map<string, {nextEntry: number, expectedSequence: bigint | undefined, expectedOffset: bigint, sealed: boolean}>} */ #segments = new Map()
19
+ /** @type {number} */ #decodedFrames = 0
20
+
21
+ /**
22
+ * @param {string} directory - Private compressed-log directory.
23
+ * @param {{filters?: Record<string, unknown>, maxDecodedFrameBytes?: number}} [options] - Read policy.
24
+ */
25
+ constructor(directory, options = {}) {
26
+ if (typeof directory !== "string" || directory.length === 0) throw new TypeError("compressed log directory is required")
27
+ this.#directory = directory
28
+ this.#options = options
29
+ }
30
+
31
+ /** Reads only index entries committed since the prior poll. */
32
+ async poll() {
33
+ await assertPrivateDirectory(this.#directory)
34
+ const entries = (await readdir(this.#directory)).filter((entry) => entry.endsWith(".jsonl.zst")).sort()
35
+ const present = new Set(entries)
36
+ for (const name of this.#segments.keys()) if (!present.has(name)) this.#segments.delete(name)
37
+ /** @type {Array<Record<string, unknown>>} */
38
+ const records = []
39
+ /** @type {Array<{segment: string, code: string}>} */
40
+ const errors = []
41
+ for (const entry of entries) {
42
+ const previous = this.#segments.get(entry)
43
+ if (previous?.sealed === true) continue
44
+ try {
45
+ const result = await readCompressedLogSegmentIncremental(join(this.#directory, entry), this.#options, previous)
46
+ this.#segments.set(entry, result.cursor)
47
+ this.#decodedFrames = Math.min(Number.MAX_SAFE_INTEGER, this.#decodedFrames + result.decodedFrames)
48
+ records.push(...result.records)
49
+ } catch (error) {
50
+ if (evidenceErrorCode(error) === "ENOENT") {
51
+ this.#segments.delete(entry)
52
+ continue
53
+ }
54
+ errors.push({segment: entry, code: evidenceErrorCode(error)})
55
+ }
56
+ }
57
+ records.sort(compareRecords)
58
+ const filtered = this.#options.filters === undefined ? records : records.filter((record) => matchesFilters(record, this.#options.filters ?? {}))
59
+ return {records: filtered, incomplete: errors.length > 0, errors}
60
+ }
61
+
62
+ /** Returns bounded cursor count and cumulative decode work for health/tests. */
63
+ status() { return {trackedSegments: this.#segments.size, decodedFrames: this.#decodedFrames} }
64
+ }
65
+
66
+ /**
67
+ * Reads and validates every durable indexed frame, preserving evidence errors.
68
+ * @param directory
69
+ * @param options
70
+ */
71
+ export async function readCompressedLogDirectory(/** @type {string} */ directory, /** @type {{filters?: Record<string, unknown>, maxDecodedFrameBytes?: number}} */ options = {}) {
72
+ await assertPrivateDirectory(directory)
73
+ const entries = await readdir(directory)
74
+ /** @type {Array<Record<string, unknown>>} */
75
+ const records = []
76
+ /** @type {Array<{segment: string, code: string}>} */
77
+ const errors = []
78
+ for (const entry of entries.sort()) {
79
+ if (!entry.endsWith(".jsonl.zst")) continue
80
+ try {
81
+ const selected = await readCompressedLogSegment(join(directory, entry), options)
82
+ records.push(...selected)
83
+ } catch (error) {
84
+ errors.push({segment: entry, code: evidenceErrorCode(error)})
85
+ }
86
+ }
87
+ records.sort(compareRecords)
88
+ const filtered = options.filters === undefined ? records : records.filter((record) => matchesFilters(record, options.filters ?? {}))
89
+ return {records: filtered, incomplete: errors.length > 0, errors}
90
+ }
91
+
92
+ /**
93
+ * Reads one segment frame-by-frame from its durable index.
94
+ * @param dataPath
95
+ * @param options
96
+ */
97
+ export async function readCompressedLogSegment(/** @type {string} */ dataPath, /** @type {{maxDecodedFrameBytes?: number}} */ options = {}) {
98
+ return (await readCompressedLogSegmentIncremental(dataPath, options)).records
99
+ }
100
+
101
+ /**
102
+ * Reads newly indexed frames from one segment and advances its validated cursor.
103
+ * @param {string} dataPath - Segment data path.
104
+ * @param {{maxDecodedFrameBytes?: number}} options - Decode bounds.
105
+ * @param {{nextEntry: number, expectedSequence: bigint | undefined, expectedOffset: bigint, sealed: boolean}} [cursor] - Prior cursor.
106
+ */
107
+ async function readCompressedLogSegmentIncremental(dataPath, options, cursor = undefined) {
108
+ const name = dataPath.slice(dataPath.lastIndexOf("/") + 1)
109
+ const match = SEGMENT_NAME.exec(name)
110
+ if (match === null) throw evidenceError("INVALID_SEGMENT_NAME")
111
+ const writerEpoch = Buffer.from(/** @type {string} */ (match[2]), "base64url")
112
+ const segmentUuid = uuidBytes(/** @type {string} */ (match[3]))
113
+ if (writerEpoch.length !== 16) throw evidenceError("INVALID_WRITER_EPOCH")
114
+ const indexPath = `${dataPath}.idx`
115
+ if (await pathExists(`${dataPath}.quarantine`)) throw evidenceError("LOG_EVIDENCE_QUARANTINED")
116
+ await Promise.all([assertPrivateRegularFile(dataPath), assertPrivateRegularFile(indexPath)])
117
+ const [dataHandle, indexHandle] = await Promise.all([
118
+ open(dataPath, constants.O_RDONLY | constants.O_NOFOLLOW),
119
+ open(indexPath, constants.O_RDONLY | constants.O_NOFOLLOW)
120
+ ])
121
+ try {
122
+ const [dataStats, indexStats] = await Promise.all([dataHandle.stat(), indexHandle.stat()])
123
+ if (indexStats.size < INDEX_HEADER_BYTES) throw evidenceError("INDEX_HEADER_TORN")
124
+ const headerBytes = Buffer.alloc(INDEX_HEADER_BYTES)
125
+ await readExactly(indexHandle, headerBytes, 0)
126
+ const header = parseIndexHeader(headerBytes)
127
+ if (!header.segmentUuid.equals(segmentUuid) || !header.writerEpoch.equals(writerEpoch)) throw evidenceError("INDEX_IDENTITY_MISMATCH")
128
+ const completeEntryBytes = indexStats.size - INDEX_HEADER_BYTES
129
+ const sealed = await pathExists(`${dataPath}.sealed`)
130
+ if (completeEntryBytes % INDEX_ENTRY_BYTES !== 0 && sealed) throw evidenceError("INDEX_TAIL_TORN")
131
+ const entryCount = Math.floor(completeEntryBytes / INDEX_ENTRY_BYTES)
132
+ const nextEntry = cursor?.nextEntry ?? 0
133
+ if (!Number.isSafeInteger(nextEntry) || nextEntry < 0 || nextEntry > entryCount) throw evidenceError("INDEX_CURSOR_INVALID")
134
+ /** @type {Array<Record<string, unknown>>} */
135
+ const records = []
136
+ let expectedSequence = cursor?.expectedSequence
137
+ let expectedOffset = cursor?.expectedOffset ?? 0n
138
+ let decodedFrames = 0
139
+ for (let index = nextEntry; index < entryCount; index += 1) {
140
+ const entryBytes = Buffer.alloc(INDEX_ENTRY_BYTES)
141
+ await readExactly(indexHandle, entryBytes, INDEX_HEADER_BYTES + index * INDEX_ENTRY_BYTES)
142
+ const entry = parseIndexEntry(entryBytes, {segmentUuid, writerEpoch})
143
+ if (expectedSequence !== undefined && entry.firstSequence !== expectedSequence) throw evidenceError("INDEX_SEQUENCE_GAP")
144
+ if (entry.dataOffset !== expectedOffset) throw evidenceError("INDEX_DATA_GAP")
145
+ if (entry.dataOffset + entry.compressedLength > BigInt(dataStats.size)) throw evidenceError("INDEX_DATA_BOUNDS")
146
+ if (entry.compressedLength > BigInt(Number.MAX_SAFE_INTEGER) || entry.uncompressedLength > BigInt(options.maxDecodedFrameBytes ?? DEFAULT_MAX_DECODED_FRAME_BYTES)) throw evidenceError("FRAME_SIZE_LIMIT")
147
+ const frame = Buffer.alloc(Number(entry.compressedLength))
148
+ await readExactly(dataHandle, frame, Number(entry.dataOffset))
149
+ if (!createHash("sha256").update(frame).digest().equals(entry.frameSha256)) throw evidenceError("FRAME_HASH_MISMATCH")
150
+ const raw = await decompressLogFrame(frame)
151
+ if (BigInt(raw.length) !== entry.uncompressedLength) throw evidenceError("FRAME_LENGTH_MISMATCH")
152
+ const inspected = inspectLogBatch(raw, writerEpoch)
153
+ if (inspected.firstSequence !== entry.firstSequence || inspected.lastSequence !== entry.lastSequence || inspected.recordCount !== entry.recordCount || inspected.minTimestamp !== entry.minTimestamp || inspected.maxTimestamp !== entry.maxTimestamp || !inspected.batchId.equals(entry.batchId)) throw evidenceError("FRAME_METADATA_MISMATCH")
154
+ records.push(...inspected.records)
155
+ decodedFrames += 1
156
+ expectedSequence = entry.lastSequence + 1n
157
+ expectedOffset = entry.dataOffset + entry.compressedLength
158
+ }
159
+ if (sealed && expectedOffset !== BigInt(dataStats.size)) throw evidenceError("SEALED_DATA_TAIL")
160
+ return {records, decodedFrames, cursor: {nextEntry: entryCount, expectedSequence, expectedOffset, sealed}}
161
+ } finally {
162
+ await Promise.allSettled([dataHandle.close(), indexHandle.close()])
163
+ }
164
+ }
165
+
166
+ /**
167
+ * @param {import('node:fs/promises').FileHandle} handle - Open file. @param {Buffer} buffer - Exact destination. @param {number} position - File position.
168
+ * @param buffer
169
+ * @param position
170
+ */
171
+ async function readExactly(handle, buffer, position) {
172
+ let offset = 0
173
+ while (offset < buffer.length) {
174
+ const {bytesRead} = await handle.read(buffer, offset, buffer.length - offset, position + offset)
175
+ if (bytesRead === 0) throw evidenceError("SHORT_READ")
176
+ offset += bytesRead
177
+ }
178
+ }
179
+
180
+ /**
181
+ *
182
+ * @param path
183
+ */
184
+ async function assertPrivateDirectory(/** @type {string} */ path) {
185
+ const stats = await lstat(path)
186
+ if (!stats.isDirectory() || (stats.mode & 0o777) !== 0o700) throw evidenceError("UNSAFE_LOG_DIRECTORY")
187
+ }
188
+
189
+ /**
190
+ *
191
+ * @param path
192
+ */
193
+ async function assertPrivateRegularFile(/** @type {string} */ path) {
194
+ const stats = await lstat(path)
195
+ if (!stats.isFile() || (stats.mode & 0o777) !== 0o600) throw evidenceError("UNSAFE_LOG_FILE")
196
+ }
197
+
198
+ /**
199
+ *
200
+ * @param path
201
+ */
202
+ async function pathExists(/** @type {string} */ path) {
203
+ try { await stat(path); return true } catch (error) { if (error !== null && typeof error === "object" && /** @type {{code?: unknown}} */ (error).code === "ENOENT") return false; throw error }
204
+ }
205
+
206
+ /**
207
+ *
208
+ * @param record
209
+ * @param filters
210
+ */
211
+ function matchesFilters(/** @type {Record<string, unknown>} */ record, /** @type {Record<string, unknown>} */ filters) {
212
+ const mappings = {
213
+ event: "event",
214
+ incident: "disconnectIncidentId",
215
+ gateway: "gatewayId",
216
+ routeClass: "routeClass",
217
+ clientGeneration: "clientConnectionGeneration",
218
+ gatewayGeneration: "gatewayConnectionGeneration",
219
+ reason: "disconnectReason"
220
+ }
221
+ for (const [filter, field] of Object.entries(mappings)) if (filters[filter] !== undefined && String(record[field]) !== String(filters[filter])) return false
222
+ const timestamp = typeof record.timestamp === "string" ? Date.parse(record.timestamp) : Number.NaN
223
+ if (filters.since !== undefined && timestamp < /** @type {number} */ (filters.since)) return false
224
+ if (filters.until !== undefined && timestamp > /** @type {number} */ (filters.until)) return false
225
+ return true
226
+ }
227
+
228
+ /**
229
+ *
230
+ * @param left
231
+ * @param right
232
+ */
233
+ function compareRecords(/** @type {Record<string, unknown>} */ left, /** @type {Record<string, unknown>} */ right) {
234
+ const time = String(left.timestamp).localeCompare(String(right.timestamp))
235
+ if (time !== 0) return time
236
+ const leftEpoch = Buffer.from(String(left.writerEpoch), "base64url")
237
+ const rightEpoch = Buffer.from(String(right.writerEpoch), "base64url")
238
+ const epoch = Buffer.compare(leftEpoch, rightEpoch)
239
+ return epoch !== 0 ? epoch : BigInt(String(left.eventSequence)) < BigInt(String(right.eventSequence)) ? -1 : BigInt(String(left.eventSequence)) > BigInt(String(right.eventSequence)) ? 1 : 0
240
+ }
241
+
242
+ /**
243
+ *
244
+ * @param error
245
+ */
246
+ function evidenceErrorCode(/** @type {unknown} */ error) {
247
+ return error !== null && typeof error === "object" && typeof /** @type {{code?: unknown}} */ (error).code === "string" ? /** @type {{code: string}} */ (error).code : "LOG_EVIDENCE_INVALID"
248
+ }
249
+
250
+ /**
251
+ *
252
+ * @param code
253
+ */
254
+ function evidenceError(/** @type {string} */ code) {
255
+ return Object.assign(new Error("Compressed log evidence is incomplete or corrupt"), {code})
256
+ }