@donkeylabs/server 2.0.35 → 2.1.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,14 +299,15 @@ 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
 
266
307
  // Create logs service with its own database
267
308
  const logsAdapter = options.logs?.adapter ?? new KyselyLogsAdapter({
268
309
  dbPath: options.logs?.dbPath,
310
+ db: options.logs?.db,
269
311
  });
270
312
  const logs = createLogs({
271
313
  ...options.logs,
@@ -296,6 +338,7 @@ export class AppServer {
296
338
  // Create adapters - use Kysely by default, or legacy SQLite if requested
297
339
  const jobAdapter = options.jobs?.adapter ?? (useLegacy ? undefined : new KyselyJobAdapter(options.db));
298
340
  const workflowAdapter = options.workflows?.adapter ?? (useLegacy ? undefined : new KyselyWorkflowAdapter(options.db));
341
+ const processAdapter = options.processes?.adapter ?? (useLegacy ? undefined : new KyselyProcessAdapter(options.db));
299
342
  const auditAdapter = options.audit?.adapter ?? new KyselyAuditAdapter(options.db);
300
343
 
301
344
  // Jobs can emit events and use Kysely adapter, with logger for scoped logging
@@ -327,6 +370,7 @@ export class AppServer {
327
370
  const processes = createProcesses({
328
371
  ...options.processes,
329
372
  events,
373
+ adapter: processAdapter,
330
374
  useWatchdog: options.watchdog?.enabled ? true : options.processes?.useWatchdog,
331
375
  });
332
376
 
@@ -338,6 +382,19 @@ export class AppServer {
338
382
  const websocket = createWebSocket(options.websocket);
339
383
  const storage = createStorage(options.storage);
340
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
+
341
398
  this.coreServices = {
342
399
  db: options.db,
343
400
  config: options.config ?? {},
@@ -355,6 +412,7 @@ export class AppServer {
355
412
  websocket,
356
413
  storage,
357
414
  logs,
415
+ health,
358
416
  };
359
417
 
360
418
  // Resolve circular dependency: workflows needs core for step handlers
@@ -661,7 +719,105 @@ export class AppServer {
661
719
  * Check if a route name is registered.
662
720
  */
663
721
  hasRoute(routeName: string): boolean {
664
- 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
+ }
665
821
  }
666
822
 
667
823
  /**
@@ -684,12 +840,17 @@ export class AppServer {
684
840
  const routes = [];
685
841
 
686
842
  for (const router of this.routers) {
843
+ const routerVersion = router.getVersion();
844
+ const routerDeprecation = router.getDeprecation();
845
+
687
846
  for (const route of router.getRoutes()) {
688
847
  routes.push({
689
848
  name: route.name,
690
849
  handler: route.handler || "typed",
691
850
  inputType: route.input ? zodSchemaToTs(route.input) : undefined,
692
851
  outputType: route.output ? zodSchemaToTs(route.output) : undefined,
852
+ version: routerVersion,
853
+ deprecated: routerDeprecation ? true : undefined,
693
854
  });
694
855
  }
695
856
  }
@@ -721,11 +882,14 @@ export class AppServer {
721
882
  inputSource?: string;
722
883
  outputSource?: string;
723
884
  eventsSource?: Record<string, string>;
885
+ version?: string;
724
886
  }> = [];
725
887
 
726
888
  const routesWithoutOutput: string[] = [];
727
889
 
728
890
  for (const router of this.routers) {
891
+ const routerVersion = router.getVersion();
892
+
729
893
  for (const route of router.getRoutes()) {
730
894
  const parts = route.name.split(".");
731
895
  const routeName = parts[parts.length - 1] || route.name;
@@ -753,6 +917,7 @@ export class AppServer {
753
917
  inputSource: route.input ? zodSchemaToTs(route.input) : undefined,
754
918
  outputSource: route.output ? zodSchemaToTs(route.output) : undefined,
755
919
  eventsSource,
920
+ ...(routerVersion ? { version: routerVersion } : {}),
756
921
  });
757
922
  }
758
923
  }
@@ -791,6 +956,7 @@ export class AppServer {
791
956
  inputSource?: string;
792
957
  outputSource?: string;
793
958
  eventsSource?: Record<string, string>;
959
+ version?: string;
794
960
  }>
795
961
  ): string {
796
962
  const baseImport =
@@ -1084,14 +1250,45 @@ ${factoryFunction}
1084
1250
  logger.info("Background services started (cron, jobs, workflows, processes)");
1085
1251
 
1086
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
+
1087
1257
  for (const route of router.getRoutes()) {
1088
- if (this.routeMap.has(route.name)) {
1089
- 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);
1090
1275
  }
1091
- this.routeMap.set(route.name, route);
1092
1276
  }
1093
1277
  }
1094
- 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)`);
1095
1292
  logger.info("Server initialized (adapter mode)");
1096
1293
 
1097
1294
  // Initialize custom services, then run onReady handlers
@@ -1107,7 +1304,13 @@ ${factoryFunction}
1107
1304
  const services = this.watchdogConfig.services ?? ["workflows", "jobs", "processes"];
1108
1305
  const workflowsDbPath = this.coreServices.workflows.getDbPath?.();
1109
1306
  const jobsDbPath = (this.options.jobs?.dbPath ?? workflowsDbPath ?? ".donkeylabs/jobs.db") as string;
1110
- const processesDbPath = (this.options.processes?.adapter?.path ?? ".donkeylabs/processes.db") as string;
1307
+ const adapterConfig = this.options.processes?.adapter;
1308
+ let processesDbPath: string | undefined;
1309
+ if (adapterConfig && typeof adapterConfig === "object" && "path" in adapterConfig) {
1310
+ processesDbPath = (adapterConfig as any).path ?? ".donkeylabs/processes.db";
1311
+ } else if (!adapterConfig && !this.options.database) {
1312
+ processesDbPath = ".donkeylabs/processes.db";
1313
+ }
1111
1314
 
1112
1315
  const config = {
1113
1316
  intervalMs: this.watchdogConfig.intervalMs ?? 5000,
@@ -1123,6 +1326,7 @@ ${factoryFunction}
1123
1326
  jobs: jobsDbPath ? { dbPath: jobsDbPath } : undefined,
1124
1327
  processes: processesDbPath ? { dbPath: processesDbPath } : undefined,
1125
1328
  sqlitePragmas: this.options.workflows?.sqlitePragmas,
1329
+ database: this.options.database,
1126
1330
  };
1127
1331
 
1128
1332
  try {
@@ -1175,11 +1379,23 @@ ${factoryFunction}
1175
1379
  const { logger } = this.coreServices;
1176
1380
  const corsHeaders = options?.corsHeaders ?? {};
1177
1381
 
1178
- const route = this.routeMap.get(routeName);
1179
- 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
+ }
1180
1394
  return null;
1181
1395
  }
1182
1396
 
1397
+ const { route, resolvedVersion, deprecated } = resolved;
1398
+
1183
1399
  const type = route.handler || "typed";
1184
1400
 
1185
1401
  // First check core handlers
@@ -1204,6 +1420,10 @@ ${factoryFunction}
1204
1420
  }
1205
1421
 
1206
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;
1207
1427
  const ctx: ServerContext = {
1208
1428
  db: this.coreServices.db,
1209
1429
  plugins: this.manager.getServices(),
@@ -1212,7 +1432,8 @@ ${factoryFunction}
1212
1432
  config: this.coreServices.config,
1213
1433
  services: this.serviceRegistry,
1214
1434
  ip,
1215
- requestId: crypto.randomUUID(),
1435
+ requestId,
1436
+ traceId,
1216
1437
  signal: req.signal,
1217
1438
  };
1218
1439
 
@@ -1236,11 +1457,18 @@ ${factoryFunction}
1236
1457
  };
1237
1458
 
1238
1459
  try {
1460
+ let response: Response;
1239
1461
  if (middlewareStack.length > 0) {
1240
- return await this.executeMiddlewareChain(req, ctx, middlewareStack, finalHandler);
1462
+ response = await this.executeMiddlewareChain(req, ctx, middlewareStack, finalHandler);
1241
1463
  } else {
1242
- return await finalHandler();
1464
+ response = await finalHandler();
1243
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;
1244
1472
  } catch (error) {
1245
1473
  if (error instanceof HttpError) {
1246
1474
  logger.warn("HTTP error thrown", {
@@ -1251,7 +1479,11 @@ ${factoryFunction}
1251
1479
  });
1252
1480
  return Response.json(error.toJSON(), {
1253
1481
  status: error.status,
1254
- headers: corsHeaders,
1482
+ headers: {
1483
+ ...corsHeaders,
1484
+ "X-Request-Id": ctx.requestId,
1485
+ "X-Trace-Id": ctx.traceId,
1486
+ },
1255
1487
  });
1256
1488
  }
1257
1489
  throw error;
@@ -1270,16 +1502,19 @@ ${factoryFunction}
1270
1502
  async callRoute<TOutput = any>(
1271
1503
  routeName: string,
1272
1504
  input: any,
1273
- ip: string = "127.0.0.1"
1505
+ ip: string = "127.0.0.1",
1506
+ options?: { version?: string }
1274
1507
  ): Promise<TOutput> {
1275
1508
  const { logger } = this.coreServices;
1276
1509
 
1277
- const route = this.routeMap.get(routeName);
1278
- if (!route) {
1510
+ const resolved = this.resolveVersionedRoute(routeName, options?.version);
1511
+ if (!resolved) {
1279
1512
  throw new Error(`Route "${routeName}" not found`);
1280
1513
  }
1514
+ const { route } = resolved;
1281
1515
 
1282
1516
  // Build context
1517
+ const requestId = crypto.randomUUID();
1283
1518
  const ctx: ServerContext = {
1284
1519
  db: this.coreServices.db,
1285
1520
  plugins: this.manager.getServices(),
@@ -1288,7 +1523,8 @@ ${factoryFunction}
1288
1523
  config: this.coreServices.config,
1289
1524
  services: this.serviceRegistry,
1290
1525
  ip,
1291
- requestId: crypto.randomUUID(),
1526
+ requestId,
1527
+ traceId: requestId,
1292
1528
  };
1293
1529
 
1294
1530
  // Validate input if schema exists
@@ -1379,99 +1615,164 @@ ${factoryFunction}
1379
1615
  const { logger } = this.coreServices;
1380
1616
 
1381
1617
  // 5. Start HTTP server with port retry logic
1382
- const fetchHandler = async (req: Request, server: ReturnType<typeof Bun.serve>) => {
1618
+ const fetchHandler = async (req: Request, bunServer: ReturnType<typeof Bun.serve>) => {
1383
1619
  const url = new URL(req.url);
1384
1620
 
1385
- // Extract client IP
1386
- 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";
1387
1624
 
1388
- // Handle SSE endpoint
1389
- if (url.pathname === "/sse" && req.method === "GET") {
1390
- 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
+ });
1391
1630
  }
1392
1631
 
1393
- // Extract action from URL path (e.g., "auth.login")
1394
- 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
+ }
1395
1638
 
1396
- const route = this.routeMap.get(actionName);
1397
- if (route) {
1398
- 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
+ }
1399
1646
 
1400
- // Handlers that accept GET requests (for browser compatibility)
1401
- const getEnabledHandlers = ["stream", "sse", "html", "raw"];
1647
+ this.activeRequests++;
1648
+ try {
1649
+ // Extract client IP
1650
+ const ip = extractClientIP(req, bunServer.requestIP(req)?.address);
1402
1651
 
1403
- // Check method based on handler type
1404
- if (req.method === "GET" && !getEnabledHandlers.includes(handlerType as string)) {
1405
- 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);
1406
1655
  }
1407
- if (req.method !== "GET" && req.method !== "POST") {
1408
- 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
+ );
1409
1671
  }
1410
- const type = route.handler || "typed";
1411
1672
 
1412
- // First check core handlers
1413
- 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"];
1414
1682
 
1415
- // If not found, check plugin handlers
1416
- if (!handler) {
1417
- for (const config of this.manager.getPlugins()) {
1418
- if (config.handlers && config.handlers[type]) {
1419
- handler = config.handlers[type] as any;
1420
- 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
+ }
1421
1702
  }
1422
1703
  }
1423
- }
1424
1704
 
1425
- if (handler) {
1426
- // Build context with core services and IP
1427
- const ctx: ServerContext = {
1428
- db: this.coreServices.db,
1429
- plugins: this.manager.getServices(),
1430
- core: this.coreServices,
1431
- errors: this.coreServices.errors, // Convenience access
1432
- config: this.coreServices.config,
1433
- services: this.serviceRegistry,
1434
- ip,
1435
- requestId: crypto.randomUUID(),
1436
- signal: req.signal,
1437
- };
1438
-
1439
- // Get middleware stack for this route
1440
- const middlewareStack = route.middleware || [];
1441
-
1442
- // Final handler execution
1443
- const finalHandler = async () => {
1444
- return await handler.execute(req, route, route.handle as any, ctx);
1445
- };
1446
-
1447
- // Execute middleware chain, then handler - with HttpError handling
1448
- try {
1449
- if (middlewareStack.length > 0) {
1450
- return await this.executeMiddlewareChain(req, ctx, middlewareStack, finalHandler);
1451
- } else {
1452
- 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;
1453
1765
  }
1454
- } catch (error) {
1455
- // Handle HttpError (thrown via ctx.errors.*)
1456
- if (error instanceof HttpError) {
1457
- logger.warn("HTTP error thrown", {
1458
- route: actionName,
1459
- status: error.status,
1460
- code: error.code,
1461
- message: error.message,
1462
- });
1463
- return Response.json(error.toJSON(), { status: error.status });
1464
- }
1465
- // Re-throw unknown errors
1466
- throw error;
1766
+ } else {
1767
+ logger.error("Handler not found", { handler: type, route: actionName });
1768
+ return new Response("Handler Not Found", { status: 500 });
1467
1769
  }
1468
- } else {
1469
- logger.error("Handler not found", { handler: type, route: actionName });
1470
- return new Response("Handler Not Found", { status: 500 });
1471
1770
  }
1472
- }
1473
1771
 
1474
- return new Response("Not Found", { status: 404 });
1772
+ return new Response("Not Found", { status: 404 });
1773
+ } finally {
1774
+ this.activeRequests--;
1775
+ }
1475
1776
  };
1476
1777
 
1477
1778
  // Try to start server, retrying with different ports if port is in use
@@ -1480,7 +1781,7 @@ ${factoryFunction}
1480
1781
 
1481
1782
  for (let attempt = 0; attempt < this.maxPortAttempts; attempt++) {
1482
1783
  try {
1483
- Bun.serve({
1784
+ this.server = Bun.serve({
1484
1785
  port: currentPort,
1485
1786
  fetch: fetchHandler,
1486
1787
  idleTimeout: 255, // Max value (255 seconds) for SSE/long-lived connections
@@ -1543,8 +1844,15 @@ ${factoryFunction}
1543
1844
  }
1544
1845
 
1545
1846
  /**
1546
- * Gracefully shutdown the server.
1547
- * 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
+ *
1548
1856
  * Safe to call multiple times (idempotent).
1549
1857
  */
1550
1858
  async shutdown(): Promise<void> {
@@ -1552,34 +1860,77 @@ ${factoryFunction}
1552
1860
  this.isShuttingDown = true;
1553
1861
 
1554
1862
  const { logger } = this.coreServices;
1555
- logger.info("Shutting down server...");
1556
-
1557
- // Run user shutdown handlers first (in reverse order - LIFO)
1558
- await this.runShutdownHandlers();
1559
-
1560
- // Flush and stop logs before other services shut down
1561
- await this.coreServices.logs.flush();
1562
- 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;
1563
1867
 
1564
- // Stop SSE (closes all client connections)
1565
- this.coreServices.sse.shutdown();
1566
-
1567
- // Stop WebSocket connections
1568
- this.coreServices.websocket.shutdown();
1569
-
1570
- // Stop background services
1571
- await this.coreServices.processes.shutdown();
1572
- await this.coreServices.workflows.stop();
1573
- await this.coreServices.jobs.stop();
1574
- await this.coreServices.cron.stop();
1868
+ logger.info("Shutting down server...");
1575
1869
 
1576
- // Stop audit service (cleanup timers)
1577
- 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
+ }
1578
1879
 
1579
- // Stop storage (cleanup connections)
1580
- 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
+ }
1581
1899
 
1582
- 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
+ }
1583
1934
  }
1584
1935
 
1585
1936
  /**