@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/docs/code-organization.md +424 -0
- package/docs/project-structure.md +37 -26
- package/docs/swift-adapter.md +293 -0
- package/docs/versioning.md +351 -0
- package/package.json +1 -1
- package/src/client/base.ts +18 -5
- package/src/core/events.ts +54 -7
- package/src/core/health.ts +165 -0
- package/src/core/index.ts +12 -0
- package/src/core/jobs.ts +11 -4
- package/src/core/logs-adapter-kysely.ts +7 -0
- package/src/core/logs.ts +3 -0
- package/src/core/migrations/workflows/002_add_metadata_column.ts +41 -7
- package/src/core/processes.ts +7 -3
- package/src/core/subprocess-bootstrap.ts +3 -0
- package/src/core/watchdog-executor.ts +58 -20
- package/src/core/workflow-executor.ts +1 -0
- package/src/core/workflow-state-machine.ts +6 -5
- package/src/core/workflows.ts +3 -2
- package/src/core.ts +9 -1
- package/src/generator/index.ts +4 -0
- package/src/harness.ts +17 -5
- package/src/index.ts +21 -0
- package/src/router.ts +22 -2
- package/src/server.ts +469 -118
- package/src/versioning.ts +154 -0
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 (
|
|
1089
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1179
|
-
|
|
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
|
|
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
|
-
|
|
1462
|
+
response = await this.executeMiddlewareChain(req, ctx, middlewareStack, finalHandler);
|
|
1241
1463
|
} else {
|
|
1242
|
-
|
|
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:
|
|
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
|
|
1278
|
-
if (!
|
|
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
|
|
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,
|
|
1618
|
+
const fetchHandler = async (req: Request, bunServer: ReturnType<typeof Bun.serve>) => {
|
|
1383
1619
|
const url = new URL(req.url);
|
|
1384
1620
|
|
|
1385
|
-
//
|
|
1386
|
-
const
|
|
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
|
-
|
|
1389
|
-
|
|
1390
|
-
return
|
|
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
|
-
|
|
1394
|
-
|
|
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
|
-
|
|
1397
|
-
if (
|
|
1398
|
-
|
|
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
|
-
|
|
1401
|
-
|
|
1647
|
+
this.activeRequests++;
|
|
1648
|
+
try {
|
|
1649
|
+
// Extract client IP
|
|
1650
|
+
const ip = extractClientIP(req, bunServer.requestIP(req)?.address);
|
|
1402
1651
|
|
|
1403
|
-
//
|
|
1404
|
-
if (
|
|
1405
|
-
return
|
|
1652
|
+
// Handle SSE endpoint
|
|
1653
|
+
if (url.pathname === "/sse" && req.method === "GET") {
|
|
1654
|
+
return this.handleSSE(req, ip);
|
|
1406
1655
|
}
|
|
1407
|
-
|
|
1408
|
-
|
|
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
|
-
|
|
1413
|
-
|
|
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
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
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
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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
|
-
}
|
|
1455
|
-
|
|
1456
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1577
|
-
|
|
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
|
-
|
|
1580
|
-
|
|
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
|
-
|
|
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
|
/**
|