bunsane 0.2.9 → 0.3.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/CHANGELOG.md +266 -0
- package/config/cache.config.ts +12 -2
- package/core/App.ts +390 -66
- package/core/ApplicationLifecycle.ts +68 -4
- package/core/Entity.ts +407 -256
- package/core/EntityHookManager.ts +88 -21
- package/core/EntityManager.ts +12 -3
- package/core/Logger.ts +4 -0
- package/core/RequestContext.ts +4 -1
- package/core/SchedulerManager.ts +92 -9
- package/core/cache/CacheFactory.ts +3 -1
- package/core/cache/CacheManager.ts +54 -17
- package/core/cache/RedisCache.ts +38 -3
- package/core/decorators/EntityHooks.ts +24 -12
- package/core/middleware/RateLimit.ts +105 -0
- package/core/middleware/index.ts +1 -0
- package/core/remote/CircuitBreaker.ts +115 -0
- package/core/remote/OutboxWorker.ts +183 -0
- package/core/remote/RemoteManager.ts +400 -0
- package/core/remote/RpcCaller.ts +310 -0
- package/core/remote/StreamConsumer.ts +535 -0
- package/core/remote/decorators.ts +121 -0
- package/core/remote/health.ts +139 -0
- package/core/remote/index.ts +37 -0
- package/core/remote/metrics.ts +99 -0
- package/core/remote/outboxSchema.ts +41 -0
- package/core/remote/types.ts +151 -0
- package/core/scheduler/DistributedLock.ts +324 -266
- package/gql/builders/ResolverBuilder.ts +4 -4
- package/gql/complexityLimit.ts +95 -0
- package/gql/index.ts +15 -3
- package/gql/visitors/ResolverGeneratorVisitor.ts +16 -2
- package/package.json +1 -1
- package/query/ComponentInclusionNode.ts +13 -6
- package/query/OrNode.ts +2 -4
- package/query/Query.ts +30 -3
- package/query/SqlIdentifier.ts +105 -0
- package/query/builders/FullTextSearchBuilder.ts +19 -6
- package/service/ServiceRegistry.ts +21 -8
- package/storage/LocalStorageProvider.ts +12 -3
- package/storage/S3StorageProvider.ts +6 -6
- package/tests/e2e/http.test.ts +6 -2
- package/tests/helpers/MockRedisClient.ts +113 -0
- package/tests/helpers/MockRedisStreamServer.ts +448 -0
- package/tests/integration/entity/Entity.saveTimeout.test.ts +110 -0
- package/tests/integration/remote/dlq.test.ts +175 -0
- package/tests/integration/remote/event-dispatch.test.ts +114 -0
- package/tests/integration/remote/outbox.test.ts +130 -0
- package/tests/integration/remote/rpc.test.ts +177 -0
- package/tests/unit/remote/CircuitBreaker.test.ts +159 -0
- package/tests/unit/remote/RemoteError.test.ts +55 -0
- package/tests/unit/remote/decorators.test.ts +195 -0
- package/tests/unit/remote/metrics.test.ts +115 -0
- package/tests/unit/remote/mockRedisStreamServer.test.ts +104 -0
- package/tests/unit/storage/S3StorageProvider.test.ts +6 -10
- package/upload/FileValidator.ts +9 -6
package/core/App.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import ApplicationLifecycle, {
|
|
2
2
|
ApplicationPhase,
|
|
3
|
+
type PhaseChangeEvent,
|
|
3
4
|
} from "./ApplicationLifecycle";
|
|
4
5
|
import {
|
|
5
6
|
GenerateTableName,
|
|
@@ -26,6 +27,14 @@ import studioEndpoint from "../endpoints";
|
|
|
26
27
|
import { type Middleware, composeMiddleware } from "./Middleware";
|
|
27
28
|
import { deepHealthCheck, readinessCheck } from "./health";
|
|
28
29
|
import { validateEnv } from "./validateEnv";
|
|
30
|
+
import {
|
|
31
|
+
RemoteManager,
|
|
32
|
+
registerRemoteHandlers,
|
|
33
|
+
setRemoteManager,
|
|
34
|
+
} from "./remote";
|
|
35
|
+
import type { RemoteManagerConfig } from "./remote";
|
|
36
|
+
import type { CacheConfig } from "../config/cache.config";
|
|
37
|
+
import { createRequestContextPlugin } from "./RequestContext";
|
|
29
38
|
|
|
30
39
|
export type CorsConfig = {
|
|
31
40
|
origin?: string | string[] | ((origin: string) => boolean);
|
|
@@ -70,10 +79,22 @@ export default class App {
|
|
|
70
79
|
private composedHandler: ((req: Request) => Promise<Response>) | null = null;
|
|
71
80
|
|
|
72
81
|
private studioEnabled: boolean = false;
|
|
82
|
+
private remote: RemoteManager | null = null;
|
|
83
|
+
private remoteConfig: Partial<RemoteManagerConfig> | null = null;
|
|
73
84
|
private server: ReturnType<typeof Bun.serve> | null = null;
|
|
74
85
|
private isShuttingDown = false;
|
|
75
86
|
private isReady = false;
|
|
87
|
+
private cacheConfig: Partial<CacheConfig> | null = null;
|
|
88
|
+
private requestContextPluginEnabled = true;
|
|
89
|
+
private phaseListener: ((event: PhaseChangeEvent) => void) | null = null;
|
|
90
|
+
private signalHandlersRegistered = false;
|
|
91
|
+
private processHandlersRegistered = false;
|
|
92
|
+
private sigTermHandler: (() => void) | null = null;
|
|
93
|
+
private sigIntHandler: (() => void) | null = null;
|
|
94
|
+
private unhandledRejectionHandler: ((reason: unknown, promise: Promise<unknown>) => void) | null = null;
|
|
95
|
+
private uncaughtExceptionHandler: ((error: Error) => void) | null = null;
|
|
76
96
|
private graphqlMaxDepth: number = 10;
|
|
97
|
+
private graphqlMaxComplexity: number = 1000;
|
|
77
98
|
private shutdownGracePeriod = 10_000;
|
|
78
99
|
private maxRequestBodySize = 50 * 1024 * 1024; // 50MB default
|
|
79
100
|
|
|
@@ -117,6 +138,9 @@ export default class App {
|
|
|
117
138
|
}
|
|
118
139
|
|
|
119
140
|
public setCors(cors: CorsConfig) {
|
|
141
|
+
if (cors.origin === undefined) {
|
|
142
|
+
throw new Error('[CORS] `origin` is required. Pass an explicit string, array, function, or "*" if you truly want to allow everyone.');
|
|
143
|
+
}
|
|
120
144
|
this.config.cors = cors;
|
|
121
145
|
// Warn about invalid configuration
|
|
122
146
|
if (cors.credentials && cors.origin === '*') {
|
|
@@ -125,19 +149,29 @@ export default class App {
|
|
|
125
149
|
}
|
|
126
150
|
|
|
127
151
|
async init() {
|
|
152
|
+
// Register process-level error handlers FIRST so failures during init
|
|
153
|
+
// (DB prep, component registration, schema build) are observable. If
|
|
154
|
+
// registration happens later (e.g. in start()) any boot-sequence
|
|
155
|
+
// unhandled rejection is silently discarded by the runtime.
|
|
156
|
+
this.registerProcessHandlers();
|
|
157
|
+
|
|
128
158
|
validateEnv();
|
|
129
159
|
logger.trace(`Initializing App`);
|
|
130
160
|
ComponentRegistry.init();
|
|
131
161
|
ServiceRegistry.init();
|
|
132
162
|
|
|
133
|
-
// Initialize CacheManager
|
|
163
|
+
// Initialize CacheManager with merged config. MUST await — initialize()
|
|
164
|
+
// is async and sets up pub/sub for cross-instance invalidation. Previously
|
|
165
|
+
// only getInstance() was called, silently skipping pub/sub setup and
|
|
166
|
+
// ignoring any app-supplied config (C04).
|
|
134
167
|
try {
|
|
135
168
|
const { CacheManager } = await import('./cache/CacheManager');
|
|
136
169
|
const cacheManager = CacheManager.getInstance();
|
|
137
|
-
|
|
138
|
-
|
|
170
|
+
await cacheManager.initialize(this.cacheConfig ?? {});
|
|
171
|
+
const config = cacheManager.getConfig();
|
|
172
|
+
logger.info({ scope: 'cache', component: 'App', msg: 'CacheManager initialized', provider: config.provider, enabled: config.enabled, strategy: config.strategy });
|
|
139
173
|
} catch (error) {
|
|
140
|
-
logger.warn({ scope: 'cache', component: 'App', msg: 'Failed to initialize CacheManager', error });
|
|
174
|
+
logger.warn({ scope: 'cache', component: 'App', msg: 'Failed to initialize CacheManager', err: error });
|
|
141
175
|
}
|
|
142
176
|
|
|
143
177
|
// Plugin initialization
|
|
@@ -147,7 +181,12 @@ export default class App {
|
|
|
147
181
|
}
|
|
148
182
|
}
|
|
149
183
|
|
|
150
|
-
|
|
184
|
+
// Remove any previous listener so repeated init() calls (tests) don't
|
|
185
|
+
// stack handlers on the lifecycle singleton.
|
|
186
|
+
if (this.phaseListener) {
|
|
187
|
+
ApplicationLifecycle.removePhaseListener(this.phaseListener);
|
|
188
|
+
}
|
|
189
|
+
this.phaseListener = async (event: PhaseChangeEvent) => {
|
|
151
190
|
const phase = event.detail;
|
|
152
191
|
logger.info(`Application phase changed to: ${phase}`);
|
|
153
192
|
// Notify plugins of phase change
|
|
@@ -209,23 +248,39 @@ export default class App {
|
|
|
209
248
|
if (envDepth) {
|
|
210
249
|
this.graphqlMaxDepth = parseInt(envDepth, 10);
|
|
211
250
|
}
|
|
251
|
+
const envComplexity = process.env.GRAPHQL_MAX_COMPLEXITY;
|
|
252
|
+
if (envComplexity) {
|
|
253
|
+
const parsed = parseInt(envComplexity, 10);
|
|
254
|
+
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
255
|
+
this.graphqlMaxComplexity = parsed;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
212
258
|
|
|
213
259
|
const yogaOptions = {
|
|
214
260
|
cors: this.config.cors,
|
|
215
261
|
maxDepth: this.graphqlMaxDepth || undefined,
|
|
262
|
+
maxComplexity: this.graphqlMaxComplexity,
|
|
216
263
|
};
|
|
217
264
|
|
|
265
|
+
// Auto-apply RequestContext plugin by default so
|
|
266
|
+
// apps using @BelongsTo / @HasMany get DataLoader
|
|
267
|
+
// batching without opt-in. Prevents N+1 query
|
|
268
|
+
// explosion. Opt out via disableRequestContextPlugin().
|
|
269
|
+
const effectivePlugins: Plugin[] = this.requestContextPluginEnabled
|
|
270
|
+
? [createRequestContextPlugin(), ...this.yogaPlugins]
|
|
271
|
+
: [...this.yogaPlugins];
|
|
272
|
+
|
|
218
273
|
if (schema) {
|
|
219
274
|
this.yoga = createYogaInstance(
|
|
220
275
|
schema,
|
|
221
|
-
|
|
276
|
+
effectivePlugins,
|
|
222
277
|
wrappedContextFactory,
|
|
223
278
|
yogaOptions
|
|
224
279
|
);
|
|
225
280
|
} else {
|
|
226
281
|
this.yoga = createYogaInstance(
|
|
227
282
|
undefined,
|
|
228
|
-
|
|
283
|
+
effectivePlugins,
|
|
229
284
|
wrappedContextFactory,
|
|
230
285
|
yogaOptions
|
|
231
286
|
);
|
|
@@ -254,6 +309,40 @@ export default class App {
|
|
|
254
309
|
`Registered scheduled tasks for ${services.length} services`
|
|
255
310
|
);
|
|
256
311
|
|
|
312
|
+
// Initialize RemoteManager (opt-in via enableRemote())
|
|
313
|
+
if (this.remoteConfig) {
|
|
314
|
+
try {
|
|
315
|
+
const rmConfig: RemoteManagerConfig = {
|
|
316
|
+
appName:
|
|
317
|
+
this.remoteConfig.appName ||
|
|
318
|
+
this.name,
|
|
319
|
+
...this.remoteConfig,
|
|
320
|
+
};
|
|
321
|
+
this.remote = new RemoteManager(rmConfig);
|
|
322
|
+
setRemoteManager(this.remote);
|
|
323
|
+
await this.remote.start();
|
|
324
|
+
|
|
325
|
+
for (const service of services) {
|
|
326
|
+
try {
|
|
327
|
+
registerRemoteHandlers(service);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
logger.warn(
|
|
330
|
+
`Failed to register remote handlers for service ${service.constructor.name}`
|
|
331
|
+
);
|
|
332
|
+
logger.warn(error);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
logger.info(
|
|
336
|
+
`RemoteManager initialized for app "${rmConfig.appName}"`
|
|
337
|
+
);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
logger.error(
|
|
340
|
+
"Failed to start RemoteManager:"
|
|
341
|
+
);
|
|
342
|
+
logger.error(error);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
257
346
|
// Collect REST endpoints from all services
|
|
258
347
|
for (const service of services) {
|
|
259
348
|
const endpoints = (service.constructor as any)
|
|
@@ -360,8 +449,21 @@ export default class App {
|
|
|
360
449
|
ApplicationPhase.APPLICATION_READY
|
|
361
450
|
);
|
|
362
451
|
} catch (error) {
|
|
363
|
-
|
|
364
|
-
|
|
452
|
+
// SYSTEM_READY failures must not be swallowed silently.
|
|
453
|
+
// Without this, the app stays forever in SYSTEM_READY
|
|
454
|
+
// (isReady=false, /health/ready → 503 forever) and k8s
|
|
455
|
+
// rollout hangs with no observable cause. Surface the
|
|
456
|
+
// failure so the readiness probe reports it and the
|
|
457
|
+
// orchestrator can restart.
|
|
458
|
+
this.isReady = false;
|
|
459
|
+
logger.fatal({ scope: 'app', component: 'App', err: error }, 'Fatal error during SYSTEM_READY phase — marking app unready');
|
|
460
|
+
// In production, exit so k8s can restart the pod.
|
|
461
|
+
// In tests, rethrow so the test sees the failure.
|
|
462
|
+
if (process.env.NODE_ENV === 'test') {
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
465
|
+
// Give the logger a chance to flush, then exit.
|
|
466
|
+
setTimeout(() => process.exit(1), 100).unref?.();
|
|
365
467
|
}
|
|
366
468
|
break;
|
|
367
469
|
}
|
|
@@ -372,7 +474,8 @@ export default class App {
|
|
|
372
474
|
break;
|
|
373
475
|
}
|
|
374
476
|
}
|
|
375
|
-
}
|
|
477
|
+
};
|
|
478
|
+
ApplicationLifecycle.addPhaseListener(this.phaseListener);
|
|
376
479
|
|
|
377
480
|
if (
|
|
378
481
|
ApplicationLifecycle.getCurrentPhase() ===
|
|
@@ -391,17 +494,31 @@ export default class App {
|
|
|
391
494
|
}
|
|
392
495
|
}
|
|
393
496
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
497
|
+
/**
|
|
498
|
+
* Resolve once the application has reached APPLICATION_READY. Previously
|
|
499
|
+
* polled every 100ms with no exit condition — a boot failure would keep
|
|
500
|
+
* the interval timer alive forever (H-MEM-1). Now attaches a one-shot
|
|
501
|
+
* phase listener and self-cleans on first match. Bounded by `timeoutMs`
|
|
502
|
+
* so callers cannot hang indefinitely; default matches waitForPhase.
|
|
503
|
+
*/
|
|
504
|
+
waitForAppReady(timeoutMs = 60_000): Promise<void> {
|
|
505
|
+
if (ApplicationLifecycle.getCurrentPhase() >= ApplicationPhase.APPLICATION_READY) {
|
|
506
|
+
return Promise.resolve();
|
|
507
|
+
}
|
|
508
|
+
return new Promise((resolve, reject) => {
|
|
509
|
+
const timer = setTimeout(() => {
|
|
510
|
+
ApplicationLifecycle.removePhaseListener(onPhase);
|
|
511
|
+
reject(new Error(`waitForAppReady timed out after ${timeoutMs}ms; current phase=${ApplicationLifecycle.getCurrentPhase()}`));
|
|
512
|
+
}, timeoutMs);
|
|
513
|
+
timer.unref?.();
|
|
514
|
+
const onPhase = (event: PhaseChangeEvent) => {
|
|
515
|
+
if (event.detail === ApplicationPhase.APPLICATION_READY) {
|
|
516
|
+
clearTimeout(timer);
|
|
517
|
+
ApplicationLifecycle.removePhaseListener(onPhase);
|
|
402
518
|
resolve();
|
|
403
519
|
}
|
|
404
|
-
}
|
|
520
|
+
};
|
|
521
|
+
ApplicationLifecycle.addPhaseListener(onPhase);
|
|
405
522
|
});
|
|
406
523
|
}
|
|
407
524
|
|
|
@@ -443,8 +560,12 @@ export default class App {
|
|
|
443
560
|
|
|
444
561
|
const configOrigin = this.config.cors.origin;
|
|
445
562
|
|
|
446
|
-
//
|
|
447
|
-
|
|
563
|
+
// origin is required by setCors; undefined here would indicate a
|
|
564
|
+
// programming error. Treat as deny rather than silent wildcard.
|
|
565
|
+
if (configOrigin === undefined) return null;
|
|
566
|
+
|
|
567
|
+
// Explicit wildcard allows all
|
|
568
|
+
if (configOrigin === '*') {
|
|
448
569
|
// If credentials enabled, cannot use wildcard - return actual origin
|
|
449
570
|
return this.config.cors.credentials ? requestOrigin : '*';
|
|
450
571
|
}
|
|
@@ -476,12 +597,16 @@ export default class App {
|
|
|
476
597
|
// If origin not allowed, return empty (no CORS headers)
|
|
477
598
|
if (requestOrigin && !allowedOrigin) return {};
|
|
478
599
|
|
|
600
|
+
// No allowedOrigin means request carried no Origin header. Skip
|
|
601
|
+
// Access-Control-Allow-Origin rather than falling back to '*'.
|
|
479
602
|
const headers: Record<string, string> = {
|
|
480
|
-
'Access-Control-Allow-Origin': allowedOrigin || '*',
|
|
481
603
|
'Access-Control-Allow-Methods': this.config.cors.methods?.join(', ') || 'GET, POST, PUT, DELETE, OPTIONS',
|
|
482
604
|
'Access-Control-Allow-Headers': this.config.cors.allowedHeaders?.join(', ') || 'Content-Type, Authorization',
|
|
483
605
|
'Vary': 'Origin',
|
|
484
606
|
};
|
|
607
|
+
if (allowedOrigin) {
|
|
608
|
+
headers['Access-Control-Allow-Origin'] = allowedOrigin;
|
|
609
|
+
}
|
|
485
610
|
|
|
486
611
|
if (this.config.cors.credentials) {
|
|
487
612
|
headers['Access-Control-Allow-Credentials'] = 'true';
|
|
@@ -498,6 +623,27 @@ export default class App {
|
|
|
498
623
|
return headers;
|
|
499
624
|
}
|
|
500
625
|
|
|
626
|
+
/**
|
|
627
|
+
* Combine multiple AbortSignals into one that aborts when any input
|
|
628
|
+
* aborts. Uses `AbortSignal.any` when available (Node 20+/current Bun),
|
|
629
|
+
* falls back to a manual combiner for older runtimes.
|
|
630
|
+
*/
|
|
631
|
+
private combineSignals(signals: AbortSignal[]): AbortSignal {
|
|
632
|
+
const anyFn = (AbortSignal as any).any;
|
|
633
|
+
if (typeof anyFn === 'function') {
|
|
634
|
+
return anyFn.call(AbortSignal, signals);
|
|
635
|
+
}
|
|
636
|
+
const controller = new AbortController();
|
|
637
|
+
for (const s of signals) {
|
|
638
|
+
if (s.aborted) {
|
|
639
|
+
controller.abort((s as any).reason);
|
|
640
|
+
return controller.signal;
|
|
641
|
+
}
|
|
642
|
+
s.addEventListener('abort', () => controller.abort((s as any).reason), { once: true });
|
|
643
|
+
}
|
|
644
|
+
return controller.signal;
|
|
645
|
+
}
|
|
646
|
+
|
|
501
647
|
private addCorsHeaders(response: Response, req?: Request): Response {
|
|
502
648
|
const corsHeaders = this.getCorsHeaders(req);
|
|
503
649
|
if (Object.keys(corsHeaders).length === 0) return response;
|
|
@@ -527,18 +673,28 @@ export default class App {
|
|
|
527
673
|
});
|
|
528
674
|
}
|
|
529
675
|
|
|
530
|
-
//
|
|
676
|
+
// Request timeout — combine the framework wall-clock with the client's
|
|
677
|
+
// abort signal. The combined signal is attached to a cloned request
|
|
678
|
+
// that is passed to Yoga / REST handlers so downstream work (DB
|
|
679
|
+
// queries, resolvers) actually gets cancelled on timeout or client
|
|
680
|
+
// disconnect. Previously the signal was created but never propagated,
|
|
681
|
+
// so the timer only logged a warning while the request continued
|
|
682
|
+
// consuming resources (C05).
|
|
531
683
|
const controller = new AbortController();
|
|
532
684
|
const timeoutId = setTimeout(() => {
|
|
533
|
-
controller.abort();
|
|
685
|
+
controller.abort(new Error(`Request timeout after 30000ms: ${method} ${url.pathname}`));
|
|
534
686
|
logger.warn(`Request timeout: ${method} ${url.pathname}`);
|
|
535
|
-
}, 30000);
|
|
687
|
+
}, 30000);
|
|
688
|
+
const combinedSignal = this.combineSignals([req.signal, controller.signal]);
|
|
689
|
+
// Rebind the request with the combined signal so handlers (Yoga, REST)
|
|
690
|
+
// see it via req.signal and can propagate cancellation.
|
|
691
|
+
req = new Request(req, { signal: combinedSignal });
|
|
536
692
|
|
|
537
693
|
try {
|
|
538
694
|
// Health check endpoint
|
|
539
695
|
if (url.pathname === "/health") {
|
|
540
|
-
clearTimeout(timeoutId);
|
|
541
696
|
const health = await deepHealthCheck();
|
|
697
|
+
clearTimeout(timeoutId);
|
|
542
698
|
return this.addCorsHeaders(new Response(
|
|
543
699
|
JSON.stringify(health.result),
|
|
544
700
|
{
|
|
@@ -550,8 +706,8 @@ export default class App {
|
|
|
550
706
|
|
|
551
707
|
// Metrics endpoint
|
|
552
708
|
if (url.pathname === "/metrics") {
|
|
553
|
-
clearTimeout(timeoutId);
|
|
554
709
|
const metrics = await this.collectMetrics();
|
|
710
|
+
clearTimeout(timeoutId);
|
|
555
711
|
return this.addCorsHeaders(new Response(
|
|
556
712
|
JSON.stringify(metrics),
|
|
557
713
|
{
|
|
@@ -561,10 +717,36 @@ export default class App {
|
|
|
561
717
|
), req);
|
|
562
718
|
}
|
|
563
719
|
|
|
720
|
+
// Remote health check
|
|
721
|
+
if (url.pathname === "/health/remote") {
|
|
722
|
+
if (!this.remote) {
|
|
723
|
+
clearTimeout(timeoutId);
|
|
724
|
+
return this.addCorsHeaders(new Response(
|
|
725
|
+
JSON.stringify({
|
|
726
|
+
healthy: false,
|
|
727
|
+
error: "Remote subsystem not enabled",
|
|
728
|
+
}),
|
|
729
|
+
{
|
|
730
|
+
status: 503,
|
|
731
|
+
headers: { "Content-Type": "application/json" },
|
|
732
|
+
}
|
|
733
|
+
), req);
|
|
734
|
+
}
|
|
735
|
+
const health = await this.remote.health();
|
|
736
|
+
clearTimeout(timeoutId);
|
|
737
|
+
return this.addCorsHeaders(new Response(
|
|
738
|
+
JSON.stringify(health),
|
|
739
|
+
{
|
|
740
|
+
status: health.healthy ? 200 : 503,
|
|
741
|
+
headers: { "Content-Type": "application/json" },
|
|
742
|
+
}
|
|
743
|
+
), req);
|
|
744
|
+
}
|
|
745
|
+
|
|
564
746
|
// Readiness probe
|
|
565
747
|
if (url.pathname === "/health/ready") {
|
|
566
|
-
clearTimeout(timeoutId);
|
|
567
748
|
const ready = await readinessCheck(this.isReady, this.isShuttingDown);
|
|
749
|
+
clearTimeout(timeoutId);
|
|
568
750
|
return this.addCorsHeaders(new Response(
|
|
569
751
|
JSON.stringify(ready.result),
|
|
570
752
|
{
|
|
@@ -911,10 +1093,27 @@ export default class App {
|
|
|
911
1093
|
this.name = name;
|
|
912
1094
|
}
|
|
913
1095
|
|
|
1096
|
+
public getName(): string {
|
|
1097
|
+
return this.name;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
914
1100
|
public setVersion(version: string) {
|
|
915
1101
|
this.version = version;
|
|
916
1102
|
}
|
|
917
1103
|
|
|
1104
|
+
/**
|
|
1105
|
+
* Enable remote cross-app communication over Redis Streams.
|
|
1106
|
+
* Must be called before `init()` (initialization happens in SYSTEM_READY).
|
|
1107
|
+
* `appName` defaults to the app name.
|
|
1108
|
+
*/
|
|
1109
|
+
public enableRemote(config: Partial<RemoteManagerConfig> = {}) {
|
|
1110
|
+
this.remoteConfig = config;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
public getRemote(): RemoteManager | null {
|
|
1114
|
+
return this.remote;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
918
1117
|
public subscribeAppReady(callback: () => void) {
|
|
919
1118
|
this.appReadyCallbacks.push(callback);
|
|
920
1119
|
}
|
|
@@ -935,6 +1134,34 @@ export default class App {
|
|
|
935
1134
|
this.graphqlMaxDepth = depth;
|
|
936
1135
|
}
|
|
937
1136
|
|
|
1137
|
+
/**
|
|
1138
|
+
* Set the maximum GraphQL query complexity. 0 disables.
|
|
1139
|
+
* Complexity = sum of per-field costs, multiplied by `first`/`limit`/`take`
|
|
1140
|
+
* arguments when present on each field.
|
|
1141
|
+
*/
|
|
1142
|
+
public setGraphQLMaxComplexity(complexity: number) {
|
|
1143
|
+
this.graphqlMaxComplexity = complexity;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Supply a cache configuration that will be merged with `defaultCacheConfig`
|
|
1148
|
+
* and passed to `CacheManager.initialize()` during `init()`. Must be called
|
|
1149
|
+
* before `init()`.
|
|
1150
|
+
*/
|
|
1151
|
+
public setCacheConfig(config: Partial<CacheConfig>) {
|
|
1152
|
+
this.cacheConfig = config;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Disable the auto-applied RequestContext plugin. Only do this if your
|
|
1157
|
+
* app does not use `@BelongsTo` / `@HasMany` relations OR you are
|
|
1158
|
+
* supplying your own DataLoader plugin. Without it, nested relation
|
|
1159
|
+
* resolvers issue one DB query per row (N+1).
|
|
1160
|
+
*/
|
|
1161
|
+
public disableRequestContextPlugin() {
|
|
1162
|
+
this.requestContextPluginEnabled = false;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
938
1165
|
/**
|
|
939
1166
|
* Set the grace period for draining connections during shutdown (ms).
|
|
940
1167
|
*/
|
|
@@ -1014,7 +1241,9 @@ export default class App {
|
|
|
1014
1241
|
try {
|
|
1015
1242
|
const { CacheManager } = await import('./cache/CacheManager');
|
|
1016
1243
|
cacheStats = await CacheManager.getInstance().getStats();
|
|
1017
|
-
} catch {
|
|
1244
|
+
} catch (err) {
|
|
1245
|
+
logger.warn({ err }, 'metrics: cache stats unavailable');
|
|
1246
|
+
}
|
|
1018
1247
|
|
|
1019
1248
|
return {
|
|
1020
1249
|
timestamp: new Date().toISOString(),
|
|
@@ -1023,6 +1252,7 @@ export default class App {
|
|
|
1023
1252
|
cache: cacheStats,
|
|
1024
1253
|
scheduler: SchedulerManager.getInstance().getMetrics(),
|
|
1025
1254
|
preparedStatements: preparedStatementCache.getStats(),
|
|
1255
|
+
remote: this.remote ? this.remote.getMetrics() : null,
|
|
1026
1256
|
};
|
|
1027
1257
|
}
|
|
1028
1258
|
|
|
@@ -1066,84 +1296,178 @@ export default class App {
|
|
|
1066
1296
|
)}`
|
|
1067
1297
|
);
|
|
1068
1298
|
|
|
1069
|
-
//
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1299
|
+
// Signal handlers now registered in init() via registerProcessHandlers()
|
|
1300
|
+
// so they cover the boot sequence (before start() runs).
|
|
1301
|
+
|
|
1302
|
+
this.isReady = true;
|
|
1303
|
+
this.appReadyCallbacks.forEach((cb) => cb());
|
|
1304
|
+
}
|
|
1075
1305
|
|
|
1076
|
-
|
|
1306
|
+
/**
|
|
1307
|
+
* Register process-level signal and error handlers. Called at the top of
|
|
1308
|
+
* `init()` so that failures during boot (DB prep, component registration,
|
|
1309
|
+
* schema build) are logged and don't silently crash the runtime.
|
|
1310
|
+
*
|
|
1311
|
+
* Uses `process.once` for signals so a double SIGTERM can't fire two
|
|
1312
|
+
* concurrent shutdown paths racing each other to `process.exit`. Also
|
|
1313
|
+
* idempotent — safe to call multiple times (e.g. in tests).
|
|
1314
|
+
*/
|
|
1315
|
+
private registerProcessHandlers(): void {
|
|
1316
|
+
if (this.processHandlersRegistered) return;
|
|
1317
|
+
|
|
1318
|
+
// Use arrow-bound handlers so `once` works cleanly.
|
|
1319
|
+
this.sigTermHandler = () => {
|
|
1320
|
+
logger.info({ scope: 'app', component: 'App', msg: 'Received SIGTERM' });
|
|
1321
|
+
this.shutdown().finally(() => process.exit(0));
|
|
1322
|
+
};
|
|
1323
|
+
this.sigIntHandler = () => {
|
|
1077
1324
|
logger.info({ scope: 'app', component: 'App', msg: 'Received SIGINT' });
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1325
|
+
this.shutdown().finally(() => process.exit(0));
|
|
1326
|
+
};
|
|
1327
|
+
process.once('SIGTERM', this.sigTermHandler);
|
|
1328
|
+
process.once('SIGINT', this.sigIntHandler);
|
|
1081
1329
|
|
|
1082
|
-
// Global error handlers to prevent silent crashes
|
|
1083
|
-
|
|
1330
|
+
// Global error handlers to prevent silent crashes during init AND runtime.
|
|
1331
|
+
this.unhandledRejectionHandler = (reason, promise) => {
|
|
1084
1332
|
logger.error({ scope: 'app', component: 'App', reason, msg: 'Unhandled promise rejection' });
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
logger.fatal({ scope: 'app', component: 'App', error, msg: 'Uncaught exception — shutting down' });
|
|
1333
|
+
};
|
|
1334
|
+
this.uncaughtExceptionHandler = (error) => {
|
|
1335
|
+
logger.fatal({ scope: 'app', component: 'App', err: error, msg: 'Uncaught exception — shutting down' });
|
|
1089
1336
|
this.shutdown().finally(() => process.exit(1));
|
|
1090
|
-
}
|
|
1337
|
+
};
|
|
1338
|
+
process.on('unhandledRejection', this.unhandledRejectionHandler);
|
|
1339
|
+
process.on('uncaughtException', this.uncaughtExceptionHandler);
|
|
1091
1340
|
|
|
1092
|
-
this.
|
|
1093
|
-
|
|
1341
|
+
this.processHandlersRegistered = true;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
private unregisterProcessHandlers(): void {
|
|
1345
|
+
if (!this.processHandlersRegistered) return;
|
|
1346
|
+
if (this.sigTermHandler) process.removeListener('SIGTERM', this.sigTermHandler);
|
|
1347
|
+
if (this.sigIntHandler) process.removeListener('SIGINT', this.sigIntHandler);
|
|
1348
|
+
if (this.unhandledRejectionHandler) process.removeListener('unhandledRejection', this.unhandledRejectionHandler);
|
|
1349
|
+
if (this.uncaughtExceptionHandler) process.removeListener('uncaughtException', this.uncaughtExceptionHandler);
|
|
1350
|
+
this.sigTermHandler = null;
|
|
1351
|
+
this.sigIntHandler = null;
|
|
1352
|
+
this.unhandledRejectionHandler = null;
|
|
1353
|
+
this.uncaughtExceptionHandler = null;
|
|
1354
|
+
this.processHandlersRegistered = false;
|
|
1094
1355
|
}
|
|
1095
1356
|
|
|
1096
1357
|
/**
|
|
1097
|
-
* Gracefully shutdown the application
|
|
1358
|
+
* Gracefully shutdown the application.
|
|
1359
|
+
*
|
|
1360
|
+
* Ordered drain: HTTP → scheduler → remote → cache → database. Each step
|
|
1361
|
+
* awaits completion before the next begins so in-flight work always sees
|
|
1362
|
+
* its dependencies still available. Total budget bounded by
|
|
1363
|
+
* `shutdownGracePeriod`; per-step budgets fall back to reasonable defaults.
|
|
1098
1364
|
*/
|
|
1099
1365
|
async shutdown(): Promise<void> {
|
|
1100
1366
|
if (this.isShuttingDown) return;
|
|
1101
1367
|
this.isShuttingDown = true;
|
|
1102
1368
|
this.isReady = false;
|
|
1103
1369
|
|
|
1104
|
-
|
|
1370
|
+
const shutdownStart = Date.now();
|
|
1371
|
+
logger.info({ scope: 'app', component: 'App', msg: 'Shutting down application', gracePeriodMs: this.shutdownGracePeriod });
|
|
1372
|
+
|
|
1373
|
+
const budgetRemaining = () => Math.max(500, this.shutdownGracePeriod - (Date.now() - shutdownStart));
|
|
1105
1374
|
|
|
1106
|
-
// Stop HTTP server
|
|
1375
|
+
// 1. Stop HTTP server: stop accepting new connections, wait for in-flight
|
|
1376
|
+
// requests to finish. Bun's server.stop(false) initiates graceful
|
|
1377
|
+
// drain but does not return a promise — we poll the server's
|
|
1378
|
+
// pendingRequests count, then force-close on deadline.
|
|
1107
1379
|
if (this.server) {
|
|
1108
1380
|
try {
|
|
1109
|
-
logger.info({ scope: 'app', component: 'App', msg: 'Draining connections' });
|
|
1381
|
+
logger.info({ scope: 'app', component: 'App', msg: 'Draining HTTP connections' });
|
|
1110
1382
|
this.server.stop(false);
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
try { this.server?.stop(true); } catch {}
|
|
1114
|
-
}, this.shutdownGracePeriod);
|
|
1115
|
-
forceTimer.unref?.();
|
|
1383
|
+
await this.waitForHttpDrain(budgetRemaining());
|
|
1384
|
+
try { this.server.stop(true); } catch {}
|
|
1116
1385
|
logger.info({ scope: 'app', component: 'App', msg: 'HTTP server stopped' });
|
|
1117
1386
|
} catch (error) {
|
|
1118
|
-
logger.warn({ scope: 'app', component: 'App', msg: 'HTTP server stop error', error });
|
|
1387
|
+
logger.warn({ scope: 'app', component: 'App', msg: 'HTTP server stop error', err: error });
|
|
1119
1388
|
}
|
|
1120
1389
|
}
|
|
1121
1390
|
|
|
1122
|
-
// Stop scheduler
|
|
1391
|
+
// 2. Stop scheduler (awaits in-flight tasks internally, see C14).
|
|
1123
1392
|
try {
|
|
1124
|
-
await SchedulerManager.getInstance().stop();
|
|
1393
|
+
await SchedulerManager.getInstance().stop(Math.min(budgetRemaining(), 15_000));
|
|
1125
1394
|
logger.info({ scope: 'app', component: 'App', msg: 'Scheduler stopped' });
|
|
1126
1395
|
} catch (error) {
|
|
1127
|
-
logger.warn({ scope: 'app', component: 'App', msg: 'Scheduler stop error', error });
|
|
1396
|
+
logger.warn({ scope: 'app', component: 'App', msg: 'Scheduler stop error', err: error });
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// 3. Shutdown RemoteManager (after scheduler, before cache — DB still available).
|
|
1400
|
+
if (this.remote) {
|
|
1401
|
+
try {
|
|
1402
|
+
await this.remote.shutdown();
|
|
1403
|
+
setRemoteManager(null);
|
|
1404
|
+
this.remote = null;
|
|
1405
|
+
logger.info({ scope: 'app', component: 'App', msg: 'RemoteManager shutdown' });
|
|
1406
|
+
} catch (error) {
|
|
1407
|
+
logger.warn({ scope: 'app', component: 'App', msg: 'RemoteManager shutdown error', err: error });
|
|
1408
|
+
}
|
|
1128
1409
|
}
|
|
1129
1410
|
|
|
1130
|
-
//
|
|
1411
|
+
// 4. Drain any fire-and-forget cache ops triggered by entity.set /
|
|
1412
|
+
// entity.remove before we disconnect the cache (H-CACHE-1).
|
|
1413
|
+
try {
|
|
1414
|
+
const { Entity } = await import('./Entity');
|
|
1415
|
+
await Entity.drainPendingCacheOps(Math.min(budgetRemaining(), 5_000));
|
|
1416
|
+
} catch (error) {
|
|
1417
|
+
logger.warn({ scope: 'cache', component: 'App', msg: 'Entity cache op drain error', err: error });
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// 5. Shutdown cache (flush pending writes, unsubscribe pub/sub, disconnect).
|
|
1131
1421
|
try {
|
|
1132
1422
|
const { CacheManager } = await import('./cache/CacheManager');
|
|
1133
1423
|
await CacheManager.getInstance().shutdown();
|
|
1134
1424
|
logger.info({ scope: 'cache', component: 'App', msg: 'Cache shutdown completed' });
|
|
1135
1425
|
} catch (error) {
|
|
1136
|
-
logger.warn({ scope: 'cache', component: 'App', msg: 'Cache shutdown error', error });
|
|
1426
|
+
logger.warn({ scope: 'cache', component: 'App', msg: 'Cache shutdown error', err: error });
|
|
1137
1427
|
}
|
|
1138
1428
|
|
|
1139
|
-
// Close database pool (last
|
|
1429
|
+
// 5. Close database pool (last — after all consumers done).
|
|
1140
1430
|
try {
|
|
1141
1431
|
db.close();
|
|
1142
1432
|
logger.info({ scope: 'app', component: 'App', msg: 'Database pool closed' });
|
|
1143
1433
|
} catch (error) {
|
|
1144
|
-
logger.warn({ scope: 'app', component: 'App', msg: 'Database pool close error', error });
|
|
1434
|
+
logger.warn({ scope: 'app', component: 'App', msg: 'Database pool close error', err: error });
|
|
1145
1435
|
}
|
|
1146
1436
|
|
|
1147
|
-
|
|
1437
|
+
// 6. Dispose lifecycle listeners so a subsequent init() (tests) doesn't
|
|
1438
|
+
// stack handlers on the singleton.
|
|
1439
|
+
try {
|
|
1440
|
+
if (this.phaseListener) {
|
|
1441
|
+
ApplicationLifecycle.removePhaseListener(this.phaseListener);
|
|
1442
|
+
this.phaseListener = null;
|
|
1443
|
+
}
|
|
1444
|
+
SchedulerManager.getInstance().disposeLifecycleIntegration();
|
|
1445
|
+
} catch { /* ignore */ }
|
|
1446
|
+
|
|
1447
|
+
// 7. Unregister process handlers (signals + error handlers) last so
|
|
1448
|
+
// shutdown errors still surface via them above.
|
|
1449
|
+
this.unregisterProcessHandlers();
|
|
1450
|
+
|
|
1451
|
+
logger.info({ scope: 'app', component: 'App', msg: 'Application shutdown completed', durationMs: Date.now() - shutdownStart });
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/**
|
|
1455
|
+
* Wait for pending HTTP requests to drain, bounded by `timeoutMs`.
|
|
1456
|
+
* Bun's `Server` exposes `pendingRequests` for this poll. If the field is
|
|
1457
|
+
* unavailable (older Bun), fall back to a fixed sleep.
|
|
1458
|
+
*/
|
|
1459
|
+
private async waitForHttpDrain(timeoutMs: number): Promise<void> {
|
|
1460
|
+
if (!this.server) return;
|
|
1461
|
+
const deadline = Date.now() + timeoutMs;
|
|
1462
|
+
// Poll pending request count. Bun exposes this on the Server object.
|
|
1463
|
+
while (Date.now() < deadline) {
|
|
1464
|
+
const pending = (this.server as any).pendingRequests ?? 0;
|
|
1465
|
+
if (pending === 0) return;
|
|
1466
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1467
|
+
}
|
|
1468
|
+
const leftover = (this.server as any).pendingRequests ?? -1;
|
|
1469
|
+
if (leftover > 0) {
|
|
1470
|
+
logger.warn({ scope: 'app', component: 'App', msg: 'HTTP drain timeout, pending requests remaining', pendingRequests: leftover });
|
|
1471
|
+
}
|
|
1148
1472
|
}
|
|
1149
1473
|
}
|