@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.
Files changed (49) hide show
  1. package/LICENSE +1 -1
  2. package/docs/api-client.md +7 -7
  3. package/docs/cache.md +1 -74
  4. package/docs/core-services.md +4 -116
  5. package/docs/cron.md +1 -1
  6. package/docs/errors.md +2 -2
  7. package/docs/events.md +3 -98
  8. package/docs/handlers.md +13 -48
  9. package/docs/logger.md +3 -58
  10. package/docs/middleware.md +2 -2
  11. package/docs/plugins.md +13 -64
  12. package/docs/project-structure.md +4 -142
  13. package/docs/rate-limiter.md +4 -136
  14. package/docs/router.md +6 -14
  15. package/docs/sse.md +1 -99
  16. package/docs/sveltekit-adapter.md +420 -0
  17. package/package.json +8 -11
  18. package/registry.d.ts +15 -14
  19. package/src/core/cache.ts +0 -75
  20. package/src/core/cron.ts +3 -96
  21. package/src/core/errors.ts +78 -11
  22. package/src/core/events.ts +1 -47
  23. package/src/core/index.ts +0 -4
  24. package/src/core/jobs.ts +0 -112
  25. package/src/core/logger.ts +12 -79
  26. package/src/core/rate-limiter.ts +29 -108
  27. package/src/core/sse.ts +1 -84
  28. package/src/core.ts +13 -104
  29. package/src/generator/index.ts +566 -0
  30. package/src/generator/zod-to-ts.ts +114 -0
  31. package/src/handlers.ts +14 -110
  32. package/src/index.ts +30 -24
  33. package/src/middleware.ts +2 -5
  34. package/src/registry.ts +4 -0
  35. package/src/router.ts +47 -1
  36. package/src/server.ts +618 -332
  37. package/README.md +0 -254
  38. package/cli/commands/dev.ts +0 -134
  39. package/cli/commands/generate.ts +0 -605
  40. package/cli/commands/init.ts +0 -205
  41. package/cli/commands/interactive.ts +0 -417
  42. package/cli/commands/plugin.ts +0 -192
  43. package/cli/commands/route.ts +0 -195
  44. package/cli/donkeylabs +0 -2
  45. package/cli/index.ts +0 -114
  46. package/docs/svelte-frontend.md +0 -324
  47. package/docs/testing.md +0 -438
  48. package/mcp/donkeylabs-mcp +0 -3238
  49. 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 { Router as RouterImpl, type IRouter, type RouteDefinition, type ServerContext } from "./router";
