@donkeylabs/server 2.0.37 → 2.2.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/src/server.ts CHANGED
@@ -5,6 +5,14 @@ import { fileURLToPath } from "node:url";
5
5
  import { PluginManager, type CoreServices, type ConfiguredPlugin } from "./core";
6
6
  import { type IRouter, type RouteDefinition, type ServerContext, type HandlerRegistry } from "./router";
7
7
  import { Handlers } from "./handlers";
8
+ import {
9
+ type VersioningConfig,
10
+ type DeprecationInfo,
11
+ type SemVer,
12
+ parseSemVer,
13
+ compareSemVer,
14
+ resolveVersion,
15
+ } from "./versioning";
8
16
  import type { MiddlewareRuntime, MiddlewareDefinition } from "./middleware";
9
17
  import {
10
18
  createLogger,
@@ -45,6 +53,7 @@ import {
45
53
  type StorageConfig,
46
54
  type LogsConfig,
47
55
  } from "./core/index";
56
+ import { createHealth, createDbHealthCheck, type HealthConfig } from "./core/health";
48
57
  import type { AdminConfig } from "./admin";
49
58
  import { zodSchemaToTs } from "./generator/zod-to-ts";
50
59
 
@@ -63,6 +72,15 @@ export interface TypeGenerationConfig {
63
72
  factoryFunction?: string;
64
73
  }
65
74
 
75
+ /** A route entry in the versioned route map */
76
+ interface VersionedRouteEntry {
77
+ version: SemVer | undefined;
78
+ route: RouteDefinition<keyof HandlerRegistry>;
79
+ deprecated?: DeprecationInfo;
80
+ /** Source router version string (for response headers) */
81
+ versionRaw?: string;
82
+ }
83
+
66
84
  export interface ServerConfig {
67
85
  /** Server port. Can also be set via PORT environment variable. Default: 3000 */
68
86
  port?: number;
@@ -104,6 +122,12 @@ export interface ServerConfig {
104
122
  websocket?: WebSocketConfig;
105
123
  storage?: StorageConfig;
106
124
  logs?: LogsConfig;
125
+ /** API versioning configuration */
126
+ versioning?: VersioningConfig;
127
+ /** Health check configuration */
128
+ health?: HealthConfig;
129
+ /** Graceful shutdown configuration */
130
+ shutdown?: ShutdownConfig;
107
131
  /**
108
132
  * Admin dashboard configuration.
109
133
  * Automatically enabled in dev mode, disabled in production.
@@ -215,12 +239,23 @@ export function defineService<N extends string, T>(
215
239
  return { name, factory };
216
240
  }
217
241
 
242
+ export interface ShutdownConfig {
243
+ /** Total timeout before force exit in ms (default: 30000) */
244
+ timeout?: number;
245
+ /** Time to wait for in-flight requests to complete in ms (default: 10000) */
246
+ drainTimeout?: number;
247
+ /** Whether to call process.exit(1) after timeout (default: true) */
248
+ forceExit?: boolean;
249
+ }
250
+
218
251
  export class AppServer {
219
252
  private port: number;
220
253
  private maxPortAttempts: number;
221
254
  private manager: PluginManager;
222
255
  private routers: IRouter[] = [];
223
256
  private routeMap: Map<string, RouteDefinition<keyof HandlerRegistry>> = new Map();
257
+ private versionedRouteMap: Map<string, VersionedRouteEntry[]> = new Map();
258
+ private versioningConfig: VersioningConfig;
224
259
  private coreServices: CoreServices;
225
260
  private typeGenConfig?: TypeGenerationConfig;
226
261
 
@@ -238,6 +273,11 @@ export class AppServer {
238
273
  private watchdogStarted = false;
239
274
  private options: ServerConfig;
240
275
 
276
+ // HTTP server & request tracking
277
+ private server: ReturnType<typeof Bun.serve> | null = null;
278
+ private activeRequests = 0;
279
+ private draining = false;
280
+
241
281
  // Custom services registry
242
282
  private serviceFactories = new Map<string, ServiceFactory<any>>();
243
283
  private serviceRegistry: Record<string, any> = {};
@@ -251,6 +291,7 @@ export class AppServer {
251
291
  this.maxPortAttempts = options.maxPortAttempts ?? 5;
252
292
  this.workflowsResumeStrategy = options.workflowsResumeStrategy ?? options.workflows?.resumeStrategy;
253
293
  this.watchdogConfig = options.watchdog;
294
+ this.versioningConfig = options.versioning ?? {};
254
295
 
255
296
  // Determine if we should use legacy databases
256
297
  const useLegacy = options.useLegacyCoreDatabases ?? false;
@@ -258,8 +299,8 @@ export class AppServer {
258
299
  // Initialize core services
259
300
  // Order matters: events → logs → logger (with PersistentTransport) → cron/jobs (with logger)
260
301
  const cache = createCache(options.cache);
261
- const events = createEvents(options.events);
262
302
  const sse = createSSE(options.sse);
303
+ const events = createEvents({ ...options.events, sse });
263
304
  const rateLimiter = createRateLimiter(options.rateLimiter);
264
305
  const errors = createErrors(options.errors);
265
306
 
@@ -341,6 +382,19 @@ export class AppServer {
341
382
  const websocket = createWebSocket(options.websocket);
342
383
  const storage = createStorage(options.storage);
343
384
 
385
+ // Health checks
386
+ const health = createHealth(options.health);
387
+ // Register built-in DB check unless explicitly disabled
388
+ if (options.health?.dbCheck !== false) {
389
+ health.register(createDbHealthCheck(options.db));
390
+ }
391
+ // Register any user-provided checks
392
+ if (options.health?.checks) {
393
+ for (const check of options.health.checks) {
394
+ health.register(check);
395
+ }
396
+ }
397
+
344
398
  this.coreServices = {
345
399
  db: options.db,
346
400
  config: options.config ?? {},
@@ -358,6 +412,7 @@ export class AppServer {
358
412
  websocket,
359
413
  storage,
360
414
  logs,
415
+ health,
361
416
  };
362
417
 
363
418
  // Resolve circular dependency: workflows needs core for step handlers
@@ -664,7 +719,105 @@ export class AppServer {
664
719
  * Check if a route name is registered.
665
720
  */
666
721
  hasRoute(routeName: string): boolean {
667
- return this.routeMap.has(routeName);
722
+ return this.routeMap.has(routeName) || this.versionedRouteMap.has(routeName);
723
+ }
724
+
725
+ /**
726
+ * Resolve a route considering API versioning.
727
+ * Returns the route + version metadata, or null if not found.
728
+ */
729
+ private resolveVersionedRoute(
730
+ actionName: string,
731
+ requestedVersion?: string | null
732
+ ): { route: RouteDefinition<keyof HandlerRegistry>; resolvedVersion?: string; deprecated?: DeprecationInfo } | null {
733
+ const headerName = this.versioningConfig.headerName ?? "X-API-Version";
734
+ const defaultBehavior = this.versioningConfig.defaultBehavior ?? "latest";
735
+
736
+ // Fast path: no version header and we have an unversioned route
737
+ if (!requestedVersion) {
738
+ const unversioned = this.routeMap.get(actionName);
739
+ if (unversioned) {
740
+ return { route: unversioned };
741
+ }
742
+
743
+ // No unversioned route - check versioned routes
744
+ const entries = this.versionedRouteMap.get(actionName);
745
+ if (!entries || entries.length === 0) {
746
+ return null;
747
+ }
748
+
749
+ if (defaultBehavior === "error") {
750
+ return null; // Caller should return 400
751
+ }
752
+
753
+ // "latest" or "unversioned" (with no unversioned route available) → use highest version
754
+ const latest = entries[0]!; // Already sorted highest-first
755
+ return {
756
+ route: latest.route,
757
+ resolvedVersion: latest.versionRaw,
758
+ deprecated: latest.deprecated,
759
+ };
760
+ }
761
+
762
+ // Version header present - resolve from versioned routes
763
+ const entries = this.versionedRouteMap.get(actionName);
764
+ if (!entries || entries.length === 0) {
765
+ // Fall back to unversioned route
766
+ const unversioned = this.routeMap.get(actionName);
767
+ return unversioned ? { route: unversioned } : null;
768
+ }
769
+
770
+ const versions = entries
771
+ .filter((e): e is VersionedRouteEntry & { version: SemVer } => e.version != null)
772
+ .map((e) => e.version);
773
+
774
+ const resolved = resolveVersion(versions, requestedVersion);
775
+ if (!resolved) {
776
+ // No matching version - fall back to unversioned if available
777
+ const unversioned = this.routeMap.get(actionName);
778
+ return unversioned ? { route: unversioned } : null;
779
+ }
780
+
781
+ const entry = entries.find(
782
+ (e) =>
783
+ e.version &&
784
+ e.version.major === resolved.major &&
785
+ e.version.minor === resolved.minor &&
786
+ e.version.patch === resolved.patch
787
+ );
788
+
789
+ if (!entry) return null;
790
+
791
+ return {
792
+ route: entry.route,
793
+ resolvedVersion: entry.versionRaw,
794
+ deprecated: entry.deprecated,
795
+ };
796
+ }
797
+
798
+ /**
799
+ * Add version/deprecation headers to a response.
800
+ */
801
+ private addVersionHeaders(
802
+ response: Response,
803
+ resolvedVersion?: string,
804
+ deprecated?: DeprecationInfo
805
+ ): void {
806
+ if (resolvedVersion && (this.versioningConfig.echoVersion !== false)) {
807
+ response.headers.set(
808
+ this.versioningConfig.headerName ?? "X-API-Version",
809
+ resolvedVersion
810
+ );
811
+ }
812
+ if (deprecated) {
813
+ if (deprecated.sunsetDate) {
814
+ response.headers.set("Sunset", deprecated.sunsetDate);
815
+ }
816
+ response.headers.set("Deprecation", "true");
817
+ if (deprecated.message) {
818
+ response.headers.set("X-Deprecation-Notice", deprecated.message);
819
+ }
820
+ }
668
821
  }
669
822
 
670
823
  /**
@@ -687,12 +840,17 @@ export class AppServer {
687
840
  const routes = [];
688
841
 
689
842
  for (const router of this.routers) {
843
+ const routerVersion = router.getVersion();
844
+ const routerDeprecation = router.getDeprecation();
845
+
690
846
  for (const route of router.getRoutes()) {
691
847
  routes.push({
692
848
  name: route.name,
693
849
  handler: route.handler || "typed",
694
850
  inputType: route.input ? zodSchemaToTs(route.input) : undefined,
695
851
  outputType: route.output ? zodSchemaToTs(route.output) : undefined,
852
+ version: routerVersion,
853
+ deprecated: routerDeprecation ? true : undefined,
696
854
  });
697
855
  }
698
856
  }
@@ -724,11 +882,14 @@ export class AppServer {
724
882
  inputSource?: string;
725
883
  outputSource?: string;
726
884
  eventsSource?: Record<string, string>;
885
+ version?: string;
727
886
  }> = [];
728
887
 
729
888
  const routesWithoutOutput: string[] = [];
730
889
 
731
890
  for (const router of this.routers) {
891
+ const routerVersion = router.getVersion();
892
+
732
893
  for (const route of router.getRoutes()) {
733
894
  const parts = route.name.split(".");
734
895
  const routeName = parts[parts.length - 1] || route.name;
@@ -756,6 +917,7 @@ export class AppServer {
756
917
  inputSource: route.input ? zodSchemaToTs(route.input) : undefined,
757
918
  outputSource: route.output ? zodSchemaToTs(route.output) : undefined,
758
919
  eventsSource,
920
+ ...(routerVersion ? { version: routerVersion } : {}),
759
921
  });
760
922
  }
761
923
  }
@@ -794,6 +956,7 @@ export class AppServer {
794
956
  inputSource?: string;
795
957
  outputSource?: string;
796
958
  eventsSource?: Record<string, string>;
959
+ version?: string;
797
960
  }>
798
961
  ): string {
799
962
  const baseImport =
@@ -1087,14 +1250,45 @@ ${factoryFunction}
1087
1250
  logger.info("Background services started (cron, jobs, workflows, processes)");
1088
1251
 
1089
1252
  for (const router of this.routers) {
1253
+ const routerVersion = router.getVersion();
1254
+ const routerDeprecation = router.getDeprecation();
1255
+ const parsedVersion = routerVersion ? parseSemVer(routerVersion) : undefined;
1256
+
1090
1257
  for (const route of router.getRoutes()) {
1091
- if (this.routeMap.has(route.name)) {
1092
- logger.warn(`Duplicate route detected`, { route: route.name });
1258
+ if (routerVersion) {
1259
+ // Versioned route add to versionedRouteMap
1260
+ if (!this.versionedRouteMap.has(route.name)) {
1261
+ this.versionedRouteMap.set(route.name, []);
1262
+ }
1263
+ this.versionedRouteMap.get(route.name)!.push({
1264
+ version: parsedVersion ?? undefined,
1265
+ route,
1266
+ deprecated: routerDeprecation,
1267
+ versionRaw: routerVersion,
1268
+ });
1269
+ } else {
1270
+ // Unversioned route → add to routeMap (backward compat)
1271
+ if (this.routeMap.has(route.name)) {
1272
+ logger.warn(`Duplicate route detected`, { route: route.name });
1273
+ }
1274
+ this.routeMap.set(route.name, route);
1093
1275
  }
1094
- this.routeMap.set(route.name, route);
1095
1276
  }
1096
1277
  }
1097
- logger.info(`Loaded ${this.routeMap.size} RPC routes`);
1278
+
1279
+ // Sort versioned entries highest-first for efficient resolution
1280
+ for (const entries of this.versionedRouteMap.values()) {
1281
+ entries.sort((a, b) => {
1282
+ if (!a.version && !b.version) return 0;
1283
+ if (!a.version) return 1;
1284
+ if (!b.version) return -1;
1285
+ return compareSemVer(b.version, a.version);
1286
+ });
1287
+ }
1288
+
1289
+ const totalRoutes = this.routeMap.size +
1290
+ [...this.versionedRouteMap.values()].reduce((sum, e) => sum + e.length, 0);
1291
+ logger.info(`Loaded ${totalRoutes} RPC routes (${this.versionedRouteMap.size} versioned)`);
1098
1292
  logger.info("Server initialized (adapter mode)");
1099
1293
 
1100
1294
  // Initialize custom services, then run onReady handlers
@@ -1185,11 +1379,23 @@ ${factoryFunction}
1185
1379
  const { logger } = this.coreServices;
1186
1380
  const corsHeaders = options?.corsHeaders ?? {};
1187
1381
 
1188
- const route = this.routeMap.get(routeName);
1189
- if (!route) {
1382
+ const headerName = this.versioningConfig.headerName ?? "X-API-Version";
1383
+ const requestedVersion = req.headers.get(headerName);
1384
+ const resolved = this.resolveVersionedRoute(routeName, requestedVersion);
1385
+ if (!resolved) {
1386
+ // If versioning is "error" mode and no version header was sent, return 400
1387
+ if (requestedVersion === null && this.versionedRouteMap.has(routeName) &&
1388
+ this.versioningConfig.defaultBehavior === "error") {
1389
+ return Response.json(
1390
+ { error: "VERSION_REQUIRED", message: `${headerName} header is required` },
1391
+ { status: 400, headers: corsHeaders }
1392
+ );
1393
+ }
1190
1394
  return null;
1191
1395
  }
1192
1396
 
1397
+ const { route, resolvedVersion, deprecated } = resolved;
1398
+
1193
1399
  const type = route.handler || "typed";
1194
1400
 
1195
1401
  // First check core handlers
@@ -1214,6 +1420,10 @@ ${factoryFunction}
1214
1420
  }
1215
1421
 
1216
1422
  // Build context
1423
+ const requestId = crypto.randomUUID();
1424
+ const traceId = req.headers.get("x-request-id")
1425
+ ?? req.headers.get("x-trace-id")
1426
+ ?? requestId;
1217
1427
  const ctx: ServerContext = {
1218
1428
  db: this.coreServices.db,
1219
1429
  plugins: this.manager.getServices(),
@@ -1222,7 +1432,8 @@ ${factoryFunction}
1222
1432
  config: this.coreServices.config,
1223
1433
  services: this.serviceRegistry,
1224
1434
  ip,
1225
- requestId: crypto.randomUUID(),
1435
+ requestId,
1436
+ traceId,
1226
1437
  signal: req.signal,
1227
1438
  };
1228
1439
 
@@ -1246,11 +1457,18 @@ ${factoryFunction}
1246
1457
  };
1247
1458
 
1248
1459
  try {
1460
+ let response: Response;
1249
1461
  if (middlewareStack.length > 0) {
1250
- return await this.executeMiddlewareChain(req, ctx, middlewareStack, finalHandler);
1462
+ response = await this.executeMiddlewareChain(req, ctx, middlewareStack, finalHandler);
1251
1463
  } else {
1252
- return await finalHandler();
1464
+ response = await finalHandler();
1253
1465
  }
1466
+ // Add trace headers
1467
+ response.headers.set("X-Request-Id", ctx.requestId);
1468
+ response.headers.set("X-Trace-Id", ctx.traceId);
1469
+ // Add version headers
1470
+ this.addVersionHeaders(response, resolvedVersion, deprecated);
1471
+ return response;
1254
1472
  } catch (error) {
1255
1473
  if (error instanceof HttpError) {
1256
1474
  logger.warn("HTTP error thrown", {
@@ -1261,7 +1479,11 @@ ${factoryFunction}
1261
1479
  });
1262
1480
  return Response.json(error.toJSON(), {
1263
1481
  status: error.status,
1264
- headers: corsHeaders,
1482
+ headers: {
1483
+ ...corsHeaders,
1484
+ "X-Request-Id": ctx.requestId,
1485
+ "X-Trace-Id": ctx.traceId,
1486
+ },
1265
1487
  });
1266
1488
  }
1267
1489
  throw error;
@@ -1280,16 +1502,19 @@ ${factoryFunction}
1280
1502
  async callRoute<TOutput = any>(
1281
1503
  routeName: string,
1282
1504
  input: any,
1283
- ip: string = "127.0.0.1"
1505
+ ip: string = "127.0.0.1",
1506
+ options?: { version?: string }
1284
1507
  ): Promise<TOutput> {
1285
1508
  const { logger } = this.coreServices;
1286
1509
 
1287
- const route = this.routeMap.get(routeName);
1288
- if (!route) {
1510
+ const resolved = this.resolveVersionedRoute(routeName, options?.version);
1511
+ if (!resolved) {
1289
1512
  throw new Error(`Route "${routeName}" not found`);
1290
1513
  }
1514
+ const { route } = resolved;
1291
1515
 
1292
1516
  // Build context
1517
+ const requestId = crypto.randomUUID();
1293
1518
  const ctx: ServerContext = {
1294
1519
  db: this.coreServices.db,
1295
1520
  plugins: this.manager.getServices(),
@@ -1298,7 +1523,8 @@ ${factoryFunction}
1298
1523
  config: this.coreServices.config,
1299
1524
  services: this.serviceRegistry,
1300
1525
  ip,
1301
- requestId: crypto.randomUUID(),
1526
+ requestId,
1527
+ traceId: requestId,
1302
1528
  };
1303
1529
 
1304
1530
  // Validate input if schema exists
@@ -1389,99 +1615,164 @@ ${factoryFunction}
1389
1615
  const { logger } = this.coreServices;
1390
1616
 
1391
1617
  // 5. Start HTTP server with port retry logic
1392
- const fetchHandler = async (req: Request, server: ReturnType<typeof Bun.serve>) => {
1618
+ const fetchHandler = async (req: Request, bunServer: ReturnType<typeof Bun.serve>) => {
1393
1619
  const url = new URL(req.url);
1394
1620
 
1395
- // Extract client IP
1396
- const ip = extractClientIP(req, server.requestIP(req)?.address);
1621
+ // Health endpoints bypass draining (load balancers need to see 503)
1622
+ const livenessPath = this.options.health?.livenessPath ?? "/_health";
1623
+ const readinessPath = this.options.health?.readinessPath ?? "/_ready";
1397
1624
 
1398
- // Handle SSE endpoint
1399
- if (url.pathname === "/sse" && req.method === "GET") {
1400
- return this.handleSSE(req, ip);
1625
+ if (url.pathname === livenessPath && req.method === "GET") {
1626
+ const result = this.coreServices.health.liveness(this.isShuttingDown);
1627
+ return Response.json(result, {
1628
+ status: result.status === "healthy" ? 200 : 503,
1629
+ });
1401
1630
  }
1402
1631
 
1403
- // Extract action from URL path (e.g., "auth.login")
1404
- const actionName = url.pathname.slice(1);
1632
+ if (url.pathname === readinessPath && req.method === "GET") {
1633
+ const result = await this.coreServices.health.check();
1634
+ return Response.json(result, {
1635
+ status: result.status === "unhealthy" ? 503 : 200,
1636
+ });
1637
+ }
1405
1638
 
1406
- const route = this.routeMap.get(actionName);
1407
- if (route) {
1408
- const handlerType = route.handler || "typed";
1639
+ // Reject new requests during drain phase
1640
+ if (this.draining) {
1641
+ return new Response("Service Unavailable", {
1642
+ status: 503,
1643
+ headers: { "Retry-After": "5", "Connection": "close" },
1644
+ });
1645
+ }
1409
1646
 
1410
- // Handlers that accept GET requests (for browser compatibility)
1411
- const getEnabledHandlers = ["stream", "sse", "html", "raw"];
1647
+ this.activeRequests++;
1648
+ try {
1649
+ // Extract client IP
1650
+ const ip = extractClientIP(req, bunServer.requestIP(req)?.address);
1412
1651
 
1413
- // Check method based on handler type
1414
- if (req.method === "GET" && !getEnabledHandlers.includes(handlerType as string)) {
1415
- return new Response("Method Not Allowed", { status: 405 });
1652
+ // Handle SSE endpoint
1653
+ if (url.pathname === "/sse" && req.method === "GET") {
1654
+ return this.handleSSE(req, ip);
1416
1655
  }
1417
- if (req.method !== "GET" && req.method !== "POST") {
1418
- return new Response("Method Not Allowed", { status: 405 });
1656
+
1657
+ // Extract action from URL path (e.g., "auth.login")
1658
+ const actionName = url.pathname.slice(1);
1659
+
1660
+ const versionHeaderName = this.versioningConfig.headerName ?? "X-API-Version";
1661
+ const requestedApiVersion = req.headers.get(versionHeaderName);
1662
+
1663
+ // Handle versioning "error" mode
1664
+ if (requestedApiVersion === null && this.versionedRouteMap.has(actionName) &&
1665
+ !this.routeMap.has(actionName) &&
1666
+ this.versioningConfig.defaultBehavior === "error") {
1667
+ return Response.json(
1668
+ { error: "VERSION_REQUIRED", message: `${versionHeaderName} header is required` },
1669
+ { status: 400 }
1670
+ );
1419
1671
  }
1420
- const type = route.handler || "typed";
1421
1672
 
1422
- // First check core handlers
1423
- let handler = Handlers[type as keyof typeof Handlers];
1673
+ const resolvedRoute = this.resolveVersionedRoute(actionName, requestedApiVersion);
1674
+ const route = resolvedRoute?.route;
1675
+ const resolvedVersion = resolvedRoute?.resolvedVersion;
1676
+ const routeDeprecation = resolvedRoute?.deprecated;
1677
+ if (route) {
1678
+ const handlerType = route.handler || "typed";
1679
+
1680
+ // Handlers that accept GET requests (for browser compatibility)
1681
+ const getEnabledHandlers = ["stream", "sse", "html", "raw"];
1424
1682
 
1425
- // If not found, check plugin handlers
1426
- if (!handler) {
1427
- for (const config of this.manager.getPlugins()) {
1428
- if (config.handlers && config.handlers[type]) {
1429
- handler = config.handlers[type] as any;
1430
- break;
1683
+ // Check method based on handler type
1684
+ if (req.method === "GET" && !getEnabledHandlers.includes(handlerType as string)) {
1685
+ return new Response("Method Not Allowed", { status: 405 });
1686
+ }
1687
+ if (req.method !== "GET" && req.method !== "POST") {
1688
+ return new Response("Method Not Allowed", { status: 405 });
1689
+ }
1690
+ const type = route.handler || "typed";
1691
+
1692
+ // First check core handlers
1693
+ let handler = Handlers[type as keyof typeof Handlers];
1694
+
1695
+ // If not found, check plugin handlers
1696
+ if (!handler) {
1697
+ for (const config of this.manager.getPlugins()) {
1698
+ if (config.handlers && config.handlers[type]) {
1699
+ handler = config.handlers[type] as any;
1700
+ break;
1701
+ }
1431
1702
  }
1432
1703
  }
1433
- }
1434
1704
 
1435
- if (handler) {
1436
- // Build context with core services and IP
1437
- const ctx: ServerContext = {
1438
- db: this.coreServices.db,
1439
- plugins: this.manager.getServices(),
1440
- core: this.coreServices,
1441
- errors: this.coreServices.errors, // Convenience access
1442
- config: this.coreServices.config,
1443
- services: this.serviceRegistry,
1444
- ip,
1445
- requestId: crypto.randomUUID(),
1446
- signal: req.signal,
1447
- };
1448
-
1449
- // Get middleware stack for this route
1450
- const middlewareStack = route.middleware || [];
1451
-
1452
- // Final handler execution
1453
- const finalHandler = async () => {
1454
- return await handler.execute(req, route, route.handle as any, ctx);
1455
- };
1456
-
1457
- // Execute middleware chain, then handler - with HttpError handling
1458
- try {
1459
- if (middlewareStack.length > 0) {
1460
- return await this.executeMiddlewareChain(req, ctx, middlewareStack, finalHandler);
1461
- } else {
1462
- return await finalHandler();
1705
+ if (handler) {
1706
+ // Build context with core services and IP
1707
+ const requestId = crypto.randomUUID();
1708
+ const traceId = req.headers.get("x-request-id")
1709
+ ?? req.headers.get("x-trace-id")
1710
+ ?? requestId;
1711
+ const ctx: ServerContext = {
1712
+ db: this.coreServices.db,
1713
+ plugins: this.manager.getServices(),
1714
+ core: this.coreServices,
1715
+ errors: this.coreServices.errors, // Convenience access
1716
+ config: this.coreServices.config,
1717
+ services: this.serviceRegistry,
1718
+ ip,
1719
+ requestId,
1720
+ traceId,
1721
+ signal: req.signal,
1722
+ };
1723
+
1724
+ // Get middleware stack for this route
1725
+ const middlewareStack = route.middleware || [];
1726
+
1727
+ // Final handler execution
1728
+ const finalHandler = async () => {
1729
+ return await handler.execute(req, route, route.handle as any, ctx);
1730
+ };
1731
+
1732
+ // Execute middleware chain, then handler - with HttpError handling
1733
+ try {
1734
+ let response: Response;
1735
+ if (middlewareStack.length > 0) {
1736
+ response = await this.executeMiddlewareChain(req, ctx, middlewareStack, finalHandler);
1737
+ } else {
1738
+ response = await finalHandler();
1739
+ }
1740
+ // Add trace headers to response
1741
+ response.headers.set("X-Request-Id", ctx.requestId);
1742
+ response.headers.set("X-Trace-Id", ctx.traceId);
1743
+ // Add version headers
1744
+ this.addVersionHeaders(response, resolvedVersion, routeDeprecation);
1745
+ return response;
1746
+ } catch (error) {
1747
+ // Handle HttpError (thrown via ctx.errors.*)
1748
+ if (error instanceof HttpError) {
1749
+ logger.warn("HTTP error thrown", {
1750
+ route: actionName,
1751
+ status: error.status,
1752
+ code: error.code,
1753
+ message: error.message,
1754
+ });
1755
+ return Response.json(error.toJSON(), {
1756
+ status: error.status,
1757
+ headers: {
1758
+ "X-Request-Id": ctx.requestId,
1759
+ "X-Trace-Id": ctx.traceId,
1760
+ },
1761
+ });
1762
+ }
1763
+ // Re-throw unknown errors
1764
+ throw error;
1463
1765
  }
1464
- } catch (error) {
1465
- // Handle HttpError (thrown via ctx.errors.*)
1466
- if (error instanceof HttpError) {
1467
- logger.warn("HTTP error thrown", {
1468
- route: actionName,
1469
- status: error.status,
1470
- code: error.code,
1471
- message: error.message,
1472
- });
1473
- return Response.json(error.toJSON(), { status: error.status });
1474
- }
1475
- // Re-throw unknown errors
1476
- throw error;
1766
+ } else {
1767
+ logger.error("Handler not found", { handler: type, route: actionName });
1768
+ return new Response("Handler Not Found", { status: 500 });
1477
1769
  }
1478
- } else {
1479
- logger.error("Handler not found", { handler: type, route: actionName });
1480
- return new Response("Handler Not Found", { status: 500 });
1481
1770
  }
1482
- }
1483
1771
 
1484
- return new Response("Not Found", { status: 404 });
1772
+ return new Response("Not Found", { status: 404 });
1773
+ } finally {
1774
+ this.activeRequests--;
1775
+ }
1485
1776
  };
1486
1777
 
1487
1778
  // Try to start server, retrying with different ports if port is in use
@@ -1490,7 +1781,7 @@ ${factoryFunction}
1490
1781
 
1491
1782
  for (let attempt = 0; attempt < this.maxPortAttempts; attempt++) {
1492
1783
  try {
1493
- Bun.serve({
1784
+ this.server = Bun.serve({
1494
1785
  port: currentPort,
1495
1786
  fetch: fetchHandler,
1496
1787
  idleTimeout: 255, // Max value (255 seconds) for SSE/long-lived connections
@@ -1553,8 +1844,15 @@ ${factoryFunction}
1553
1844
  }
1554
1845
 
1555
1846
  /**
1556
- * Gracefully shutdown the server.
1557
- * Runs shutdown handlers, stops background services, and closes connections.
1847
+ * Gracefully shutdown the server in phases.
1848
+ * 1. Stop accepting new requests (draining)
1849
+ * 2. Wait for in-flight requests to complete
1850
+ * 3. Run user shutdown handlers (LIFO)
1851
+ * 4. Stop real-time services (SSE, WebSocket)
1852
+ * 5. Stop background services (events, processes, workflows, jobs, cron)
1853
+ * 6. Stop auxiliary services (logs, audit, storage)
1854
+ * 7. Close database
1855
+ *
1558
1856
  * Safe to call multiple times (idempotent).
1559
1857
  */
1560
1858
  async shutdown(): Promise<void> {
@@ -1562,34 +1860,77 @@ ${factoryFunction}
1562
1860
  this.isShuttingDown = true;
1563
1861
 
1564
1862
  const { logger } = this.coreServices;
1565
- logger.info("Shutting down server...");
1566
-
1567
- // Run user shutdown handlers first (in reverse order - LIFO)
1568
- await this.runShutdownHandlers();
1569
-
1570
- // Flush and stop logs before other services shut down
1571
- await this.coreServices.logs.flush();
1572
- this.coreServices.logs.stop();
1863
+ const shutdownConfig = this.options.shutdown ?? {};
1864
+ const totalTimeout = shutdownConfig.timeout ?? 30000;
1865
+ const drainTimeout = shutdownConfig.drainTimeout ?? 10000;
1866
+ const forceExit = shutdownConfig.forceExit !== false;
1573
1867
 
1574
- // Stop SSE (closes all client connections)
1575
- this.coreServices.sse.shutdown();
1576
-
1577
- // Stop WebSocket connections
1578
- this.coreServices.websocket.shutdown();
1579
-
1580
- // Stop background services
1581
- await this.coreServices.processes.shutdown();
1582
- await this.coreServices.workflows.stop();
1583
- await this.coreServices.jobs.stop();
1584
- await this.coreServices.cron.stop();
1868
+ logger.info("Shutting down server...");
1585
1869
 
1586
- // Stop audit service (cleanup timers)
1587
- this.coreServices.audit.stop();
1870
+ // Force exit timer - .unref() so it doesn't keep process alive
1871
+ let forceExitTimer: ReturnType<typeof setTimeout> | undefined;
1872
+ if (forceExit) {
1873
+ forceExitTimer = setTimeout(() => {
1874
+ logger.error("Shutdown timed out, forcing exit");
1875
+ process.exit(1);
1876
+ }, totalTimeout);
1877
+ forceExitTimer.unref();
1878
+ }
1588
1879
 
1589
- // Stop storage (cleanup connections)
1590
- this.coreServices.storage.stop();
1880
+ try {
1881
+ // Phase 1: Stop accepting new requests
1882
+ this.draining = true;
1883
+ this.server?.stop();
1884
+ logger.info("Phase 1: Stopped accepting new requests");
1885
+
1886
+ // Phase 2: Drain in-flight requests
1887
+ if (this.activeRequests > 0) {
1888
+ logger.info(`Phase 2: Draining ${this.activeRequests} in-flight request(s)...`);
1889
+ const drainStart = Date.now();
1890
+ while (this.activeRequests > 0 && Date.now() - drainStart < drainTimeout) {
1891
+ await new Promise(resolve => setTimeout(resolve, 50));
1892
+ }
1893
+ if (this.activeRequests > 0) {
1894
+ logger.warn(`Drain timeout reached with ${this.activeRequests} request(s) still active`);
1895
+ } else {
1896
+ logger.info("Phase 2: All requests drained");
1897
+ }
1898
+ }
1591
1899
 
1592
- logger.info("Server shutdown complete");
1900
+ // Phase 3: Run user shutdown handlers (LIFO)
1901
+ await this.runShutdownHandlers();
1902
+ logger.info("Phase 3: User shutdown handlers complete");
1903
+
1904
+ // Phase 4: Stop real-time services
1905
+ this.coreServices.sse.shutdown();
1906
+ this.coreServices.websocket.shutdown();
1907
+ logger.info("Phase 4: Real-time services stopped");
1908
+
1909
+ // Phase 5: Stop background services
1910
+ await this.coreServices.events.stop();
1911
+ await this.coreServices.processes.shutdown();
1912
+ await this.coreServices.workflows.stop();
1913
+ await this.coreServices.jobs.stop();
1914
+ await this.coreServices.cron.stop();
1915
+ logger.info("Phase 5: Background services stopped");
1916
+
1917
+ // Phase 6: Stop auxiliary services
1918
+ await this.coreServices.logs.flush();
1919
+ this.coreServices.logs.stop();
1920
+ this.coreServices.audit.stop();
1921
+ this.coreServices.storage.stop();
1922
+ logger.info("Phase 6: Auxiliary services stopped");
1923
+
1924
+ // Phase 7: Close database
1925
+ await this.coreServices.db.destroy();
1926
+ logger.info("Phase 7: Database closed");
1927
+
1928
+ logger.info("Server shutdown complete");
1929
+ } finally {
1930
+ if (forceExitTimer) {
1931
+ clearTimeout(forceExitTimer);
1932
+ }
1933
+ }
1593
1934
  }
1594
1935
 
1595
1936
  /**