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,328 @@
1
+ /**
2
+ * @file FallbackCache.ts
3
+ * @description LRU + TTL cache for emergency fallback responses
4
+ *
5
+ * When a circuit is open or a service is unavailable, this cache returns
6
+ * the last known-good response instead of a cold error.
7
+ *
8
+ * Key design decisions:
9
+ * 1. Always marks cached responses with X-Immortal-Fallback: true header
10
+ * 2. TTL prevents stale data from being served indefinitely
11
+ * 3. LRU eviction prevents unbounded memory growth
12
+ * 4. Per-key TTL allows different expiry times per route/service
13
+ * 5. Cache is NOT a source of truth — it's a safety net
14
+ */
15
+
16
+ import { getEventBus } from "../event-bus.js";
17
+ import { getLogger } from "../logger.js";
18
+
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+ // TYPES
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+
23
+ export interface CacheEntry<T> {
24
+ value: T;
25
+ createdAt: number;
26
+ expiresAt: number;
27
+ hits: number;
28
+ key: string;
29
+ }
30
+
31
+ export interface CacheStats {
32
+ size: number;
33
+ maxSize: number;
34
+ hits: number;
35
+ misses: number;
36
+ hitRate: number;
37
+ evictions: number;
38
+ totalEntries: number;
39
+ }
40
+
41
+ // ─────────────────────────────────────────────────────────────────────────────
42
+ // LRU CACHE IMPLEMENTATION
43
+ // ─────────────────────────────────────────────────────────────────────────────
44
+
45
+ export class FallbackCache<T = unknown> {
46
+ private readonly cache = new Map<string, CacheEntry<T>>();
47
+ private readonly maxSize: number;
48
+ private readonly defaultTtlMs: number;
49
+ private hits = 0;
50
+ private misses = 0;
51
+ private evictions = 0;
52
+
53
+ constructor(options: { maxSize?: number; defaultTtlMs?: number } = {}) {
54
+ this.maxSize = options.maxSize ?? 500;
55
+ this.defaultTtlMs = options.defaultTtlMs ?? 5 * 60 * 1000; // 5 minutes default
56
+ }
57
+
58
+ /**
59
+ * Store a value in the cache.
60
+ * @param key - Cache key (typically route path + params hash)
61
+ * @param value - Value to cache
62
+ * @param ttlMs - Time-to-live in ms (overrides default)
63
+ */
64
+ set(key: string, value: T, ttlMs?: number): void {
65
+ const now = Date.now();
66
+ const ttl = ttlMs ?? this.defaultTtlMs;
67
+
68
+ // If key exists, update in place (preserves LRU order by re-inserting)
69
+ if (this.cache.has(key)) {
70
+ this.cache.delete(key);
71
+ }
72
+
73
+ // Evict LRU entry if at capacity
74
+ if (this.cache.size >= this.maxSize) {
75
+ const lruKey = this.cache.keys().next().value;
76
+ if (lruKey !== undefined) {
77
+ this.cache.delete(lruKey);
78
+ this.evictions++;
79
+ }
80
+ }
81
+
82
+ // Insert at end (most recently used position in Map)
83
+ this.cache.set(key, {
84
+ value,
85
+ createdAt: now,
86
+ expiresAt: now + ttl,
87
+ hits: 0,
88
+ key,
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Retrieve a value from the cache.
94
+ * Returns undefined if not found or expired.
95
+ * Moves hit entry to MRU position (LRU promotion).
96
+ */
97
+ get(key: string): T | undefined {
98
+ const entry = this.cache.get(key);
99
+
100
+ if (!entry) {
101
+ this.misses++;
102
+ return undefined;
103
+ }
104
+
105
+ // Check TTL
106
+ if (Date.now() > entry.expiresAt) {
107
+ this.cache.delete(key);
108
+ this.misses++;
109
+ return undefined;
110
+ }
111
+
112
+ // LRU promotion: move to end
113
+ this.cache.delete(key);
114
+ entry.hits++;
115
+ this.cache.set(key, entry);
116
+ this.hits++;
117
+
118
+ return entry.value;
119
+ }
120
+
121
+ /**
122
+ * Check if a key exists and is not expired
123
+ */
124
+ has(key: string): boolean {
125
+ const entry = this.cache.get(key);
126
+ if (!entry) return false;
127
+ if (Date.now() > entry.expiresAt) {
128
+ this.cache.delete(key);
129
+ return false;
130
+ }
131
+ return true;
132
+ }
133
+
134
+ /**
135
+ * Invalidate a specific cache entry
136
+ */
137
+ invalidate(key: string): boolean {
138
+ return this.cache.delete(key);
139
+ }
140
+
141
+ /**
142
+ * Invalidate all entries matching a prefix pattern
143
+ */
144
+ invalidatePattern(prefix: string): number {
145
+ let count = 0;
146
+ for (const key of this.cache.keys()) {
147
+ if (key.startsWith(prefix)) {
148
+ this.cache.delete(key);
149
+ count++;
150
+ }
151
+ }
152
+ return count;
153
+ }
154
+
155
+ /**
156
+ * Get cache statistics
157
+ */
158
+ getStats(): CacheStats {
159
+ const total = this.hits + this.misses;
160
+ return {
161
+ size: this.cache.size,
162
+ maxSize: this.maxSize,
163
+ hits: this.hits,
164
+ misses: this.misses,
165
+ hitRate: total > 0 ? this.hits / total : 0,
166
+ evictions: this.evictions,
167
+ totalEntries: this.cache.size,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Remove all expired entries (run periodically to prevent memory leaks)
173
+ */
174
+ purgeExpired(): number {
175
+ const now = Date.now();
176
+ let purged = 0;
177
+ for (const [key, entry] of this.cache) {
178
+ if (now > entry.expiresAt) {
179
+ this.cache.delete(key);
180
+ purged++;
181
+ }
182
+ }
183
+ return purged;
184
+ }
185
+
186
+ /**
187
+ * Clear all entries
188
+ */
189
+ clear(): void {
190
+ this.cache.clear();
191
+ this.hits = 0;
192
+ this.misses = 0;
193
+ this.evictions = 0;
194
+ }
195
+
196
+ /**
197
+ * Get the number of cached entries
198
+ */
199
+ get size(): number {
200
+ return this.cache.size;
201
+ }
202
+ }
203
+
204
+ // ─────────────────────────────────────────────────────────────────────────────
205
+ // ROUTE FALLBACK CACHE — HTTP-aware wrapper
206
+ // ─────────────────────────────────────────────────────────────────────────────
207
+
208
+ export interface CachedResponse {
209
+ body: unknown;
210
+ statusCode: number;
211
+ headers: Record<string, string>;
212
+ cachedAt: number;
213
+ }
214
+
215
+ export class RouteFallbackCache {
216
+ private readonly cache: FallbackCache<CachedResponse>;
217
+ private readonly purgeIntervalMs: number;
218
+ private purgeInterval?: ReturnType<typeof setInterval>;
219
+
220
+ constructor(options: {
221
+ maxSize?: number;
222
+ defaultTtlMs?: number;
223
+ purgeIntervalMs?: number;
224
+ } = {}) {
225
+ this.cache = new FallbackCache({
226
+ maxSize: options.maxSize ?? 200,
227
+ defaultTtlMs: options.defaultTtlMs ?? 5 * 60 * 1000,
228
+ });
229
+ this.purgeIntervalMs = options.purgeIntervalMs ?? 60_000;
230
+ }
231
+
232
+ /**
233
+ * Start automatic cache purging
234
+ */
235
+ start(): void {
236
+ this.purgeInterval = setInterval(() => {
237
+ const purged = this.cache.purgeExpired();
238
+ if (purged > 0) {
239
+ getLogger().debug(`RouteFallbackCache: purged ${purged} expired entries`);
240
+ }
241
+ }, this.purgeIntervalMs).unref();
242
+ }
243
+
244
+ /**
245
+ * Stop automatic purging
246
+ */
247
+ stop(): void {
248
+ if (this.purgeInterval) {
249
+ clearInterval(this.purgeInterval);
250
+ delete this.purgeInterval;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Cache a successful HTTP response
256
+ */
257
+ cacheResponse(
258
+ route: string,
259
+ method: string,
260
+ response: { body: unknown; statusCode?: number; headers?: Record<string, string> },
261
+ ttlMs?: number
262
+ ): void {
263
+ const key = this.buildKey(route, method);
264
+ this.cache.set(key, {
265
+ body: response.body,
266
+ statusCode: response.statusCode ?? 200,
267
+ headers: {
268
+ ...response.headers,
269
+ "Content-Type": "application/json",
270
+ },
271
+ cachedAt: Date.now(),
272
+ }, ttlMs);
273
+ }
274
+
275
+ /**
276
+ * Get a cached fallback response for a route
277
+ * Returns undefined if no cache entry or expired
278
+ */
279
+ getFallback(
280
+ route: string,
281
+ method: string
282
+ ): (CachedResponse & { isFallback: true; ageMs: number }) | undefined {
283
+ const key = this.buildKey(route, method);
284
+ const entry = this.cache.get(key);
285
+
286
+ if (!entry) return undefined;
287
+
288
+ getEventBus().emit("fallback:cache-hit", {
289
+ route,
290
+ data: { method, ageMs: Date.now() - entry.cachedAt, key },
291
+ });
292
+
293
+ return {
294
+ ...entry,
295
+ isFallback: true,
296
+ ageMs: Date.now() - entry.cachedAt,
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Create a cache middleware for automatic request/response caching
302
+ */
303
+ createCacheMiddleware(options: {
304
+ ttlMs?: number;
305
+ shouldCache?: (route: string, method: string) => boolean;
306
+ } = {}): {
307
+ cacheResponse: (route: string, method: string, body: unknown, status?: number) => void;
308
+ getFallback: (route: string, method: string) => (CachedResponse & { isFallback: true; ageMs: number }) | undefined;
309
+ } {
310
+ return {
311
+ cacheResponse: (route, method, body, status = 200) => {
312
+ const shouldCache = options.shouldCache?.(route, method) ?? true;
313
+ if (shouldCache && status < 400) {
314
+ this.cacheResponse(route, method, { body, statusCode: status }, options.ttlMs);
315
+ }
316
+ },
317
+ getFallback: (route, method) => this.getFallback(route, method),
318
+ };
319
+ }
320
+
321
+ getStats(): CacheStats {
322
+ return this.cache.getStats();
323
+ }
324
+
325
+ private buildKey(route: string, method: string): string {
326
+ return `${method.toUpperCase()}:${route}`;
327
+ }
328
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * @file RetryEngine.ts
3
+ * @description Retry engine with Exponential Backoff + Jitter + Retry Budget
4
+ *
5
+ * Key engineering decisions:
6
+ * 1. Exponential backoff with full jitter (not equal jitter) to prevent thundering herd
7
+ * 2. Retry budget prevents global retry storms — max N retries/second across ALL operations
8
+ * 3. isRetryable() classification prevents retrying non-retryable errors (e.g. 400 Bad Request)
9
+ * 4. RetryExhaustedError wraps the last error for full context preservation
10
+ */
11
+
12
+ import type { RetryConfig } from "../types.js";
13
+ import { RetryExhaustedError } from "../types.js";
14
+ import { DEFAULT_RETRY } from "../config/defaults.js";
15
+ import { getEventBus } from "../event-bus.js";
16
+ import { getLogger } from "../logger.js";
17
+ import { AsyncBoundary } from "../safe-zone/AsyncBoundary.js";
18
+
19
+ // ─────────────────────────────────────────────────────────────────────────────
20
+ // RETRY BUDGET (Global rate limiter for retries)
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+
23
+ class RetryBudget {
24
+ private tokens: number;
25
+ private readonly maxTokens: number;
26
+ private lastRefill: number;
27
+
28
+ constructor(maxPerSecond: number) {
29
+ this.maxTokens = maxPerSecond;
30
+ this.tokens = maxPerSecond;
31
+ this.lastRefill = Date.now();
32
+ }
33
+
34
+ tryConsume(): boolean {
35
+ this.refill();
36
+ if (this.tokens >= 1) {
37
+ this.tokens -= 1;
38
+ return true;
39
+ }
40
+ return false;
41
+ }
42
+
43
+ private refill(): void {
44
+ const now = Date.now();
45
+ const elapsed = (now - this.lastRefill) / 1000;
46
+ this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.maxTokens);
47
+ this.lastRefill = now;
48
+ }
49
+
50
+ getAvailable(): number {
51
+ this.refill();
52
+ return Math.floor(this.tokens);
53
+ }
54
+ }
55
+
56
+ // Global budget shared across all RetryEngine instances
57
+ let _globalBudget: RetryBudget | null = null;
58
+
59
+ function getGlobalBudget(maxPerSecond: number): RetryBudget {
60
+ if (!_globalBudget) {
61
+ _globalBudget = new RetryBudget(maxPerSecond);
62
+ }
63
+ return _globalBudget;
64
+ }
65
+
66
+ // ─────────────────────────────────────────────────────────────────────────────
67
+ // RETRY ENGINE CLASS
68
+ // ─────────────────────────────────────────────────────────────────────────────
69
+
70
+ export class RetryEngine {
71
+ private readonly config: Required<RetryConfig>;
72
+
73
+ constructor(config: RetryConfig = {}) {
74
+ this.config = { ...DEFAULT_RETRY, ...config };
75
+ }
76
+
77
+ /**
78
+ * Execute a function with retry logic.
79
+ *
80
+ * @param fn - The async function to execute
81
+ * @param operationName - Name for logging/telemetry
82
+ * @returns Result of fn on success
83
+ * @throws RetryExhaustedError if all attempts fail
84
+ */
85
+ async execute<T>(fn: () => Promise<T>, operationName = "anonymous"): Promise<T> {
86
+ const bus = getEventBus();
87
+ const logger = getLogger();
88
+ const budget = getGlobalBudget(this.config.retryBudget);
89
+ const requestId = AsyncBoundary.getRequestId();
90
+
91
+ let lastError: unknown;
92
+ let attempt = 0;
93
+
94
+ while (true) {
95
+ try {
96
+ const result = await fn();
97
+
98
+ if (attempt > 0) {
99
+ bus.emit("retry:success", {
100
+ requestId,
101
+ data: { operationName, successAfterAttempts: attempt + 1 },
102
+ });
103
+ logger.info(`Retry succeeded after ${attempt + 1} attempts`, { operationName });
104
+ }
105
+
106
+ return result;
107
+ } catch (err) {
108
+ lastError = err;
109
+ attempt++;
110
+
111
+ // Check if retryable
112
+ if (!this.config.isRetryable(err)) {
113
+ logger.debug(`Error not retryable — failing immediately`, {
114
+ operationName,
115
+ attempt,
116
+ error: (err as Error).message,
117
+ });
118
+ throw err;
119
+ }
120
+
121
+ // Check attempt limit
122
+ if (attempt >= this.config.maxAttempts) {
123
+ bus.emit("retry:exhausted", {
124
+ requestId,
125
+ data: { operationName, attempts: attempt },
126
+ error: { name: "RetryExhausted", message: `Failed after ${attempt} attempts` },
127
+ });
128
+ throw new RetryExhaustedError(attempt, lastError);
129
+ }
130
+
131
+ // Check global retry budget
132
+ if (!budget.tryConsume()) {
133
+ logger.warn(`Retry budget exhausted — failing immediately to protect system`, {
134
+ operationName,
135
+ attempt,
136
+ budgetAvailable: budget.getAvailable(),
137
+ });
138
+ throw new RetryExhaustedError(attempt, lastError);
139
+ }
140
+
141
+ // Calculate backoff with full jitter
142
+ const delay = this.calculateDelay(attempt);
143
+
144
+ bus.emit("retry:attempt", {
145
+ requestId,
146
+ data: {
147
+ operationName,
148
+ attempt,
149
+ maxAttempts: this.config.maxAttempts,
150
+ nextRetryMs: delay,
151
+ error: (err as Error).message,
152
+ },
153
+ });
154
+
155
+ logger.debug(`Retry attempt ${attempt}/${this.config.maxAttempts} in ${delay}ms`, {
156
+ operationName,
157
+ error: (err as Error).message,
158
+ });
159
+
160
+ await sleep(delay);
161
+ }
162
+ }
163
+ }
164
+
165
+ private calculateDelay(attempt: number): number {
166
+ // Exponential backoff: baseDelay * 2^attempt
167
+ const exponential = this.config.baseDelayMs * Math.pow(2, attempt - 1);
168
+ // Cap at maxDelayMs
169
+ const capped = Math.min(exponential, this.config.maxDelayMs);
170
+ // Full jitter: random between 0 and capped
171
+ const jitter = Math.random() * capped * this.config.jitterFactor;
172
+ return Math.floor(capped - jitter + jitter * Math.random());
173
+ }
174
+
175
+ /** Reset the global retry budget (for testing) */
176
+ static resetBudget(): void {
177
+ _globalBudget = null;
178
+ }
179
+ }
180
+
181
+ // ─────────────────────────────────────────────────────────────────────────────
182
+ // FUNCTIONAL API
183
+ // ─────────────────────────────────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Execute a function with retry logic.
187
+ * Functional alternative to RetryEngine class.
188
+ *
189
+ * @example
190
+ * const data = await withRetry(
191
+ * () => fetchFromDatabase(id),
192
+ * { maxAttempts: 3, baseDelayMs: 100 }
193
+ * );
194
+ */
195
+ export async function withRetry<T>(
196
+ fn: () => Promise<T>,
197
+ config?: RetryConfig,
198
+ operationName?: string
199
+ ): Promise<T> {
200
+ const engine = new RetryEngine(config);
201
+ return engine.execute(fn, operationName);
202
+ }
203
+
204
+ /**
205
+ * Utility: exponential backoff delay calculator (exported for use in other modules)
206
+ */
207
+ export function calculateBackoff(
208
+ attempt: number,
209
+ baseMs: number,
210
+ maxMs: number,
211
+ jitterFactor = 0.3
212
+ ): number {
213
+ const exponential = baseMs * Math.pow(2, attempt - 1);
214
+ const capped = Math.min(exponential, maxMs);
215
+ const jitter = Math.random() * capped * jitterFactor;
216
+ return Math.floor(capped - jitter + jitter * Math.random());
217
+ }
218
+
219
+ // ─────────────────────────────────────────────────────────────────────────────
220
+ // UTILITIES
221
+ // ─────────────────────────────────────────────────────────────────────────────
222
+
223
+ function sleep(ms: number): Promise<void> {
224
+ return new Promise((resolve) => setTimeout(resolve, ms).unref());
225
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @file Timeout.ts
3
+ * @description AbortController-based operation timeout wrapper
4
+ * Ensures no operation can hang indefinitely, blocking resources
5
+ */
6
+
7
+ import { TimeoutError } from "../types.js";
8
+
9
+ /**
10
+ * Wrap any async operation with a timeout.
11
+ * Uses AbortController for clean cancellation signal propagation.
12
+ *
13
+ * @param fn - Async function that optionally accepts an AbortSignal
14
+ * @param timeoutMs - Timeout in milliseconds
15
+ * @param operationName - Name for error messages
16
+ * @returns Result of fn or throws TimeoutError
17
+ */
18
+ export async function withTimeout<T>(
19
+ fn: (signal: AbortSignal) => Promise<T>,
20
+ timeoutMs: number,
21
+ operationName = "operation"
22
+ ): Promise<T> {
23
+ const controller = new AbortController();
24
+ const { signal } = controller;
25
+
26
+ const timeoutId = setTimeout(() => {
27
+ controller.abort(new TimeoutError(operationName, timeoutMs));
28
+ }, timeoutMs);
29
+
30
+ try {
31
+ const result = await Promise.race([
32
+ fn(signal),
33
+ new Promise<never>((_, reject) => {
34
+ signal.addEventListener("abort", () => {
35
+ reject(signal.reason instanceof TimeoutError
36
+ ? signal.reason
37
+ : new TimeoutError(operationName, timeoutMs)
38
+ );
39
+ }, { once: true });
40
+ }),
41
+ ]);
42
+
43
+ clearTimeout(timeoutId);
44
+ return result;
45
+ } catch (error) {
46
+ clearTimeout(timeoutId);
47
+ controller.abort(); // Ensure abort propagates even on non-timeout errors
48
+
49
+ if (error instanceof TimeoutError) throw error;
50
+
51
+ // If operation was aborted by timeout but threw a different error, wrap it
52
+ if (signal.aborted) {
53
+ throw new TimeoutError(operationName, timeoutMs);
54
+ }
55
+
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Create a reusable timeout wrapper for a specific timeout value
62
+ */
63
+ export function createTimeoutWrapper(timeoutMs: number) {
64
+ return function <T>(
65
+ fn: (signal: AbortSignal) => Promise<T>,
66
+ operationName?: string
67
+ ): Promise<T> {
68
+ return withTimeout(fn, timeoutMs, operationName);
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Simple timeout without AbortSignal (for legacy code compatibility)
74
+ * Less clean but works with functions that don't accept AbortSignal
75
+ */
76
+ export async function withSimpleTimeout<T>(
77
+ fn: () => Promise<T>,
78
+ timeoutMs: number,
79
+ operationName = "operation"
80
+ ): Promise<T> {
81
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
82
+
83
+ const timeoutPromise = new Promise<never>((_, reject) => {
84
+ timeoutId = setTimeout(() => {
85
+ reject(new TimeoutError(operationName, timeoutMs));
86
+ }, timeoutMs);
87
+ });
88
+
89
+ try {
90
+ const result = await Promise.race([fn(), timeoutPromise]);
91
+ clearTimeout(timeoutId);
92
+ return result;
93
+ } catch (error) {
94
+ clearTimeout(timeoutId);
95
+ throw error;
96
+ }
97
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @file recovery/index.ts
3
+ * @description Recovery layer public exports
4
+ */
5
+
6
+ export { withTimeout, withSimpleTimeout, createTimeoutWrapper } from "./Timeout.js";
7
+ export { RetryEngine, withRetry, calculateBackoff } from "./RetryEngine.js";
8
+ export { CircuitBreaker, CircuitBreakerRegistry } from "./CircuitBreaker.js";
9
+ export type { CircuitState } from "./CircuitBreaker.js";
10
+ export { FallbackCache, RouteFallbackCache } from "./FallbackCache.js";
11
+ export type { CacheEntry, CacheStats, CachedResponse } from "./FallbackCache.js";