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,317 @@
1
+ /**
2
+ * @file SafeWrapper.ts
3
+ * @description Safe zone wrapping for route handlers and middleware
4
+ *
5
+ * This is the core mechanism that prevents a single failing route from
6
+ * crashing the entire server. It wraps every handler in:
7
+ * 1. An AsyncLocalStorage context (request tracking)
8
+ * 2. A try/catch that handles both sync and async errors
9
+ * 3. Error capture + fallback response dispatch
10
+ *
11
+ * The key insight: most Express/Node server crashes happen because
12
+ * async errors inside route handlers are never caught. SafeWrapper
13
+ * eliminates this class of failure entirely.
14
+ */
15
+
16
+ import type { RequestContext, FallbackGenerator, SafeZoneConfig } from "../types.js";
17
+ import { AsyncBoundary } from "./AsyncBoundary.js";
18
+ import { ErrorTrap, serializeError } from "./ErrorTrap.js";
19
+ import type { ImmortalEventBus } from "../event-bus.js";
20
+ import { getLogger } from "../logger.js";
21
+
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+ // GENERIC HANDLER TYPE (Framework-agnostic)
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+
26
+ export type AnyFunction = (...args: unknown[]) => unknown | Promise<unknown>;
27
+
28
+ // Express-style types (without importing express)
29
+ export type RequestLike = {
30
+ path?: string;
31
+ url?: string;
32
+ method?: string;
33
+ headers?: Record<string, string | string[] | undefined>;
34
+ [key: string]: unknown;
35
+ };
36
+
37
+ export type ResponseLike = {
38
+ headersSent?: boolean;
39
+ statusCode?: number;
40
+ status?: (code: number) => ResponseLike;
41
+ json?: (body: unknown) => ResponseLike;
42
+ send?: (body: unknown) => ResponseLike;
43
+ end?: (body?: string) => void;
44
+ setHeader?: (name: string, value: string) => void;
45
+ [key: string]: unknown;
46
+ };
47
+
48
+ export type NextLike = (error?: unknown) => void;
49
+
50
+ export type RouteHandler = (
51
+ req: RequestLike,
52
+ res: ResponseLike,
53
+ next?: NextLike
54
+ ) => unknown | Promise<unknown>;
55
+
56
+ export type Middleware = (
57
+ req: RequestLike,
58
+ res: ResponseLike,
59
+ next: NextLike
60
+ ) => unknown | Promise<unknown>;
61
+
62
+ // ─────────────────────────────────────────────────────────────────────────────
63
+ // SAFE ZONE CONTEXT
64
+ // ─────────────────────────────────────────────────────────────────────────────
65
+
66
+ export interface SafeZoneContext {
67
+ errorTrap: ErrorTrap;
68
+ bus: ImmortalEventBus;
69
+ config: Required<SafeZoneConfig>;
70
+ fallback?: FallbackGenerator;
71
+ }
72
+
73
+ // ─────────────────────────────────────────────────────────────────────────────
74
+ // SAFE WRAPPER IMPLEMENTATION
75
+ // ─────────────────────────────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * Wrap a route handler in a safe execution boundary.
79
+ *
80
+ * @param handler - The route handler function to wrap
81
+ * @param ctx - Safe zone context (errorTrap, bus, config, fallback)
82
+ * @returns A new handler with full error containment
83
+ */
84
+ export function wrapHandler(handler: RouteHandler, ctx: SafeZoneContext): RouteHandler {
85
+ const logger = getLogger();
86
+
87
+ return async function immortalWrappedHandler(
88
+ req: RequestLike,
89
+ res: ResponseLike,
90
+ next?: NextLike
91
+ ): Promise<void> {
92
+ const requestId = crypto.randomUUID();
93
+ const route = req.path ?? req.url ?? "unknown";
94
+ const method = req.method ?? "UNKNOWN";
95
+
96
+ const requestContext: RequestContext = {
97
+ requestId,
98
+ route,
99
+ method,
100
+ startedAt: Date.now(),
101
+ };
102
+
103
+ await AsyncBoundary.run(requestContext, async () => {
104
+ try {
105
+ await Promise.resolve(handler(req, res, next));
106
+ } catch (error) {
107
+ const duration = Date.now() - requestContext.startedAt;
108
+
109
+ logger.warn(`Error in handler [${method} ${route}]`, {
110
+ requestId,
111
+ durationMs: duration,
112
+ error: (error as Error).message,
113
+ });
114
+
115
+ // Capture the error for telemetry
116
+ ctx.errorTrap.capture(error, { requestId, route });
117
+
118
+ // Attempt recovery
119
+ await dispatchFallback(error, req, res, next, ctx, requestContext);
120
+ }
121
+ });
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Wrap a middleware function in a safe execution boundary.
127
+ * Middlewares with errors are forwarded to next(err) instead of fallback.
128
+ */
129
+ export function wrapMiddleware(middleware: Middleware, ctx: SafeZoneContext): Middleware {
130
+ const logger = getLogger();
131
+
132
+ return async function immortalWrappedMiddleware(
133
+ req: RequestLike,
134
+ res: ResponseLike,
135
+ next: NextLike
136
+ ): Promise<void> {
137
+ const requestId =
138
+ AsyncBoundary.getRequestId() ?? crypto.randomUUID();
139
+ const route = req.path ?? req.url ?? "unknown";
140
+
141
+ try {
142
+ await Promise.resolve(middleware(req, res, next));
143
+ } catch (error) {
144
+ logger.warn(`Error in middleware [${route}]`, {
145
+ requestId,
146
+ error: (error as Error).message,
147
+ });
148
+
149
+ ctx.errorTrap.capture(error, { requestId, route });
150
+
151
+ // For middleware, always forward to next(err)
152
+ next(error);
153
+ }
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Wrap any async function for safe execution (non-HTTP context).
159
+ * Returns the result or undefined if an error occurs.
160
+ */
161
+ export function wrapAsync<Args extends unknown[], R>(
162
+ fn: (...args: Args) => Promise<R>,
163
+ ctx: SafeZoneContext,
164
+ operationName = "anonymous"
165
+ ): (...args: Args) => Promise<R | undefined> {
166
+ const logger = getLogger();
167
+
168
+ return async (...args: Args): Promise<R | undefined> => {
169
+ const requestId = AsyncBoundary.getRequestId() ?? crypto.randomUUID();
170
+
171
+ try {
172
+ return await fn(...args);
173
+ } catch (error) {
174
+ logger.warn(`Error in async operation '${operationName}'`, {
175
+ requestId,
176
+ error: (error as Error).message,
177
+ });
178
+
179
+ ctx.errorTrap.capture(error, { requestId, operation: operationName });
180
+ return undefined;
181
+ }
182
+ };
183
+ }
184
+
185
+ // ─────────────────────────────────────────────────────────────────────────────
186
+ // FALLBACK DISPATCH
187
+ // ─────────────────────────────────────────────────────────────────────────────
188
+
189
+ async function dispatchFallback(
190
+ error: unknown,
191
+ req: RequestLike,
192
+ res: ResponseLike,
193
+ next: NextLike | undefined,
194
+ ctx: SafeZoneContext,
195
+ requestContext: RequestContext
196
+ ): Promise<void> {
197
+ // If response already started, can't send a new one — just log
198
+ if (res.headersSent) {
199
+ getLogger().warn("Cannot send fallback — response already sent", {
200
+ requestId: requestContext.requestId,
201
+ });
202
+ return;
203
+ }
204
+
205
+ // If a custom fallback is defined, use it
206
+ const fallbackFn = ctx.fallback ?? ctx.config.defaultFallback;
207
+ if (fallbackFn) {
208
+ try {
209
+ const fallbackResponse = await Promise.resolve(
210
+ fallbackFn(error, requestContext)
211
+ );
212
+
213
+ ctx.bus.emit("fallback:default-used", {
214
+ requestId: requestContext.requestId,
215
+ route: requestContext.route,
216
+ data: { hasCustomFallback: !!ctx.fallback },
217
+ });
218
+
219
+ if (res.setHeader) res.setHeader("X-Immortal-Fallback", "true");
220
+ if (res.setHeader) res.setHeader("X-Immortal-Request-Id", requestContext.requestId);
221
+
222
+ if (res.json) {
223
+ res.status?.(200).json?.(fallbackResponse);
224
+ } else if (res.send) {
225
+ res.status?.(200).send?.(JSON.stringify(fallbackResponse));
226
+ }
227
+ return;
228
+ } catch (fallbackError) {
229
+ getLogger().error("Fallback function itself threw an error", {
230
+ requestId: requestContext.requestId,
231
+ fallbackError: (fallbackError as Error).message,
232
+ });
233
+ }
234
+ }
235
+
236
+ // No fallback: forward to framework error handler via next(err)
237
+ if (next) {
238
+ next(error);
239
+ return;
240
+ }
241
+
242
+ // Last resort: send a generic 500 response
243
+ const serialized = serializeError(error);
244
+ const body: Record<string, unknown> = {
245
+ error: "Internal Server Error",
246
+ requestId: requestContext.requestId,
247
+ timestamp: new Date().toISOString(),
248
+ };
249
+
250
+ if (ctx.config.exposeErrors) {
251
+ body["details"] = serialized;
252
+ }
253
+
254
+ if (res.setHeader) res.setHeader("X-Immortal-Request-Id", requestContext.requestId);
255
+
256
+ if (res.json) {
257
+ res.status?.(500).json?.(body);
258
+ } else if (res.send) {
259
+ res.status?.(500).send?.(JSON.stringify(body));
260
+ } else if (res.end) {
261
+ res.end(JSON.stringify(body));
262
+ }
263
+ }
264
+
265
+ // ─────────────────────────────────────────────────────────────────────────────
266
+ // SAFE ZONE FACTORY
267
+ // ─────────────────────────────────────────────────────────────────────────────
268
+
269
+ export interface SafeZoneAPI {
270
+ /** Wrap a route handler */
271
+ handler: (fn: RouteHandler, fallback?: FallbackGenerator) => RouteHandler;
272
+ /** Wrap a middleware */
273
+ middleware: (fn: Middleware) => Middleware;
274
+ /** Wrap an async function */
275
+ async: <Args extends unknown[], R>(
276
+ fn: (...args: Args) => Promise<R>,
277
+ name?: string
278
+ ) => (...args: Args) => Promise<R | undefined>;
279
+ /** Create a decorator factory for class methods */
280
+ method: () => MethodDecorator;
281
+ }
282
+
283
+ /**
284
+ * Create a SafeZone API bound to specific config and error trap
285
+ */
286
+ export function createSafeZone(ctx: SafeZoneContext): SafeZoneAPI {
287
+ return {
288
+ handler: (fn: RouteHandler, fallback?: FallbackGenerator) =>
289
+ wrapHandler(fn, fallback !== undefined ? { ...ctx, fallback } : ctx),
290
+
291
+ middleware: (fn: Middleware) =>
292
+ wrapMiddleware(fn, ctx),
293
+
294
+ async: <Args extends unknown[], R>(
295
+ fn: (...args: Args) => Promise<R>,
296
+ name?: string
297
+ ) => wrapAsync(fn, ctx, name),
298
+
299
+ method: (): MethodDecorator => {
300
+ return function (
301
+ _target: object,
302
+ propertyKey: string | symbol,
303
+ descriptor: PropertyDescriptor
304
+ ): PropertyDescriptor {
305
+ const originalMethod = descriptor.value as (...args: unknown[]) => Promise<unknown>;
306
+
307
+ descriptor.value = wrapAsync(
308
+ originalMethod,
309
+ ctx,
310
+ String(propertyKey)
311
+ );
312
+
313
+ return descriptor;
314
+ };
315
+ },
316
+ };
317
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @file safe-zone/index.ts
3
+ * @description Safe Zone layer public exports
4
+ */
5
+
6
+ export { AsyncBoundary, asyncBoundary, getRequestContext, getRequestId } from "./AsyncBoundary.js";
7
+ export { ErrorTrap, classifyError, serializeError } from "./ErrorTrap.js";
8
+ export type { CapturedError, ErrorClassification } from "./ErrorTrap.js";
9
+ export {
10
+ wrapHandler,
11
+ wrapMiddleware,
12
+ wrapAsync,
13
+ createSafeZone,
14
+ } from "./SafeWrapper.js";
15
+ export type {
16
+ RouteHandler,
17
+ Middleware,
18
+ SafeZoneContext,
19
+ SafeZoneAPI,
20
+ RequestLike,
21
+ ResponseLike,
22
+ NextLike,
23
+ } from "./SafeWrapper.js";
@@ -0,0 +1,243 @@
1
+ /**
2
+ * @file ClusterManager.ts
3
+ * @description Node.js Cluster API wrapper with Supervisor integration
4
+ * Manages multiple OS processes for multi-core utilization
5
+ * with automatic restart on worker failure
6
+ */
7
+
8
+ import * as os from "node:os";
9
+ import type { SupervisorConfig, WorkerStatus } from "../types.js";
10
+ import { DEFAULT_SUPERVISOR } from "../config/defaults.js";
11
+ import { getEventBus } from "../event-bus.js";
12
+ import { getLogger } from "../logger.js";
13
+ import { calculateBackoff } from "../recovery/RetryEngine.js";
14
+
15
+ // ─────────────────────────────────────────────────────────────────────────────
16
+ // CLUSTER MANAGER
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+
19
+ export interface ClusterManagerOptions {
20
+ /** Number of workers (default: os.cpus().length) */
21
+ workerCount?: number;
22
+ /** Supervisor config for restart limits */
23
+ supervisorConfig?: SupervisorConfig;
24
+ /** Called when a worker exits */
25
+ onWorkerExit?: (workerId: number, exitCode: number, signal: string | null) => void;
26
+ /** Called when a worker is escalated */
27
+ onEscalation?: (workerId: number) => void;
28
+ }
29
+
30
+ interface ClusterWorkerMeta {
31
+ id: number;
32
+ pid: number | undefined;
33
+ restartCount: number;
34
+ restartTimestamps: number[];
35
+ startedAt: number;
36
+ status: WorkerStatus["state"];
37
+ }
38
+
39
+ export class ClusterManager {
40
+ private readonly workerCount: number;
41
+ private readonly config: Required<SupervisorConfig>;
42
+ private readonly workerMeta = new Map<number, ClusterWorkerMeta>();
43
+ private readonly options: ClusterManagerOptions;
44
+ private running = false;
45
+
46
+ constructor(options: ClusterManagerOptions = {}) {
47
+ this.options = options;
48
+ this.workerCount = options.workerCount ?? os.cpus().length;
49
+ this.config = { ...DEFAULT_SUPERVISOR, ...options.supervisorConfig };
50
+ }
51
+
52
+ /**
53
+ * Check if this process is the cluster primary (formerly "master")
54
+ * Uses dynamic import to avoid pulling cluster into non-cluster environments
55
+ */
56
+ static isPrimary(): boolean {
57
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
58
+ const cluster = require("node:cluster") as import("node:cluster").Cluster;
59
+ return cluster.isPrimary;
60
+ }
61
+
62
+ /**
63
+ * Start cluster mode — forks `workerCount` processes.
64
+ *
65
+ * @example
66
+ * if (ClusterManager.isPrimary()) {
67
+ * const mgr = new ClusterManager({ workerCount: 4 });
68
+ * await mgr.start();
69
+ * } else {
70
+ * await startApp();
71
+ * }
72
+ */
73
+ async start(): Promise<void> {
74
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
75
+ const cluster = require("node:cluster") as import("node:cluster").Cluster;
76
+
77
+ if (!cluster.isPrimary) {
78
+ throw new Error("ClusterManager.start() must only be called in the primary process");
79
+ }
80
+
81
+ this.running = true;
82
+ const logger = getLogger();
83
+ const bus = getEventBus();
84
+
85
+ logger.info(`ClusterManager: Forking ${this.workerCount} workers...`);
86
+
87
+ for (let i = 0; i < this.workerCount; i++) {
88
+ this.forkWorkerInternal(cluster);
89
+ }
90
+
91
+ cluster.on("exit", (worker, code, signal) => {
92
+ const meta = this.workerMeta.get(worker.id);
93
+ if (!meta || !this.running) return;
94
+
95
+ logger.warn(`Cluster worker ${worker.id} (PID: ${String(worker.process.pid)}) exited`, {
96
+ code,
97
+ signal,
98
+ restartCount: meta.restartCount,
99
+ });
100
+
101
+ bus.emit("worker:crashed", {
102
+ workerId: worker.id,
103
+ data: { pid: worker.process.pid, exitCode: code, signal },
104
+ });
105
+
106
+ this.options.onWorkerExit?.(worker.id, code ?? -1, signal);
107
+
108
+ const windowStart = Date.now() - this.config.windowMs;
109
+ meta.restartTimestamps = meta.restartTimestamps.filter((t) => t > windowStart);
110
+
111
+ if (meta.restartTimestamps.length >= this.config.maxRestartsInWindow) {
112
+ meta.status = "escalated";
113
+ logger.error(
114
+ `Cluster worker ${worker.id} escalated — ` +
115
+ `crashed ${meta.restartCount} times in ${this.config.windowMs}ms`
116
+ );
117
+ bus.emit("worker:escalated", {
118
+ workerId: worker.id,
119
+ data: { restartCount: meta.restartCount },
120
+ });
121
+ this.options.onEscalation?.(worker.id);
122
+ return;
123
+ }
124
+
125
+ const delay = calculateBackoff(meta.restartCount + 1, this.config.baseRestartDelayMs, 30_000);
126
+
127
+ setTimeout(() => {
128
+ if (this.running) {
129
+ logger.info(`Cluster: Restarting worker ${worker.id} in ${delay}ms`);
130
+ this.forkWorkerInternal(cluster, worker.id);
131
+ }
132
+ }, delay).unref();
133
+ });
134
+
135
+ cluster.on("online", (worker) => {
136
+ const meta = this.workerMeta.get(worker.id);
137
+ if (meta) {
138
+ meta.pid = worker.process.pid;
139
+ meta.status = "running";
140
+ }
141
+
142
+ bus.emit("worker:started", {
143
+ workerId: worker.id,
144
+ data: { pid: worker.process.pid },
145
+ });
146
+
147
+ logger.info(`Cluster worker ${worker.id} (PID: ${String(worker.process.pid)}) online`);
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Gracefully restart all cluster workers (zero-downtime rolling restart)
153
+ */
154
+ async rollingRestart(): Promise<void> {
155
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
156
+ const cluster = require("node:cluster") as import("node:cluster").Cluster;
157
+ const logger = getLogger();
158
+ logger.info("ClusterManager: Starting rolling restart...");
159
+
160
+ const workers = Array.from(Object.values(cluster.workers ?? {}));
161
+
162
+ for (const worker of workers) {
163
+ if (!worker) continue;
164
+
165
+ const newWorkerForked = new Promise<void>((resolve) => {
166
+ cluster.once("online", () => resolve());
167
+ this.forkWorkerInternal(cluster);
168
+ });
169
+
170
+ await newWorkerForked;
171
+ worker.kill("SIGTERM");
172
+ logger.info(`ClusterManager: Worker ${worker.id} replaced`);
173
+ await sleep(500);
174
+ }
175
+
176
+ logger.info("ClusterManager: Rolling restart complete");
177
+ }
178
+
179
+ /**
180
+ * Stop all cluster workers
181
+ */
182
+ async stop(): Promise<void> {
183
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
184
+ const cluster = require("node:cluster") as import("node:cluster").Cluster;
185
+ this.running = false;
186
+ const logger = getLogger();
187
+ logger.info("ClusterManager: Stopping all workers...");
188
+
189
+ const workers = Object.values(cluster.workers ?? {});
190
+ for (const worker of workers) {
191
+ worker?.kill("SIGTERM");
192
+ }
193
+
194
+ logger.info("ClusterManager: All workers signaled");
195
+ }
196
+
197
+ /**
198
+ * Get current status of all cluster workers
199
+ */
200
+ getWorkerStatuses(): WorkerStatus[] {
201
+ return Array.from(this.workerMeta.values()).map((meta) => {
202
+ const status: WorkerStatus = {
203
+ id: meta.id,
204
+ state: meta.status,
205
+ restartCount: meta.restartCount,
206
+ uptime: Date.now() - meta.startedAt,
207
+ };
208
+ if (meta.pid !== undefined) status.pid = meta.pid;
209
+ const lastRestart = meta.restartTimestamps.at(-1);
210
+ if (lastRestart !== undefined) status.lastRestartTime = lastRestart;
211
+ return status;
212
+ });
213
+ }
214
+
215
+ private forkWorkerInternal(
216
+ cluster: import("node:cluster").Cluster,
217
+ existingId?: number
218
+ ): import("node:cluster").Worker {
219
+ const worker = cluster.fork();
220
+
221
+ const meta: ClusterWorkerMeta = this.workerMeta.get(existingId ?? worker.id) ?? {
222
+ id: worker.id,
223
+ pid: undefined,
224
+ restartCount: 0,
225
+ restartTimestamps: [],
226
+ startedAt: Date.now(),
227
+ status: "restarting",
228
+ };
229
+
230
+ meta.id = worker.id;
231
+ meta.restartCount++;
232
+ meta.restartTimestamps.push(Date.now());
233
+ meta.startedAt = Date.now();
234
+
235
+ this.workerMeta.set(worker.id, meta);
236
+
237
+ return worker;
238
+ }
239
+ }
240
+
241
+ function sleep(ms: number): Promise<void> {
242
+ return new Promise((resolve) => setTimeout(resolve, ms).unref());
243
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @file RestartStrategy.ts
3
+ * @description Erlang/OTP-inspired restart strategies for Worker supervision
4
+ *
5
+ * one_for_one: Only the failed worker is restarted. Others continue unchanged.
6
+ * Use when workers are independent (most common case).
7
+ *
8
+ * one_for_all: If any worker fails, ALL workers are restarted.
9
+ * Use when workers share critical coupled state.
10
+ *
11
+ * rest_for_one: The failed worker AND all workers started after it are restarted.
12
+ * Use when workers have ordered dependencies (A → B → C).
13
+ */
14
+
15
+ import type { RestartStrategy } from "../types.js";
16
+
17
+ export interface WorkerEntry {
18
+ id: string;
19
+ index: number; // Order of registration (used for rest_for_one)
20
+ restart: () => void;
21
+ }
22
+
23
+ export interface RestartDecision {
24
+ workersToRestart: string[]; // IDs of workers to restart
25
+ strategy: RestartStrategy;
26
+ }
27
+
28
+ /**
29
+ * Determine which workers to restart based on strategy and failed worker
30
+ */
31
+ export function computeRestartDecision(
32
+ failedWorkerId: string,
33
+ allWorkers: WorkerEntry[],
34
+ strategy: RestartStrategy
35
+ ): RestartDecision {
36
+ const failed = allWorkers.find((w) => w.id === failedWorkerId);
37
+
38
+ if (!failed) {
39
+ // Unknown worker — safe to ignore
40
+ return { workersToRestart: [], strategy };
41
+ }
42
+
43
+ switch (strategy) {
44
+ case "one_for_one":
45
+ // Only restart the failed worker
46
+ return { workersToRestart: [failedWorkerId], strategy };
47
+
48
+ case "one_for_all":
49
+ // Restart ALL workers in the group
50
+ return {
51
+ workersToRestart: allWorkers.map((w) => w.id),
52
+ strategy,
53
+ };
54
+
55
+ case "rest_for_one":
56
+ // Restart failed worker + all workers with higher index (started after it)
57
+ return {
58
+ workersToRestart: allWorkers
59
+ .filter((w) => w.index >= failed.index)
60
+ .sort((a, b) => b.index - a.index) // Restart in reverse order (most dependent first)
61
+ .map((w) => w.id),
62
+ strategy,
63
+ };
64
+
65
+ default:
66
+ return { workersToRestart: [failedWorkerId], strategy };
67
+ }
68
+ }