socketduct 0.1.18 → 0.1.20

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 (55) hide show
  1. package/.env.example +17 -5
  2. package/README.md +5 -5
  3. package/changelog.d/20260925143000-reverse-client-process-recovery.md +1 -0
  4. package/changelog.d/20260925170000-disconnect-attribution-compressed-logs.md +1 -0
  5. package/docs/architecture.md +11 -9
  6. package/docs/docker.md +5 -4
  7. package/docs/logging.md +131 -159
  8. package/docs/protocol.md +43 -7
  9. package/docs/reverse-gateway.md +75 -9
  10. package/docs/running.md +16 -13
  11. package/docs/security.md +16 -5
  12. package/docs/session-lifecycle.md +14 -3
  13. package/docs/socket-api.md +54 -20
  14. package/package.json +5 -1
  15. package/scripts/verify-zstd-interoperability.mjs +41 -0
  16. package/src/cli/socketduct.js +143 -0
  17. package/src/client/multiplexed-transport-set.js +12 -5
  18. package/src/client/virtual-socket.js +52 -0
  19. package/src/config/environment.js +56 -7
  20. package/src/index.js +64 -26
  21. package/src/logging/compressed-log-reader.js +256 -0
  22. package/src/logging/compressed-log-worker.js +430 -0
  23. package/src/logging/compressed-log-writer.js +538 -0
  24. package/src/logging/log-batch.js +72 -0
  25. package/src/logging/log-retention.js +241 -0
  26. package/src/logging/log-segment-index.js +176 -0
  27. package/src/logging/structured-logger.js +136 -30
  28. package/src/logging/zstd-frame-codec.js +73 -0
  29. package/src/reverse/client.js +372 -82
  30. package/src/reverse/disconnect-attribution.js +191 -0
  31. package/src/reverse/gateway.js +68 -18
  32. package/src/reverse/hub.js +215 -32
  33. package/src/reverse/runtime.js +5 -3
  34. package/src/spool/spool-file.js +136 -7
  35. package/tensorbuzz.yml +4 -2
  36. package/test/e2e/logger-shutdown-process.test.js +86 -0
  37. package/test/e2e/reverse-client-recovery-process.test.js +170 -0
  38. package/test/e2e/reverse-gateway-process.test.js +15 -14
  39. package/test/fixtures/reverse-client-recovery.mjs +123 -0
  40. package/test/integration/logging-cli.test.js +168 -0
  41. package/test/integration/logging-compressed-recovery.test.js +186 -0
  42. package/test/integration/reverse-gateway.test.js +597 -1
  43. package/test/integration/reverse-route-class.test.js +6 -1
  44. package/test/unit/client/multiplexed-transport-set.test.js +12 -1
  45. package/test/unit/config/docker-gateway-files.test.js +2 -0
  46. package/test/unit/config/environment.test.js +54 -14
  47. package/test/unit/index.test.js +3 -0
  48. package/test/unit/logging/compressed-log-worker.test.js +29 -0
  49. package/test/unit/logging/compressed-log-writer.test.js +183 -0
  50. package/test/unit/logging/log-retention.test.js +86 -0
  51. package/test/unit/logging/log-segment-index.test.js +104 -0
  52. package/test/unit/logging/structured-logger.test.js +14 -1
  53. package/test/unit/logging/zstd-frame-codec.test.js +24 -0
  54. package/test/unit/reverse/disconnect-attribution.test.js +82 -0
  55. package/test/unit/spool/spool-file.test.js +133 -0
@@ -7,6 +7,7 @@ import {Duplex} from "node:stream"
7
7
  * @property {(streamId: number, callback: (error?: Error | null) => void) => void} _endStream - End the writable side.
8
8
  * @property {(streamId: number, error: Error | null) => void} _destroyStream - Destroy the virtual stream.
9
9
  * @property {(streamId: number) => void} _resumeReadable - Resume inbound flow.
10
+ * @property {(streamId: number, bytes: number) => void} [_consumeReadable] - Report bytes returned to the application.
10
11
  * @property {() => {address?: string, port?: number}} physicalAddress - Current physical endpoint.
11
12
  * @property {() => unknown} ref - Reference owner resources.
12
13
  * @property {() => unknown} unref - Unreference owner resources.
@@ -14,6 +15,16 @@ import {Duplex} from "node:stream"
14
15
 
15
16
  /** A stream-local failure where the physical connection may remain usable. */
