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.
- package/.dockerignore +11 -0
- package/.env.example +131 -0
- package/AGENTS.md +168 -0
- package/Dockerfile +30 -0
- package/README.md +134 -0
- package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
- package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
- package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
- package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
- package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
- package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
- package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
- package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
- package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
- package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
- package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
- package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
- package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
- package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
- package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
- package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
- package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
- package/changelog.d/20260902231542-reverse-close-position.md +1 -0
- package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
- package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
- package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
- package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
- package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
- package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
- package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
- package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
- package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
- package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
- package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
- package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
- package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
- package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
- package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
- package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
- package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
- package/docker-compose.dev.yml +19 -0
- package/docker-compose.reverse-gateway.yml +36 -0
- package/docker-compose.yml +73 -0
- package/docs/architecture.md +264 -0
- package/docs/docker-topology.md +164 -0
- package/docs/docker.md +281 -0
- package/docs/logging.md +185 -0
- package/docs/plans/initial-implementation.md +205 -0
- package/docs/plans/socket-docker-integration.md +221 -0
- package/docs/protocol.md +558 -0
- package/docs/releasing.md +18 -0
- package/docs/reverse-gateway.md +186 -0
- package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
- package/docs/running.md +215 -0
- package/docs/security.md +258 -0
- package/docs/session-lifecycle.md +127 -0
- package/docs/socket-api.md +448 -0
- package/eslint.config.js +36 -0
- package/package.json +44 -0
- package/scripts/deploy-gateway.js +194 -0
- package/scripts/test.js +28 -0
- package/src/auth/passwords.js +38 -0
- package/src/auth/static-token-store.js +41 -0
- package/src/auth/user-store.js +139 -0
- package/src/client/connect.js +76 -0
- package/src/client/deferred-virtual-socket.js +312 -0
- package/src/client/http-agent.js +101 -0
- package/src/client/https-agent.js +366 -0
- package/src/client/multiplexed-connection.js +1504 -0
- package/src/client/multiplexed-transport-set.js +917 -0
- package/src/client/multiplexed.js +3 -0
- package/src/client/socketduct-socket.js +1174 -0
- package/src/client/virtual-socket.js +256 -0
- package/src/config/environment.js +400 -0
- package/src/edge/tcp-edge.js +82 -0
- package/src/index.js +101 -0
- package/src/logging/structured-logger.js +745 -0
- package/src/protocol/compression.js +325 -0
- package/src/protocol/frame.js +341 -0
- package/src/protocol/v3-compression.js +44 -0
- package/src/protocol/v3-frame.js +209 -0
- package/src/proxy/multiplexed-relay.js +798 -0
- package/src/proxy/tcp-relay.js +1191 -0
- package/src/relay.js +3 -0
- package/src/reverse/client.js +721 -0
- package/src/reverse/common.js +88 -0
- package/src/reverse/gateway.js +663 -0
- package/src/reverse/hub.js +563 -0
- package/src/reverse/index.js +3 -0
- package/src/reverse/runtime.js +78 -0
- package/src/server/control-handler.js +170 -0
- package/src/server/primary.js +64 -0
- package/src/server/relay-runtime.js +124 -0
- package/src/server/worker.js +39 -0
- package/src/sessions/session.js +212 -0
- package/src/spool/spool-file.js +451 -0
- package/tensorbuzz.yml +36 -0
- package/test/e2e/gateway-process.test.js +262 -0
- package/test/e2e/reverse-gateway-process.test.js +285 -0
- package/test/fixtures/logging/enospc-matrix.mjs +238 -0
- package/test/fixtures/logging/enospc-recovery.mjs +146 -0
- package/test/fixtures/logging/process-writer.mjs +20 -0
- package/test/helpers/tls-certificates.js +115 -0
- package/test/integration/initial-target-recovery.test.js +518 -0
- package/test/integration/logging-recovery.test.js +163 -0
- package/test/integration/multiplexed-compatibility.test.js +254 -0
- package/test/integration/multiplexed-corrections.test.js +551 -0
- package/test/integration/multiplexed-failures.test.js +339 -0
- package/test/integration/multiplexed-transport-set.test.js +456 -0
- package/test/integration/multiplexed-transport.test.js +208 -0
- package/test/integration/reverse-gateway.test.js +1293 -0
- package/test/integration/socketduct-http-agent.test.js +435 -0
- package/test/integration/socketduct-https-agent.test.js +669 -0
- package/test/integration/stream-compression.test.js +1267 -0
- package/test/integration/tcp-forwarding.test.js +2878 -0
- package/test/integration/tls-forwarding.test.js +748 -0
- package/test/unit/auth/auth.test.js +64 -0
- package/test/unit/auth/static-token-store.test.js +14 -0
- package/test/unit/client/connect.test.js +58 -0
- package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
- package/test/unit/client/multiplexed-transport-set.test.js +906 -0
- package/test/unit/client/socketduct-socket.test.js +1124 -0
- package/test/unit/client/virtual-socket.test.js +98 -0
- package/test/unit/config/docker-gateway-files.test.js +274 -0
- package/test/unit/config/environment.test.js +507 -0
- package/test/unit/index.test.js +78 -0
- package/test/unit/logging/structured-logger.test.js +674 -0
- package/test/unit/protocol/compression.test.js +421 -0
- package/test/unit/protocol/frame.test.js +336 -0
- package/test/unit/protocol/v3-compression.test.js +31 -0
- package/test/unit/protocol/v3-frame.test.js +57 -0
- package/test/unit/release-configuration.test.js +21 -0
- package/test/unit/server/control-handler.test.js +291 -0
- package/test/unit/server/primary.test.js +59 -0
- package/test/unit/server/relay-runtime.test.js +434 -0
- package/test/unit/sessions/session.test.js +84 -0
- package/test/unit/spool/spool-file.test.js +295 -0
- 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
|
+
}
|