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
@@ -7,7 +7,10 @@ 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, data: Buffer, callback: (error?: Error | null) => void) => void} [_writeDurableStream] - Atomically admit one durable application operation.
11
+ * @property {(streamId: number, bytes: number) => void} [_deliverReadable] - Report bytes exposed to the application.
10
12
  * @property {(streamId: number, bytes: number) => void} [_consumeReadable] - Report bytes returned to the application.
13
+ * @property {(streamId: number, bytes: number) => Promise<number>} [_acknowledgeReadable] - Durably acknowledge application bytes.
11
14
  * @property {() => {address?: string, port?: number}} physicalAddress - Current physical endpoint.
12
15
  * @property {() => unknown} ref - Reference owner resources.
13
16
  * @property {() => unknown} unref - Unreference owner resources.
@@ -15,6 +18,16 @@ import {Duplex} from "node:stream"
15
18
 
16
19
  /** A stream-local failure where the physical connection may remain usable. */
17
20
  export class SocketductStreamError extends Error {
21
+ /** @type {string | undefined} */ disconnectIncidentId
22
+ /** @type {string | undefined} */ disconnectReason
23
+ /** @type {'reverse_hub' | 'reverse_client' | 'reverse_gateway' | undefined} */ incidentOrigin
24
+ /** @type {'client' | 'gateway' | undefined} */ physicalRole
25
+ /** @type {number | undefined} */ clientConnectionGeneration
26
+ /** @type {number | undefined} */ gatewayConnectionGeneration
27
+ /** @type {number | undefined} */ fanOutCount
28
+ /** @type {number | undefined} */ resumeSuccessCount
29
+ /** @type {number | undefined} */ terminalStreamCount
30
+
18
31
  /**
19
32
  * @param {string} message - Safe description.
20
33
  * @param {string} code - Stable code.
@@ -64,6 +77,18 @@ export class VirtualSocket extends Duplex {
64
77
  /** @type {number} */
65
78
  #emittedDataBytes = 0
66
79
 
80
+ /** @type {boolean} */
81
+ #manualReceiveAck
82
+
83
+ /** @type {number} */
84
+ #deliveredReadableBytes = 0
85
+
86
+ /** @type {number} */
87
+ #requestedReceiveAckBytes = 0
88
+
89
+ /** @type {WeakMap<Buffer, {resolve: () => void, reject: (error: Error) => void}>} */
90
+ #durableWrites = new WeakMap()
91
+
67
92
  /** @type {boolean} */
68
93
  connecting = true
69
94
 
@@ -94,7 +119,7 @@ export class VirtualSocket extends Duplex {
94
119
  /**
95
120
  * @param {VirtualSocketOwner} connection - Owner.
96
121
  * @param {number} streamId - Allocated stream ID.
97
- * @param {{host: string, port: number, allowHalfOpen?: boolean, readableHighWaterMark?: number, writableHighWaterMark?: number}} options - Socket metadata/options.
122
+ * @param {{host: string, port: number, allowHalfOpen?: boolean, readableHighWaterMark?: number, writableHighWaterMark?: number, manualReceiveAck?: boolean}} options - Socket metadata/options.
98
123
  */
99
124
  constructor(connection, streamId, options) {
100
125
  super({
@@ -104,6 +129,7 @@ export class VirtualSocket extends Duplex {
104
129
  })
105
130
  this.#connection = connection
106
131
  this.#streamId = streamId
132
+ this.#manualReceiveAck = options.manualReceiveAck ?? false
107
133
  this.remoteAddress = options.host
108
134
  this.remotePort = options.port
109
135
  this.remoteFamily = options.host.includes(":") ? "IPv6" : "IPv4"
@@ -114,6 +140,11 @@ export class VirtualSocket extends Duplex {
114
140
  return this.#streamId
115
141
  }
116
142
 
143
+ /** @returns {boolean} Whether application-controlled receive ACK is enabled. */
144
+ get manualReceiveAck() {
145
+ return this.#manualReceiveAck
146
+ }
147
+
117
148
  /** @returns {this} Socket-style no-op connect; opening starts at construction. */
118
149
  connect() {
119
150
  return this
@@ -141,7 +172,11 @@ export class VirtualSocket extends Duplex {
141
172
  if (chunk !== null) {
142
173
  const bytes = typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length
143
174
  const emittedBytes = this.#emittedDataBytes - emittedBefore
144
- if (bytes > emittedBytes) this.#connection._consumeReadable?.(this.#streamId, bytes - emittedBytes)
175
+ if (bytes > emittedBytes) {
176
+ this.#deliveredReadableBytes += bytes - emittedBytes
177
+ this.#connection._deliverReadable?.(this.#streamId, bytes - emittedBytes)
178
+ this.#connection._consumeReadable?.(this.#streamId, bytes - emittedBytes)
179
+ }
145
180
  }
146
181
  return chunk
147
182
  }
@@ -155,14 +190,57 @@ export class VirtualSocket extends Duplex {
155
190
  * @override
156
191
  */
157
192
  emit(event, ...args) {
158
- const emitted = super.emit(event, ...args)
159
193
  const chunk = args[0]
160
194
  if (event === "data" && (typeof chunk === "string" || Buffer.isBuffer(chunk))) {
161
195
  const bytes = typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length
162
196
  this.#emittedDataBytes += bytes
197
+ this.#deliveredReadableBytes += bytes
198
+ this.#connection._deliverReadable?.(this.#streamId, bytes)
199
+ const emitted = super.emit(event, ...args)
163
200
  this.#connection._consumeReadable?.(this.#streamId, bytes)
201
+ return emitted
164
202
  }
165
- return emitted
203
+ return super.emit(event, ...args)
204
+ }
205
+
206
+ /**
207
+ * Durably admits one complete application operation before any of its bytes
208
+ * become eligible for transmission.
209
+ * @param {string | ArrayBufferView} chunk - Complete serialized operation.
210
+ * @param {BufferEncoding} [encoding] - String encoding.
211
+ * @returns {Promise<void>} Resolves after all bytes and recovery metadata are committed.
212
+ */
213
+ writeDurable(chunk, encoding = "utf8") {
214
+ if (this.destroyed) return Promise.reject(contractError("Socketduct stream is destroyed", "ERR_STREAM_DESTROYED"))
215
+ if (this.writableEnded || this.writableFinished) return Promise.reject(contractError("Socketduct stream has ended", "ERR_STREAM_WRITE_AFTER_END"))
216
+ if (this.#connection._writeDurableStream === undefined) return Promise.reject(contractError("Durable write admission is unavailable for this Socketduct transport", "SOCKETDUCT_DURABLE_WRITE_UNSUPPORTED"))
217
+ const data = bufferFromView(chunk, encoding)
218
+ this.#resetTimeout()
219
+ return new Promise((resolve, reject) => {
220
+ this.#durableWrites.set(data, {resolve, reject})
221
+ this.write(data, (error) => {
222
+ if (error !== undefined && error !== null) reject(error)
223
+ })
224
+ })
225
+ }
226
+
227
+ /**
228
+ * Advances the durable receive ACK by application bytes after parser/journal
229
+ * state is durable.
230
+ * @param {number} bytes - Newly durable application bytes.
231
+ * @returns {Promise<number>} New cumulative durable receive sequence.
232
+ */
233
+ acknowledgeReceive(bytes) {
234
+ if (!this.#manualReceiveAck) throw contractError("Manual receive acknowledgement is not enabled for this socket", "SOCKETDUCT_MANUAL_ACK_DISABLED")
235
+ if (!Number.isSafeInteger(bytes) || bytes <= 0) throw contractError("Receive acknowledgement bytes must be a positive safe integer", "SOCKETDUCT_INVALID_RECEIVE_ACK")
236
+ if (this.destroyed) return Promise.reject(contractError("Socketduct stream is destroyed", "ERR_STREAM_DESTROYED"))
237
+ if (this.#requestedReceiveAckBytes + bytes > this.#deliveredReadableBytes) throw contractError("Receive acknowledgement exceeds bytes delivered to the application", "SOCKETDUCT_INVALID_RECEIVE_ACK")
238
+ if (this.#connection._acknowledgeReadable === undefined) return Promise.reject(contractError("Manual receive acknowledgement is unavailable for this Socketduct transport", "SOCKETDUCT_MANUAL_ACK_UNSUPPORTED"))
239
+ this.#requestedReceiveAckBytes += bytes
240
+ return this.#connection._acknowledgeReadable(this.#streamId, bytes).catch((error) => {
241
+ this.#requestedReceiveAckBytes -= bytes
242
+ throw error
243
+ })
166
244
  }
167
245
 
168
246
  /**
@@ -173,6 +251,24 @@ export class VirtualSocket extends Duplex {
173
251
  * @returns {void}
174
252
  */
175
253
  _write(chunk, encoding, callback) {
254
+ const durableChunk = Buffer.isBuffer(chunk) ? /** @type {Buffer} */ (chunk) : undefined
255
+ const durable = durableChunk === undefined ? undefined : this.#durableWrites.get(durableChunk)
256
+ if (durable !== undefined) {
257
+ const data = /** @type {Buffer} */ (durableChunk)
258
+ this.#durableWrites.delete(data)
259
+ this.#resetTimeout()
260
+ this.#connection._writeDurableStream?.(this.#streamId, data, (error) => {
261
+ if (error !== undefined && error !== null) durable.reject(error)
262
+ else {
263
+ this.bytesWritten += data.length
264
+ durable.resolve()
265
+ }
266
+ // Admission failures reject writeDurable() but remain retryable, so the
267
+ // Writable queue advances without turning the whole socket terminal.
268
+ callback()
269
+ })
270
+ return
271
+ }
176
272
  const data = typeof chunk === "string" ? Buffer.from(chunk, encoding) : Buffer.from(chunk)
177
273
  this.#resetTimeout()
178
274
  this.#connection._writeStream(this.#streamId, data, (error) => {
@@ -296,3 +392,22 @@ export class VirtualSocket extends Duplex {
296
392
  this.#timeoutTimer.unref?.()
297
393
  }
298
394
  }
395
+
396
+ /**
397
+ * @param {string} message - Safe message.
398
+ * @param {string} code - Stable code.
399
+ */
400
+ function contractError(message, code) {
401
+ return new SocketductStreamError(message, code)
402
+ }
403
+
404
+ /**
405
+ * @param {string | ArrayBufferView} value - Bytes.
406
+ * @param {BufferEncoding} encoding - String encoding.
407
+ * @returns {Buffer} Owned bytes.
408
+ */
409
+ function bufferFromView(value, encoding) {
410
+ if (typeof value === "string") return Buffer.from(value, encoding)
411
+ if (!ArrayBuffer.isView(value)) throw new TypeError("Durable write requires a string, TypedArray, or DataView")
412
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength)
413
+ }
@@ -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,14 +1,15 @@
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"
11
- export {createMultiplexedSocketductTransportSet, MultiplexedSocketductTransportSet, SocketductTransportSetLimitError, SocketductTransportSetNotReadyError, SocketductTransportSetTerminalError} from "./client/multiplexed-transport-set.js"
12
+ export {createMultiplexedSocketductTransportSet, MultiplexedSocketductTransportSet, SocketductRecoveryLookupError, SocketductTransportSetLimitError, SocketductTransportSetNotReadyError, SocketductTransportSetTerminalError} from "./client/multiplexed-transport-set.js"
12
13
  export {SocketductSessionLostError, SocketductStreamError, VirtualSocket} from "./client/virtual-socket.js"
13
14
  export {createOutboundReverseGateway, createReverseGatewayConnection, OutboundReverseGateway, ReverseGatewayConnection, startReverseGatewayHub} from "./reverse/index.js"
14
15
 
@@ -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]}`) {