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,311 @@
1
+ /**
2
+ * @file Supervisor.ts
3
+ * @description Erlang/OTP-inspired Worker Supervisor
4
+ *
5
+ * Engineering honesty about the restart limit:
6
+ * If a worker keeps crashing repeatedly within a time window, it's a
7
+ * programming error or unresolvable runtime condition. Infinite restart
8
+ * loops waste CPU and hide the real problem. After maxRestartsInWindow,
9
+ * the supervisor ESCALATES (stops retrying, fires an alert) instead of
10
+ * continuing to crash-loop.
11
+ *
12
+ * This is exactly what Erlang's supervisor does with max_restart / max_period.
13
+ */
14
+
15
+ import type { SupervisorConfig, WorkerStatus } from "../types.js";
16
+ import { WorkerEscalatedError } from "../types.js";
17
+ import { DEFAULT_SUPERVISOR } from "../config/defaults.js";
18
+ import { getEventBus } from "../event-bus.js";
19
+ import { getLogger } from "../logger.js";
20
+ import { computeRestartDecision } from "./RestartStrategy.js";
21
+ import type { WorkerEntry } from "./RestartStrategy.js";
22
+ import { calculateBackoff } from "../recovery/RetryEngine.js";
23
+
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+ // TYPES
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+
28
+ export type SpawnFunction = () => { terminate?: () => Promise<void> | void };
29
+
30
+ export interface ManagedWorker {
31
+ id: string;
32
+ index: number;
33
+ spawn: SpawnFunction;
34
+ instance?: ReturnType<SpawnFunction>;
35
+ status: WorkerStatus["state"];
36
+ restartCount: number;
37
+ restartTimestamps: number[]; // timestamps within current window
38
+ startedAt: number;
39
+ pid?: number;
40
+ memoryMb?: number;
41
+ cpuPercent?: number;
42
+ }
43
+
44
+ export interface SupervisorOptions {
45
+ config?: SupervisorConfig;
46
+ /** Called when a worker is escalated (for alerting) */
47
+ onEscalation?: (workerId: string, error: WorkerEscalatedError) => void;
48
+ }
49
+
50
+ // ─────────────────────────────────────────────────────────────────────────────
51
+ // SUPERVISOR CLASS
52
+ // ─────────────────────────────────────────────────────────────────────────────
53
+
54
+ export class Supervisor {
55
+ private readonly workers = new Map<string, ManagedWorker>();
56
+ private readonly config: Required<SupervisorConfig>;
57
+ private readonly onEscalation: ((workerId: string, error: WorkerEscalatedError) => void) | undefined;
58
+ private stopped = false;
59
+
60
+ constructor(options: SupervisorOptions = {}) {
61
+ this.config = { ...DEFAULT_SUPERVISOR, ...options.config };
62
+ this.onEscalation = options.onEscalation;
63
+ }
64
+
65
+ /**
66
+ * Register a worker with the supervisor.
67
+ * The supervisor will automatically restart it if it fails.
68
+ *
69
+ * @param id - Unique worker identifier
70
+ * @param spawnFn - Factory function to create/restart the worker
71
+ * @returns The initial worker instance
72
+ */
73
+ register(id: string, spawnFn: SpawnFunction): ReturnType<SpawnFunction> {
74
+ if (this.workers.has(id)) {
75
+ throw new Error(`Worker '${id}' is already registered with this supervisor`);
76
+ }
77
+
78
+ const worker: ManagedWorker = {
79
+ id,
80
+ index: this.workers.size,
81
+ spawn: spawnFn,
82
+ status: "running",
83
+ restartCount: 0,
84
+ restartTimestamps: [],
85
+ startedAt: Date.now(),
86
+ };
87
+
88
+ const instance = spawnFn();
89
+ worker.instance = instance;
90
+ this.workers.set(id, worker);
91
+
92
+ getEventBus().emit("worker:started", {
93
+ workerId: id,
94
+ data: { index: worker.index, strategy: this.config.strategy },
95
+ });
96
+
97
+ getLogger().info(`Supervisor: Worker '${id}' registered and started`);
98
+ return instance;
99
+ }
100
+
101
+ /**
102
+ * Notify the supervisor that a worker has exited/crashed.
103
+ * This triggers the restart logic based on the configured strategy.
104
+ *
105
+ * @param workerId - ID of the crashed worker
106
+ * @param exitCode - Exit code (0 = clean, non-zero = crash)
107
+ */
108
+ onWorkerExit(workerId: string, exitCode = 1): void {
109
+ if (this.stopped) return;
110
+
111
+ const worker = this.workers.get(workerId);
112
+ if (!worker) {
113
+ getLogger().warn(`Supervisor: Unknown worker '${workerId}' exited`);
114
+ return;
115
+ }
116
+
117
+ const bus = getEventBus();
118
+ const logger = getLogger();
119
+
120
+ bus.emit("worker:crashed", {
121
+ workerId,
122
+ data: { exitCode, restartCount: worker.restartCount },
123
+ });
124
+
125
+ logger.warn(`Supervisor: Worker '${workerId}' crashed (exit: ${exitCode})`);
126
+
127
+ // Clean up expired restart timestamps
128
+ const windowStart = Date.now() - this.config.windowMs;
129
+ worker.restartTimestamps = worker.restartTimestamps.filter((t) => t > windowStart);
130
+
131
+ // Check restart limit
132
+ if (worker.restartTimestamps.length >= this.config.maxRestartsInWindow) {
133
+ this.escalate(workerId, worker);
134
+ return;
135
+ }
136
+
137
+ // Determine which workers to restart
138
+ const workerEntries: WorkerEntry[] = Array.from(this.workers.values()).map((w) => ({
139
+ id: w.id,
140
+ index: w.index,
141
+ restart: () => this.restartWorker(w.id),
142
+ }));
143
+
144
+ const decision = computeRestartDecision(workerId, workerEntries, this.config.strategy);
145
+
146
+ // Execute restarts with exponential backoff
147
+ for (const idToRestart of decision.workersToRestart) {
148
+ const restartCount = this.workers.get(idToRestart)?.restartCount ?? 0;
149
+ const delay = calculateBackoff(
150
+ restartCount + 1,
151
+ this.config.baseRestartDelayMs,
152
+ 30_000 // max 30s restart delay
153
+ );
154
+
155
+ logger.info(`Supervisor: Restarting '${idToRestart}' in ${delay}ms (attempt #${restartCount + 1})`);
156
+
157
+ setTimeout(() => {
158
+ if (!this.stopped) {
159
+ this.restartWorker(idToRestart);
160
+ }
161
+ }, delay).unref();
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Gracefully stop a managed worker
167
+ */
168
+ async stop(workerId: string): Promise<void> {
169
+ const worker = this.workers.get(workerId);
170
+ if (!worker) return;
171
+
172
+ worker.status = "stopped";
173
+
174
+ if (worker.instance?.terminate) {
175
+ await worker.instance.terminate();
176
+ }
177
+
178
+ this.workers.delete(workerId);
179
+ getLogger().info(`Supervisor: Worker '${workerId}' stopped`);
180
+ }
181
+
182
+ /**
183
+ * Stop all workers (graceful shutdown)
184
+ */
185
+ async stopAll(): Promise<void> {
186
+ this.stopped = true;
187
+
188
+ const stopPromises = Array.from(this.workers.keys()).map((id) => this.stop(id));
189
+ await Promise.allSettled(stopPromises);
190
+
191
+ getLogger().info("Supervisor: All workers stopped");
192
+ }
193
+
194
+ /**
195
+ * Get current status of all managed workers
196
+ */
197
+ getWorkerStatuses(): WorkerStatus[] {
198
+ return Array.from(this.workers.values()).map((w) => {
199
+ const status: WorkerStatus = {
200
+ id: w.id,
201
+ state: w.status,
202
+ restartCount: w.restartCount,
203
+ uptime: Date.now() - w.startedAt,
204
+ };
205
+ if (w.pid !== undefined) status.pid = w.pid;
206
+ const lastRestart = w.restartTimestamps.at(-1);
207
+ if (lastRestart !== undefined) status.lastRestartTime = lastRestart;
208
+ if (w.memoryMb !== undefined) status.memoryMb = w.memoryMb;
209
+ if (w.cpuPercent !== undefined) status.cpuPercent = w.cpuPercent;
210
+ return status;
211
+ });
212
+ }
213
+
214
+ /**
215
+ * Update resource metrics for a worker (called by HealthMonitor)
216
+ */
217
+ updateWorkerMetrics(
218
+ workerId: string,
219
+ metrics: { memoryMb?: number; cpuPercent?: number; pid?: number }
220
+ ): void {
221
+ const worker = this.workers.get(workerId);
222
+ if (!worker) return;
223
+
224
+ if (metrics.memoryMb !== undefined) worker.memoryMb = metrics.memoryMb;
225
+ if (metrics.cpuPercent !== undefined) worker.cpuPercent = metrics.cpuPercent;
226
+ if (metrics.pid !== undefined) worker.pid = metrics.pid;
227
+ }
228
+
229
+ /**
230
+ * Request graceful restart of a specific worker (from HealthMonitor)
231
+ */
232
+ requestGracefulRestart(workerId: string, reason = "health-threshold"): void {
233
+ const worker = this.workers.get(workerId);
234
+ if (!worker || worker.status !== "running") return;
235
+
236
+ getLogger().info(`Supervisor: Graceful restart requested for '${workerId}' (reason: ${reason})`);
237
+ getEventBus().emit("memory:proactive-restart", {
238
+ workerId,
239
+ data: { reason },
240
+ });
241
+
242
+ // Graceful restart: stop current, allow supervisor to restart
243
+ if (worker.instance?.terminate) {
244
+ Promise.resolve(worker.instance.terminate()).catch(() => void 0);
245
+ }
246
+ // Simulate exit to trigger restart logic
247
+ this.onWorkerExit(workerId, 0);
248
+ }
249
+
250
+ // ─────────────────────────────────────────────────────────────────────────
251
+ // PRIVATE
252
+ // ─────────────────────────────────────────────────────────────────────────
253
+
254
+ private restartWorker(workerId: string): void {
255
+ const worker = this.workers.get(workerId);
256
+ if (!worker || this.stopped) return;
257
+
258
+ try {
259
+ worker.status = "restarting";
260
+ worker.restartCount++;
261
+ worker.restartTimestamps.push(Date.now());
262
+ worker.startedAt = Date.now();
263
+
264
+ const instance = worker.spawn();
265
+ worker.instance = instance;
266
+ worker.status = "running";
267
+
268
+ getEventBus().emit("worker:restarted", {
269
+ workerId,
270
+ data: {
271
+ restartCount: worker.restartCount,
272
+ strategy: this.config.strategy,
273
+ },
274
+ });
275
+
276
+ getLogger().info(`Supervisor: Worker '${workerId}' restarted (count: ${worker.restartCount})`);
277
+ } catch (err) {
278
+ getLogger().error(`Supervisor: Failed to restart worker '${workerId}'`, {
279
+ error: (err as Error).message,
280
+ });
281
+ worker.status = "stopped";
282
+ }
283
+ }
284
+
285
+ private escalate(workerId: string, worker: ManagedWorker): void {
286
+ const bus = getEventBus();
287
+ const logger = getLogger();
288
+
289
+ worker.status = "escalated";
290
+ const error = new WorkerEscalatedError(workerId, worker.restartCount);
291
+
292
+ bus.emit("worker:escalated", {
293
+ workerId,
294
+ data: {
295
+ restartCount: worker.restartCount,
296
+ windowMs: this.config.windowMs,
297
+ maxRestartsInWindow: this.config.maxRestartsInWindow,
298
+ },
299
+ error: { name: error.name, message: error.message, isOperational: false },
300
+ });
301
+
302
+ logger.error(
303
+ `Supervisor: Worker '${workerId}' ESCALATED — ` +
304
+ `crashed ${worker.restartCount} times in ${this.config.windowMs}ms window. ` +
305
+ `Manual intervention required.`,
306
+ { workerId, restartCount: worker.restartCount }
307
+ );
308
+
309
+ this.onEscalation?.(workerId, error);
310
+ }
311
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @file supervision/index.ts
3
+ * @description Supervision layer public exports
4
+ */
5
+
6
+ export { Supervisor } from "./Supervisor.js";
7
+ export type { ManagedWorker, SupervisorOptions, SpawnFunction } from "./Supervisor.js";
8
+ export { ClusterManager } from "./ClusterManager.js";
9
+ export type { ClusterManagerOptions } from "./ClusterManager.js";
10
+ export { computeRestartDecision } from "./RestartStrategy.js";
11
+ export type { WorkerEntry, RestartDecision } from "./RestartStrategy.js";