16
17
  export class SocketductStreamError extends Error {
18
+ /** @type {string | undefined} */ disconnectIncidentId
19
+ /** @type {string | undefined} */ disconnectReason
20
+ /** @type {'reverse_hub' | 'reverse_client' | 'reverse_gateway' | undefined} */ incidentOrigin
21
+ /** @type {'client' | 'gateway' | undefined} */ physicalRole
22
+ /** @type {number | undefined} */ clientConnectionGeneration
23
+ /** @type {number | undefined} */ gatewayConnectionGeneration
24
+ /** @type {number | undefined} */ fanOutCount
25
+ /** @type {number | undefined} */ resumeSuccessCount
26
+ /** @type {number | undefined} */ terminalStreamCount
27
+
17
28
  /**
18
29
  * @param {string} message - Safe description.
19
30
  * @param {string} code - Stable code.
@@ -60,6 +71,9 @@ export class VirtualSocket extends Duplex {
60
71
  /** @type {boolean} */
61
72
  #remoteEnded = false
62
73
 
74
+ /** @type {number} */
75
+ #emittedDataBytes = 0
76
+
63
77
  /** @type {boolean} */
64
78
  connecting = true
65
79
 
@@ -123,6 +137,44 @@ export class VirtualSocket extends Duplex {
123
137
  this.#connection._resumeReadable(this.#streamId)
124
138
  }
125
139
 
140
+ /**
141
+ * Reports application consumption after Node has emitted flowing data or
142
+ * fulfilled a paused-mode read. Owners that need durable receive ACKs can
143
+ * therefore distinguish consumed bytes from process-local readable bytes.
144
+ * @param {number} [size] - Requested bytes.
145
+ * @returns {Buffer | string | null} Readable chunk.
146
+ * @override
147
+ */
148
+ read(size) {
149
+ const emittedBefore = this.#emittedDataBytes
150
+ const chunk = super.read(size)
151
+ if (chunk !== null) {
152
+ const bytes = typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length
153
+ const emittedBytes = this.#emittedDataBytes - emittedBefore
154
+ if (bytes > emittedBytes) this.#connection._consumeReadable?.(this.#streamId, bytes - emittedBytes)
155
+ }
156
+ return chunk
157
+ }
158
+
159
+ /**
160
+ * A flowing Readable may emit directly from push() without returning a chunk
161
+ * through read(). Report DATA only after application listeners have run.
162
+ * @param {string | symbol} event - Event name.
163
+ * @param {...unknown} args - Event values.
164
+ * @returns {boolean} Whether the event had listeners.
165
+ * @override
166
+ */
167
+ emit(event, ...args) {
168
+ const emitted = super.emit(event, ...args)
169
+ const chunk = args[0]
170
+ if (event === "data" && (typeof chunk === "string" || Buffer.isBuffer(chunk))) {
171
+ const bytes = typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length
172
+ this.#emittedDataBytes += bytes
173
+ this.#connection._consumeReadable?.(this.#streamId, bytes)
174
+ }
175
+ return emitted
176
+ }
177
+
126
178
  /**
127
179
  * @override
128
180
  * @param {Buffer | string | Uint8Array} chunk - Application bytes.
@@ -1,6 +1,16 @@
1
1
  import {isIP} from "node:net"
2
2
 
3
- import {DEFAULT_LOG_MAX_FILE_BYTES, DEFAULT_LOG_MAX_FILES, MAX_LOG_FILES, MIN_LOG_MAX_FILE_BYTES} from "../logging/structured-logger.js"
3
+ import {
4
+ DEFAULT_LOG_COMPRESSION_LEVEL,
5
+ DEFAULT_LOG_CRITICAL_FLUSH_INTERVAL_MS,
6
+ DEFAULT_LOG_CRITICAL_RESERVE_BYTES,
7
+ DEFAULT_LOG_FLUSH_INTERVAL_MS,
8
+ DEFAULT_LOG_FRAME_BYTES,
9
+ DEFAULT_LOG_QUEUE_BYTES,
10
+ DEFAULT_LOG_RETENTION_BYTES,
11
+ DEFAULT_LOG_RETENTION_DAYS,
12
+ DEFAULT_LOG_SEGMENT_BYTES
13
+ } from "../logging/compressed-log-writer.js"
4
14
 
5
15
  const DEFAULT_RELAY_HOST = "127.0.0.1"
6
16
  const DEFAULT_RELAY_PORT = 3100
@@ -52,7 +62,8 @@ const SUPPORTED_PROTOCOL_VERSIONS = new Set([2, 3])
52
62
  * @property {number} handshakeTimeoutMs - Milliseconds to wait for auth after TCP/TLS connect before closing the socket. Default 10000.
53
63
  * @property {number} maxInputBytesPerConnection - Per-connection input byte budget before the first authenticated session frame. Default 65536.
54
64
  * @property {number} [statusIntervalMs] - Safe status-log interval; zero disables periodic status logs.
55
- * @property {{directory: string, maxFileBytes: number, maxFiles: number}} [fileLogging] - Optional persistent JSONL logging configuration.
65
+ * @property {{directory: string, frameMaxBytes: number, flushIntervalMs: number, criticalFlushIntervalMs: number, maxSegmentBytes: number, retentionDays: number, retentionBytes: number, maxQueueBytes: number, criticalReserveBytes: number, compressionLevel: number}} [fileLogging] - Optional durable compressed JSONL logging configuration.
66
+ * @property {{key: Buffer, keyEpoch: string}} [connectionFingerprint] - Hub-only keyed diagnostic correlation configuration.
56
67
  * @property {'auto' | 'off'} [compression] - Whether to negotiate stream.data compression when the runtime supports it. Defaults to auto.
57
68
  * @property {2 | 3} [protocolVersion] - Relay wire protocol; defaults to rollback-compatible v2.
58
69
  * @property {number} [minCompressBytes] - Payloads smaller than this are sent raw.
@@ -133,13 +144,34 @@ export function loadEnvironmentConfig(env = process.env) {
133
144
  ...(env.SOCKETDUCT_REVERSE_HUB_SERVERNAME?.trim() ? {servername: env.SOCKETDUCT_REVERSE_HUB_SERVERNAME.trim()} : {})
134
145
  }
135
146
  const logDirectory = env.SOCKETDUCT_LOG_DIRECTORY?.trim() || undefined
147
+ if (env.SOCKETDUCT_LOG_MAX_FILE_BYTES !== undefined || env.SOCKETDUCT_LOG_MAX_FILES !== undefined) throw new Error("SOCKETDUCT_LOG_MAX_FILE_BYTES and SOCKETDUCT_LOG_MAX_FILES were replaced by compressed log frame, segment, and retention settings")
136
148
  let fileLogging
137
149
  if (logDirectory !== undefined) {
138
- const maxFileBytes = parsePositiveInteger(env.SOCKETDUCT_LOG_MAX_FILE_BYTES, "SOCKETDUCT_LOG_MAX_FILE_BYTES", DEFAULT_LOG_MAX_FILE_BYTES)
139
- if (!Number.isSafeInteger(maxFileBytes) || maxFileBytes < MIN_LOG_MAX_FILE_BYTES) throw new Error(`SOCKETDUCT_LOG_MAX_FILE_BYTES must be a safe integer of at least ${MIN_LOG_MAX_FILE_BYTES}`)
140
- const maxFiles = parsePositiveInteger(env.SOCKETDUCT_LOG_MAX_FILES, "SOCKETDUCT_LOG_MAX_FILES", DEFAULT_LOG_MAX_FILES)
141
- if (maxFiles > MAX_LOG_FILES) throw new Error(`SOCKETDUCT_LOG_MAX_FILES must be no greater than ${MAX_LOG_FILES}`)
142
- fileLogging = {directory: logDirectory, maxFileBytes, maxFiles}
150
+ const frameMaxBytes = parseBoundedInteger(env.SOCKETDUCT_LOG_FRAME_MAX_BYTES, "SOCKETDUCT_LOG_FRAME_MAX_BYTES", DEFAULT_LOG_FRAME_BYTES, 128, 16 * 1024 * 1024)
151
+ const flushIntervalMs = parseBoundedInteger(env.SOCKETDUCT_LOG_FLUSH_INTERVAL_MS, "SOCKETDUCT_LOG_FLUSH_INTERVAL_MS", DEFAULT_LOG_FLUSH_INTERVAL_MS, 1, 60_000)
152
+ const criticalFlushIntervalMs = parseBoundedInteger(env.SOCKETDUCT_LOG_CRITICAL_FLUSH_INTERVAL_MS, "SOCKETDUCT_LOG_CRITICAL_FLUSH_INTERVAL_MS", DEFAULT_LOG_CRITICAL_FLUSH_INTERVAL_MS, 1, 60_000)
153
+ const maxSegmentBytes = parseBoundedInteger(env.SOCKETDUCT_LOG_SEGMENT_MAX_BYTES, "SOCKETDUCT_LOG_SEGMENT_MAX_BYTES", DEFAULT_LOG_SEGMENT_BYTES, 1024, 4 * 1024 * 1024 * 1024)
154
+ const retentionDays = parseBoundedInteger(env.SOCKETDUCT_LOG_RETENTION_DAYS, "SOCKETDUCT_LOG_RETENTION_DAYS", DEFAULT_LOG_RETENTION_DAYS, 1, 3650)
155
+ const retentionBytes = parseBoundedInteger(env.SOCKETDUCT_LOG_RETENTION_MAX_BYTES, "SOCKETDUCT_LOG_RETENTION_MAX_BYTES", DEFAULT_LOG_RETENTION_BYTES, 1024, 1024 * 1024 * 1024 * 1024)
156
+ const maxQueueBytes = parseBoundedInteger(env.SOCKETDUCT_LOG_QUEUE_MAX_BYTES, "SOCKETDUCT_LOG_QUEUE_MAX_BYTES", DEFAULT_LOG_QUEUE_BYTES, 1024, 1024 * 1024 * 1024)
157
+ const criticalReserveBytes = parseBoundedInteger(env.SOCKETDUCT_LOG_CRITICAL_RESERVE_BYTES, "SOCKETDUCT_LOG_CRITICAL_RESERVE_BYTES", DEFAULT_LOG_CRITICAL_RESERVE_BYTES, 0, maxQueueBytes - 1)
158
+ const compressionLevel = parseBoundedInteger(env.SOCKETDUCT_LOG_COMPRESSION_LEVEL, "SOCKETDUCT_LOG_COMPRESSION_LEVEL", DEFAULT_LOG_COMPRESSION_LEVEL, 1, 22)
159
+ if (maxQueueBytes - criticalReserveBytes < 512) throw new Error("SOCKETDUCT_LOG_CRITICAL_RESERVE_BYTES must leave at least 512 noncritical queue bytes")
160
+ if (maxSegmentBytes > retentionBytes) throw new Error("SOCKETDUCT_LOG_SEGMENT_MAX_BYTES must not exceed SOCKETDUCT_LOG_RETENTION_MAX_BYTES")
161
+ fileLogging = {directory: logDirectory, frameMaxBytes, flushIntervalMs, criticalFlushIntervalMs, maxSegmentBytes, retentionDays, retentionBytes, maxQueueBytes, criticalReserveBytes, compressionLevel}
162
+ }
163
+ const fingerprintKeyText = env.SOCKETDUCT_CONNECTION_FINGERPRINT_KEY?.trim() || undefined
164
+ const fingerprintKeyEpoch = env.SOCKETDUCT_CONNECTION_FINGERPRINT_KEY_EPOCH?.trim() || undefined
165
+ if (mode !== "reverse-hub" && (fingerprintKeyText !== undefined || fingerprintKeyEpoch !== undefined)) throw new Error("SOCKETDUCT_CONNECTION_FINGERPRINT_KEY is hub-only")
166
+ if ((fingerprintKeyText === undefined) !== (fingerprintKeyEpoch === undefined)) throw new Error("SOCKETDUCT_CONNECTION_FINGERPRINT_KEY and SOCKETDUCT_CONNECTION_FINGERPRINT_KEY_EPOCH must be supplied together")
167
+ if (mode === "reverse-hub" && fileLogging !== undefined && fingerprintKeyText === undefined) throw new Error("SOCKETDUCT_CONNECTION_FINGERPRINT_KEY is required for reverse-hub persistent logging")
168
+ let connectionFingerprint
169
+ if (fingerprintKeyText !== undefined && fingerprintKeyEpoch !== undefined) {
170
+ if (!/^[A-Za-z0-9_-]+$/.test(fingerprintKeyText)) throw new Error("SOCKETDUCT_CONNECTION_FINGERPRINT_KEY must be unpadded base64url")
171
+ const key = Buffer.from(fingerprintKeyText, "base64url")
172
+ if (key.length < 32 || key.toString("base64url") !== fingerprintKeyText) throw new Error("SOCKETDUCT_CONNECTION_FINGERPRINT_KEY must encode at least 32 bytes")
173
+ if (!/^[A-Za-z0-9_-]{1,32}$/.test(fingerprintKeyEpoch)) throw new Error("SOCKETDUCT_CONNECTION_FINGERPRINT_KEY_EPOCH must be 1-32 URL-safe characters")
174
+ connectionFingerprint = {key, keyEpoch: fingerprintKeyEpoch}
143
175
  }
144
176
 
145
177
  return {
@@ -184,6 +216,7 @@ export function loadEnvironmentConfig(env = process.env) {
184
216
  DEFAULT_STATUS_INTERVAL_MS
185
217
  ),
186
218
  ...(fileLogging === undefined ? {} : {fileLogging}),
219
+ ...(connectionFingerprint === undefined ? {} : {connectionFingerprint}),
187
220
  compression,
188
221
  protocolVersion,
189
222
  minCompressBytes: parsePositiveInteger(
@@ -343,6 +376,22 @@ function parseNonNegativeInteger(value, name, defaultValue) {
343
376
  return parsed
344
377
  }
345
378
 
379
+ /**
380
+ * Parses one bounded integer environment setting.
381
+ * @param {string | undefined} value - Raw value.
382
+ * @param {string} name - Environment variable name.
383
+ * @param {number} defaultValue - Default value.
384
+ * @param {number} minimum - Inclusive minimum.
385
+ * @param {number} maximum - Inclusive maximum.
386
+ * @returns {number} Parsed bounded integer.
387
+ */
388
+ function parseBoundedInteger(value, name, defaultValue, minimum, maximum) {
389
+ if (value === undefined || value === "") return defaultValue
390
+ const parsed = Number(value)
391
+ if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) throw new Error(`${name} must be an integer from ${minimum} to ${maximum}`)
392
+ return parsed
393
+ }
394
+
346
395
  /**
347
396
  * @param {string} value - Comma-separated username:token entries.
348
397
  * @returns {StaticTokenConfig[]} Parsed static tokens.
package/src/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import {loadEnvironmentConfig} from "./config/environment.js"
2
- import {createJsonlFileLogger} from "./logging/structured-logger.js"
2
+ import {createCompressedJsonlFileLogger} from "./logging/compressed-log-writer.js"
3
3
  import {startRelayRuntime} from "./server/relay-runtime.js"
4
4
  import {startOutboundReverseGatewayRuntime, startReverseHubRuntime} from "./reverse/runtime.js"
5
5
 
6
6
  export {createSocketductConnectionFactory} from "./client/connect.js"
7
7
  export {createJsonlFileLogger, createStructuredLogger} from "./logging/structured-logger.js"
8
+ export {createCompressedJsonlFileLogger, CompressedJsonlFileLogger} from "./logging/compressed-log-writer.js"
8
9
  export {SocketductHttpAgent} from "./client/http-agent.js"
9
10
  export {connectSocketduct, SocketductRelayError, SocketductSocket} from "./client/socketduct-socket.js"
10
11
  export {createMultiplexedSocketductConnection, MultiplexedSocketductConnection} from "./client/multiplexed-connection.js"
@@ -33,11 +34,12 @@ export function getStartupSummary() {
33
34
  */