4
- import { Handlers, type HandlerRuntime } from "./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
- * Register a custom handler for use in routes.
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
- registerHandler(name: string, handler: HandlerRuntime<any>): this {
125
- this.customHandlers.set(name, handler);
113
+ use(router: IRouter): this {
114
+ this.routers.push(router);
126
115
  return this;
127
116
  }
128
117
 
129
118
  /**
130
- * Register an event schema for validation.
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
- registerEvent<T>(name: string, schema: z.ZodType<T>): this {
137
- this.coreServices.events.register(name, schema);
138
- return this;
121
+ getServices(): any {
122
+ return this.manager.getServices();
139
123
  }
140
124
 
141
125
  /**
142
- * Register multiple event schemas.
126
+ * Get the database instance.
143
127
  */
144
- registerEvents(schemas: Record<string, z.ZodType<any>>): this {
145
- this.coreServices.events.registerMany(schemas);
146
- return this;
128
+ getDb(): CoreServices["db"] {
129
+ return this.manager.getCore().db;
147
130
  }
148
131
 
149
- /**
150
- * Register an SSE channel with optional event schemas.
151
- * Channels must be registered before broadcasting.
152
- *
153
- * @example
154
- * server.registerSSEChannel("notifications", {
155
- * events: { "new": z.object({ id: z.string(), message: z.string() }) }
156
- * });
157
- */
158
- registerSSEChannel(name: string, config?: { events?: Record<string, z.ZodType<any>>; pattern?: boolean }): this {
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
- * Register multiple SSE channels.
165
- */
166
- registerSSEChannels(channels: Record<string, { events?: Record<string, z.ZodType<any>>; pattern?: boolean }>): this {
167
- this.coreServices.sse.registerChannels(channels);
168
- return this;
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
- * Register a rate limit rule for a route pattern.
173
- * Rules are auto-enforced in the request handler.
174
- *
175
- * @example
176
- * server.registerRateLimit("auth.login", { limit: 5, window: "15m" });
177
- * server.registerRateLimit("api.*", { limit: 100, window: "1m" });
178
- */
179
- registerRateLimit(pattern: string, rule: {
180
- limit: number;
181
- window: string | number;
182
- keyFn?: (ctx: { ip: string; route: string; user?: any }) => string;
183
- skip?: (ctx: { ip: string; route: string; user?: any }) => boolean;
184
- message?: string;
185
- }): this {
186
- this.coreServices.rateLimiter.registerRule(pattern, rule);
187
- return this;
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
- * Register multiple rate limit rules.
174
+ * Get core services (for advanced use cases).
192
175
  */
193
- registerRateLimits(rules: Record<string, {
194
- limit: number;
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
- * Register a job with schema validation.
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
- registerJob<T = any, R = any>(name: string, definition: JobDefinition<T, R>): this {
214
- this.coreServices.jobs.registerJob(name, definition);
215
- return this;
183
+ getRouteMap(): Map<string, RouteDefinition> {
184
+ return this.routeMap;
216
185
  }
217
186
 
218
187
  /**
219
- * Register multiple jobs at once.
188
+ * Check if a route name is registered.
220
189
  */
221
- registerJobs(jobs: Record<string, JobDefinition>): this {
222
- this.coreServices.jobs.registerJobs(jobs);
223
- return this;
190
+ hasRoute(routeName: string): boolean {
191
+ return this.routeMap.has(routeName);
224
192
  }
225
193
 
226
194
  /**
227
- * Register a cron task.
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
- registerCronTask(name: string, definition: CronTaskDefinition): this {
237
- this.coreServices.cron.registerTask(name, definition);
238
- return this;
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
- * Register multiple cron tasks at once.
248
+ * Generate client code from route metadata.
243
249
  */
244
- registerCronTasks(tasks: Record<string, CronTaskDefinition>): this {
245
- this.coreServices.cron.registerTasks(tasks);
246
- return this;
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
- /** Create a new router with prefix or register an existing router */
250
- router(prefixOrRouter: string | IRouter): IRouter {
251
- if (typeof prefixOrRouter === "string") {
252
- const newRouter = new RouterImpl(prefixOrRouter);
253
- this.routers.push(newRouter);
254
- return newRouter;
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
- getServices(): any {
261
- return this.manager.getServices();
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
- getDb(): CoreServices["db"] {
265
- return this.manager.getCore().db;
428
+ ${methodBlocks.join("\n\n") || " // No routes defined"}
429
+ }
430
+
431
+ ${factoryFunction}
432
+ `;
266
433
  }
267
434
 
268
435
  /**
269
- * Introspect all registered services, handlers, routes, and plugins.
270
- * Useful for debugging and documentation generation.
436
+ * Initialize server without starting HTTP server.
437
+ * Used by adapters (e.g., SvelteKit) that manage their own HTTP server.
271
438
  */
272
- inspect(): ServerInspection {
273
- const plugins = this.manager.getPlugins().map(p => ({
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
- private resolveMiddleware(name: string): MiddlewareRuntime<any> | undefined {
337
- for (const plugin of this.manager.getPlugins()) {
338
- // Use resolved middleware (from calling middleware function during init)
339
- const resolvedMiddleware = (plugin as any)._resolvedMiddleware;
340
- if (resolvedMiddleware && resolvedMiddleware[name]) {
341
- return resolvedMiddleware[name] as MiddlewareRuntime<any>;
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
- return undefined;
460
+ logger.info(`Loaded ${this.routeMap.size} RPC routes`);
461
+ logger.info("Server initialized (adapter mode)");
345
462
  }
346
463
 
347
- private async executeMiddlewareChain(
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
- ctx: ServerContext,
350
- stack: MiddlewareDefinition[],
351
- finalHandler: () => Promise<Response>
352
- ): Promise<Response> {
353
- let next = finalHandler;
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
- for (let i = stack.length - 1; i >= 0; i--) {
356
- const mwDef = stack[i];
357
- if (!mwDef) continue;
477
+ const route = this.routeMap.get(routeName);
478
+ if (!route) {
479
+ return null;
480
+ }
358
481
 
359
- const mwRuntime = this.resolveMiddleware(mwDef.name);
482
+ const type = route.handler || "typed";
360
483
 
361
- if (!mwRuntime) {
362
- console.warn(`[Server] Middleware '${mwDef.name}' not found, skipping`);
363
- continue;
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
- const currentNext = next;
367
- const config = mwDef.config;
368
- next = () => mwRuntime.execute(req, ctx, currentNext, config);
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
- return next();
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
- getCore(): CoreServices {
375
- return this.coreServices;
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
- /** Get all registered routes for code generation */
379
- getRoutes(): { name: string; handler: string }[] {
380
- const routes: { name: string; handler: string }[] = [];
381
- for (const router of this.routers) {
382
- for (const route of router.getRoutes()) {
383
- routes.push({
384
- name: route.name,
385
- handler: route.handler || "typed",
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
- async start(): Promise<void> {
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
- // If running in generate mode, export routes and exit
396
- if (process.env.DONKEYLABS_GENERATE === "1") {
397
- const routes = this.getRoutes();
398
- console.log(JSON.stringify({ routes }));
399
- process.exit(0);
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
- if (req.method !== "POST") {
426
- return new Response("Method Not Allowed", { status: 405 });
690
+ // Handle SSE endpoint
691
+ if (url.pathname === "/sse" && req.method === "GET") {
692
+ return this.handleSSE(req, ip);
427
693
  }
428
694
 
429
- const actionName = url.pathname.slice(1);
430
- const route = this.routeMap.get(actionName);
695
+ // We only allow POST for RPC routes
696
+ if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
431
697
 
432
- if (!route) {
433
- return new Response("Not Found", { status: 404 });
434
- }
698
+ // Extract action from URL path (e.g., "auth.login")
699
+ const actionName = url.pathname.slice(1);
435
700
 
436
- // Auto rate limit check against registered rules
437
- const rateLimitResult = await this.coreServices.rateLimiter.checkRoute(actionName, { ip });
438
- if (rateLimitResult && !rateLimitResult.allowed) {
439
- const rule = this.coreServices.rateLimiter.matchRoute(actionName);
440
- return Response.json(
441
- {
442
- error: "TOO_MANY_REQUESTS",
443
- message: rule?.message ?? "Rate limit exceeded. Please try again later.",
444
- retryAfter: rateLimitResult.retryAfter,
445
- },
446
- {
447
- status: 429,
448
- headers: {
449
- "Retry-After": String(rateLimitResult.retryAfter),
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
- // Check plugin handlers
467
- if (!handler) {
468
- for (const plugin of this.manager.getPlugins()) {
469
- if (plugin.handlers && plugin.handlers[type]) {
470
- handler = plugin.handlers[type] as any;
471
- break;
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
- if (!handler) {
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
- async shutdown(): Promise<void> {
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