immortal-js 1.0.0

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 (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +577 -0
  3. package/docs/CHANGELOG.md +21 -0
  4. package/docs/CONTRIBUTING.md +41 -0
  5. package/docs/api/chaos.md +179 -0
  6. package/docs/api/dashboard.md +109 -0
  7. package/docs/api/isolation.md +191 -0
  8. package/docs/api/lifecycle.md +187 -0
  9. package/docs/api/monitoring.md +313 -0
  10. package/docs/api/plugins.md +217 -0
  11. package/docs/api/recovery.md +267 -0
  12. package/docs/api/safe-zone.md +236 -0
  13. package/docs/api/supervision.md +285 -0
  14. package/docs/guides/configuration.md +168 -0
  15. package/docs/guides/express.md +171 -0
  16. package/docs/guides/fastify.md +188 -0
  17. package/docs/guides/koa.md +102 -0
  18. package/docs/guides/nestjs.md +182 -0
  19. package/docs/guides/testing.md +91 -0
  20. package/examples/express-basic/index.ts +462 -0
  21. package/examples/express-basic/package.json +21 -0
  22. package/examples/express-basic/tsconfig.json +24 -0
  23. package/examples/fastify-microservice/index.ts +342 -0
  24. package/examples/fastify-microservice/package.json +22 -0
  25. package/examples/fastify-microservice/tsconfig.json +24 -0
  26. package/examples/invoice-service/data/invoices.db +0 -0
  27. package/examples/invoice-service/data/invoices.db-shm +0 -0
  28. package/examples/invoice-service/data/invoices.db-wal +0 -0
  29. package/examples/invoice-service/package.json +25 -0
  30. package/examples/invoice-service/public/index.html +5025 -0
  31. package/examples/invoice-service/src/db.ts +608 -0
  32. package/examples/invoice-service/src/pdf.ts +358 -0
  33. package/examples/invoice-service/src/server.ts +527 -0
  34. package/examples/invoice-service/src/store.ts +159 -0
  35. package/examples/invoice-service/src/types.ts +193 -0
  36. package/examples/invoice-service/tsconfig.json +23 -0
  37. package/examples/nestjs-enterprise/app.module.ts +561 -0
  38. package/examples/nestjs-enterprise/main.ts +67 -0
  39. package/examples/nestjs-enterprise/package.json +26 -0
  40. package/examples/nestjs-enterprise/tsconfig.json +27 -0
  41. package/immortal-js-1.0.0.tgz +0 -0
  42. package/package.json +33 -0
  43. package/packages/adapter-express/package.json +34 -0
  44. package/packages/adapter-express/src/index.ts +349 -0
  45. package/packages/adapter-express/tsconfig.json +14 -0
  46. package/packages/adapter-fastify/package.json +56 -0
  47. package/packages/adapter-fastify/src/plugin.ts +226 -0
  48. package/packages/adapter-fastify/tsconfig.json +14 -0
  49. package/packages/adapter-koa/package.json +55 -0
  50. package/packages/adapter-koa/src/index.ts +207 -0
  51. package/packages/adapter-koa/tsconfig.json +14 -0
  52. package/packages/adapter-nestjs/package.json +61 -0
  53. package/packages/adapter-nestjs/src/immortal.module.ts +313 -0
  54. package/packages/adapter-nestjs/src/index.ts +14 -0
  55. package/packages/adapter-nestjs/tsconfig.json +16 -0
  56. package/packages/core/package.json +56 -0
  57. package/packages/core/src/chaos/ChaosEngine.ts +249 -0
  58. package/packages/core/src/config/defaults.ts +200 -0
  59. package/packages/core/src/config/schema.ts +199 -0
  60. package/packages/core/src/event-bus.ts +168 -0
  61. package/packages/core/src/index.ts +164 -0
  62. package/packages/core/src/isolation/BulkheadPool.ts +279 -0
  63. package/packages/core/src/isolation/WorkerSandbox.ts +306 -0
  64. package/packages/core/src/isolation/index.ts +8 -0
  65. package/packages/core/src/lifecycle/GracefulShutdown.ts +161 -0
  66. package/packages/core/src/logger.ts +104 -0
  67. package/packages/core/src/monitoring/DiagnosticsChannel.ts +248 -0
  68. package/packages/core/src/monitoring/HealthMonitor.ts +191 -0
  69. package/packages/core/src/monitoring/MemoryLeakGuard.ts +340 -0
  70. package/packages/core/src/monitoring/MetricsCollector.ts +219 -0
  71. package/packages/core/src/monitoring/index.ts +10 -0
  72. package/packages/core/src/plugins/BuiltinPlugins.ts +269 -0
  73. package/packages/core/src/recovery/CircuitBreaker.ts +334 -0
  74. package/packages/core/src/recovery/FallbackCache.ts +328 -0
  75. package/packages/core/src/recovery/RetryEngine.ts +225 -0
  76. package/packages/core/src/recovery/Timeout.ts +97 -0
  77. package/packages/core/src/recovery/index.ts +11 -0
  78. package/packages/core/src/runtime.ts +242 -0
  79. package/packages/core/src/safe-zone/AsyncBoundary.ts +114 -0
  80. package/packages/core/src/safe-zone/ErrorTrap.ts +347 -0
  81. package/packages/core/src/safe-zone/SafeWrapper.ts +317 -0
  82. package/packages/core/src/safe-zone/index.ts +23 -0
  83. package/packages/core/src/supervision/ClusterManager.ts +243 -0
  84. package/packages/core/src/supervision/RestartStrategy.ts +68 -0
  85. package/packages/core/src/supervision/Supervisor.ts +311 -0
  86. package/packages/core/src/supervision/index.ts +11 -0
  87. package/packages/core/src/types.ts +470 -0
  88. package/packages/core/test/bulkhead.test.ts +310 -0
  89. package/packages/core/test/circuit-breaker.test.ts +153 -0
  90. package/packages/core/test/memory-guard.test.ts +213 -0
  91. package/packages/core/test/retry.test.ts +110 -0
  92. package/packages/core/test/safe-zone.test.ts +271 -0
  93. package/packages/core/test/supervisor.test.ts +310 -0
  94. package/packages/core/tsconfig.json +13 -0
  95. package/packages/dashboard/package.json +56 -0
  96. package/packages/dashboard/server/DashboardServer.ts +454 -0
  97. package/packages/dashboard/tsconfig.json +14 -0
  98. package/tsconfig.json +25 -0
@@ -0,0 +1,306 @@
1
+ /**
2
+ * @file WorkerSandbox.ts
3
+ * @description Worker Thread sandbox for dangerous/heavy operations
4
+ *
5
+ * Use cases:
6
+ * - Executing user-supplied code (code evaluation)
7
+ * - Processing untrusted files (parsing, validation)
8
+ * - CPU-intensive operations that would block the event loop
9
+ * - Operations with unknown memory footprint
10
+ *
11
+ * If the sandboxed Worker crashes (stack overflow, out-of-memory, etc.),
12
+ * the Main Thread is completely unaffected. The Supervisor is notified
13
+ * to restart the Worker if needed.
14
+ *
15
+ * Communication via postMessage (structured clone serialization) — no shared mutable state.
16
+ */
17
+
18
+ import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
19
+ import { EventEmitter } from "node:events";
20
+ import * as path from "node:path";
21
+ import * as fs from "node:fs";
22
+ import * as os from "node:os";
23
+ import { getLogger } from "../logger.js";
24
+ import { getEventBus } from "../event-bus.js";
25
+ import { TimeoutError } from "../types.js";
26
+
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+ // MESSAGE PROTOCOL
29
+ // ─────────────────────────────────────────────────────────────────────────────
30
+
31
+ interface WorkerRequest {
32
+ id: string;
33
+ type: "execute";
34
+ code?: string;
35
+ args?: unknown[];
36
+ timeoutMs?: number;
37
+ }
38
+
39
+ interface WorkerResponse {
40
+ id: string;
41
+ type: "result" | "error" | "ready";
42
+ result?: unknown;
43
+ error?: { name: string; message: string; stack?: string };
44
+ }
45
+
46
+ // ─────────────────────────────────────────────────────────────────────────────
47
+ // WORKER SANDBOX CLASS (Main Thread)
48
+ // ─────────────────────────────────────────────────────────────────────────────
49
+
50
+ export interface WorkerSandboxOptions {
51
+ /** Timeout for individual operations in ms (default: 30_000) */
52
+ operationTimeoutMs?: number;
53
+ /** Maximum memory for the worker in MB (enforced via --max-old-space-size) */
54
+ maxMemoryMb?: number;
55
+ /** Worker environment variables */
56
+ env?: Record<string, string>;
57
+ /** Shared resource limits */
58
+ resourceLimits?: {
59
+ maxOldGenerationSizeMb?: number;
60
+ maxYoungGenerationSizeMb?: number;
61
+ stackSizeMb?: number;
62
+ codeRangeSizeMb?: number;
63
+ };
64
+ }
65
+
66
+ export class WorkerSandbox extends EventEmitter {
67
+ private worker: Worker | null = null;
68
+ private pending = new Map<string, {
69
+ resolve: (value: unknown) => void;
70
+ reject: (reason: unknown) => void;
71
+ timeoutId: ReturnType<typeof setTimeout>;
72
+ }>();
73
+ private ready = false;
74
+ private readonly options: Required<WorkerSandboxOptions>;
75
+ private restartCount = 0;
76
+
77
+ constructor(options: WorkerSandboxOptions = {}) {
78
+ super();
79
+ this.options = {
80
+ operationTimeoutMs: options.operationTimeoutMs ?? 30_000,
81
+ maxMemoryMb: options.maxMemoryMb ?? 512,
82
+ env: options.env ?? {},
83
+ resourceLimits: options.resourceLimits ?? {},
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Start the worker sandbox
89
+ */
90
+ async start(): Promise<void> {
91
+ await this.spawnWorker();
92
+ getLogger().info(`WorkerSandbox started (PID: ${this.worker?.threadId})`);
93
+ }
94
+
95
+ /**
96
+ * Execute code in the sandbox and return the result
97
+ */
98
+ async execute<T = unknown>(
99
+ code: string,
100
+ args?: unknown[],
101
+ timeoutMs?: number
102
+ ): Promise<T> {
103
+ if (!this.worker || !this.ready) {
104
+ throw new Error("WorkerSandbox is not running. Call start() first.");
105
+ }
106
+
107
+ const id = crypto.randomUUID();
108
+ const timeout = timeoutMs ?? this.options.operationTimeoutMs;
109
+
110
+ return new Promise<T>((resolve, reject) => {
111
+ const timeoutId = setTimeout(() => {
112
+ this.pending.delete(id);
113
+ reject(new TimeoutError(`worker:execute`, timeout));
114
+ }, timeout);
115
+
116
+ this.pending.set(id, {
117
+ resolve: resolve as (value: unknown) => void,
118
+ reject,
119
+ timeoutId,
120
+ });
121
+
122
+ const request: WorkerRequest = { id, type: "execute", code, timeoutMs: timeout };
123
+ if (args !== undefined) request.args = args;
124
+ this.worker!.postMessage(request);
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Gracefully terminate the worker
130
+ */
131
+ async terminate(): Promise<void> {
132
+ if (!this.worker) return;
133
+
134
+ // Reject all pending operations
135
+ for (const [id, pending] of this.pending) {
136
+ clearTimeout(pending.timeoutId);
137
+ pending.reject(new Error("WorkerSandbox terminated"));
138
+ this.pending.delete(id);
139
+ }
140
+
141
+ await this.worker.terminate();
142
+ this.worker = null;
143
+ this.ready = false;
144
+ getLogger().info("WorkerSandbox terminated");
145
+ }
146
+
147
+ /**
148
+ * Check if the sandbox is ready to accept work
149
+ */
150
+ isReady(): boolean {
151
+ return this.ready && this.worker !== null;
152
+ }
153
+
154
+ private async spawnWorker(): Promise<void> {
155
+ const workerScript = this.generateWorkerScript();
156
+ const tmpFile = path.join(os.tmpdir(), `immortal-sandbox-${Date.now()}.mjs`);
157
+
158
+ try {
159
+ fs.writeFileSync(tmpFile, workerScript, "utf-8");
160
+
161
+ this.worker = new Worker(tmpFile, {
162
+ workerData: { sandboxed: true },
163
+ env: { ...process.env, ...this.options.env } as Record<string, string>,
164
+ resourceLimits: {
165
+ maxOldGenerationSizeMb: this.options.maxMemoryMb,
166
+ maxYoungGenerationSizeMb: Math.floor(this.options.maxMemoryMb / 4),
167
+ ...this.options.resourceLimits,
168
+ },
169
+ });
170
+
171
+ this.worker.on("message", (msg: WorkerResponse) => this.handleMessage(msg));
172
+ this.worker.on("error", (err) => this.handleWorkerError(err));
173
+ this.worker.on("exit", (code) => this.handleWorkerExit(code));
174
+
175
+ // Wait for ready signal
176
+ await new Promise<void>((resolve, reject) => {
177
+ const timeout = setTimeout(() => reject(new Error("Worker failed to start")), 10_000);
178
+ this.once("ready", () => { clearTimeout(timeout); resolve(); });
179
+ this.once("error", (err) => { clearTimeout(timeout); reject(err); });
180
+ });
181
+
182
+ } finally {
183
+ // Clean up temp file
184
+ try { fs.unlinkSync(tmpFile); } catch { /* ignore */ }
185
+ }
186
+ }
187
+
188
+ private handleMessage(msg: WorkerResponse): void {
189
+ if (msg.type === "ready") {
190
+ this.ready = true;
191
+ this.emit("ready");
192
+ return;
193
+ }
194
+
195
+ const pending = this.pending.get(msg.id);
196
+ if (!pending) return;
197
+
198
+ clearTimeout(pending.timeoutId);
199
+ this.pending.delete(msg.id);
200
+
201
+ if (msg.type === "error" && msg.error) {
202
+ const err = new Error(msg.error.message);
203
+ err.name = msg.error.name;
204
+ if (msg.error.stack) err.stack = msg.error.stack;
205
+ pending.reject(err);
206
+ } else {
207
+ pending.resolve(msg.result);
208
+ }
209
+ }
210
+
211
+ private handleWorkerError(err: Error): void {
212
+ getLogger().error("WorkerSandbox error", { error: err.message });
213
+ getEventBus().emit("worker:crashed", { data: { error: err.message, restartCount: this.restartCount } });
214
+ this.emit("error", err);
215
+
216
+ // Reject all pending
217
+ for (const [id, pending] of this.pending) {
218
+ clearTimeout(pending.timeoutId);
219
+ pending.reject(err);
220
+ this.pending.delete(id);
221
+ }
222
+ }
223
+
224
+ private handleWorkerExit(code: number): void {
225
+ this.ready = false;
226
+ getLogger().warn(`WorkerSandbox exited with code ${code}`);
227
+ getEventBus().emit("worker:crashed", { data: { exitCode: code, restartCount: this.restartCount } });
228
+ this.emit("exit", code);
229
+ }
230
+
231
+ private generateWorkerScript(): string {
232
+ return `
233
+ import { parentPort, workerData } from 'node:worker_threads';
234
+
235
+ // Signal ready
236
+ parentPort.postMessage({ type: 'ready' });
237
+
238
+ parentPort.on('message', async (msg) => {
239
+ if (msg.type !== 'execute') return;
240
+
241
+ try {
242
+ // Execute code in sandbox using Function constructor
243
+ // This is intentionally restricted — no access to Node internals beyond what's allowed
244
+ const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
245
+ const fn = new AsyncFunction('args', msg.code);
246
+ const result = await fn(msg.args ?? []);
247
+ parentPort.postMessage({ id: msg.id, type: 'result', result });
248
+ } catch (err) {
249
+ parentPort.postMessage({
250
+ id: msg.id,
251
+ type: 'error',
252
+ error: {
253
+ name: err.name ?? 'Error',
254
+ message: err.message ?? String(err),
255
+ stack: err.stack,
256
+ },
257
+ });
258
+ }
259
+ });
260
+
261
+ process.on('uncaughtException', (err) => {
262
+ parentPort.postMessage({
263
+ id: 'uncaught',
264
+ type: 'error',
265
+ error: { name: err.name, message: err.message, stack: err.stack }
266
+ });
267
+ });
268
+ `;
269
+ }
270
+ }
271
+
272
+ // ─────────────────────────────────────────────────────────────────────────────
273
+ // WORKER THREAD SIDE HANDLER (re-exported for user Worker scripts)
274
+ // ─────────────────────────────────────────────────────────────────────────────
275
+
276
+ /**
277
+ * Use this in Worker Thread files to simplify the boilerplate
278
+ */
279
+ export function setupWorkerThread(
280
+ handler: (data: unknown) => Promise<unknown>
281
+ ): void {
282
+ if (isMainThread) {
283
+ throw new Error("setupWorkerThread() must be called from a Worker Thread");
284
+ }
285
+
286
+ parentPort!.postMessage({ type: "ready" });
287
+
288
+ parentPort!.on("message", async (msg: WorkerRequest) => {
289
+ if (msg.type !== "execute") return;
290
+
291
+ try {
292
+ const result = await handler(workerData);
293
+ parentPort!.postMessage({ id: msg.id, type: "result", result });
294
+ } catch (err) {
295
+ parentPort!.postMessage({
296
+ id: msg.id,
297
+ type: "error",
298
+ error: {
299
+ name: (err as Error).name,
300
+ message: (err as Error).message,
301
+ stack: (err as Error).stack,
302
+ },
303
+ });
304
+ }
305
+ });
306
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @file isolation/index.ts
3
+ * @description Isolation layer public exports
4
+ */
5
+
6
+ export { BulkheadPool, BulkheadRegistry } from "./BulkheadPool.js";
7
+ export { WorkerSandbox, setupWorkerThread } from "./WorkerSandbox.js";
8
+ export type { WorkerSandboxOptions } from "./WorkerSandbox.js";
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @file GracefulShutdown.ts
3
+ * @description Graceful shutdown orchestration
4
+ *
5
+ * 4-phase shutdown sequence:
6
+ * Phase 1: Stop accepting new connections (server.close())
7
+ * Phase 2: Grace period — wait for in-flight requests to complete
8
+ * Phase 3: Close external connections (DB, Redis, queues) in reverse dependency order
9
+ * Phase 4: Final cleanup + process exit
10
+ *
11
+ * If any in-flight request exceeds the grace period, it is forcibly terminated
12
+ * and logged as an exceptional event requiring review.
13
+ */
14
+
15
+ import type { GracefulShutdownConfig, ShutdownHook } from "../types.js";
16
+ import { DEFAULT_GRACEFUL_SHUTDOWN } from "../config/defaults.js";
17
+ import { getLogger } from "../logger.js";
18
+ import type { ImmortalEventBus } from "../event-bus.js";
19
+
20
+ // ─────────────────────────────────────────────────────────────────────────────
21
+ // GRACEFUL SHUTDOWN CLASS
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+
24
+ export class GracefulShutdown {
25
+ private readonly config: Required<GracefulShutdownConfig>;
26
+ private readonly bus: ImmortalEventBus;
27
+ private shutdownInProgress = false;
28
+ private readonly hooks: ShutdownHook[] = [];
29
+ private serverRef?: { close: (cb?: () => void) => void };
30
+
31
+ constructor(config: GracefulShutdownConfig = {}, bus: ImmortalEventBus) {
32
+ this.config = { ...DEFAULT_GRACEFUL_SHUTDOWN, ...config };
33
+ this.bus = bus;
34
+ this.hooks.push(...(config.onShutdown ?? []));
35
+
36
+ this.installSignalHandlers();
37
+ }
38
+
39
+ /**
40
+ * Register an HTTP server for graceful connection draining
41
+ */
42
+ registerServer(server: { close: (cb?: () => void) => void }): void {
43
+ this.serverRef = server;
44
+ }
45
+
46
+ /**
47
+ * Add a cleanup hook (run during shutdown in registration order)
48
+ */
49
+ addHook(hook: ShutdownHook): void {
50
+ this.hooks.push(hook);
51
+ }
52
+
53
+ /**
54
+ * Manually trigger graceful shutdown (useful for Supervisor-initiated restarts)
55
+ */
56
+ async execute(signal = "manual"): Promise<void> {
57
+ if (this.shutdownInProgress) {
58
+ getLogger().warn("Graceful shutdown already in progress — ignoring duplicate signal");
59
+ return;
60
+ }
61
+
62
+ this.shutdownInProgress = true;
63
+ const logger = getLogger();
64
+ const startTime = Date.now();
65
+
66
+ logger.info(`Graceful shutdown initiated (signal: ${signal})`);
67
+
68
+ this.bus.emit("shutdown:initiated", {
69
+ data: { signal, timeoutMs: this.config.timeoutMs },
70
+ });
71
+
72
+ // ── Phase 1: Stop accepting new connections ───────────────────────
73
+ if (this.serverRef) {
74
+ logger.info("Phase 1: Stopping server from accepting new connections...");
75
+ await new Promise<void>((resolve) => {
76
+ this.serverRef!.close(() => {
77
+ logger.info("Phase 1: Server closed to new connections");
78
+ resolve();
79
+ });
80
+ });
81
+ }
82
+
83
+ // ── Phase 2: Grace period for in-flight requests ──────────────────
84
+ logger.info(`Phase 2: Grace period — ${this.config.timeoutMs}ms for in-flight requests...`);
85
+
86
+ const gracefulComplete = this.runHooksWithTimeout();
87
+
88
+ let forced = false;
89
+ const forceTimeout = new Promise<"forced">((resolve) => {
90
+ setTimeout(() => {
91
+ forced = true;
92
+ resolve("forced");
93
+ }, this.config.timeoutMs).unref();
94
+ });
95
+
96
+ const raceResult = await Promise.race([gracefulComplete.then(() => "complete"), forceTimeout]);
97
+
98
+ if (raceResult === "forced" || forced) {
99
+ logger.error("Graceful shutdown timeout — forcing exit", {
100
+ elapsedMs: Date.now() - startTime,
101
+ timeoutMs: this.config.timeoutMs,
102
+ });
103
+
104
+ this.bus.emit("shutdown:forced", {
105
+ data: {
106
+ elapsedMs: Date.now() - startTime,
107
+ signal,
108
+ reason: "grace-period-exceeded",
109
+ },
110
+ });
111
+
112
+ process.exit(1);
113
+ }
114
+
115
+ // ── Phase 3 & 4: Hooks ran within grace period ────────────────────
116
+ const elapsed = Date.now() - startTime;
117
+ logger.info(`Graceful shutdown complete in ${elapsed}ms`);
118
+
119
+ this.bus.emit("shutdown:complete", {
120
+ data: { elapsedMs: elapsed, signal },
121
+ });
122
+
123
+ // Final exit
124
+ process.exit(0);
125
+ }
126
+
127
+ // ─────────────────────────────────────────────────────────────────────────
128
+ // PRIVATE
129
+ // ─────────────────────────────────────────────────────────────────────────
130
+
131
+ private async runHooksWithTimeout(): Promise<void> {
132
+ const logger = getLogger();
133
+
134
+ // Run hooks in sequence (order matters for dependency cleanup)
135
+ for (const hook of this.hooks) {
136
+ try {
137
+ await Promise.resolve(hook(this.config.signals[0] ?? "SIGTERM"));
138
+ logger.debug("Shutdown hook completed");
139
+ } catch (err) {
140
+ logger.error("Shutdown hook failed", {
141
+ error: (err as Error).message,
142
+ });
143
+ // Continue with other hooks even if one fails
144
+ }
145
+ }
146
+ }
147
+
148
+ private installSignalHandlers(): void {
149
+ const logger = getLogger();
150
+
151
+ for (const signal of this.config.signals) {
152
+ process.once(signal as NodeJS.Signals, () => {
153
+ logger.info(`Received ${signal} — initiating graceful shutdown`);
154
+ this.execute(signal).catch((err) => {
155
+ logger.error("Graceful shutdown failed", { error: (err as Error).message });
156
+ process.exit(1);
157
+ });
158
+ });
159
+ }
160
+ }
161
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @file logger.ts
3
+ * @description Immortal.js internal structured logger
4
+ * Supports log levels, field redaction, and custom transports
5
+ */
6
+
7
+ import type { LoggerConfig, LogTransport } from "./types.js";
8
+
9
+ const LEVEL_RANK = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 } as const;
10
+ type Level = keyof typeof LEVEL_RANK;
11
+
12
+ class ImmortalLogger implements LogTransport {
13
+ private level: Level;
14
+ private redact: Set<string>;
15
+ private transport?: LogTransport;
16
+
17
+ constructor(config: Required<LoggerConfig>) {
18
+ this.level = config.level;
19
+ this.redact = new Set(config.redact);
20
+ this.transport = config.transport;
21
+ }
22
+
23
+ private shouldLog(level: Level): boolean {
24
+ return LEVEL_RANK[level] >= LEVEL_RANK[this.level];
25
+ }
26
+
27
+ private sanitize(meta?: Record<string, unknown>): Record<string, unknown> | undefined {
28
+ if (!meta) return undefined;
29
+ const result: Record<string, unknown> = {};
30
+ for (const [key, value] of Object.entries(meta)) {
31
+ if (this.redact.has(key)) {
32
+ result[key] = "[REDACTED]";
33
+ } else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
34
+ result[key] = this.sanitize(value as Record<string, unknown>);
35
+ } else {
36
+ result[key] = value;
37
+ }
38
+ }
39
+ return result;
40
+ }
41
+
42
+ private format(level: Level, message: string, meta?: Record<string, unknown>): string {
43
+ const ts = new Date().toISOString();
44
+ const sanitized = this.sanitize(meta);
45
+ const base = `[${ts}] [IMMORTAL] [${level.toUpperCase()}] ${message}`;
46
+ return sanitized ? `${base} ${JSON.stringify(sanitized)}` : base;
47
+ }
48
+
49
+ debug(message: string, meta?: Record<string, unknown>): void {
50
+ if (!this.shouldLog("debug")) return;
51
+ if (this.transport) {
52
+ this.transport.debug(message, this.sanitize(meta));
53
+ } else {
54
+ console.debug(this.format("debug", message, meta));
55
+ }
56
+ }
57
+
58
+ info(message: string, meta?: Record<string, unknown>): void {
59
+ if (!this.shouldLog("info")) return;
60
+ if (this.transport) {
61
+ this.transport.info(message, this.sanitize(meta));
62
+ } else {
63
+ console.info(this.format("info", message, meta));
64
+ }
65
+ }
66
+
67
+ warn(message: string, meta?: Record<string, unknown>): void {
68
+ if (!this.shouldLog("warn")) return;
69
+ if (this.transport) {
70
+ this.transport.warn(message, this.sanitize(meta));
71
+ } else {
72
+ console.warn(this.format("warn", message, meta));
73
+ }
74
+ }
75
+
76
+ error(message: string, meta?: Record<string, unknown>): void {
77
+ if (!this.shouldLog("error")) return;
78
+ if (this.transport) {
79
+ this.transport.error(message, this.sanitize(meta));
80
+ } else {
81
+ console.error(this.format("error", message, meta));
82
+ }
83
+ }
84
+
85
+ updateLevel(level: Level): void {
86
+ this.level = level;
87
+ }
88
+ }
89
+
90
+ let _logger: ImmortalLogger | null = null;
91
+
92
+ export function createLogger(config: Required<LoggerConfig>): ImmortalLogger {
93
+ _logger = new ImmortalLogger(config);
94
+ return _logger;
95
+ }
96
+
97
+ export function getLogger(): ImmortalLogger {
98
+ if (!_logger) {
99
+ _logger = new ImmortalLogger({ level: "info", redact: [], transport: undefined as never });
100
+ }
101
+ return _logger;
102
+ }
103
+
104
+ export type { ImmortalLogger };