34
35
  async function main() {
35
36
  const config = loadEnvironmentConfig()
36
- const logger = config.fileLogging === undefined ? undefined : createJsonlFileLogger({...config.fileLogging, role: config.mode})
37
+ const logger = config.fileLogging === undefined ? undefined : createCompressedJsonlFileLogger({...config.fileLogging, role: config.mode})
37
38
  if (config.mode === "summary") {
38
39
  const summary = getStartupSummary()
39
40
  console.log(`${summary.name} starting in ${summary.mode} mode on ${summary.runtime}`)
40
- logger?.log("process.started", {runtimeMode: config.mode})
41
+ logger?.log("process.started", {runtimeMode: config.mode, ...loggingConfigurationFields(config.fileLogging)})
42
+ await logger?.close()
41
43
  return
42
44
  }
43
45
 
@@ -45,31 +47,29 @@ async function main() {
45
47
  let service
46
48
  /** @type {() => Promise<void>} */
47
49
  let closeRuntime
48
- if (config.mode === "relay") {
49
- const runtime = await startRelayRuntime(config)
50
- service = runtime.relay
51
- closeRuntime = runtime.close
52
- } else if (config.mode === "reverse-hub") {
53
- const runtime = await startReverseHubRuntime(config)
54
- service = runtime.hub
55
- closeRuntime = runtime.close
56
- } else {
57
- const runtime = await startOutboundReverseGatewayRuntime(config, logger)
58
- service = runtime.gateway
59
- closeRuntime = runtime.close
50
+ try {
51
+ if (config.mode === "relay") {
52
+ const runtime = await startRelayRuntime(config)
53
+ service = runtime.relay
54
+ closeRuntime = runtime.close
55
+ } else if (config.mode === "reverse-hub") {
56
+ const runtime = await startReverseHubRuntime(config, logger)
57
+ service = runtime.hub
58
+ closeRuntime = runtime.close
59
+ } else {
60
+ const runtime = await startOutboundReverseGatewayRuntime(config, logger)
61
+ service = runtime.gateway
62
+ closeRuntime = runtime.close
63
+ }
64
+ } catch (startupError) {
65
+ try {
66
+ await logger?.close()
67
+ } catch (loggerError) {
68
+ throw new AggregateError([startupError, loggerError], "Socketduct startup and diagnostic logger shutdown both failed", {cause: loggerError})
69
+ }
70
+ throw startupError
60
71
  }
61
72
  const tlsStatus = config.mode === "relay" && config.relayTls === undefined ? "plaintext" : "TLS"
62
- console.log(JSON.stringify({
63
- event: `${config.mode}.started`,
64
- ...(config.mode === "reverse-gateway" ? {gatewayId: config.reverseGatewayId} : {host: config.relayHost, port: service.port}),
65
- transportSecurity: tlsStatus,
66
- compression: config.mode.startsWith("reverse-") ? "zstd" : config.compression
67
- }))
68
- logger?.log("process.started", {
69
- runtimeMode: config.mode,
70
- transportSecurity: tlsStatus,
71
- compression: config.mode.startsWith("reverse-") ? "zstd" : config.compression
72
- })
73
73
  const statusIntervalMs = config.statusIntervalMs ?? 30_000
74
74
  const statusTimer = statusIntervalMs > 0
75
75
  ? setInterval(() => {
@@ -80,9 +80,15 @@ async function main() {
80
80
  : undefined
81
81
  statusTimer?.unref()
82
82
 
83
+ let shuttingDown = false
83
84
  const shutdown = () => {
85
+ if (shuttingDown) return
86
+ shuttingDown = true
84
87
  if (statusTimer !== undefined) clearInterval(statusTimer)
85
88
  closeRuntime()
89
+ .then(() => { logger?.log("process.stopped", {runtimeMode: config.mode}) })
90
+ .then(() => logger?.flush())
91
+ .then(() => logger?.close())
86
92
  .then(() => process.exit(0))
87
93
  .catch((error) => {
88
94
  console.error(error)
@@ -91,6 +97,38 @@ async function main() {
91
97
  }
92
98
  process.once("SIGINT", shutdown)
93
99
  process.once("SIGTERM", shutdown)
100
+ logger?.log("process.started", {
101
+ runtimeMode: config.mode,
102
+ transportSecurity: tlsStatus,
103
+ compression: config.mode.startsWith("reverse-") ? "zstd" : config.compression,
104
+ ...loggingConfigurationFields(config.fileLogging)
105
+ })
106
+ console.log(JSON.stringify({
107
+ event: `${config.mode}.started`,
108
+ ...(config.mode === "reverse-gateway" ? {gatewayId: config.reverseGatewayId} : {host: config.relayHost, port: service.port}),
109
+ transportSecurity: tlsStatus,
110
+ compression: config.mode.startsWith("reverse-") ? "zstd" : config.compression
111
+ }))
112
+ }
113
+
114
+ /**
115
+ * Returns only non-sensitive effective logger settings for the startup record.
116
+ * @param {import('./config/environment.js').RelayEnvironmentConfig['fileLogging']} fileLogging - Managed sink settings.
117
+ * @returns {Record<string, number>} Safe numeric settings.
118
+ */
119
+ function loggingConfigurationFields(fileLogging) {
120
+ if (fileLogging === undefined) return {}
121
+ return {
122
+ frameMaxBytes: fileLogging.frameMaxBytes,
123
+ flushIntervalMs: fileLogging.flushIntervalMs,
124
+ criticalFlushIntervalMs: fileLogging.criticalFlushIntervalMs,
125
+ maxSegmentBytes: fileLogging.maxSegmentBytes,
126
+ retentionDays: fileLogging.retentionDays,
127
+ retentionBytes: fileLogging.retentionBytes,
128
+ maxQueueBytes: fileLogging.maxQueueBytes,
129
+ criticalReserveBytes: fileLogging.criticalReserveBytes,
130
+ compressionLevel: fileLogging.compressionLevel
131
+ }
94
132
  }
95
133
 
96
134
  if (import.meta.url === `file://${process.argv[1]}`) {
@@ -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
+ }