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,745 @@
1
+ import {appendFileSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, renameSync, statSync, unlinkSync} from "node:fs"
2
+ import {join} from "node:path"
3
+
4
+ export const DEFAULT_LOG_MAX_FILE_BYTES = 10 * 1024 * 1024
5
+ export const DEFAULT_LOG_MAX_FILES = 5
6
+ export const MIN_LOG_MAX_FILE_BYTES = 1024
7
+ export const MAX_LOG_FILES = 100
8
+
9
+ const MAX_JSONL_BYTES = 1024
10
+ const MAX_GATEWAY_ID_BYTES = 64
11
+ const MAX_ERROR_NAME_BYTES = 96
12
+ const MAX_ERROR_MESSAGE_BYTES = 512
13
+ const MAX_ERROR_STACK_BYTES = 512
14
+ const ERROR_DETAILS = Symbol("errorDetails")
15
+ const ROLE = /^[a-z][a-z0-9-]{0,63}$/
16
+ const GATEWAY_ID = /^[A-Za-z0-9_-]+$/
17
+ const RUNTIME_MODES = new Set(["summary", "relay", "reverse-hub", "reverse-gateway"])
18
+ const TRANSPORT_SECURITY = new Set(["plaintext", "TLS"])
19
+ const COMPRESSION = new Set(["auto", "off", "zstd", "none"])
20
+ const STATES = new Set(["configured", "connecting", "registering", "ready", "reconnecting", "disconnected", "closed", "starting", "degraded", "failed"])
21
+ const SAFE_ERROR_CODES = new Set([
22
+ "AUTH_FAILED",
23
+ "AUTH_NEGOTIATION_FAILED",
24
+ "AUTH_OWNERSHIP_MISMATCH",
25
+ "AUTH_REQUIRED",
26
+ "BUFFER_LIMIT",
27
+ "CONNECTION_REJECTED",
28
+ "CONNECTION_RESUME_LOST",
29
+ "ECONNABORTED",
30
+ "ECONNREFUSED",
31
+ "ECONNRESET",
32
+ "EHOSTUNREACH",
33
+ "ENETDOWN",
34
+ "ENETUNREACH",
35
+ "ENOTFOUND",
36
+ "EPIPE",
37
+ "ETIMEDOUT",
38
+ "GATEWAY_DISCONNECTED",
39
+ "GATEWAY_REGISTRATION_FAILED",
40
+ "GATEWAY_REPLACED",
41
+ "GATEWAY_SELECTION_FAILED",
42
+ "GATEWAY_STREAM_LIMIT",
43
+ "GATEWAY_UNAVAILABLE",
44
+ "HANDSHAKE_TIMEOUT",
45
+ "HUB_SHUTDOWN",
46
+ "INPUT_LIMIT_EXCEEDED",
47
+ "INVALID_STREAM_OPEN",
48
+ "PROTOCOL_NEGOTIATION_FAILED",
49
+ "PROTOCOL_VIOLATION",
50
+ "RECONNECT_EXHAUSTED",
51
+ "RESUME_REJECTED",
52
+ "RESUME_SESSION_LOST",
53
+ "SESSION_LOST",
54
+ "SOCKETDUCT_AUTH_TIMEOUT",
55
+ "SOCKETDUCT_BUFFER_LIMIT",
56
+ "SOCKETDUCT_CONNECTION_CLOSED",
57
+ "SOCKETDUCT_GATEWAY_CLOSED",
58
+ "SOCKETDUCT_MEMBER_TERMINAL",
59
+ "SOCKETDUCT_SET_BUFFER_LIMIT",
60
+ "SOCKETDUCT_SET_PENDING_OPEN_LIMIT",
61
+ "SOCKETDUCT_SET_QUEUE_LIMIT",
62
+ "SOCKETDUCT_SET_SPOOL_LIMIT",
63
+ "SOCKETDUCT_TRANSPORT_SET_CLOSED",
64
+ "SOCKETDUCT_TRANSPORT_SET_NOT_READY",
65
+ "SOCKETDUCT_TRANSPORT_SET_TERMINAL",
66
+ "STALE_GENERATION",
67
+ "STREAM_RESET",
68
+ "TARGET_CLOSED",
69
+ "TARGET_OPEN_FAILED",
70
+ "TARGET_OPEN_TIMEOUT",
71
+ "TARGET_READ_FAILED",
72
+ "TARGET_UNAUTHORIZED",
73
+ "TLS_ERROR",
74
+ "TRANSPORT_CLOSED",
75
+ "UNKNOWN"
76
+ ])
77
+ const NUMBER_FIELDS = new Set([
78
+ "activeTargets",
79
+ "activeTunnels",
80
+ "attachedSessions",
81
+ "attempt",
82
+ "authenticatedConnections",
83
+ "bufferedBytes",
84
+ "bufferedTargetBytes",
85
+ "bufferLimitBytes",
86
+ "clientPhysicalConnections",
87
+ "closedMembers",
88
+ "compressionBytesSaved",
89
+ "configuredMembers",
90
+ "connectingMembers",
91
+ "connectionGeneration",
92
+ "connectionIdentities",
93
+ "controlQueuedBytes",
94
+ "dataQueuedBytes",
95
+ "degradedMembers",
96
+ "delayMs",
97
+ "detachedSessions",
98
+ "gatewayPhysicalConnections",
99
+ "gatewayRegistrations",
100
+ "generation",
101
+ "healthyGateways",
102
+ "maxPendingOpens",
103
+ "minimumReadyMembers",
104
+ "openFailures",
105
+ "pendingOpens",
106
+ "physicalAccepts",
107
+ "physicalConnections",
108
+ "physicalConnects",
109
+ "queuedBytes",
110
+ "rawDataBytes",
111
+ "readyMembers",
112
+ "reconnectAttempts",
113
+ "reconnectSuccesses",
114
+ "registeredGateways",
115
+ "rejectedOpens",
116
+ "rejectedRegistrations",
117
+ "retainedClientIdentities",
118
+ "resets",
119
+ "resumeFailures",
120
+ "resumeSuccesses",
121
+ "sessions",
122
+ "spoolBytes",
123
+ "spoolCleanupFailures",
124
+ "streamId",
125
+ "streamOpenLatencyMs",
126
+ "targetConnectionAttempts",
127
+ "targetOpenFailures",
128
+ "targetOpens",
129
+ "targetRetryTimers",
130
+ "virtualOpens",
131
+ "virtualStreams",
132
+ "wireDataBytes"
133
+ ])
134
+ const RUNTIME_STATUS_FIELDS = new Set([...NUMBER_FIELDS, "compression", "physicalState"])
135
+ const EVENT_FIELDS = new Map([
136
+ ["process.started", new Set(["runtimeMode", "transportSecurity", "compression"])],
137
+ ["runtime.status", RUNTIME_STATUS_FIELDS],
138
+ ["transport.connecting", new Set(["generation", "attempt", "gatewayId", "reconnect"])],
139
+ ["transport.connected", new Set(["generation", "attempt", "gatewayId", "physicalConnects", "reconnect"])],
140
+ ["transport.disconnected", new Set(["generation", "gatewayId", "virtualStreams", "errorCode"])],
141
+ ["transport.reconnect_scheduled", new Set(["generation", "attempt", "delayMs", "gatewayId", "errorCode"])],
142
+ ["transport.reconnect_succeeded", new Set(["generation", "attempt", "gatewayId", "physicalConnects"])],
143
+ ["transport.failed", new Set(["generation", "attempt", "gatewayId", "errorCode"])],
144
+ ["transport_set.started", new Set(["configuredMembers", "minimumReadyMembers"])],
145
+ ["transport_set.ready", new Set(["configuredMembers", "readyMembers", "minimumReadyMembers"])],
146
+ ["transport_set.member_state", new Set(["gatewayId", "state", "generation", "physicalConnects", "reconnectAttempts", "errorCode"])],
147
+ ["transport_set.member_replaced", new Set(["gatewayId", "generation", "physicalConnects"])],
148
+ ["transport_set.terminal_failure", new Set(["configuredMembers", "closedMembers", "errorCode"])],
149
+ ["virtual_stream.opening", new Set(["streamId", "generation", "gatewayId", "recovered"])],
150
+ ["virtual_stream.opened", new Set(["streamId", "generation", "gatewayId", "recovered"])],
151
+ ["virtual_stream.closed", new Set(["streamId", "generation", "gatewayId"])],
152
+ ["virtual_stream.error", new Set(["streamId", "generation", "gatewayId", "errorCode"])],
153
+ ["reverse_gateway.target_opened", new Set(["streamId", "generation", "gatewayId"])],
154
+ ["reverse_gateway.target_open_failed", new Set(["streamId", "generation", "gatewayId", "errorCode"])],
155
+ ["reverse_gateway.target_closed", new Set(["streamId", "generation", "gatewayId", "errorCode"])]
156
+ ])
157
+ const FAILED_LOGGERS = new WeakSet()
158
+
159
+ /**
160
+ * @typedef {object} DiagnosticLogger
161
+ * @property {(event: string, fields?: Record<string, unknown>) => void} log - Records one diagnostic event.
162
+ * @typedef {object} StructuredLoggerOptions
163
+ * @property {string} role - Stable process/component role.
164
+ * @property {(line: string) => unknown} writeLine - Receives one complete JSON line.
165
+ * @property {number} [processId] - Process identity, defaults to process.pid.
166
+ * @property {() => Date} [now] - Clock provider.
167
+ * @property {{write(chunk: string): unknown}} [stderr] - Failure report destination.
168
+ * @typedef {object} JsonlFileLoggerOptions
169
+ * @property {string} directory - Persistent log directory.
170
+ * @property {string} role - Stable process/component role used in the filename.
171
+ * @property {number} [processId] - Process identity, defaults to process.pid.
172
+ * @property {number} [maxFileBytes] - Maximum bytes per active or rotated file.
173
+ * @property {number} [maxFiles] - Total retained files including the active file.
174
+ * @property {() => Date} [now] - Clock provider.
175
+ * @property {{write(chunk: string): unknown}} [stderr] - Failure report destination.
176
+ */
177
+
178
+ /**
179
+ * Creates an allowlisted structured logger around a caller-provided line writer.
180
+ * Writer errors are isolated and reported at most once.
181
+ * @param {StructuredLoggerOptions} options - Logger identity and writer.
182
+ * @returns {DiagnosticLogger} Best-effort logger.
183
+ */
184
+ export function createStructuredLogger(options) {
185
+ return createStructuredLoggerWithFailureCode(options, "LOGGER_WRITER_ERROR")
186
+ }
187
+
188
+ /**
189
+ * @param {StructuredLoggerOptions} options - Logger identity and writer.
190
+ * @param {'LOGGER_IO_ERROR' | 'LOGGER_WRITER_ERROR'} failureCode - Fixed failure classification.
191
+ * @returns {DiagnosticLogger} Best-effort logger.
192
+ */
193
+ function createStructuredLoggerWithFailureCode(options, failureCode) {
194
+ validateRole(options?.role)
195
+ const role = options.role
196
+ const writeLine = options.writeLine
197
+ if (typeof writeLine !== "function") throw new TypeError("writeLine must be a function")
198
+ const processId = validateProcessId(options.processId ?? process.pid)
199
+ const now = options.now ?? (() => new Date())
200
+ if (typeof now !== "function") throw new TypeError("now must be a function")
201
+ const stderr = options.stderr ?? process.stderr
202
+ let reported = false
203
+ const reportOnce = () => {
204
+ if (reported) return
205
+ reported = true
206
+ reportFailure(stderr, failureCode)
207
+ }
208
+
209
+ return {
210
+ log(event, fields = {}) {
211
+ try {
212
+ const safeFields = sanitizeFields(event, fields)
213
+ if (safeFields === undefined) return
214
+ const timestamp = now()
215
+ if (!(timestamp instanceof Date) || !Number.isFinite(timestamp.getTime())) throw new TypeError("Logger clock returned an invalid date")
216
+ const base = {timestamp: timestamp.toISOString(), event, processId, role}
217
+ let line = formatLogLine(base, safeFields)
218
+ if (Buffer.byteLength(line) > MAX_JSONL_BYTES && safeFields.errorCode !== undefined) {
219
+ /** @type {Record<string, string | number | boolean>} */
220
+ const boundedFields = {...safeFields}
221
+ delete boundedFields.errorStack
222
+ if (typeof boundedFields.errorMessage === "string") {
223
+ const emptyMessageLine = formatLogLine(base, {...boundedFields, errorMessage: ""})
224
+ const availableBytes = Math.max(0, MAX_JSONL_BYTES - Buffer.byteLength(emptyMessageLine))
225
+ boundedFields.errorMessage = truncateDiagnosticText(boundedFields.errorMessage, availableBytes)
226
+ }
227
+ line = formatLogLine(base, boundedFields)
228
+ if (Buffer.byteLength(line) > MAX_JSONL_BYTES) {
229
+ delete boundedFields.errorMessage
230
+ line = formatLogLine(base, boundedFields)
231
+ }
232
+ if (Buffer.byteLength(line) > MAX_JSONL_BYTES) {
233
+ delete boundedFields.errorName
234
+ line = formatLogLine(base, boundedFields)
235
+ }
236
+ }
237
+ if (Buffer.byteLength(line) > MAX_JSONL_BYTES) line = formatLogLine(base, {})
238
+ observeRejection(writeLine(line), reportOnce)
239
+ } catch {
240
+ reportOnce()
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Creates a synchronous append-only JSONL logger with numbered rotation.
248
+ * Each call opens and closes the file, avoiding descriptor lifecycle coupling.
249
+ * @param {JsonlFileLoggerOptions} options - File sink options.
250
+ * @returns {DiagnosticLogger} Best-effort file logger.
251
+ */
252
+ export function createJsonlFileLogger(options) {
253
+ if (typeof options?.directory !== "string" || options.directory.trim().length === 0) throw new TypeError("directory must be a non-empty string")
254
+ validateRole(options.role)
255
+ const directory = options.directory
256
+ const role = options.role
257
+ const processId = validateProcessId(options.processId ?? process.pid)
258
+ const maxFileBytes = validateMaxFileBytes(options.maxFileBytes ?? DEFAULT_LOG_MAX_FILE_BYTES)
259
+ const maxFiles = validateMaxFiles(options.maxFiles ?? DEFAULT_LOG_MAX_FILES)
260
+ const fileName = `${role}-${processId}.jsonl`
261
+ const activePath = join(directory, fileName)
262
+ let familyPruned = false
263
+
264
+ return createStructuredLoggerWithFailureCode({
265
+ role,
266
+ processId,
267
+ ...(options.now === undefined ? {} : {now: options.now}),
268
+ ...(options.stderr === undefined ? {} : {stderr: options.stderr}),
269
+ writeLine(line) {
270
+ const bytes = Buffer.byteLength(line)
271
+ if (bytes > maxFileBytes) throw new Error("Bounded JSON line exceeds configured file size")
272
+ mkdirSync(directory, {recursive: true, mode: 0o700})
273
+ const directoryStats = lstatSync(directory)
274
+ if (!directoryStats.isDirectory() || (directoryStats.mode & 0o777) !== 0o700) throw new Error("Unsafe log directory")
275
+ let active = inspectLogPath(activePath)
276
+ if (active?.incomplete && (maxFiles === 1 || active.size > maxFileBytes)) throw new Error("Cannot retain incomplete log within configured bounds")
277
+ if (!familyPruned) {
278
+ pruneObsoleteRoleFiles(directory, role, processId, maxFiles)
279
+ pruneFileFamily(directory, fileName, maxFileBytes, maxFiles)
280
+ familyPruned = true
281
+ active = inspectLogPath(activePath)
282
+ }
283
+ if (active !== undefined && (active.incomplete || active.size + bytes > maxFileBytes)) {
284
+ if (active.incomplete && (maxFiles === 1 || active.size > maxFileBytes)) throw new Error("Cannot retain incomplete log within configured bounds")
285
+ rotate(activePath, maxFiles)
286
+ }
287
+ const descriptor = openSync(activePath, constants.O_RDWR | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600)
288
+ try {
289
+ const current = inspectLogDescriptor(descriptor)
290
+ if (current.incomplete || current.size + bytes > maxFileBytes) throw new Error("Active log is not safe to append")
291
+ appendFileSync(descriptor, line, {encoding: "utf8"})
292
+ } finally {
293
+ closeSync(descriptor)
294
+ }
295
+ }
296
+ }, "LOGGER_IO_ERROR")
297
+ }
298
+
299
+ /**
300
+ * @param {string} path - Active log path.
301
+ * @returns {{size: number, incomplete: boolean} | undefined} Bounded tail state, or an absent path.
302
+ */
303
+ function inspectLogPath(path) {
304
+ let descriptor
305
+ try {
306
+ descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
307
+ } catch (error) {
308
+ if (isMissing(error)) return undefined
309
+ throw error
310
+ }
311
+ try {
312
+ return inspectLogDescriptor(descriptor)
313
+ } finally {
314
+ closeSync(descriptor)
315
+ }
316
+ }
317
+
318
+ /**
319
+ * @param {number} descriptor - Open log descriptor.
320
+ * @returns {{size: number, incomplete: boolean}} Size and final-byte framing state.
321
+ */
322
+ function inspectLogDescriptor(descriptor) {
323
+ const stats = fstatSync(descriptor)
324
+ if (!stats.isFile() || (stats.mode & 0o777) !== 0o600) throw new Error("Unsafe log file")
325
+ if (stats.size === 0) return {size: 0, incomplete: false}
326
+ const tail = Buffer.alloc(1)
327
+ if (readSync(descriptor, tail, 0, 1, stats.size - 1) !== 1) throw new Error("Could not inspect log tail")
328
+ return {size: stats.size, incomplete: tail[0] !== 10}
329
+ }
330
+
331
+ /**
332
+ * Retains at most maxFiles logs across process instances that are definitely
333
+ * exited. Live and indeterminate process families are never cleanup targets.
334
+ * @param {string} directory - Configured log directory.
335
+ * @param {string} role - Validated logger role.
336
+ * @param {number} currentProcessId - This logger's configured process ID.
337
+ * @param {number} maxFiles - Obsolete same-role file retention bound.
338
+ * @returns {void}
339
+ */
340
+ function pruneObsoleteRoleFiles(directory, role, currentProcessId, maxFiles) {
341
+ /** @type {Map<number, boolean>} */
342
+ const exitedProcesses = new Map()
343
+ /** @type {Array<{entry: string, path: string, processId: number, modifiedAt: number, device: number, inode: number, size: number, changedAt: number}>} */
344
+ const candidates = []
345
+ for (const entry of readdirSync(directory)) {
346
+ const processId = roleLogProcessId(entry, role)
347
+ if (processId === undefined || processId === currentProcessId) continue
348
+ let exited = exitedProcesses.get(processId)
349
+ if (exited === undefined) {
350
+ exited = processDefinitelyExited(processId)
351
+ exitedProcesses.set(processId, exited)
352
+ }
353
+ if (!exited) continue
354
+ const path = join(directory, entry)
355
+ let stats
356
+ try {
357
+ stats = lstatSync(path)
358
+ } catch (error) {
359
+ if (isMissing(error)) continue
360
+ throw error
361
+ }
362
+ if (!stats.isFile()) continue
363
+ candidates.push({
364
+ entry,
365
+ path,
366
+ processId,
367
+ modifiedAt: Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : 0,
368
+ device: stats.dev,
369
+ inode: stats.ino,
370
+ size: stats.size,
371
+ changedAt: stats.ctimeMs
372
+ })
373
+ }
374
+ candidates.sort((left, right) => {
375
+ if (left.modifiedAt !== right.modifiedAt) return right.modifiedAt - left.modifiedAt
376
+ if (left.entry < right.entry) return -1
377
+ if (left.entry > right.entry) return 1
378
+ return 0
379
+ })
380
+ for (const candidate of candidates.slice(maxFiles)) {
381
+ // Re-probe around an unchanged-file check. If the PID was reused or the
382
+ // family changed while cleanup was pending, preserve it for a later scan.
383
+ if (!processDefinitelyExited(candidate.processId) || !sameFile(candidate) || !processDefinitelyExited(candidate.processId)) continue
384
+ unlinkIfPresent(candidate.path)
385
+ }
386
+ }
387
+
388
+ /**
389
+ * @param {string} entry - Directory entry.
390
+ * @param {string} role - Validated role prefix.
391
+ * @returns {number | undefined} Parsed owner PID for a recognized log file.
392
+ */
393
+ function roleLogProcessId(entry, role) {
394
+ const prefix = `${role}-`
395
+ if (!entry.startsWith(prefix)) return undefined
396
+ const match = /^(0|[1-9][0-9]*)\.jsonl(?:\.[1-9][0-9]*)?$/.exec(entry.slice(prefix.length))
397
+ if (match === null) return undefined
398
+ const processId = Number(match[1])
399
+ return Number.isSafeInteger(processId) && processId > 0 ? processId : undefined
400
+ }
401
+
402
+ /**
403
+ * @param {{path: string, device: number, inode: number, size: number, changedAt: number}} candidate - Original file snapshot.
404
+ * @returns {boolean} Whether the candidate is still the same regular file.
405
+ */
406
+ function sameFile(candidate) {
407
+ try {
408
+ const stats = lstatSync(candidate.path)
409
+ return stats.isFile() && stats.dev === candidate.device && stats.ino === candidate.inode && stats.size === candidate.size && stats.ctimeMs === candidate.changedAt
410
+ } catch (error) {
411
+ if (isMissing(error)) return false
412
+ throw error
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Treats only ESRCH as proof that a PID is unused. PID reuse, permission
418
+ * failures, unsupported probes, and malformed errors all retain the family.
419
+ * @param {number} processId - Candidate owner PID.
420
+ * @returns {boolean} Whether the process is definitely exited.
421
+ */
422
+ function processDefinitelyExited(processId) {
423
+ try {
424
+ process.kill(processId, 0)
425
+ return false
426
+ } catch (error) {
427
+ try {
428
+ return error !== null && typeof error === "object" && /** @type {{code?: unknown}} */ (error).code === "ESRCH"
429
+ } catch {
430
+ return false
431
+ }
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Removes only this process/role family's files that violate current bounds.
437
+ * @param {string} directory - Configured log directory.
438
+ * @param {string} fileName - Active file basename.
439
+ * @param {number} maxFileBytes - Per-file byte bound.
440
+ * @param {number} maxFiles - Total family retention bound.
441
+ * @returns {void}
442
+ */
443
+ function pruneFileFamily(directory, fileName, maxFileBytes, maxFiles) {
444
+ const rotatedPrefix = `${fileName}.`
445
+ for (const entry of readdirSync(directory)) {
446
+ let index
447
+ if (entry === fileName) {
448
+ index = 0
449
+ } else if (entry.startsWith(rotatedPrefix) && /^[1-9][0-9]*$/.test(entry.slice(rotatedPrefix.length))) {
450
+ index = Number(entry.slice(rotatedPrefix.length))
451
+ } else {
452
+ continue
453
+ }
454
+ const path = join(directory, entry)
455
+ if (!Number.isSafeInteger(index) || index >= maxFiles || statSync(path).size > maxFileBytes) unlinkSync(path)
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Sends an allowlisted event to an optional embedded logger without allowing a
461
+ * logger exception to alter transport behavior.
462
+ * @param {DiagnosticLogger | undefined} logger - Optional consumer logger.
463
+ * @param {string} event - Allowlisted event name.
464
+ * @param {Record<string, unknown>} [fields] - Candidate diagnostic fields.
465
+ * @returns {void}
466
+ */
467
+ export function logDiagnostic(logger, event, fields = {}) {
468
+ try {
469
+ if (logger === undefined || logger === null || typeof logger.log !== "function") return
470
+ const safeFields = sanitizeFields(event, fields)
471
+ if (safeFields !== undefined) {
472
+ const result = /** @type {(event: string, fields: Record<string, unknown>) => unknown} */ (logger.log)(event, safeFields)
473
+ observeRejection(result, () => reportLoggerFailure(logger))
474
+ }
475
+ } catch {
476
+ reportLoggerFailure(logger)
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Returns error fields for operational diagnostics.
482
+ * @param {unknown} error - Failure value.
483
+ * @param {string} [fallback] - Known fallback code.
484
+ * @returns {{errorCode: string, errorClass: string, errorMessage?: string, errorName?: string, errorStack?: string}} Error classification and details.
485
+ */
486
+ export function errorDiagnostics(error, fallback = "UNKNOWN") {
487
+ let code
488
+ let errorMessage
489
+ let errorName
490
+ let errorStack
491
+ try {
492
+ if (error instanceof Error) {
493
+ code = /** @type {{code?: unknown}} */ (error).code
494
+ errorMessage = error.message
495
+ errorName = error.name
496
+ errorStack = error.stack
497
+ } else if (typeof error === "string") {
498
+ errorMessage = error
499
+ } else if (error !== null && typeof error === "object") {
500
+ code = /** @type {{code?: unknown}} */ (error).code
501
+ }
502
+ } catch {
503
+ code = undefined
504
+ }
505
+ const errorCode = normalizeErrorCode(typeof code === "string" ? code : fallback)
506
+ const diagnostics = {
507
+ errorCode,
508
+ errorClass: classifyError(errorCode)
509
+ }
510
+ Object.defineProperty(diagnostics, ERROR_DETAILS, {
511
+ enumerable: true,
512
+ value: {
513
+ ...(errorName === undefined ? {} : {errorName: truncateDiagnosticText(redactCredentialText(errorName), MAX_ERROR_NAME_BYTES)}),
514
+ ...(errorMessage === undefined ? {} : {errorMessage: truncateDiagnosticText(redactCredentialText(errorMessage), MAX_ERROR_MESSAGE_BYTES)}),
515
+ ...(errorStack === undefined ? {} : {errorStack: truncateDiagnosticText(redactCredentialText(errorStack), MAX_ERROR_STACK_BYTES)})
516
+ }
517
+ })
518
+ return diagnostics
519
+ }
520
+
521
+ /**
522
+ * @param {string} event - Event name.
523
+ * @param {Record<string, unknown>} fields - Candidate fields.
524
+ * @returns {Record<string, string | number | boolean> | undefined} Allowlisted fields, or undefined for an unknown event.
525
+ */
526
+ function sanitizeFields(event, fields) {
527
+ const allowed = EVENT_FIELDS.get(event)
528
+ if (allowed === undefined || typeof fields !== "object" || fields === null || Array.isArray(fields)) return undefined
529
+ /** @type {Record<string, string | number | boolean>} */
530
+ const safe = {}
531
+ for (const field of allowed) {
532
+ const value = fields[field]
533
+ if (NUMBER_FIELDS.has(field)) {
534
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) safe[field] = value
535
+ continue
536
+ }
537
+ if (field === "gatewayId") {
538
+ if (typeof value === "string") {
539
+ const bounded = value.slice(0, MAX_GATEWAY_ID_BYTES)
540
+ if (GATEWAY_ID.test(bounded)) safe.gatewayId = bounded
541
+ }
542
+ continue
543
+ }
544
+ if (field === "runtimeMode" && typeof value === "string" && RUNTIME_MODES.has(value)) safe.runtimeMode = value
545
+ else if (field === "transportSecurity" && typeof value === "string" && TRANSPORT_SECURITY.has(value)) safe.transportSecurity = value
546
+ else if (field === "compression" && typeof value === "string" && COMPRESSION.has(value)) safe.compression = value
547
+ else if ((field === "state" || field === "physicalState") && typeof value === "string" && STATES.has(value)) safe[field] = value
548
+ else if ((field === "reconnect" || field === "recovered") && typeof value === "boolean") safe[field] = value
549
+ else if (field === "errorCode" && value !== undefined) {
550
+ const errorCode = normalizeErrorCode(value)
551
+ safe.errorCode = errorCode
552
+ safe.errorClass = classifyError(errorCode)
553
+ }
554
+ }
555
+ const errorDetails = Object.getOwnPropertyDescriptor(fields, ERROR_DETAILS)?.value
556
+ if (allowed.has("errorCode") && errorDetails !== null && typeof errorDetails === "object" && !Array.isArray(errorDetails)) {
557
+ const typedErrorDetails = /** @type {{errorMessage?: unknown, errorName?: unknown, errorStack?: unknown}} */ (errorDetails)
558
+ if (typeof typedErrorDetails.errorName === "string") safe.errorName = typedErrorDetails.errorName
559
+ if (typeof typedErrorDetails.errorMessage === "string") safe.errorMessage = typedErrorDetails.errorMessage
560
+ if (typeof typedErrorDetails.errorStack === "string") safe.errorStack = typedErrorDetails.errorStack
561
+ Object.defineProperty(safe, ERROR_DETAILS, {enumerable: true, value: errorDetails})
562
+ }
563
+ return safe
564
+ }
565
+
566
+ /**
567
+ * @param {{event: string, processId: number, role: string, timestamp: string}} base - Required JSONL fields.
568
+ * @param {Record<string, string | number | boolean>} fields - Sanitized event fields.
569
+ * @returns {string} One JSONL record.
570
+ */
571
+ function formatLogLine(base, fields) {
572
+ return `${JSON.stringify({...base, ...fields})}\n`
573
+ }
574
+
575
+ /**
576
+ * @param {string} value - Diagnostic value.
577
+ * @param {number} maxBytes - Maximum UTF-8 byte length.
578
+ * @returns {string} Bounded diagnostic value.
579
+ */
580
+ function truncateDiagnosticText(value, maxBytes) {
581
+ let truncated = ""
582
+ let bytes = 0
583
+ for (const character of value) {
584
+ const characterBytes = Buffer.byteLength(character)
585
+ if (bytes + characterBytes > maxBytes) break
586
+ truncated += character
587
+ bytes += characterBytes
588
+ }
589
+ return truncated
590
+ }
591
+
592
+ /**
593
+ * @param {string} value - Error text.
594
+ * @returns {string} Error text without credential-shaped values.
595
+ */
596
+ function redactCredentialText(value) {
597
+ return value
598
+ .replace(/(Bearer\s+)[^\s,;]+/giu, "$1[REDACTED]")
599
+ .replace(/(\/\/)[^\s/@]+@/gu, "$1[REDACTED]@")
600
+ .replace(/((?:password|token|authorization)\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/giu, "$1[REDACTED]")
601
+ }
602
+
603
+ /**
604
+ * @param {unknown} value - Candidate error code.
605
+ * @returns {string} Allowlisted error code.
606
+ */
607
+ function normalizeErrorCode(value) {
608
+ if (typeof value !== "string") return "UNKNOWN"
609
+ if (SAFE_ERROR_CODES.has(value)) return value
610
+ if (value.startsWith("ERR_TLS_") || value.startsWith("ERR_SSL_") || value.includes("CERT") || value.includes("VERIFY") || value.includes("SELF_SIGNED") || value.includes("ISSUER")) return "TLS_ERROR"
611
+ return "UNKNOWN"
612
+ }
613
+
614
+ /**
615
+ * @param {string} code - Sanitized error code.
616
+ * @returns {string} Bounded error class.
617
+ */
618
+ function classifyError(code) {
619
+ if (code === "UNKNOWN") return "unknown"
620
+ if (code === "TLS_ERROR") return "tls"
621
+ if (code === "SOCKETDUCT_AUTH_TIMEOUT" || code === "HANDSHAKE_TIMEOUT" || code === "TARGET_OPEN_TIMEOUT" || code === "ETIMEDOUT") return "timeout"
622
+ if (code.startsWith("AUTH_") || code.includes("NEGOTIATION")) return "authentication"
623
+ if (code.startsWith("SOCKETDUCT_SET_") || code.includes("BUFFER_LIMIT") || code.includes("STREAM_LIMIT") || code === "INPUT_LIMIT_EXCEEDED") return "limit"
624
+ if (code.startsWith("E") || code.includes("DISCONNECTED") || code.includes("UNAVAILABLE") || code === "TRANSPORT_CLOSED") return "network"
625
+ if (code.includes("PROTOCOL") || code.includes("GENERATION")) return "protocol"
626
+ return "lifecycle"
627
+ }
628
+
629
+ /**
630
+ * @param {string} activePath - Active JSONL path.
631
+ * @param {number} maxFiles - Total retained files.
632
+ * @returns {void}
633
+ */
634
+ function rotate(activePath, maxFiles) {
635
+ if (maxFiles === 1) {
636
+ unlinkIfPresent(activePath)
637
+ return
638
+ }
639
+ unlinkIfPresent(`${activePath}.${maxFiles - 1}`)
640
+ for (let index = maxFiles - 2; index >= 1; index -= 1) renameIfPresent(`${activePath}.${index}`, `${activePath}.${index + 1}`)
641
+ renameSync(activePath, `${activePath}.1`)
642
+ }
643
+
644
+ /**
645
+ * @param {string} source - Existing path.
646
+ * @param {string} destination - Rotated path.
647
+ * @returns {void}
648
+ */
649
+ function renameIfPresent(source, destination) {
650
+ try {
651
+ renameSync(source, destination)
652
+ } catch (error) {
653
+ if (!isMissing(error)) throw error
654
+ }
655
+ }
656
+
657
+ /**
658
+ * @param {string} path - Path to remove if present.
659
+ * @returns {void}
660
+ */
661
+ function unlinkIfPresent(path) {
662
+ try {
663
+ unlinkSync(path)
664
+ } catch (error) {
665
+ if (!isMissing(error)) throw error
666
+ }
667
+ }
668
+
669
+ /**
670
+ * @param {unknown} error - Filesystem error.
671
+ * @returns {boolean} Whether the error represents a missing path.
672
+ */
673
+ function isMissing(error) {
674
+ return error !== null && typeof error === "object" && /** @type {{code?: unknown}} */ (error).code === "ENOENT"
675
+ }
676
+
677
+ /**
678
+ * @param {{write(chunk: string): unknown}} stderr - Failure destination.
679
+ * @param {string} code - Bounded code.
680
+ * @returns {void}
681
+ */
682
+ function reportFailure(stderr, code) {
683
+ try {
684
+ stderr.write(`Socketduct logger failure (${code})\n`)
685
+ } catch {
686
+ // Logging must never recursively report its own failure.
687
+ }
688
+ }
689
+
690
+ /**
691
+ * Attaches a rejection handler when a custom writer returns a promise.
692
+ * @param {unknown} result - Writer result.
693
+ * @param {() => void} onRejected - Bounded failure reporter.
694
+ * @returns {void}
695
+ */
696
+ function observeRejection(result, onRejected) {
697
+ if (result === null || (typeof result !== "object" && typeof result !== "function")) return
698
+ Promise.resolve(result).catch(onRejected)
699
+ }
700
+
701
+ /**
702
+ * Reports one failure for a caller-provided logger object.
703
+ * @param {DiagnosticLogger | undefined} logger - Failed logger.
704
+ * @returns {void}
705
+ */
706
+ function reportLoggerFailure(logger) {
707
+ if (logger === undefined || logger === null || typeof logger !== "object" || FAILED_LOGGERS.has(logger)) return
708
+ FAILED_LOGGERS.add(logger)
709
+ reportFailure(process.stderr, "LOGGER_WRITER_ERROR")
710
+ }
711
+
712
+ /**
713
+ * @param {string} role - Candidate role.
714
+ * @returns {void}
715
+ */
716
+ function validateRole(role) {
717
+ if (typeof role !== "string" || !ROLE.test(role)) throw new TypeError("role must be 1-64 lowercase letters, digits, or hyphens")
718
+ }
719
+
720
+ /**
721
+ * @param {number} processId - Candidate process ID.
722
+ * @returns {number} Validated process ID.
723
+ */
724
+ function validateProcessId(processId) {
725
+ if (!Number.isSafeInteger(processId) || processId < 0) throw new RangeError("processId must be a non-negative safe integer")
726
+ return processId
727
+ }
728
+
729
+ /**
730
+ * @param {number} value - Candidate file byte bound.
731
+ * @returns {number} Validated byte bound.
732
+ */
733
+ export function validateMaxFileBytes(value) {
734
+ if (!Number.isSafeInteger(value) || value < MIN_LOG_MAX_FILE_BYTES) throw new RangeError(`maxFileBytes must be an integer of at least ${MIN_LOG_MAX_FILE_BYTES}`)
735
+ return value
736
+ }
737
+
738
+ /**
739
+ * @param {number} value - Candidate retention bound.
740
+ * @returns {number} Validated retention bound.
741
+ */
742
+ export function validateMaxFiles(value) {
743
+ if (!Number.isSafeInteger(value) || value < 1 || value > MAX_LOG_FILES) throw new RangeError(`maxFiles must be an integer from 1 to ${MAX_LOG_FILES}`)
744
+ return value
745
+ }