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.
- package/LICENSE +21 -0
- package/README.md +577 -0
- package/docs/CHANGELOG.md +21 -0
- package/docs/CONTRIBUTING.md +41 -0
- package/docs/api/chaos.md +179 -0
- package/docs/api/dashboard.md +109 -0
- package/docs/api/isolation.md +191 -0
- package/docs/api/lifecycle.md +187 -0
- package/docs/api/monitoring.md +313 -0
- package/docs/api/plugins.md +217 -0
- package/docs/api/recovery.md +267 -0
- package/docs/api/safe-zone.md +236 -0
- package/docs/api/supervision.md +285 -0
- package/docs/guides/configuration.md +168 -0
- package/docs/guides/express.md +171 -0
- package/docs/guides/fastify.md +188 -0
- package/docs/guides/koa.md +102 -0
- package/docs/guides/nestjs.md +182 -0
- package/docs/guides/testing.md +91 -0
- package/examples/express-basic/index.ts +462 -0
- package/examples/express-basic/package.json +21 -0
- package/examples/express-basic/tsconfig.json +24 -0
- package/examples/fastify-microservice/index.ts +342 -0
- package/examples/fastify-microservice/package.json +22 -0
- package/examples/fastify-microservice/tsconfig.json +24 -0
- package/examples/invoice-service/data/invoices.db +0 -0
- package/examples/invoice-service/data/invoices.db-shm +0 -0
- package/examples/invoice-service/data/invoices.db-wal +0 -0
- package/examples/invoice-service/package.json +25 -0
- package/examples/invoice-service/public/index.html +5025 -0
- package/examples/invoice-service/src/db.ts +608 -0
- package/examples/invoice-service/src/pdf.ts +358 -0
- package/examples/invoice-service/src/server.ts +527 -0
- package/examples/invoice-service/src/store.ts +159 -0
- package/examples/invoice-service/src/types.ts +193 -0
- package/examples/invoice-service/tsconfig.json +23 -0
- package/examples/nestjs-enterprise/app.module.ts +561 -0
- package/examples/nestjs-enterprise/main.ts +67 -0
- package/examples/nestjs-enterprise/package.json +26 -0
- package/examples/nestjs-enterprise/tsconfig.json +27 -0
- package/immortal-js-1.0.0.tgz +0 -0
- package/package.json +33 -0
- package/packages/adapter-express/package.json +34 -0
- package/packages/adapter-express/src/index.ts +349 -0
- package/packages/adapter-express/tsconfig.json +14 -0
- package/packages/adapter-fastify/package.json +56 -0
- package/packages/adapter-fastify/src/plugin.ts +226 -0
- package/packages/adapter-fastify/tsconfig.json +14 -0
- package/packages/adapter-koa/package.json +55 -0
- package/packages/adapter-koa/src/index.ts +207 -0
- package/packages/adapter-koa/tsconfig.json +14 -0
- package/packages/adapter-nestjs/package.json +61 -0
- package/packages/adapter-nestjs/src/immortal.module.ts +313 -0
- package/packages/adapter-nestjs/src/index.ts +14 -0
- package/packages/adapter-nestjs/tsconfig.json +16 -0
- package/packages/core/package.json +56 -0
- package/packages/core/src/chaos/ChaosEngine.ts +249 -0
- package/packages/core/src/config/defaults.ts +200 -0
- package/packages/core/src/config/schema.ts +199 -0
- package/packages/core/src/event-bus.ts +168 -0
- package/packages/core/src/index.ts +164 -0
- package/packages/core/src/isolation/BulkheadPool.ts +279 -0
- package/packages/core/src/isolation/WorkerSandbox.ts +306 -0
- package/packages/core/src/isolation/index.ts +8 -0
- package/packages/core/src/lifecycle/GracefulShutdown.ts +161 -0
- package/packages/core/src/logger.ts +104 -0
- package/packages/core/src/monitoring/DiagnosticsChannel.ts +248 -0
- package/packages/core/src/monitoring/HealthMonitor.ts +191 -0
- package/packages/core/src/monitoring/MemoryLeakGuard.ts +340 -0
- package/packages/core/src/monitoring/MetricsCollector.ts +219 -0
- package/packages/core/src/monitoring/index.ts +10 -0
- package/packages/core/src/plugins/BuiltinPlugins.ts +269 -0
- package/packages/core/src/recovery/CircuitBreaker.ts +334 -0
- package/packages/core/src/recovery/FallbackCache.ts +328 -0
- package/packages/core/src/recovery/RetryEngine.ts +225 -0
- package/packages/core/src/recovery/Timeout.ts +97 -0
- package/packages/core/src/recovery/index.ts +11 -0
- package/packages/core/src/runtime.ts +242 -0
- package/packages/core/src/safe-zone/AsyncBoundary.ts +114 -0
- package/packages/core/src/safe-zone/ErrorTrap.ts +347 -0
- package/packages/core/src/safe-zone/SafeWrapper.ts +317 -0
- package/packages/core/src/safe-zone/index.ts +23 -0
- package/packages/core/src/supervision/ClusterManager.ts +243 -0
- package/packages/core/src/supervision/RestartStrategy.ts +68 -0
- package/packages/core/src/supervision/Supervisor.ts +311 -0
- package/packages/core/src/supervision/index.ts +11 -0
- package/packages/core/src/types.ts +470 -0
- package/packages/core/test/bulkhead.test.ts +310 -0
- package/packages/core/test/circuit-breaker.test.ts +153 -0
- package/packages/core/test/memory-guard.test.ts +213 -0
- package/packages/core/test/retry.test.ts +110 -0
- package/packages/core/test/safe-zone.test.ts +271 -0
- package/packages/core/test/supervisor.test.ts +310 -0
- package/packages/core/tsconfig.json +13 -0
- package/packages/dashboard/package.json +56 -0
- package/packages/dashboard/server/DashboardServer.ts +454 -0
- package/packages/dashboard/tsconfig.json +14 -0
- 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
|
+
}
|