bunsane 0.3.0 → 0.3.2
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/.claude/scheduled_tasks.lock +1 -0
- package/CHANGELOG.md +104 -0
- package/CLAUDE.md +20 -0
- package/config/cache.config.ts +35 -1
- package/core/App.ts +24 -1060
- package/core/ArcheType.ts +78 -2110
- package/core/Entity.ts +136 -41
- package/core/RequestContext.ts +85 -36
- package/core/RequestLoaders.ts +89 -31
- package/core/SchedulerManager.ts +13 -13
- package/core/app/bootstrap.ts +133 -0
- package/core/app/cors.ts +94 -0
- package/core/app/graphqlSetup.ts +56 -0
- package/core/app/healthEndpoints.ts +31 -0
- package/core/app/metricsCollector.ts +27 -0
- package/core/app/preparedStatementWarmup.ts +55 -0
- package/core/app/processHandlers.ts +43 -0
- package/core/app/requestRouter.ts +309 -0
- package/core/app/restRegistry.ts +72 -0
- package/core/app/shutdown.ts +97 -0
- package/core/app/studioRouter.ts +83 -0
- package/core/archetype/customTypes.ts +100 -0
- package/core/archetype/decorators.ts +171 -0
- package/core/archetype/fieldResolvers.ts +621 -0
- package/core/archetype/helpers.ts +29 -0
- package/core/archetype/relationLoader.ts +118 -0
- package/core/archetype/schemaBuilder.ts +141 -0
- package/core/archetype/weaver.ts +218 -0
- package/core/archetype/zodSchemaBuilder.ts +527 -0
- package/core/cache/CacheManager.ts +144 -9
- package/core/components/BaseComponent.ts +12 -2
- package/core/middleware/AccessLog.ts +8 -1
- package/database/PreparedStatementCache.ts +17 -16
- package/database/cancellable.ts +22 -0
- package/database/instrumentedDb.ts +141 -0
- package/docs/RFC_APP_REFACTOR.md +248 -0
- package/docs/RFC_REFACTOR_TARGETS.md +251 -0
- package/package.json +1 -1
- package/query/ComponentInclusionNode.ts +5 -5
- package/query/Query.ts +65 -48
- package/service/ServiceRegistry.ts +7 -1
- package/service/index.ts +4 -2
- package/tests/integration/loaders/RequestLoaders.abort.test.ts +82 -0
- package/tests/integration/query/Query.abort.test.ts +66 -0
- package/tests/unit/cache/CacheManager.test.ts +152 -1
- package/tests/unit/database/cancellable.test.ts +81 -0
- package/tests/unit/database/instrumentedDb.test.ts +160 -0
- package/tests/unit/entity/Entity.components.test.ts +73 -0
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +51 -0
- package/tests/unit/entity/Entity.reload.test.ts +63 -0
- package/tests/unit/entity/Entity.requireComponents.test.ts +72 -0
- package/tests/unit/query/Query.emptyString.test.ts +69 -0
- package/tests/unit/query/Query.test.ts +6 -4
- package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +95 -0
package/core/App.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import ApplicationLifecycle, {
|
|
1
|
+
import ApplicationLifecycle, {
|
|
2
2
|
ApplicationPhase,
|
|
3
3
|
type PhaseChangeEvent,
|
|
4
4
|
} from "./ApplicationLifecycle";
|
|
@@ -11,30 +11,28 @@ import {
|
|
|
11
11
|
} from "../database/DatabaseHelper";
|
|
12
12
|
import { ComponentRegistry } from "./components";
|
|
13
13
|
import { logger as MainLogger } from "./Logger";
|
|
14
|
-
import { getSerializedMetadataStorage } from "./metadata";
|
|
15
14
|
const logger = MainLogger.child({ scope: "App" });
|
|
16
|
-
import { createYogaInstance } from "../gql";
|
|
17
15
|
import ServiceRegistry from "../service/ServiceRegistry";
|
|
18
16
|
import { type Plugin, createPubSub } from "graphql-yoga";
|
|
19
17
|
import * as path from "path";
|
|
20
|
-
import { SchedulerManager } from "./SchedulerManager";
|
|
21
|
-
import { registerScheduledTasks } from "../scheduler";
|
|
22
18
|
import { OpenAPISpecGenerator, type SwaggerEndpointMetadata } from "../swagger";
|
|
23
19
|
import type BasePlugin from "../plugins";
|
|
24
20
|
import { preparedStatementCache } from "../database/PreparedStatementCache";
|
|
25
21
|
import db from "../database";
|
|
26
|
-
import studioEndpoint from "../endpoints";
|
|
27
22
|
import { type Middleware, composeMiddleware } from "./Middleware";
|
|
28
|
-
import { deepHealthCheck, readinessCheck } from "./health";
|
|
29
23
|
import { validateEnv } from "./validateEnv";
|
|
30
|
-
import {
|
|
31
|
-
RemoteManager,
|
|
32
|
-
registerRemoteHandlers,
|
|
33
|
-
setRemoteManager,
|
|
34
|
-
} from "./remote";
|
|
35
|
-
import type { RemoteManagerConfig } from "./remote";
|
|
24
|
+
import type { RemoteManager, RemoteManagerConfig } from "./remote";
|
|
36
25
|
import type { CacheConfig } from "../config/cache.config";
|
|
37
|
-
import {
|
|
26
|
+
import { assertValidCorsConfig } from "./app/cors";
|
|
27
|
+
import {
|
|
28
|
+
registerProcessHandlers as registerProcessHandlersFn,
|
|
29
|
+
unregisterProcessHandlers as unregisterProcessHandlersFn,
|
|
30
|
+
} from "./app/processHandlers";
|
|
31
|
+
import { runShutdown } from "./app/shutdown";
|
|
32
|
+
import { warmUpPreparedStatementCache as warmUpPreparedStatementCacheFn } from "./app/preparedStatementWarmup";
|
|
33
|
+
import { collectMetrics as collectMetricsFn } from "./app/metricsCollector";
|
|
34
|
+
import { createPhaseListener } from "./app/bootstrap";
|
|
35
|
+
import { handleRequest as handleRequestFn } from "./app/requestRouter";
|
|
38
36
|
|
|
39
37
|
export type CorsConfig = {
|
|
40
38
|
origin?: string | string[] | ((origin: string) => boolean);
|
|
@@ -138,14 +136,8 @@ export default class App {
|
|
|
138
136
|
}
|
|
139
137
|
|
|
140
138
|
public setCors(cors: CorsConfig) {
|
|
141
|
-
|
|
142
|
-
throw new Error('[CORS] `origin` is required. Pass an explicit string, array, function, or "*" if you truly want to allow everyone.');
|
|
143
|
-
}
|
|
139
|
+
assertValidCorsConfig(cors);
|
|
144
140
|
this.config.cors = cors;
|
|
145
|
-
// Warn about invalid configuration
|
|
146
|
-
if (cors.credentials && cors.origin === '*') {
|
|
147
|
-
console.warn('[CORS] Warning: credentials=true with origin="*" is invalid per spec. Origin will be reflected from request.');
|
|
148
|
-
}
|
|
149
141
|
}
|
|
150
142
|
|
|
151
143
|
async init() {
|
|
@@ -160,7 +152,7 @@ export default class App {
|
|
|
160
152
|
ComponentRegistry.init();
|
|
161
153
|
ServiceRegistry.init();
|
|
162
154
|
|
|
163
|
-
// Initialize CacheManager with merged config. MUST await
|
|
155
|
+
// Initialize CacheManager with merged config. MUST await — initialize()
|
|
164
156
|
// is async and sets up pub/sub for cross-instance invalidation. Previously
|
|
165
157
|
// only getInstance() was called, silently skipping pub/sub setup and
|
|
166
158
|
// ignoring any app-supplied config (C04).
|
|
@@ -186,295 +178,7 @@ export default class App {
|
|
|
186
178
|
if (this.phaseListener) {
|
|
187
179
|
ApplicationLifecycle.removePhaseListener(this.phaseListener);
|
|
188
180
|
}
|
|
189
|
-
this.phaseListener =
|
|
190
|
-
const phase = event.detail;
|
|
191
|
-
logger.info(`Application phase changed to: ${phase}`);
|
|
192
|
-
// Notify plugins of phase change
|
|
193
|
-
for (const plugin of this.plugins) {
|
|
194
|
-
if (plugin.onPhaseChange) {
|
|
195
|
-
await plugin.onPhaseChange(phase, this);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
switch (phase) {
|
|
199
|
-
case ApplicationPhase.DATABASE_READY: {
|
|
200
|
-
// Warm up prepared statement cache with common query patterns
|
|
201
|
-
try {
|
|
202
|
-
await this.warmUpPreparedStatementCache();
|
|
203
|
-
} catch (error) {
|
|
204
|
-
logger.warn(
|
|
205
|
-
"Failed to warm up prepared statement cache:",
|
|
206
|
-
error as any
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
break;
|
|
210
|
-
}
|
|
211
|
-
case ApplicationPhase.SYSTEM_READY: {
|
|
212
|
-
// Perform cache health check
|
|
213
|
-
try {
|
|
214
|
-
const { CacheManager } = await import('./cache/CacheManager');
|
|
215
|
-
const cacheManager = CacheManager.getInstance();
|
|
216
|
-
const config = cacheManager.getConfig();
|
|
217
|
-
|
|
218
|
-
if (config.enabled) {
|
|
219
|
-
const isHealthy = await cacheManager.getProvider().ping();
|
|
220
|
-
if (isHealthy) {
|
|
221
|
-
logger.info({ scope: 'cache', component: 'App', msg: 'Cache health check passed' });
|
|
222
|
-
} else {
|
|
223
|
-
logger.warn({ scope: 'cache', component: 'App', msg: 'Cache health check failed' });
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
} catch (error) {
|
|
227
|
-
logger.warn({ scope: 'cache', component: 'App', msg: 'Cache health check error', error });
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
try {
|
|
231
|
-
const schema = ServiceRegistry.getSchema();
|
|
232
|
-
|
|
233
|
-
// Wrap user's context factory to automatically spread Yoga context
|
|
234
|
-
const wrappedContextFactory = this.contextFactory
|
|
235
|
-
? async (yogaContext: any) => {
|
|
236
|
-
const userContext =
|
|
237
|
-
await this.contextFactory!(yogaContext);
|
|
238
|
-
// Merge Yoga's context with user's context, preserving Yoga properties
|
|
239
|
-
return {
|
|
240
|
-
...yogaContext, // Yoga context (request, params, etc.)
|
|
241
|
-
...userContext, // User's additional context
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
: undefined;
|
|
245
|
-
|
|
246
|
-
// Read env override for GraphQL depth limit
|
|
247
|
-
const envDepth = process.env.GRAPHQL_MAX_DEPTH;
|
|
248
|
-
if (envDepth) {
|
|
249
|
-
this.graphqlMaxDepth = parseInt(envDepth, 10);
|
|
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
|
-
}
|
|
258
|
-
|
|
259
|
-
const yogaOptions = {
|
|
260
|
-
cors: this.config.cors,
|
|
261
|
-
maxDepth: this.graphqlMaxDepth || undefined,
|
|
262
|
-
maxComplexity: this.graphqlMaxComplexity,
|
|
263
|
-
};
|
|
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
|
-
|
|
273
|
-
if (schema) {
|
|
274
|
-
this.yoga = createYogaInstance(
|
|
275
|
-
schema,
|
|
276
|
-
effectivePlugins,
|
|
277
|
-
wrappedContextFactory,
|
|
278
|
-
yogaOptions
|
|
279
|
-
);
|
|
280
|
-
} else {
|
|
281
|
-
this.yoga = createYogaInstance(
|
|
282
|
-
undefined,
|
|
283
|
-
effectivePlugins,
|
|
284
|
-
wrappedContextFactory,
|
|
285
|
-
yogaOptions
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// Get all services for processing
|
|
290
|
-
const services = ServiceRegistry.getServices();
|
|
291
|
-
|
|
292
|
-
// Initialize Scheduler
|
|
293
|
-
const scheduler = SchedulerManager.getInstance();
|
|
294
|
-
scheduler.config.enableLogging =
|
|
295
|
-
this.config.scheduler.logging;
|
|
296
|
-
|
|
297
|
-
// Register scheduled tasks for all services
|
|
298
|
-
for (const service of services) {
|
|
299
|
-
try {
|
|
300
|
-
registerScheduledTasks(service);
|
|
301
|
-
} catch (error) {
|
|
302
|
-
logger.warn(
|
|
303
|
-
`Failed to register scheduled tasks for service ${service.constructor.name}`
|
|
304
|
-
);
|
|
305
|
-
logger.warn(error);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
logger.info(
|
|
309
|
-
`Registered scheduled tasks for ${services.length} services`
|
|
310
|
-
);
|
|
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
|
-
|
|
346
|
-
// Collect REST endpoints from all services
|
|
347
|
-
for (const service of services) {
|
|
348
|
-
const endpoints = (service.constructor as any)
|
|
349
|
-
.httpEndpoints;
|
|
350
|
-
if (endpoints) {
|
|
351
|
-
for (const endpoint of endpoints) {
|
|
352
|
-
const endpointInfo = {
|
|
353
|
-
method: endpoint.method,
|
|
354
|
-
path: endpoint.path,
|
|
355
|
-
handler: endpoint.handler.bind(service),
|
|
356
|
-
service: service,
|
|
357
|
-
};
|
|
358
|
-
logger.trace(
|
|
359
|
-
`Registered REST endpoint: [${endpoint.method}] ${endpoint.path} for service ${service.constructor.name}`
|
|
360
|
-
);
|
|
361
|
-
this.restEndpoints.push(endpointInfo);
|
|
362
|
-
this.restEndpointMap.set(
|
|
363
|
-
`${endpoint.method}:${endpoint.path}`,
|
|
364
|
-
endpointInfo
|
|
365
|
-
);
|
|
366
|
-
|
|
367
|
-
// Check if this endpoint has a swagger operation
|
|
368
|
-
if (
|
|
369
|
-
(endpoint.handler as any)
|
|
370
|
-
.swaggerOperation
|
|
371
|
-
) {
|
|
372
|
-
// Collect tags from class and method decorators
|
|
373
|
-
const classTags =
|
|
374
|
-
(service.constructor as any)
|
|
375
|
-
.swaggerClassTags || [];
|
|
376
|
-
const methodTags =
|
|
377
|
-
(service.constructor as any)
|
|
378
|
-
.swaggerMethodTags?.[
|
|
379
|
-
endpoint.handler.name
|
|
380
|
-
] || [];
|
|
381
|
-
const allTags = [
|
|
382
|
-
...classTags,
|
|
383
|
-
...methodTags,
|
|
384
|
-
];
|
|
385
|
-
|
|
386
|
-
logger.trace(
|
|
387
|
-
`Generating OpenAPI spec for endpoint: [${
|
|
388
|
-
endpoint.method
|
|
389
|
-
}] ${
|
|
390
|
-
endpoint.path
|
|
391
|
-
} with tags: ${allTags.join(", ")}`
|
|
392
|
-
);
|
|
393
|
-
|
|
394
|
-
// Merge tags into the operation
|
|
395
|
-
const operation = {
|
|
396
|
-
...(endpoint.handler as any)
|
|
397
|
-
.swaggerOperation,
|
|
398
|
-
};
|
|
399
|
-
if (allTags.length > 0) {
|
|
400
|
-
operation.tags = [
|
|
401
|
-
...(operation.tags || []),
|
|
402
|
-
...allTags,
|
|
403
|
-
];
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
this.openAPISpecGenerator!.addEndpoint({
|
|
407
|
-
method: endpoint.method,
|
|
408
|
-
path: endpoint.path,
|
|
409
|
-
operation,
|
|
410
|
-
});
|
|
411
|
-
logger.trace(
|
|
412
|
-
`Registered OpenAPI spec for endpoint: [${endpoint.method}] ${endpoint.path}`
|
|
413
|
-
);
|
|
414
|
-
} else {
|
|
415
|
-
if (this.enforceDocs) {
|
|
416
|
-
logger.warn(
|
|
417
|
-
`No swagger operation found for endpoint: [${endpoint.method}] ${endpoint.path} in service ${service.constructor.name}`
|
|
418
|
-
);
|
|
419
|
-
this.openAPISpecGenerator!.addEndpoint(
|
|
420
|
-
{
|
|
421
|
-
method: endpoint.method,
|
|
422
|
-
path: endpoint.path,
|
|
423
|
-
operation: {
|
|
424
|
-
summary: `No description for ${endpoint.path}. Don't use this endpoint until it's properly documented!`,
|
|
425
|
-
requestBody: {
|
|
426
|
-
content: {
|
|
427
|
-
"application/json":
|
|
428
|
-
{
|
|
429
|
-
schema: {},
|
|
430
|
-
},
|
|
431
|
-
},
|
|
432
|
-
},
|
|
433
|
-
responses: {
|
|
434
|
-
"200": {
|
|
435
|
-
description:
|
|
436
|
-
"Success",
|
|
437
|
-
},
|
|
438
|
-
},
|
|
439
|
-
},
|
|
440
|
-
}
|
|
441
|
-
);
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
ApplicationLifecycle.setPhase(
|
|
449
|
-
ApplicationPhase.APPLICATION_READY
|
|
450
|
-
);
|
|
451
|
-
} catch (error) {
|
|
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?.();
|
|
467
|
-
}
|
|
468
|
-
break;
|
|
469
|
-
}
|
|
470
|
-
case ApplicationPhase.APPLICATION_READY: {
|
|
471
|
-
if (process.env.NODE_ENV !== "test") {
|
|
472
|
-
this.start();
|
|
473
|
-
}
|
|
474
|
-
break;
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
};
|
|
181
|
+
this.phaseListener = createPhaseListener(this);
|
|
478
182
|
ApplicationLifecycle.addPhaseListener(this.phaseListener);
|
|
479
183
|
|
|
480
184
|
if (
|
|
@@ -496,7 +200,7 @@ export default class App {
|
|
|
496
200
|
|
|
497
201
|
/**
|
|
498
202
|
* Resolve once the application has reached APPLICATION_READY. Previously
|
|
499
|
-
* polled every 100ms with no exit condition
|
|
203
|
+
* polled every 100ms with no exit condition — a boot failure would keep
|
|
500
204
|
* the interval timer alive forever (H-MEM-1). Now attaches a one-shot
|
|
501
205
|
* phase listener and self-cleans on first match. Bounded by `timeoutMs`
|
|
502
206
|
* so callers cannot hang indefinitely; default matches waitForPhase.
|
|
@@ -555,538 +259,8 @@ export default class App {
|
|
|
555
259
|
this.staticAssets.set(route, resolvedFolder);
|
|
556
260
|
}
|
|
557
261
|
|
|
558
|
-
private validateOrigin(requestOrigin: string | null | undefined): string | null {
|
|
559
|
-
if (!this.config.cors || !requestOrigin) return null;
|
|
560
|
-
|
|
561
|
-
const configOrigin = this.config.cors.origin;
|
|
562
|
-
|
|
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 === '*') {
|
|
569
|
-
// If credentials enabled, cannot use wildcard - return actual origin
|
|
570
|
-
return this.config.cors.credentials ? requestOrigin : '*';
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
// String match
|
|
574
|
-
if (typeof configOrigin === 'string') {
|
|
575
|
-
return requestOrigin === configOrigin ? configOrigin : null;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
// Array - check if origin is in list
|
|
579
|
-
if (Array.isArray(configOrigin)) {
|
|
580
|
-
return configOrigin.includes(requestOrigin) ? requestOrigin : null;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
// Function validator
|
|
584
|
-
if (typeof configOrigin === 'function') {
|
|
585
|
-
return configOrigin(requestOrigin) ? requestOrigin : null;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
return null;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
private getCorsHeaders(req?: Request): Record<string, string> {
|
|
592
|
-
if (!this.config.cors) return {};
|
|
593
|
-
|
|
594
|
-
const requestOrigin = req?.headers.get('Origin');
|
|
595
|
-
const allowedOrigin = this.validateOrigin(requestOrigin);
|
|
596
|
-
|
|
597
|
-
// If origin not allowed, return empty (no CORS headers)
|
|
598
|
-
if (requestOrigin && !allowedOrigin) return {};
|
|
599
|
-
|
|
600
|
-
// No allowedOrigin means request carried no Origin header. Skip
|
|
601
|
-
// Access-Control-Allow-Origin rather than falling back to '*'.
|
|
602
|
-
const headers: Record<string, string> = {
|
|
603
|
-
'Access-Control-Allow-Methods': this.config.cors.methods?.join(', ') || 'GET, POST, PUT, DELETE, OPTIONS',
|
|
604
|
-
'Access-Control-Allow-Headers': this.config.cors.allowedHeaders?.join(', ') || 'Content-Type, Authorization',
|
|
605
|
-
'Vary': 'Origin',
|
|
606
|
-
};
|
|
607
|
-
if (allowedOrigin) {
|
|
608
|
-
headers['Access-Control-Allow-Origin'] = allowedOrigin;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
if (this.config.cors.credentials) {
|
|
612
|
-
headers['Access-Control-Allow-Credentials'] = 'true';
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
if (this.config.cors.exposedHeaders?.length) {
|
|
616
|
-
headers['Access-Control-Expose-Headers'] = this.config.cors.exposedHeaders.join(', ');
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
if (this.config.cors.maxAge !== undefined) {
|
|
620
|
-
headers['Access-Control-Max-Age'] = String(this.config.cors.maxAge);
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
return headers;
|
|
624
|
-
}
|
|
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
|
-
|
|
647
|
-
private addCorsHeaders(response: Response, req?: Request): Response {
|
|
648
|
-
const corsHeaders = this.getCorsHeaders(req);
|
|
649
|
-
if (Object.keys(corsHeaders).length === 0) return response;
|
|
650
|
-
|
|
651
|
-
const newHeaders = new Headers(response.headers);
|
|
652
|
-
for (const [key, value] of Object.entries(corsHeaders)) {
|
|
653
|
-
newHeaders.set(key, value);
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
return new Response(response.body, {
|
|
657
|
-
status: response.status,
|
|
658
|
-
statusText: response.statusText,
|
|
659
|
-
headers: newHeaders,
|
|
660
|
-
});
|
|
661
|
-
}
|
|
662
|
-
|
|
663
262
|
private async handleRequest(req: Request): Promise<Response> {
|
|
664
|
-
|
|
665
|
-
const method = req.method;
|
|
666
|
-
const startTime = Date.now();
|
|
667
|
-
|
|
668
|
-
// Handle CORS preflight requests
|
|
669
|
-
if (method === 'OPTIONS') {
|
|
670
|
-
return new Response(null, {
|
|
671
|
-
status: 204,
|
|
672
|
-
headers: this.getCorsHeaders(req),
|
|
673
|
-
});
|
|
674
|
-
}
|
|
675
|
-
|
|
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).
|
|
683
|
-
const controller = new AbortController();
|
|
684
|
-
const timeoutId = setTimeout(() => {
|
|
685
|
-
controller.abort(new Error(`Request timeout after 30000ms: ${method} ${url.pathname}`));
|
|
686
|
-
logger.warn(`Request timeout: ${method} ${url.pathname}`);
|
|
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 });
|
|
692
|
-
|
|
693
|
-
try {
|
|
694
|
-
// Health check endpoint
|
|
695
|
-
if (url.pathname === "/health") {
|
|
696
|
-
const health = await deepHealthCheck();
|
|
697
|
-
clearTimeout(timeoutId);
|
|
698
|
-
return this.addCorsHeaders(new Response(
|
|
699
|
-
JSON.stringify(health.result),
|
|
700
|
-
{
|
|
701
|
-
status: health.httpStatus,
|
|
702
|
-
headers: { "Content-Type": "application/json" },
|
|
703
|
-
}
|
|
704
|
-
), req);
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
// Metrics endpoint
|
|
708
|
-
if (url.pathname === "/metrics") {
|
|
709
|
-
const metrics = await this.collectMetrics();
|
|
710
|
-
clearTimeout(timeoutId);
|
|
711
|
-
return this.addCorsHeaders(new Response(
|
|
712
|
-
JSON.stringify(metrics),
|
|
713
|
-
{
|
|
714
|
-
status: 200,
|
|
715
|
-
headers: { "Content-Type": "application/json" },
|
|
716
|
-
}
|
|
717
|
-
), req);
|
|
718
|
-
}
|
|
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
|
-
|
|
746
|
-
// Readiness probe
|
|
747
|
-
if (url.pathname === "/health/ready") {
|
|
748
|
-
const ready = await readinessCheck(this.isReady, this.isShuttingDown);
|
|
749
|
-
clearTimeout(timeoutId);
|
|
750
|
-
return this.addCorsHeaders(new Response(
|
|
751
|
-
JSON.stringify(ready.result),
|
|
752
|
-
{
|
|
753
|
-
status: ready.httpStatus,
|
|
754
|
-
headers: { "Content-Type": "application/json" },
|
|
755
|
-
}
|
|
756
|
-
), req);
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// OpenAPI spec endpoint
|
|
760
|
-
if (url.pathname === "/openapi.json") {
|
|
761
|
-
clearTimeout(timeoutId);
|
|
762
|
-
return this.addCorsHeaders(new Response(this.openAPISpecGenerator!.toJSON(), {
|
|
763
|
-
headers: { "Content-Type": "application/json" },
|
|
764
|
-
}), req);
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
// Swagger UI endpoint
|
|
768
|
-
if (url.pathname === "/docs") {
|
|
769
|
-
clearTimeout(timeoutId);
|
|
770
|
-
const swaggerUIHTML = `
|
|
771
|
-
<!DOCTYPE html>
|
|
772
|
-
<html>
|
|
773
|
-
<head>
|
|
774
|
-
<title>${this.name} Documentation</title>
|
|
775
|
-
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui.css" />
|
|
776
|
-
<style>
|
|
777
|
-
html { box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll; }
|
|
778
|
-
*, *:before, *:after { box-sizing: inherit; }
|
|
779
|
-
body { margin: 0; background: #fafafa; }
|
|
780
|
-
</style>
|
|
781
|
-
</head>
|
|
782
|
-
<body>
|
|
783
|
-
<div id="swagger-ui"></div>
|
|
784
|
-
<script src="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui-bundle.js"></script>
|
|
785
|
-
<script>
|
|
786
|
-
window.onload = function() {
|
|
787
|
-
const ui = SwaggerUIBundle({
|
|
788
|
-
url: '/openapi.json',
|
|
789
|
-
dom_id: '#swagger-ui',
|
|
790
|
-
deepLinking: true,
|
|
791
|
-
presets: [
|
|
792
|
-
SwaggerUIBundle.presets.apis,
|
|
793
|
-
SwaggerUIBundle.presets.standalone
|
|
794
|
-
],
|
|
795
|
-
plugins: [
|
|
796
|
-
SwaggerUIBundle.plugins.DownloadUrl
|
|
797
|
-
],
|
|
798
|
-
layout: "BaseLayout"
|
|
799
|
-
});
|
|
800
|
-
};
|
|
801
|
-
</script>
|
|
802
|
-
</body>
|
|
803
|
-
</html>`;
|
|
804
|
-
return this.addCorsHeaders(new Response(swaggerUIHTML, {
|
|
805
|
-
headers: { "Content-Type": "text/html" },
|
|
806
|
-
}), req);
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
// Studio API endpoints
|
|
810
|
-
if (this.studioEnabled && url.pathname.startsWith("/studio/api/")) {
|
|
811
|
-
clearTimeout(timeoutId);
|
|
812
|
-
|
|
813
|
-
// Studio tables endpoint
|
|
814
|
-
if (url.pathname === "/studio/api/tables") {
|
|
815
|
-
return this.addCorsHeaders(await studioEndpoint.getTables(), req);
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
// Studio stats endpoint
|
|
819
|
-
if (url.pathname === "/studio/api/stats") {
|
|
820
|
-
return this.addCorsHeaders(await studioEndpoint.handleStudioStatsRequest(), req);
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
// Studio components endpoint
|
|
824
|
-
if (url.pathname === "/studio/api/components") {
|
|
825
|
-
return this.addCorsHeaders(await studioEndpoint.handleStudioComponentsRequest(), req);
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
// Studio query endpoint (POST only)
|
|
829
|
-
if (url.pathname === "/studio/api/query" && method === "POST") {
|
|
830
|
-
const body = await req.json();
|
|
831
|
-
return this.addCorsHeaders(await studioEndpoint.handleStudioQueryRequest(body), req);
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
const studioApiPath = url.pathname.replace("/studio/api/", "");
|
|
835
|
-
const pathSegments = studioApiPath.split("/");
|
|
836
|
-
|
|
837
|
-
if (pathSegments[0] === "entity" && pathSegments[1]) {
|
|
838
|
-
const entityId = pathSegments[1];
|
|
839
|
-
return this.addCorsHeaders(
|
|
840
|
-
await studioEndpoint.handleEntityInspectorRequest(entityId),
|
|
841
|
-
req
|
|
842
|
-
);
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
if (pathSegments[0] === "table" && pathSegments[1]) {
|
|
846
|
-
const tableName = pathSegments[1];
|
|
847
|
-
|
|
848
|
-
if (method === "DELETE") {
|
|
849
|
-
const body = await req.json();
|
|
850
|
-
return this.addCorsHeaders(await studioEndpoint.handleStudioTableDeleteRequest(
|
|
851
|
-
tableName,
|
|
852
|
-
body
|
|
853
|
-
), req);
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
const limit = url.searchParams.get("limit");
|
|
857
|
-
const offset = url.searchParams.get("offset");
|
|
858
|
-
const search = url.searchParams.get("search");
|
|
859
|
-
|
|
860
|
-
return this.addCorsHeaders(await studioEndpoint.handleStudioTableRequest(tableName, {
|
|
861
|
-
limit: limit ? parseInt(limit, 10) : undefined,
|
|
862
|
-
offset: offset ? parseInt(offset, 10) : undefined,
|
|
863
|
-
search: search ?? undefined,
|
|
864
|
-
}), req);
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
if (pathSegments[0] === "arche-type" && pathSegments[1]) {
|
|
868
|
-
const archeTypeName = pathSegments[1];
|
|
869
|
-
|
|
870
|
-
if (method === "DELETE") {
|
|
871
|
-
const body = await req.json();
|
|
872
|
-
return this.addCorsHeaders(await studioEndpoint.handleStudioArcheTypeDeleteRequest(
|
|
873
|
-
archeTypeName,
|
|
874
|
-
body
|
|
875
|
-
), req);
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
const limit = url.searchParams.get("limit");
|
|
879
|
-
const offset = url.searchParams.get("offset");
|
|
880
|
-
const search = url.searchParams.get("search");
|
|
881
|
-
const includeDeleted = url.searchParams.get("include_deleted");
|
|
882
|
-
|
|
883
|
-
return this.addCorsHeaders(await studioEndpoint.handleStudioArcheTypeRecordsRequest(
|
|
884
|
-
archeTypeName,
|
|
885
|
-
{
|
|
886
|
-
limit: limit ? parseInt(limit, 10) : undefined,
|
|
887
|
-
offset: offset ? parseInt(offset, 10) : undefined,
|
|
888
|
-
search: search ?? undefined,
|
|
889
|
-
include_deleted: includeDeleted === "true",
|
|
890
|
-
}
|
|
891
|
-
), req);
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
return this.addCorsHeaders(new Response(
|
|
895
|
-
JSON.stringify({ error: "Studio API endpoint not found" }),
|
|
896
|
-
{
|
|
897
|
-
status: 404,
|
|
898
|
-
headers: { "Content-Type": "application/json" },
|
|
899
|
-
}
|
|
900
|
-
), req);
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
// Studio endpoint - handle both root and all sub-routes
|
|
904
|
-
if (
|
|
905
|
-
this.studioEnabled &&
|
|
906
|
-
(url.pathname === "/studio" ||
|
|
907
|
-
url.pathname.startsWith("/studio/"))
|
|
908
|
-
) {
|
|
909
|
-
clearTimeout(timeoutId);
|
|
910
|
-
|
|
911
|
-
// Skip API routes - they're handled by the API handler above
|
|
912
|
-
if (url.pathname.startsWith("/studio/api/")) {
|
|
913
|
-
return this.addCorsHeaders(new Response(
|
|
914
|
-
JSON.stringify({
|
|
915
|
-
error: "Studio API endpoint not found",
|
|
916
|
-
}),
|
|
917
|
-
{
|
|
918
|
-
status: 404,
|
|
919
|
-
headers: { "Content-Type": "application/json" },
|
|
920
|
-
}
|
|
921
|
-
), req);
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
// Check if this is a request for static assets (CSS, JS, etc.)
|
|
925
|
-
if (url.pathname.startsWith("/studio/assets/")) {
|
|
926
|
-
// Let the static assets handler below handle this
|
|
927
|
-
// Don't return here, fall through to static assets handler
|
|
928
|
-
} else {
|
|
929
|
-
// For all other /studio/* routes, serve the React app's index.html
|
|
930
|
-
const studioIndexPath = path.join(
|
|
931
|
-
import.meta.dirname,
|
|
932
|
-
"..",
|
|
933
|
-
"studio",
|
|
934
|
-
"dist",
|
|
935
|
-
"index.html"
|
|
936
|
-
);
|
|
937
|
-
try {
|
|
938
|
-
const studioFile = Bun.file(studioIndexPath);
|
|
939
|
-
if (await studioFile.exists()) {
|
|
940
|
-
let html = await studioFile.text();
|
|
941
|
-
// Inject metadata into the HTML
|
|
942
|
-
const metadata = getSerializedMetadataStorage();
|
|
943
|
-
const metadataScript = `<script>window.bunsaneMetadata = ${JSON.stringify(
|
|
944
|
-
metadata
|
|
945
|
-
)};</script>`;
|
|
946
|
-
// Insert before the closing </head> tag
|
|
947
|
-
html = html.replace(
|
|
948
|
-
"</head>",
|
|
949
|
-
`${metadataScript}</head>`
|
|
950
|
-
);
|
|
951
|
-
return this.addCorsHeaders(new Response(html, {
|
|
952
|
-
headers: { "Content-Type": "text/html" },
|
|
953
|
-
}), req);
|
|
954
|
-
} else {
|
|
955
|
-
return this.addCorsHeaders(new Response(
|
|
956
|
-
"Studio not built. Run `bun run build:studio` to build the studio.",
|
|
957
|
-
{
|
|
958
|
-
status: 404,
|
|
959
|
-
headers: { "Content-Type": "text/plain" },
|
|
960
|
-
}
|
|
961
|
-
), req);
|
|
962
|
-
}
|
|
963
|
-
} catch (error) {
|
|
964
|
-
console.log("Error loading studio index.html:", error);
|
|
965
|
-
return this.addCorsHeaders(new Response("Studio not available", {
|
|
966
|
-
status: 404,
|
|
967
|
-
headers: { "Content-Type": "text/plain" },
|
|
968
|
-
}), req);
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
for (const [route, folder] of this.staticAssets) {
|
|
973
|
-
if (url.pathname.startsWith(route)) {
|
|
974
|
-
const relativePath = url.pathname.slice(route.length);
|
|
975
|
-
const filePath = path.join(folder, relativePath);
|
|
976
|
-
try {
|
|
977
|
-
const file = Bun.file(filePath);
|
|
978
|
-
if (await file.exists()) {
|
|
979
|
-
clearTimeout(timeoutId);
|
|
980
|
-
return this.addCorsHeaders(new Response(file), req);
|
|
981
|
-
}
|
|
982
|
-
} catch (error) {
|
|
983
|
-
logger.error(
|
|
984
|
-
`Error serving static file ${filePath}:`,
|
|
985
|
-
error as any
|
|
986
|
-
);
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
// Lookup REST endpoint using map for O(1) performance
|
|
992
|
-
const endpointKey = `${method}:${url.pathname}`;
|
|
993
|
-
let endpoint = this.restEndpointMap.get(endpointKey);
|
|
994
|
-
|
|
995
|
-
// If exact match not found, try pattern matching for parameterized routes
|
|
996
|
-
if (!endpoint) {
|
|
997
|
-
for (const ep of this.restEndpoints) {
|
|
998
|
-
if (ep.method !== method) continue;
|
|
999
|
-
// Convert route pattern to regex (e.g., /api/v1/users/:id -> /api/v1/users/[^/]+)
|
|
1000
|
-
const pattern = ep.path.replace(/:[^/]+/g, '[^/]+');
|
|
1001
|
-
const regex = new RegExp(`^${pattern}$`);
|
|
1002
|
-
if (regex.test(url.pathname)) {
|
|
1003
|
-
endpoint = ep;
|
|
1004
|
-
break;
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
if (endpoint) {
|
|
1010
|
-
try {
|
|
1011
|
-
const result = await endpoint.handler(req);
|
|
1012
|
-
const duration = Date.now() - startTime;
|
|
1013
|
-
logger.trace(
|
|
1014
|
-
`REST ${method} ${url.pathname} completed in ${duration}ms`
|
|
1015
|
-
);
|
|
1016
|
-
|
|
1017
|
-
clearTimeout(timeoutId);
|
|
1018
|
-
if (result instanceof Response) {
|
|
1019
|
-
return this.addCorsHeaders(result, req);
|
|
1020
|
-
} else {
|
|
1021
|
-
return this.addCorsHeaders(new Response(JSON.stringify(result), {
|
|
1022
|
-
headers: { "Content-Type": "application/json" },
|
|
1023
|
-
}), req);
|
|
1024
|
-
}
|
|
1025
|
-
} catch (error) {
|
|
1026
|
-
const duration = Date.now() - startTime;
|
|
1027
|
-
logger.error(
|
|
1028
|
-
`Error in REST endpoint ${method} ${endpoint.path} after ${duration}ms`,
|
|
1029
|
-
error as any
|
|
1030
|
-
);
|
|
1031
|
-
clearTimeout(timeoutId);
|
|
1032
|
-
return this.addCorsHeaders(new Response(
|
|
1033
|
-
JSON.stringify({
|
|
1034
|
-
error: "Internal server error",
|
|
1035
|
-
code: "INTERNAL_ERROR",
|
|
1036
|
-
...(process.env.NODE_ENV === 'development' && {
|
|
1037
|
-
message: (error as Error)?.message,
|
|
1038
|
-
}),
|
|
1039
|
-
}),
|
|
1040
|
-
{
|
|
1041
|
-
status: 500,
|
|
1042
|
-
headers: { "Content-Type": "application/json" },
|
|
1043
|
-
}
|
|
1044
|
-
), req);
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
if (this.yoga) {
|
|
1049
|
-
const response = await this.yoga(req);
|
|
1050
|
-
const duration = Date.now() - startTime;
|
|
1051
|
-
logger.trace(`GraphQL request completed in ${duration}ms`);
|
|
1052
|
-
clearTimeout(timeoutId);
|
|
1053
|
-
return response;
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
clearTimeout(timeoutId);
|
|
1057
|
-
return this.addCorsHeaders(new Response("Not Found", { status: 404 }), req);
|
|
1058
|
-
} catch (error) {
|
|
1059
|
-
const duration = Date.now() - startTime;
|
|
1060
|
-
logger.error(
|
|
1061
|
-
`Request failed after ${duration}ms: ${method} ${url.pathname}`,
|
|
1062
|
-
error as any
|
|
1063
|
-
);
|
|
1064
|
-
clearTimeout(timeoutId);
|
|
1065
|
-
|
|
1066
|
-
if ((error as Error).name === "AbortError") {
|
|
1067
|
-
return this.addCorsHeaders(new Response(
|
|
1068
|
-
JSON.stringify({ error: "Request timeout", code: "TIMEOUT_ERROR" }),
|
|
1069
|
-
{
|
|
1070
|
-
status: 408,
|
|
1071
|
-
headers: { "Content-Type": "application/json" },
|
|
1072
|
-
}
|
|
1073
|
-
), req);
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
return this.addCorsHeaders(new Response(
|
|
1077
|
-
JSON.stringify({
|
|
1078
|
-
error: "Internal server error",
|
|
1079
|
-
code: "INTERNAL_ERROR",
|
|
1080
|
-
...(process.env.NODE_ENV === 'development' && {
|
|
1081
|
-
message: (error as Error)?.message,
|
|
1082
|
-
}),
|
|
1083
|
-
}),
|
|
1084
|
-
{
|
|
1085
|
-
status: 500,
|
|
1086
|
-
headers: { "Content-Type": "application/json" },
|
|
1087
|
-
}
|
|
1088
|
-
), req);
|
|
1089
|
-
}
|
|
263
|
+
return handleRequestFn(this, req);
|
|
1090
264
|
}
|
|
1091
265
|
|
|
1092
266
|
public setName(name: string) {
|
|
@@ -1177,83 +351,12 @@ export default class App {
|
|
|
1177
351
|
this.maxRequestBodySize = bytes;
|
|
1178
352
|
}
|
|
1179
353
|
|
|
1180
|
-
/**
|
|
1181
|
-
* Warm up the prepared statement cache with common query patterns
|
|
1182
|
-
*/
|
|
1183
354
|
private async warmUpPreparedStatementCache(): Promise<void> {
|
|
1184
|
-
|
|
1185
|
-
const components = ComponentRegistry.getComponents();
|
|
1186
|
-
|
|
1187
|
-
if (components.length === 0) {
|
|
1188
|
-
logger.trace(
|
|
1189
|
-
"No components registered yet, skipping cache warm-up"
|
|
1190
|
-
);
|
|
1191
|
-
return;
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
const commonQueries: Array<{ sql: string; key: string }> = [];
|
|
1195
|
-
|
|
1196
|
-
// Generate some common query patterns
|
|
1197
|
-
// 1. Simple entity count
|
|
1198
|
-
commonQueries.push({
|
|
1199
|
-
sql: "SELECT COUNT(*) as count FROM (SELECT DISTINCT ec.entity_id as id FROM entity_components ec WHERE ec.deleted_at IS NULL) AS subquery",
|
|
1200
|
-
key: "count_all_entities",
|
|
1201
|
-
});
|
|
1202
|
-
|
|
1203
|
-
// 2. Common component queries (first few components)
|
|
1204
|
-
for (let i = 0; i < Math.min(5, components.length); i++) {
|
|
1205
|
-
const component = components[i];
|
|
1206
|
-
if (component) {
|
|
1207
|
-
const { name, ctor } = component;
|
|
1208
|
-
const typeId = ComponentRegistry.getComponentId(name);
|
|
1209
|
-
if (typeId) {
|
|
1210
|
-
commonQueries.push({
|
|
1211
|
-
sql: `SELECT DISTINCT ec.entity_id as id FROM entity_components ec WHERE ec.type_id = '${typeId}' AND ec.deleted_at IS NULL LIMIT 10`,
|
|
1212
|
-
key: `find_${name.toLowerCase()}_sample`,
|
|
1213
|
-
});
|
|
1214
|
-
}
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
// 3. Multi-component queries (if we have multiple components)
|
|
1219
|
-
if (components.length >= 2) {
|
|
1220
|
-
const typeIds = components
|
|
1221
|
-
.slice(0, 3)
|
|
1222
|
-
.map((component: { name: string; ctor: any }) =>
|
|
1223
|
-
ComponentRegistry.getComponentId(component.name)
|
|
1224
|
-
)
|
|
1225
|
-
.filter((id: string | undefined) => id)
|
|
1226
|
-
.join("','");
|
|
1227
|
-
|
|
1228
|
-
if (typeIds) {
|
|
1229
|
-
commonQueries.push({
|
|
1230
|
-
sql: `SELECT DISTINCT ec.entity_id as id FROM entity_components ec WHERE ec.type_id IN ('${typeIds}') AND ec.deleted_at IS NULL LIMIT 10`,
|
|
1231
|
-
key: "find_multi_component_sample",
|
|
1232
|
-
});
|
|
1233
|
-
}
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
await preparedStatementCache.warmUp(commonQueries, db);
|
|
355
|
+
return warmUpPreparedStatementCacheFn(this);
|
|
1237
356
|
}
|
|
1238
357
|
|
|
1239
358
|
private async collectMetrics() {
|
|
1240
|
-
|
|
1241
|
-
try {
|
|
1242
|
-
const { CacheManager } = await import('./cache/CacheManager');
|
|
1243
|
-
cacheStats = await CacheManager.getInstance().getStats();
|
|
1244
|
-
} catch (err) {
|
|
1245
|
-
logger.warn({ err }, 'metrics: cache stats unavailable');
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
return {
|
|
1249
|
-
timestamp: new Date().toISOString(),
|
|
1250
|
-
uptime: process.uptime(),
|
|
1251
|
-
process: process.memoryUsage(),
|
|
1252
|
-
cache: cacheStats,
|
|
1253
|
-
scheduler: SchedulerManager.getInstance().getMetrics(),
|
|
1254
|
-
preparedStatements: preparedStatementCache.getStats(),
|
|
1255
|
-
remote: this.remote ? this.remote.getMetrics() : null,
|
|
1256
|
-
};
|
|
359
|
+
return collectMetricsFn(this);
|
|
1257
360
|
}
|
|
1258
361
|
|
|
1259
362
|
async start() {
|
|
@@ -1310,164 +413,25 @@ export default class App {
|
|
|
1310
413
|
*
|
|
1311
414
|
* Uses `process.once` for signals so a double SIGTERM can't fire two
|
|
1312
415
|
* concurrent shutdown paths racing each other to `process.exit`. Also
|
|
1313
|
-
* idempotent
|
|
416
|
+
* idempotent — safe to call multiple times (e.g. in tests).
|
|
1314
417
|
*/
|
|
1315
418
|
private registerProcessHandlers(): void {
|
|
1316
|
-
|
|
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 = () => {
|
|
1324
|
-
logger.info({ scope: 'app', component: 'App', msg: 'Received SIGINT' });
|
|
1325
|
-
this.shutdown().finally(() => process.exit(0));
|
|
1326
|
-
};
|
|
1327
|
-
process.once('SIGTERM', this.sigTermHandler);
|
|
1328
|
-
process.once('SIGINT', this.sigIntHandler);
|
|
1329
|
-
|
|
1330
|
-
// Global error handlers to prevent silent crashes during init AND runtime.
|
|
1331
|
-
this.unhandledRejectionHandler = (reason, promise) => {
|
|
1332
|
-
logger.error({ scope: 'app', component: 'App', reason, msg: 'Unhandled promise rejection' });
|
|
1333
|
-
};
|
|
1334
|
-
this.uncaughtExceptionHandler = (error) => {
|
|
1335
|
-
logger.fatal({ scope: 'app', component: 'App', err: error, msg: 'Uncaught exception — shutting down' });
|
|
1336
|
-
this.shutdown().finally(() => process.exit(1));
|
|
1337
|
-
};
|
|
1338
|
-
process.on('unhandledRejection', this.unhandledRejectionHandler);
|
|
1339
|
-
process.on('uncaughtException', this.uncaughtExceptionHandler);
|
|
1340
|
-
|
|
1341
|
-
this.processHandlersRegistered = true;
|
|
419
|
+
registerProcessHandlersFn(this);
|
|
1342
420
|
}
|
|
1343
421
|
|
|
1344
422
|
private unregisterProcessHandlers(): void {
|
|
1345
|
-
|
|
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;
|
|
423
|
+
unregisterProcessHandlersFn(this);
|
|
1355
424
|
}
|
|
1356
425
|
|
|
1357
426
|
/**
|
|
1358
427
|
* Gracefully shutdown the application.
|
|
1359
428
|
*
|
|
1360
|
-
* Ordered drain: HTTP
|
|
429
|
+
* Ordered drain: HTTP → scheduler → remote → cache → database. Each step
|
|
1361
430
|
* awaits completion before the next begins so in-flight work always sees
|
|
1362
431
|
* its dependencies still available. Total budget bounded by
|
|
1363
432
|
* `shutdownGracePeriod`; per-step budgets fall back to reasonable defaults.
|
|
1364
433
|
*/
|
|
1365
434
|
async shutdown(): Promise<void> {
|
|
1366
|
-
|
|
1367
|
-
this.isShuttingDown = true;
|
|
1368
|
-
this.isReady = false;
|
|
1369
|
-
|
|
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));
|
|
1374
|
-
|
|
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.
|
|
1379
|
-
if (this.server) {
|
|
1380
|
-
try {
|
|
1381
|
-
logger.info({ scope: 'app', component: 'App', msg: 'Draining HTTP connections' });
|
|
1382
|
-
this.server.stop(false);
|
|
1383
|
-
await this.waitForHttpDrain(budgetRemaining());
|
|
1384
|
-
try { this.server.stop(true); } catch {}
|
|
1385
|
-
logger.info({ scope: 'app', component: 'App', msg: 'HTTP server stopped' });
|
|
1386
|
-
} catch (error) {
|
|
1387
|
-
logger.warn({ scope: 'app', component: 'App', msg: 'HTTP server stop error', err: error });
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
|
|
1391
|
-
// 2. Stop scheduler (awaits in-flight tasks internally, see C14).
|
|
1392
|
-
try {
|
|
1393
|
-
await SchedulerManager.getInstance().stop(Math.min(budgetRemaining(), 15_000));
|
|
1394
|
-
logger.info({ scope: 'app', component: 'App', msg: 'Scheduler stopped' });
|
|
1395
|
-
} catch (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
|
-
}
|
|
1409
|
-
}
|
|
1410
|
-
|
|
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).
|
|
1421
|
-
try {
|
|
1422
|
-
const { CacheManager } = await import('./cache/CacheManager');
|
|
1423
|
-
await CacheManager.getInstance().shutdown();
|
|
1424
|
-
logger.info({ scope: 'cache', component: 'App', msg: 'Cache shutdown completed' });
|
|
1425
|
-
} catch (error) {
|
|
1426
|
-
logger.warn({ scope: 'cache', component: 'App', msg: 'Cache shutdown error', err: error });
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
// 5. Close database pool (last — after all consumers done).
|
|
1430
|
-
try {
|
|
1431
|
-
db.close();
|
|
1432
|
-
logger.info({ scope: 'app', component: 'App', msg: 'Database pool closed' });
|
|
1433
|
-
} catch (error) {
|
|
1434
|
-
logger.warn({ scope: 'app', component: 'App', msg: 'Database pool close error', err: error });
|
|
1435
|
-
}
|
|
1436
|
-
|
|
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
|
-
}
|
|
435
|
+
return runShutdown(this);
|
|
1472
436
|
}
|
|
1473
437
|
}
|