@donkeylabs/server 0.3.0 → 0.4.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/LICENSE +1 -1
- package/docs/api-client.md +7 -7
- package/docs/cache.md +1 -74
- package/docs/core-services.md +4 -116
- package/docs/cron.md +1 -1
- package/docs/errors.md +2 -2
- package/docs/events.md +3 -98
- package/docs/handlers.md +13 -48
- package/docs/logger.md +3 -58
- package/docs/middleware.md +2 -2
- package/docs/plugins.md +13 -64
- package/docs/project-structure.md +4 -142
- package/docs/rate-limiter.md +4 -136
- package/docs/router.md +6 -14
- package/docs/sse.md +1 -99
- package/docs/sveltekit-adapter.md +420 -0
- package/package.json +8 -11
- package/registry.d.ts +15 -14
- package/src/core/cache.ts +0 -75
- package/src/core/cron.ts +3 -96
- package/src/core/errors.ts +78 -11
- package/src/core/events.ts +1 -47
- package/src/core/index.ts +0 -4
- package/src/core/jobs.ts +0 -112
- package/src/core/logger.ts +12 -79
- package/src/core/rate-limiter.ts +29 -108
- package/src/core/sse.ts +1 -84
- package/src/core.ts +13 -104
- package/src/generator/index.ts +566 -0
- package/src/generator/zod-to-ts.ts +114 -0
- package/src/handlers.ts +14 -110
- package/src/index.ts +30 -24
- package/src/middleware.ts +2 -5
- package/src/registry.ts +4 -0
- package/src/router.ts +47 -1
- package/src/server.ts +618 -332
- package/README.md +0 -254
- package/cli/commands/dev.ts +0 -134
- package/cli/commands/generate.ts +0 -605
- package/cli/commands/init.ts +0 -205
- package/cli/commands/interactive.ts +0 -417
- package/cli/commands/plugin.ts +0 -192
- package/cli/commands/route.ts +0 -195
- package/cli/donkeylabs +0 -2
- package/cli/index.ts +0 -114
- package/docs/svelte-frontend.md +0 -324
- package/docs/testing.md +0 -438
- package/mcp/donkeylabs-mcp +0 -3238
- package/mcp/server.ts +0 -3238
package/src/server.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
2
4
|
import { PluginManager, type CoreServices, type ConfiguredPlugin } from "./core";
|
|
3
|
-
import {
|
|
4
|
-
import { Handlers
|
|
5
|
+
import { type IRouter, type RouteDefinition, type ServerContext } from "./router";
|
|
6
|
+
import { Handlers } from "./handlers";
|
|
5
7
|
import type { MiddlewareRuntime, MiddlewareDefinition } from "./middleware";
|
|
6
8
|
import {
|
|
7
9
|
createLogger,
|
|
@@ -22,14 +24,31 @@ import {
|
|
|
22
24
|
type SSEConfig,
|
|
23
25
|
type RateLimiterConfig,
|
|
24
26
|
type ErrorsConfig,
|
|
25
|
-
type JobDefinition,
|
|
26
|
-
type CronTaskDefinition,
|
|
27
27
|
} from "./core/index";
|
|
28
|
+
import { zodSchemaToTs } from "./generator/zod-to-ts";
|
|
29
|
+
|
|
30
|
+
export interface TypeGenerationConfig {
|
|
31
|
+
/** Output path for generated client types (e.g., "./src/lib/api.ts") */
|
|
32
|
+
output: string;
|
|
33
|
+
/** Custom base import for the client */
|
|
34
|
+
baseImport?: string;
|
|
35
|
+
/** Custom base class name */
|
|
36
|
+
baseClass?: string;
|
|
37
|
+
/** Constructor signature (e.g., "baseUrl: string, options?: ApiClientOptions") */
|
|
38
|
+
constructorSignature?: string;
|
|
39
|
+
/** Constructor body (e.g., "super(baseUrl, options);") */
|
|
40
|
+
constructorBody?: string;
|
|
41
|
+
/** Factory function code (optional, replaces default createApi) */
|
|
42
|
+
factoryFunction?: string;
|
|
43
|
+
}
|
|
28
44
|
|
|
29
45
|
export interface ServerConfig {
|
|
30
46
|
port?: number;
|
|
31
47
|
db: CoreServices["db"];
|
|
32
48
|
config?: Record<string, any>;
|
|
49
|
+
/** Auto-generate client types on startup in dev mode */
|
|
50
|
+
generateTypes?: TypeGenerationConfig;
|
|
51
|
+
// Core service configurations
|
|
33
52
|
logger?: LoggerConfig;
|
|
34
53
|
cache?: CacheConfig;
|
|
35
54
|
events?: EventsConfig;
|
|
@@ -40,54 +59,23 @@ export interface ServerConfig {
|
|
|
40
59
|
errors?: ErrorsConfig;
|
|
41
60
|
}
|
|
42
61
|
|
|
43
|
-
export interface ServerInspection {
|
|
44
|
-
port: number;
|
|
45
|
-
plugins: Array<{
|
|
46
|
-
name: string;
|
|
47
|
-
version?: string;
|
|
48
|
-
dependencies: string[];
|
|
49
|
-
hasHandlers: boolean;
|
|
50
|
-
hasMiddleware: boolean;
|
|
51
|
-
hasEvents: boolean;
|
|
52
|
-
hasSseChannels: boolean;
|
|
53
|
-
hasJobs: boolean;
|
|
54
|
-
hasCronTasks: boolean;
|
|
55
|
-
hasCustomErrors: boolean;
|
|
56
|
-
}>;
|
|
57
|
-
routes: Array<{ name: string; handler: string }>;
|
|
58
|
-
handlers: string[];
|
|
59
|
-
events: string[];
|
|
60
|
-
sseChannels: string[];
|
|
61
|
-
jobs: Array<{ name: string; pluginName?: string; description?: string }>;
|
|
62
|
-
cronTasks: Array<{
|
|
63
|
-
id: string;
|
|
64
|
-
name: string;
|
|
65
|
-
expression: string;
|
|
66
|
-
enabled: boolean;
|
|
67
|
-
pluginName?: string;
|
|
68
|
-
description?: string;
|
|
69
|
-
lastRun?: Date;
|
|
70
|
-
nextRun?: Date;
|
|
71
|
-
}>;
|
|
72
|
-
rateLimitRules: Array<{ pattern: string; limit: number; window: string | number }>;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
62
|
export class AppServer {
|
|
76
63
|
private port: number;
|
|
77
64
|
private manager: PluginManager;
|
|
78
65
|
private routers: IRouter[] = [];
|
|
79
66
|
private routeMap: Map<string, RouteDefinition> = new Map();
|
|
80
|
-
private customHandlers: Map<string, HandlerRuntime<any>> = new Map();
|
|
81
67
|
private coreServices: CoreServices;
|
|
68
|
+
private typeGenConfig?: TypeGenerationConfig;
|
|
82
69
|
|
|
83
70
|
constructor(options: ServerConfig) {
|
|
84
71
|
this.port = options.port ?? 3000;
|
|
85
72
|
|
|
73
|
+
// Initialize core services
|
|
86
74
|
const logger = createLogger(options.logger);
|
|
87
75
|
const cache = createCache(options.cache);
|
|
88
76
|
const events = createEvents(options.events);
|
|
89
77
|
const cron = createCron(options.cron);
|
|
90
|
-
const jobs = createJobs({ ...options.jobs, events });
|
|
78
|
+
const jobs = createJobs({ ...options.jobs, events }); // Jobs can emit events
|
|
91
79
|
const sse = createSSE(options.sse);
|
|
92
80
|
const rateLimiter = createRateLimiter(options.rateLimiter);
|
|
93
81
|
const errors = createErrors(options.errors);
|
|
@@ -106,306 +94,580 @@ export class AppServer {
|
|
|
106
94
|
};
|
|
107
95
|
|
|
108
96
|
this.manager = new PluginManager(this.coreServices);
|
|
97
|
+
this.typeGenConfig = options.generateTypes;
|
|
109
98
|
}
|
|
110
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Register a plugin.
|
|
102
|
+
* For plugins with config, call the plugin factory first: registerPlugin(authPlugin({ key: "..." }))
|
|
103
|
+
* Plugins are initialized in dependency order when start() is called.
|
|
104
|
+
*/
|
|
111
105
|
registerPlugin(plugin: ConfiguredPlugin): this {
|
|
112
106
|
this.manager.register(plugin);
|
|
113
107
|
return this;
|
|
114
108
|
}
|
|
115
109
|
|
|
116
110
|
/**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* @example
|
|
120
|
-
* server.registerHandler("echo", EchoHandler);
|
|
121
|
-
* // Then in routes:
|
|
122
|
-
* router.route("test").echo({ handle: ... });
|
|
111
|
+
* Add a router to handle RPC routes.
|
|
123
112
|
*/
|
|
124
|
-
|
|
125
|
-
this.
|
|
113
|
+
use(router: IRouter): this {
|
|
114
|
+
this.routers.push(router);
|
|
126
115
|
return this;
|
|
127
116
|
}
|
|
128
117
|
|
|
129
118
|
/**
|
|
130
|
-
*
|
|
131
|
-
* Events must be registered before they can be emitted.
|
|
132
|
-
*
|
|
133
|
-
* @example
|
|
134
|
-
* server.registerEvent("user.created", z.object({ userId: z.string() }));
|
|
119
|
+
* Get plugin services (for testing or advanced use cases).
|
|
135
120
|
*/
|
|
136
|
-
|
|
137
|
-
this.
|
|
138
|
-
return this;
|
|
121
|
+
getServices(): any {
|
|
122
|
+
return this.manager.getServices();
|
|
139
123
|
}
|
|
140
124
|
|
|
141
125
|
/**
|
|
142
|
-
*
|
|
126
|
+
* Get the database instance.
|
|
143
127
|
*/
|
|
144
|
-
|
|
145
|
-
this.
|
|
146
|
-
return this;
|
|
128
|
+
getDb(): CoreServices["db"] {
|
|
129
|
+
return this.manager.getCore().db;
|
|
147
130
|
}
|
|
148
131
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
this.coreServices.sse.registerChannel(name, config);
|
|
160
|
-
return this;
|
|
132
|
+
// Resolve middleware runtime from plugins
|
|
133
|
+
private resolveMiddleware(name: string): MiddlewareRuntime<any> | undefined {
|
|
134
|
+
for (const plugin of this.manager.getPlugins()) {
|
|
135
|
+
// Middleware is resolved and stored in _resolvedMiddleware during plugin init
|
|
136
|
+
const resolved = (plugin as any)._resolvedMiddleware as Record<string, MiddlewareRuntime<any>> | undefined;
|
|
137
|
+
if (resolved && resolved[name]) {
|
|
138
|
+
return resolved[name];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
161
142
|
}
|
|
162
143
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
144
|
+
// Execute middleware chain, then call final handler
|
|
145
|
+
private async executeMiddlewareChain(
|
|
146
|
+
req: Request,
|
|
147
|
+
ctx: ServerContext,
|
|
148
|
+
stack: MiddlewareDefinition[],
|
|
149
|
+
finalHandler: () => Promise<Response>
|
|
150
|
+
): Promise<Response> {
|
|
151
|
+
// Build chain from end to start (last middleware wraps first)
|
|
152
|
+
let next = finalHandler;
|
|
170
153
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
return
|
|
154
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
155
|
+
const mwDef = stack[i];
|
|
156
|
+
if (!mwDef) continue;
|
|
157
|
+
|
|
158
|
+
const mwRuntime = this.resolveMiddleware(mwDef.name);
|
|
159
|
+
|
|
160
|
+
if (!mwRuntime) {
|
|
161
|
+
console.warn(`[Server] Middleware '${mwDef.name}' not found, skipping`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const currentNext = next;
|
|
166
|
+
const config = mwDef.config;
|
|
167
|
+
next = () => mwRuntime.execute(req, ctx, currentNext, config);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return next();
|
|
188
171
|
}
|
|
189
172
|
|
|
190
173
|
/**
|
|
191
|
-
*
|
|
174
|
+
* Get core services (for advanced use cases).
|
|
192
175
|
*/
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
window: string | number;
|
|
196
|
-
keyFn?: (ctx: { ip: string; route: string; user?: any }) => string;
|
|
197
|
-
skip?: (ctx: { ip: string; route: string; user?: any }) => boolean;
|
|
198
|
-
message?: string;
|
|
199
|
-
}>): this {
|
|
200
|
-
this.coreServices.rateLimiter.registerRules(rules);
|
|
201
|
-
return this;
|
|
176
|
+
getCore(): CoreServices {
|
|
177
|
+
return this.coreServices;
|
|
202
178
|
}
|
|
203
179
|
|
|
204
180
|
/**
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
* @example
|
|
208
|
-
* server.registerJob("email.send", {
|
|
209
|
-
* schema: z.object({ to: z.string().email(), subject: z.string() }),
|
|
210
|
-
* handler: async (data) => { await sendEmail(data); }
|
|
211
|
-
* });
|
|
181
|
+
* Get the internal route map for adapter introspection.
|
|
212
182
|
*/
|
|
213
|
-
|
|
214
|
-
this.
|
|
215
|
-
return this;
|
|
183
|
+
getRouteMap(): Map<string, RouteDefinition> {
|
|
184
|
+
return this.routeMap;
|
|
216
185
|
}
|
|
217
186
|
|
|
218
187
|
/**
|
|
219
|
-
*
|
|
188
|
+
* Check if a route name is registered.
|
|
220
189
|
*/
|
|
221
|
-
|
|
222
|
-
this.
|
|
223
|
-
return this;
|
|
190
|
+
hasRoute(routeName: string): boolean {
|
|
191
|
+
return this.routeMap.has(routeName);
|
|
224
192
|
}
|
|
225
193
|
|
|
226
194
|
/**
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
* @example
|
|
230
|
-
* server.registerCronTask("cleanup.daily", {
|
|
231
|
-
* expression: "0 0 * * *",
|
|
232
|
-
* handler: async () => { await cleanupOldRecords(); },
|
|
233
|
-
* description: "Clean up old records daily at midnight"
|
|
234
|
-
* });
|
|
195
|
+
* Generate client types from registered routes.
|
|
196
|
+
* Called automatically on startup in dev mode if generateTypes config is provided.
|
|
235
197
|
*/
|
|
236
|
-
|
|
237
|
-
this.
|
|
238
|
-
|
|
198
|
+
private async generateTypes(): Promise<void> {
|
|
199
|
+
if (!this.typeGenConfig) return;
|
|
200
|
+
|
|
201
|
+
const { logger } = this.coreServices;
|
|
202
|
+
const isDev = process.env.NODE_ENV !== "production";
|
|
203
|
+
|
|
204
|
+
if (!isDev) {
|
|
205
|
+
logger.debug("Skipping type generation in production mode");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Collect all route metadata
|
|
210
|
+
const routes: Array<{
|
|
211
|
+
name: string;
|
|
212
|
+
prefix: string;
|
|
213
|
+
routeName: string;
|
|
214
|
+
handler: "typed" | "raw";
|
|
215
|
+
inputSource?: string;
|
|
216
|
+
outputSource?: string;
|
|
217
|
+
}> = [];
|
|
218
|
+
|
|
219
|
+
for (const router of this.routers) {
|
|
220
|
+
for (const route of router.getRoutes()) {
|
|
221
|
+
const parts = route.name.split(".");
|
|
222
|
+
const routeName = parts[parts.length - 1] || route.name;
|
|
223
|
+
const prefix = parts.slice(0, -1).join(".");
|
|
224
|
+
|
|
225
|
+
routes.push({
|
|
226
|
+
name: route.name,
|
|
227
|
+
prefix,
|
|
228
|
+
routeName,
|
|
229
|
+
handler: (route.handler || "typed") as "typed" | "raw",
|
|
230
|
+
inputSource: route.input ? zodSchemaToTs(route.input) : undefined,
|
|
231
|
+
outputSource: route.output ? zodSchemaToTs(route.output) : undefined,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Generate the client code
|
|
237
|
+
const code = this.generateClientCode(routes);
|
|
238
|
+
|
|
239
|
+
// Write to output file
|
|
240
|
+
const outputDir = dirname(this.typeGenConfig.output);
|
|
241
|
+
await mkdir(outputDir, { recursive: true });
|
|
242
|
+
await writeFile(this.typeGenConfig.output, code);
|
|
243
|
+
|
|
244
|
+
logger.info(`Generated API client types`, { output: this.typeGenConfig.output, routes: routes.length });
|
|
239
245
|
}
|
|
240
246
|
|
|
241
247
|
/**
|
|
242
|
-
*
|
|
248
|
+
* Generate client code from route metadata.
|
|
243
249
|
*/
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
250
|
+
private generateClientCode(
|
|
251
|
+
routes: Array<{
|
|
252
|
+
name: string;
|
|
253
|
+
prefix: string;
|
|
254
|
+
routeName: string;
|
|
255
|
+
handler: "typed" | "raw";
|
|
256
|
+
inputSource?: string;
|
|
257
|
+
outputSource?: string;
|
|
258
|
+
}>
|
|
259
|
+
): string {
|
|
260
|
+
const baseImport =
|
|
261
|
+
this.typeGenConfig?.baseImport ??
|
|
262
|
+
'import { UnifiedApiClientBase, type ClientOptions } from "@donkeylabs/adapter-sveltekit/client";';
|
|
263
|
+
const baseClass = this.typeGenConfig?.baseClass ?? "UnifiedApiClientBase";
|
|
264
|
+
const constructorSignature =
|
|
265
|
+
this.typeGenConfig?.constructorSignature ?? "options?: ClientOptions";
|
|
266
|
+
const constructorBody =
|
|
267
|
+
this.typeGenConfig?.constructorBody ?? "super(options);";
|
|
268
|
+
const defaultFactory = `/**
|
|
269
|
+
* Create an API client instance
|
|
270
|
+
*/
|
|
271
|
+
export function createApi(options?: ClientOptions) {
|
|
272
|
+
return new ApiClient(options);
|
|
273
|
+
}`;
|
|
274
|
+
const factoryFunction = this.typeGenConfig?.factoryFunction ?? defaultFactory;
|
|
275
|
+
|
|
276
|
+
// Helper functions
|
|
277
|
+
const toPascalCase = (str: string): string =>
|
|
278
|
+
str
|
|
279
|
+
.split(/[._-]/)
|
|
280
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
281
|
+
.join("");
|
|
282
|
+
|
|
283
|
+
const toCamelCase = (str: string): string => {
|
|
284
|
+
const pascal = toPascalCase(str);
|
|
285
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// Common prefix stripping is disabled to respect explicit router nesting (e.g. api.health)
|
|
289
|
+
const routesToProcess = routes;
|
|
290
|
+
const commonPrefix = "";
|
|
291
|
+
|
|
292
|
+
// Build recursive tree for nested routes
|
|
293
|
+
type RouteNode = {
|
|
294
|
+
children: Map<string, RouteNode>;
|
|
295
|
+
routes: typeof routes;
|
|
296
|
+
};
|
|
297
|
+
const rootNode: RouteNode = { children: new Map(), routes: [] };
|
|
298
|
+
|
|
299
|
+
for (const route of routesToProcess) {
|
|
300
|
+
const parts = route.name.split(".");
|
|
301
|
+
let currentNode = rootNode;
|
|
302
|
+
// Navigate/Build tree
|
|
303
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
304
|
+
const part = parts[i]!;
|
|
305
|
+
if (!currentNode.children.has(part)) {
|
|
306
|
+
currentNode.children.set(part, { children: new Map(), routes: [] });
|
|
307
|
+
}
|
|
308
|
+
currentNode = currentNode.children.get(part)!;
|
|
309
|
+
}
|
|
310
|
+
// Add route to the leaf node (last part is the method name)
|
|
311
|
+
currentNode.routes.push({
|
|
312
|
+
...route,
|
|
313
|
+
routeName: parts[parts.length - 1]! // precise method name
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Recursive function to generate Type definitions
|
|
318
|
+
function generateTypeBlock(node: RouteNode, indent: string): string {
|
|
319
|
+
const blocks: string[] = [];
|
|
320
|
+
|
|
321
|
+
// 1. Valid Input/Output types for routes at this level
|
|
322
|
+
if (node.routes.length > 0) {
|
|
323
|
+
const routeTypes = node.routes.map(r => {
|
|
324
|
+
if (r.handler !== "typed") return "";
|
|
325
|
+
const routeNs = toPascalCase(r.routeName);
|
|
326
|
+
const inputType = r.inputSource ?? "Record<string, never>";
|
|
327
|
+
const outputType = r.outputSource ?? "unknown";
|
|
328
|
+
return `${indent}export namespace ${routeNs} {
|
|
329
|
+
${indent} export type Input = Expand<${inputType}>;
|
|
330
|
+
${indent} export type Output = Expand<${outputType}>;
|
|
331
|
+
${indent}}
|
|
332
|
+
${indent}export type ${routeNs} = { Input: ${routeNs}.Input; Output: ${routeNs}.Output };`;
|
|
333
|
+
}).filter(Boolean);
|
|
334
|
+
if (routeTypes.length) blocks.push(routeTypes.join("\n\n"));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 2. Nested namespaces
|
|
338
|
+
for (const [name, child] of node.children) {
|
|
339
|
+
const nsName = toPascalCase(name);
|
|
340
|
+
blocks.push(`${indent}export namespace ${nsName} {\n${generateTypeBlock(child, indent + " ")}\n${indent}}`);
|
|
341
|
+
}
|
|
342
|
+
return blocks.join("\n\n");
|
|
343
|
+
}
|
|
248
344
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
this
|
|
254
|
-
|
|
345
|
+
// Recursive function to generate Client Methods
|
|
346
|
+
function generateMethodBlock(node: RouteNode, indent: string, parentPath: string, isTopLevel: boolean): string {
|
|
347
|
+
const blocks: string[] = [];
|
|
348
|
+
|
|
349
|
+
// 1. Methods at this level
|
|
350
|
+
const methods = node.routes.map(r => {
|
|
351
|
+
const methodName = toCamelCase(r.routeName);
|
|
352
|
+
// r.name is the full path e.g. "api.v1.users.get"
|
|
353
|
+
|
|
354
|
+
if (r.handler === "typed") {
|
|
355
|
+
const pathParts = r.name.split(".");
|
|
356
|
+
const typePath = ["Routes", ...pathParts.slice(0, -1).map(toPascalCase), toPascalCase(r.routeName)];
|
|
357
|
+
const inputType = typePath.join(".") + ".Input";
|
|
358
|
+
const outputType = typePath.join(".") + ".Output";
|
|
359
|
+
|
|
360
|
+
return `${indent}${methodName}: (input: ${inputType}): Promise<${outputType}> => this.request("${r.name}", input)`;
|
|
361
|
+
} else {
|
|
362
|
+
return `${indent}${methodName}: (init?: RequestInit): Promise<Response> => this.rawRequest("${r.name}", init)`;
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
if (methods.length) blocks.push(methods.join(",\n"));
|
|
366
|
+
|
|
367
|
+
// 2. Nested Objects
|
|
368
|
+
for (const [name, child] of node.children) {
|
|
369
|
+
const camelName = toCamelCase(name);
|
|
370
|
+
const separator = isTopLevel ? " = " : ": ";
|
|
371
|
+
const terminator = isTopLevel ? ";" : "";
|
|
372
|
+
// For top level, we output `name = { ... };`
|
|
373
|
+
// For nested, we output `name: { ... }` (comma handled by join)
|
|
374
|
+
|
|
375
|
+
blocks.push(`${indent}${camelName}${separator}{\n${generateMethodBlock(child, indent + " ", "", false)}\n${indent}}${terminator}`);
|
|
376
|
+
}
|
|
377
|
+
// Top level blocks are separated by nothing (class members). Nested by comma.
|
|
378
|
+
// Wait, blocks.join needs care.
|
|
379
|
+
// If isTopLevel, join with "\n\n". If nested, join with ",\n".
|
|
380
|
+
return blocks.join(isTopLevel ? "\n\n" : ",\n");
|
|
255
381
|
}
|
|
256
|
-
this.routers.push(prefixOrRouter);
|
|
257
|
-
return prefixOrRouter;
|
|
258
|
-
}
|
|
259
382
|
|
|
260
|
-
|
|
261
|
-
|
|
383
|
+
const typeBlocks: string[] = [generateTypeBlock(rootNode, " ")];
|
|
384
|
+
// rootNode children are top-level namespaces (api, health) -> Top Level Class Properties
|
|
385
|
+
const methodBlocks: string[] = [generateMethodBlock(rootNode, " ", "", true)];
|
|
386
|
+
|
|
387
|
+
return `// Auto-generated by @donkeylabs/server
|
|
388
|
+
// DO NOT EDIT MANUALLY
|
|
389
|
+
|
|
390
|
+
${baseImport}
|
|
391
|
+
|
|
392
|
+
// Utility type that forces TypeScript to expand types on hover
|
|
393
|
+
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Handler interface for implementing route handlers in model classes.
|
|
397
|
+
* @example
|
|
398
|
+
* class CounterModel implements Handler<Routes.Counter.get> {
|
|
399
|
+
* handle(input: Routes.Counter.get.Input): Routes.Counter.get.Output {
|
|
400
|
+
* return { count: 0 };
|
|
401
|
+
* }
|
|
402
|
+
* }
|
|
403
|
+
*/
|
|
404
|
+
export interface Handler<T extends { Input: any; Output: any }> {
|
|
405
|
+
handle(input: T["Input"]): T["Output"] | Promise<T["Output"]>;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Re-export server context for model classes
|
|
409
|
+
export { type ServerContext as AppContext } from "@donkeylabs/server";
|
|
410
|
+
|
|
411
|
+
// ============================================
|
|
412
|
+
// Route Types
|
|
413
|
+
// ============================================
|
|
414
|
+
|
|
415
|
+
export namespace Routes {
|
|
416
|
+
${typeBlocks.join("\n\n") || " // No typed routes found"}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ============================================
|
|
420
|
+
// API Client
|
|
421
|
+
// ============================================
|
|
422
|
+
|
|
423
|
+
export class ApiClient extends ${baseClass} {
|
|
424
|
+
constructor(${constructorSignature}) {
|
|
425
|
+
${constructorBody}
|
|
262
426
|
}
|
|
263
427
|
|
|
264
|
-
|
|
265
|
-
|
|
428
|
+
${methodBlocks.join("\n\n") || " // No routes defined"}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
${factoryFunction}
|
|
432
|
+
`;
|
|
266
433
|
}
|
|
267
434
|
|
|
268
435
|
/**
|
|
269
|
-
*
|
|
270
|
-
*
|
|
436
|
+
* Initialize server without starting HTTP server.
|
|
437
|
+
* Used by adapters (e.g., SvelteKit) that manage their own HTTP server.
|
|
271
438
|
*/
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
name: p.name,
|
|
275
|
-
version: p.version,
|
|
276
|
-
dependencies: p.dependencies ? [...p.dependencies] : [],
|
|
277
|
-
hasHandlers: !!p.handlers && Object.keys(p.handlers).length > 0,
|
|
278
|
-
hasMiddleware: !!p.middleware,
|
|
279
|
-
hasEvents: !!p.events && Object.keys(p.events).length > 0,
|
|
280
|
-
hasSseChannels: !!p.sseChannels && Object.keys(p.sseChannels).length > 0,
|
|
281
|
-
hasJobs: !!p.jobs && Object.keys(p.jobs).length > 0,
|
|
282
|
-
hasCronTasks: !!p.cronTasks && Object.keys(p.cronTasks).length > 0,
|
|
283
|
-
hasCustomErrors: !!p.customErrors && Object.keys(p.customErrors).length > 0,
|
|
284
|
-
}));
|
|
285
|
-
|
|
286
|
-
const routes = this.getRoutes();
|
|
287
|
-
|
|
288
|
-
const handlers = [
|
|
289
|
-
...Object.keys(Handlers),
|
|
290
|
-
...Array.from(this.customHandlers.keys()),
|
|
291
|
-
...this.manager.getPlugins().flatMap(p =>
|
|
292
|
-
p.handlers ? Object.keys(p.handlers) : []
|
|
293
|
-
),
|
|
294
|
-
];
|
|
295
|
-
|
|
296
|
-
const events = this.coreServices.events.listRegistered();
|
|
297
|
-
|
|
298
|
-
const sseChannels = this.coreServices.sse.listChannels
|
|
299
|
-
? this.coreServices.sse.listChannels()
|
|
300
|
-
: [];
|
|
301
|
-
|
|
302
|
-
const jobs = this.coreServices.jobs.listRegisteredJobs();
|
|
303
|
-
|
|
304
|
-
const cronTasks = this.coreServices.cron.list();
|
|
305
|
-
|
|
306
|
-
const rateLimitRules = this.coreServices.rateLimiter.listRules
|
|
307
|
-
? this.coreServices.rateLimiter.listRules()
|
|
308
|
-
: [];
|
|
309
|
-
|
|
310
|
-
return {
|
|
311
|
-
port: this.port,
|
|
312
|
-
plugins,
|
|
313
|
-
routes,
|
|
314
|
-
handlers: [...new Set(handlers)],
|
|
315
|
-
events,
|
|
316
|
-
sseChannels,
|
|
317
|
-
jobs: jobs.map(j => ({
|
|
318
|
-
name: j.name,
|
|
319
|
-
pluginName: j.pluginName,
|
|
320
|
-
description: j.description,
|
|
321
|
-
})),
|
|
322
|
-
cronTasks: cronTasks.map(t => ({
|
|
323
|
-
id: t.id,
|
|
324
|
-
name: t.name,
|
|
325
|
-
expression: t.expression,
|
|
326
|
-
enabled: t.enabled,
|
|
327
|
-
pluginName: t.pluginName,
|
|
328
|
-
description: t.description,
|
|
329
|
-
lastRun: t.lastRun,
|
|
330
|
-
nextRun: t.nextRun,
|
|
331
|
-
})),
|
|
332
|
-
rateLimitRules,
|
|
333
|
-
};
|
|
334
|
-
}
|
|
439
|
+
async initialize(): Promise<void> {
|
|
440
|
+
const { logger } = this.coreServices;
|
|
335
441
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
442
|
+
// Auto-generate types in dev mode if configured
|
|
443
|
+
await this.generateTypes();
|
|
444
|
+
|
|
445
|
+
await this.manager.migrate();
|
|
446
|
+
await this.manager.init();
|
|
447
|
+
|
|
448
|
+
this.coreServices.cron.start();
|
|
449
|
+
this.coreServices.jobs.start();
|
|
450
|
+
logger.info("Background services started (cron, jobs)");
|
|
451
|
+
|
|
452
|
+
for (const router of this.routers) {
|
|
453
|
+
for (const route of router.getRoutes()) {
|
|
454
|
+
if (this.routeMap.has(route.name)) {
|
|
455
|
+
logger.warn(`Duplicate route detected`, { route: route.name });
|
|
456
|
+
}
|
|
457
|
+
this.routeMap.set(route.name, route);
|
|
342
458
|
}
|
|
343
459
|
}
|
|
344
|
-
|
|
460
|
+
logger.info(`Loaded ${this.routeMap.size} RPC routes`);
|
|
461
|
+
logger.info("Server initialized (adapter mode)");
|
|
345
462
|
}
|
|
346
463
|
|
|
347
|
-
|
|
464
|
+
/**
|
|
465
|
+
* Handle a single API request. Used by adapters.
|
|
466
|
+
* Returns null if the route is not found.
|
|
467
|
+
*/
|
|
468
|
+
async handleRequest(
|
|
348
469
|
req: Request,
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
): Promise<Response> {
|
|
353
|
-
|
|
470
|
+
routeName: string,
|
|
471
|
+
ip: string,
|
|
472
|
+
options?: { corsHeaders?: Record<string, string> }
|
|
473
|
+
): Promise<Response | null> {
|
|
474
|
+
const { logger } = this.coreServices;
|
|
475
|
+
const corsHeaders = options?.corsHeaders ?? {};
|
|
354
476
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
477
|
+
const route = this.routeMap.get(routeName);
|
|
478
|
+
if (!route) {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
358
481
|
|
|
359
|
-
|
|
482
|
+
const type = route.handler || "typed";
|
|
360
483
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
484
|
+
// First check core handlers
|
|
485
|
+
let handler = Handlers[type as keyof typeof Handlers];
|
|
486
|
+
|
|
487
|
+
// If not found, check plugin handlers
|
|
488
|
+
if (!handler) {
|
|
489
|
+
for (const config of this.manager.getPlugins()) {
|
|
490
|
+
if (config.handlers && config.handlers[type]) {
|
|
491
|
+
handler = config.handlers[type] as any;
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
364
494
|
}
|
|
495
|
+
}
|
|
365
496
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
497
|
+
if (!handler) {
|
|
498
|
+
logger.error("Handler not found", { handler: type, route: routeName });
|
|
499
|
+
return Response.json(
|
|
500
|
+
{ error: "HANDLER_NOT_FOUND", message: "Handler not found" },
|
|
501
|
+
{ status: 500, headers: corsHeaders }
|
|
502
|
+
);
|
|
369
503
|
}
|
|
370
504
|
|
|
371
|
-
|
|
372
|
-
|
|
505
|
+
// Build context
|
|
506
|
+
const ctx: ServerContext = {
|
|
507
|
+
db: this.coreServices.db,
|
|
508
|
+
plugins: this.manager.getServices(),
|
|
509
|
+
core: this.coreServices,
|
|
510
|
+
errors: this.coreServices.errors,
|
|
511
|
+
config: this.coreServices.config,
|
|
512
|
+
ip,
|
|
513
|
+
requestId: crypto.randomUUID(),
|
|
514
|
+
};
|
|
373
515
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
516
|
+
// Get middleware stack
|
|
517
|
+
const middlewareStack = route.middleware || [];
|
|
518
|
+
|
|
519
|
+
// Final handler
|
|
520
|
+
const finalHandler = async () => {
|
|
521
|
+
const response = await handler.execute(req, route, route.handle, ctx);
|
|
522
|
+
// Add CORS headers if provided
|
|
523
|
+
if (Object.keys(corsHeaders).length > 0 && response instanceof Response) {
|
|
524
|
+
const newHeaders = new Headers(response.headers);
|
|
525
|
+
Object.entries(corsHeaders).forEach(([k, v]) => newHeaders.set(k, v));
|
|
526
|
+
return new Response(response.body, {
|
|
527
|
+
status: response.status,
|
|
528
|
+
statusText: response.statusText,
|
|
529
|
+
headers: newHeaders,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
return response;
|
|
533
|
+
};
|
|
377
534
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
535
|
+
try {
|
|
536
|
+
if (middlewareStack.length > 0) {
|
|
537
|
+
return await this.executeMiddlewareChain(req, ctx, middlewareStack, finalHandler);
|
|
538
|
+
} else {
|
|
539
|
+
return await finalHandler();
|
|
540
|
+
}
|
|
541
|
+
} catch (error) {
|
|
542
|
+
if (error instanceof HttpError) {
|
|
543
|
+
logger.warn("HTTP error thrown", {
|
|
544
|
+
route: routeName,
|
|
545
|
+
status: error.status,
|
|
546
|
+
code: error.code,
|
|
547
|
+
message: error.message,
|
|
548
|
+
});
|
|
549
|
+
return Response.json(error.toJSON(), {
|
|
550
|
+
status: error.status,
|
|
551
|
+
headers: corsHeaders,
|
|
386
552
|
});
|
|
387
553
|
}
|
|
554
|
+
throw error;
|
|
388
555
|
}
|
|
389
|
-
return routes;
|
|
390
556
|
}
|
|
391
557
|
|
|
392
|
-
|
|
558
|
+
/**
|
|
559
|
+
* Call a route directly without HTTP (for SSR).
|
|
560
|
+
* This bypasses the HTTP layer and calls the route handler directly.
|
|
561
|
+
*
|
|
562
|
+
* @param routeName - The route name (e.g., "api.counter.get")
|
|
563
|
+
* @param input - The input data for the route
|
|
564
|
+
* @param ip - Client IP address (optional, defaults to "127.0.0.1")
|
|
565
|
+
* @returns The route handler result
|
|
566
|
+
*/
|
|
567
|
+
async callRoute<TOutput = any>(
|
|
568
|
+
routeName: string,
|
|
569
|
+
input: any,
|
|
570
|
+
ip: string = "127.0.0.1"
|
|
571
|
+
): Promise<TOutput> {
|
|
393
572
|
const { logger } = this.coreServices;
|
|
394
573
|
|
|
395
|
-
|
|
396
|
-
if (
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
574
|
+
const route = this.routeMap.get(routeName);
|
|
575
|
+
if (!route) {
|
|
576
|
+
throw new Error(`Route "${routeName}" not found`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Build context
|
|
580
|
+
const ctx: ServerContext = {
|
|
581
|
+
db: this.coreServices.db,
|
|
582
|
+
plugins: this.manager.getServices(),
|
|
583
|
+
core: this.coreServices,
|
|
584
|
+
errors: this.coreServices.errors,
|
|
585
|
+
config: this.coreServices.config,
|
|
586
|
+
ip,
|
|
587
|
+
requestId: crypto.randomUUID(),
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
// Validate input if schema exists
|
|
591
|
+
if (route.input) {
|
|
592
|
+
const result = route.input.safeParse(input);
|
|
593
|
+
if (!result.success) {
|
|
594
|
+
throw new HttpError(400, "VALIDATION_ERROR", result.error.message);
|
|
595
|
+
}
|
|
596
|
+
input = result.data;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Execute through middleware chain if present
|
|
600
|
+
const middlewareStack = route.middleware || [];
|
|
601
|
+
|
|
602
|
+
const finalHandler = async () => {
|
|
603
|
+
return route.handle(input, ctx);
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
try {
|
|
607
|
+
if (middlewareStack.length > 0) {
|
|
608
|
+
// Create a fake request for middleware compatibility
|
|
609
|
+
const fakeReq = new Request("http://localhost/" + routeName, {
|
|
610
|
+
method: "POST",
|
|
611
|
+
body: JSON.stringify(input),
|
|
612
|
+
});
|
|
613
|
+
const response = await this.executeMiddlewareChain(
|
|
614
|
+
fakeReq,
|
|
615
|
+
ctx,
|
|
616
|
+
middlewareStack,
|
|
617
|
+
async () => {
|
|
618
|
+
const result = await finalHandler();
|
|
619
|
+
// Return as Response for middleware chain, we'll extract later
|
|
620
|
+
return Response.json(result);
|
|
621
|
+
}
|
|
622
|
+
);
|
|
623
|
+
// Extract result from Response
|
|
624
|
+
if (response instanceof Response) {
|
|
625
|
+
return response.json();
|
|
626
|
+
}
|
|
627
|
+
return response;
|
|
628
|
+
} else {
|
|
629
|
+
return await finalHandler();
|
|
630
|
+
}
|
|
631
|
+
} catch (error) {
|
|
632
|
+
if (error instanceof HttpError) {
|
|
633
|
+
logger.warn("Route error (SSR)", {
|
|
634
|
+
route: routeName,
|
|
635
|
+
status: error.status,
|
|
636
|
+
code: error.code,
|
|
637
|
+
});
|
|
638
|
+
// Re-throw as a proper error for SSR
|
|
639
|
+
throw error;
|
|
640
|
+
}
|
|
641
|
+
throw error;
|
|
400
642
|
}
|
|
643
|
+
}
|
|
401
644
|
|
|
645
|
+
/**
|
|
646
|
+
* Start the server.
|
|
647
|
+
* This will:
|
|
648
|
+
* 1. Run all plugin migrations
|
|
649
|
+
* 2. Initialize all plugins in dependency order
|
|
650
|
+
* 3. Start cron and jobs services
|
|
651
|
+
* 4. Start the HTTP server
|
|
652
|
+
*/
|
|
653
|
+
async start() {
|
|
654
|
+
const { logger } = this.coreServices;
|
|
655
|
+
|
|
656
|
+
// Auto-generate types in dev mode if configured
|
|
657
|
+
await this.generateTypes();
|
|
658
|
+
|
|
659
|
+
// 1. Run migrations
|
|
402
660
|
await this.manager.migrate();
|
|
661
|
+
|
|
662
|
+
// 2. Initialize plugins
|
|
403
663
|
await this.manager.init();
|
|
404
664
|
|
|
665
|
+
// 3. Start background services
|
|
405
666
|
this.coreServices.cron.start();
|
|
406
667
|
this.coreServices.jobs.start();
|
|
407
668
|
logger.info("Background services started (cron, jobs)");
|
|
408
669
|
|
|
670
|
+
// 4. Build route map
|
|
409
671
|
for (const router of this.routers) {
|
|
410
672
|
for (const route of router.getRoutes()) {
|
|
411
673
|
if (this.routeMap.has(route.name)) {
|
|
@@ -416,109 +678,133 @@ export class AppServer {
|
|
|
416
678
|
}
|
|
417
679
|
logger.info(`Loaded ${this.routeMap.size} RPC routes`);
|
|
418
680
|
|
|
681
|
+
// 5. Start HTTP server
|
|
419
682
|
Bun.serve({
|
|
420
683
|
port: this.port,
|
|
421
684
|
fetch: async (req, server) => {
|
|
422
685
|
const url = new URL(req.url);
|
|
686
|
+
|
|
687
|
+
// Extract client IP
|
|
423
688
|
const ip = extractClientIP(req, server.requestIP(req)?.address);
|
|
424
689
|
|
|
425
|
-
|
|
426
|
-
|
|
690
|
+
// Handle SSE endpoint
|
|
691
|
+
if (url.pathname === "/sse" && req.method === "GET") {
|
|
692
|
+
return this.handleSSE(req, ip);
|
|
427
693
|
}
|
|
428
694
|
|
|
429
|
-
|
|
430
|
-
|
|
695
|
+
// We only allow POST for RPC routes
|
|
696
|
+
if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
|
|
431
697
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
698
|
+
// Extract action from URL path (e.g., "auth.login")
|
|
699
|
+
const actionName = url.pathname.slice(1);
|
|
435
700
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
"X-RateLimit-Limit": String(rateLimitResult.limit),
|
|
451
|
-
"X-RateLimit-Remaining": String(rateLimitResult.remaining),
|
|
452
|
-
"X-RateLimit-Reset": rateLimitResult.resetAt.toISOString(),
|
|
453
|
-
},
|
|
701
|
+
const route = this.routeMap.get(actionName);
|
|
702
|
+
if (route) {
|
|
703
|
+
const type = route.handler || "typed";
|
|
704
|
+
|
|
705
|
+
// First check core handlers
|
|
706
|
+
let handler = Handlers[type as keyof typeof Handlers];
|
|
707
|
+
|
|
708
|
+
// If not found, check plugin handlers
|
|
709
|
+
if (!handler) {
|
|
710
|
+
for (const config of this.manager.getPlugins()) {
|
|
711
|
+
if (config.handlers && config.handlers[type]) {
|
|
712
|
+
handler = config.handlers[type] as any;
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
454
715
|
}
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
const type = route.handler || "typed";
|
|
459
|
-
let handler: HandlerRuntime<any> | undefined = Handlers[type as keyof typeof Handlers];
|
|
460
|
-
|
|
461
|
-
// Check user-registered handlers
|
|
462
|
-
if (!handler) {
|
|
463
|
-
handler = this.customHandlers.get(type);
|
|
464
|
-
}
|
|
716
|
+
}
|
|
465
717
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
718
|
+
if (handler) {
|
|
719
|
+
// Build context with core services and IP
|
|
720
|
+
const ctx: ServerContext = {
|
|
721
|
+
db: this.coreServices.db,
|
|
722
|
+
plugins: this.manager.getServices(),
|
|
723
|
+
core: this.coreServices,
|
|
724
|
+
errors: this.coreServices.errors, // Convenience access
|
|
725
|
+
config: this.coreServices.config,
|
|
726
|
+
ip,
|
|
727
|
+
requestId: crypto.randomUUID(),
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
// Get middleware stack for this route
|
|
731
|
+
const middlewareStack = route.middleware || [];
|
|
732
|
+
|
|
733
|
+
// Final handler execution
|
|
734
|
+
const finalHandler = async () => {
|
|
735
|
+
return await handler.execute(req, route, route.handle, ctx);
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
// Execute middleware chain, then handler - with HttpError handling
|
|
739
|
+
try {
|
|
740
|
+
if (middlewareStack.length > 0) {
|
|
741
|
+
return await this.executeMiddlewareChain(req, ctx, middlewareStack, finalHandler);
|
|
742
|
+
} else {
|
|
743
|
+
return await finalHandler();
|
|
744
|
+
}
|
|
745
|
+
} catch (error) {
|
|
746
|
+
// Handle HttpError (thrown via ctx.errors.*)
|
|
747
|
+
if (error instanceof HttpError) {
|
|
748
|
+
logger.warn("HTTP error thrown", {
|
|
749
|
+
route: actionName,
|
|
750
|
+
status: error.status,
|
|
751
|
+
code: error.code,
|
|
752
|
+
message: error.message,
|
|
753
|
+
});
|
|
754
|
+
return Response.json(error.toJSON(), { status: error.status });
|
|
755
|
+
}
|
|
756
|
+
// Re-throw unknown errors
|
|
757
|
+
throw error;
|
|
472
758
|
}
|
|
759
|
+
} else {
|
|
760
|
+
logger.error("Handler not found", { handler: type, route: actionName });
|
|
761
|
+
return new Response("Handler Not Found", { status: 500 });
|
|
473
762
|
}
|
|
474
763
|
}
|
|
475
764
|
|
|
476
|
-
|
|
477
|
-
logger.error("Handler not found", { handler: type, route: actionName });
|
|
478
|
-
return new Response("Handler Not Found", { status: 500 });
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
const ctx: ServerContext = {
|
|
482
|
-
db: this.coreServices.db,
|
|
483
|
-
plugins: this.manager.getServices(),
|
|
484
|
-
core: this.coreServices,
|
|
485
|
-
errors: this.coreServices.errors,
|
|
486
|
-
config: this.coreServices.config,
|
|
487
|
-
ip,
|
|
488
|
-
requestId: crypto.randomUUID(),
|
|
489
|
-
};
|
|
490
|
-
|
|
491
|
-
const routeMiddleware = route.middleware || [];
|
|
492
|
-
const routeHandler = async () => handler.execute(req, route, route.handle, ctx);
|
|
493
|
-
|
|
494
|
-
try {
|
|
495
|
-
if (routeMiddleware.length > 0) {
|
|
496
|
-
return await this.executeMiddlewareChain(req, ctx, routeMiddleware, routeHandler);
|
|
497
|
-
}
|
|
498
|
-
return await routeHandler();
|
|
499
|
-
} catch (error) {
|
|
500
|
-
if (error instanceof HttpError) {
|
|
501
|
-
logger.warn("HTTP error thrown", {
|
|
502
|
-
route: actionName,
|
|
503
|
-
status: error.status,
|
|
504
|
-
code: error.code,
|
|
505
|
-
message: error.message,
|
|
506
|
-
});
|
|
507
|
-
return Response.json(error.toJSON(), { status: error.status });
|
|
508
|
-
}
|
|
509
|
-
throw error;
|
|
510
|
-
}
|
|
765
|
+
return new Response("Not Found", { status: 404 });
|
|
511
766
|
}
|
|
512
767
|
});
|
|
513
768
|
|
|
514
769
|
logger.info(`Server running at http://localhost:${this.port}`);
|
|
515
770
|
}
|
|
516
771
|
|
|
517
|
-
|
|
772
|
+
/**
|
|
773
|
+
* Handle SSE (Server-Sent Events) connections.
|
|
774
|
+
* Used by both standalone server and adapters.
|
|
775
|
+
*/
|
|
776
|
+
handleSSE(req: Request, ip: string): Response {
|
|
777
|
+
const url = new URL(req.url);
|
|
778
|
+
const channels = url.searchParams.get("channels")?.split(",").filter(Boolean) || [];
|
|
779
|
+
const lastEventId = req.headers.get("last-event-id") || undefined;
|
|
780
|
+
|
|
781
|
+
const { client, response } = this.coreServices.sse.addClient({ lastEventId });
|
|
782
|
+
|
|
783
|
+
// Subscribe to requested channels
|
|
784
|
+
for (const channel of channels) {
|
|
785
|
+
this.coreServices.sse.subscribe(client.id, channel);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Clean up when connection closes
|
|
789
|
+
req.signal.addEventListener("abort", () => {
|
|
790
|
+
this.coreServices.sse.removeClient(client.id);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
return response;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Gracefully shutdown the server.
|
|
798
|
+
* Stops background services and closes SSE connections.
|
|
799
|
+
*/
|
|
800
|
+
async shutdown() {
|
|
518
801
|
const { logger } = this.coreServices;
|
|
519
802
|
logger.info("Shutting down server...");
|
|
520
803
|
|
|
804
|
+
// Stop SSE (closes all client connections)
|
|
521
805
|
this.coreServices.sse.shutdown();
|
|
806
|
+
|
|
807
|
+
// Stop background services
|
|
522
808
|
await this.coreServices.jobs.stop();
|
|
523
809
|
await this.coreServices.cron.stop();
|
|
524
810
|
|