@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/docs/cache.md +27 -34
- package/docs/code-organization.md +424 -0
- package/docs/project-structure.md +37 -26
- package/docs/rate-limiter.md +23 -28
- package/docs/swift-adapter.md +293 -0
- package/docs/versioning.md +351 -0
- package/package.json +6 -2
- package/src/client/base.ts +18 -5
- package/src/core/cache-adapter-redis.ts +113 -0
- package/src/core/events.ts +54 -7
- package/src/core/health.ts +165 -0
- package/src/core/index.ts +22 -0
- package/src/core/jobs.ts +11 -4
- package/src/core/rate-limit-adapter-redis.ts +109 -0
- package/src/core/subprocess-bootstrap.ts +3 -0
- 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 +458 -117
- 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,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 (
|
|
1092
|
-
|
|
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
|
-
|
|
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
|
|
1189
|
-
|
|
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
|
|
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
|
-
|
|
1462
|
+
response = await this.executeMiddlewareChain(req, ctx, middlewareStack, finalHandler);
|
|
1251
1463
|
} else {
|
|
1252
|
-
|
|
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:
|
|
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
|
|
1288
|
-
if (!
|
|
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
|
|
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,
|
|
1618
|
+
const fetchHandler = async (req: Request, bunServer: ReturnType<typeof Bun.serve>) => {
|
|
1393
1619
|
const url = new URL(req.url);
|
|
1394
1620
|
|
|
1395
|
-
//
|
|
1396
|
-
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";
|
|
1397
1624
|
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
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
|
+
});
|
|
1401
1630
|
}
|
|
1402
1631
|
|
|
1403
|
-
|
|
1404
|
-
|
|
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
|
-
|
|
1407
|
-
if (
|
|
1408
|
-
|
|
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
|
-
|
|
1411
|
-
|
|
1647
|
+
this.activeRequests++;
|
|
1648
|
+
try {
|
|
1649
|
+
// Extract client IP
|
|
1650
|
+
const ip = extractClientIP(req, bunServer.requestIP(req)?.address);
|
|
1412
1651
|
|
|
1413
|
-
//
|
|
1414
|
-
if (
|
|
1415
|
-
return
|
|
1652
|
+
// Handle SSE endpoint
|
|
1653
|
+
if (url.pathname === "/sse" && req.method === "GET") {
|
|
1654
|
+
return this.handleSSE(req, ip);
|
|
1416
1655
|
}
|
|
1417
|
-
|
|
1418
|
-
|
|
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
|
-
|
|
1423
|
-
|
|
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
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
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
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
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
|
-
}
|
|
1465
|
-
|
|
1466
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1587
|
-
|
|
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
|
-
|
|
1590
|
-
|
|
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
|
-
|
|
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
|
/**
|