@donkeylabs/server 0.3.0 → 0.3.1
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 +6 -6
- 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 +551 -0
- package/src/handlers.ts +14 -110
- package/src/index.ts +19 -23
- package/src/middleware.ts +2 -5
- package/src/registry.ts +4 -0
- package/src/server.ts +354 -337
- 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,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { PluginManager, type CoreServices, type ConfiguredPlugin } from "./core";
|
|
3
|
-
import {
|
|
4
|
-
import { Handlers
|
|
3
|
+
import { type IRouter, type RouteDefinition, type ServerContext } from "./router";
|
|
4
|
+
import { Handlers } from "./handlers";
|
|
5
5
|
import type { MiddlewareRuntime, MiddlewareDefinition } from "./middleware";
|
|
6
6
|
import {
|
|
7
7
|
createLogger,
|
|
@@ -22,14 +22,13 @@ import {
|
|
|
22
22
|
type SSEConfig,
|
|
23
23
|
type RateLimiterConfig,
|
|
24
24
|
type ErrorsConfig,
|
|
25
|
-
type JobDefinition,
|
|
26
|
-
type CronTaskDefinition,
|
|
27
25
|
} from "./core/index";
|
|
28
26
|
|
|
29
27
|
export interface ServerConfig {
|
|
30
28
|
port?: number;
|
|
31
29
|
db: CoreServices["db"];
|
|
32
30
|
config?: Record<string, any>;
|
|
31
|
+
// Core service configurations
|
|
33
32
|
logger?: LoggerConfig;
|
|
34
33
|
cache?: CacheConfig;
|
|
35
34
|
events?: EventsConfig;
|
|
@@ -40,54 +39,22 @@ export interface ServerConfig {
|
|
|
40
39
|
errors?: ErrorsConfig;
|
|
41
40
|
}
|
|
42
41
|
|
|
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
42
|
export class AppServer {
|
|
76
43
|
private port: number;
|
|
77
44
|
private manager: PluginManager;
|
|
78
45
|
private routers: IRouter[] = [];
|
|
79
46
|
private routeMap: Map<string, RouteDefinition> = new Map();
|
|
80
|
-
private customHandlers: Map<string, HandlerRuntime<any>> = new Map();
|
|
81
47
|
private coreServices: CoreServices;
|
|
82
48
|
|
|
83
49
|
constructor(options: ServerConfig) {
|
|
84
50
|
this.port = options.port ?? 3000;
|
|
85
51
|
|
|
52
|
+
// Initialize core services
|
|
86
53
|
const logger = createLogger(options.logger);
|
|
87
54
|
const cache = createCache(options.cache);
|
|
88
55
|
const events = createEvents(options.events);
|
|
89
56
|
const cron = createCron(options.cron);
|
|
90
|
-
const jobs = createJobs({ ...options.jobs, events });
|
|
57
|
+
const jobs = createJobs({ ...options.jobs, events }); // Jobs can emit events
|
|
91
58
|
const sse = createSSE(options.sse);
|
|
92
59
|
const rateLimiter = createRateLimiter(options.rateLimiter);
|
|
93
60
|
const errors = createErrors(options.errors);
|
|
@@ -108,248 +75,58 @@ export class AppServer {
|
|
|
108
75
|
this.manager = new PluginManager(this.coreServices);
|
|
109
76
|
}
|
|
110
77
|
|
|
111
|
-
registerPlugin(plugin: ConfiguredPlugin): this {
|
|
112
|
-
this.manager.register(plugin);
|
|
113
|
-
return this;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
78
|
/**
|
|
117
|
-
* Register a
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* server.registerHandler("echo", EchoHandler);
|
|
121
|
-
* // Then in routes:
|
|
122
|
-
* router.route("test").echo({ handle: ... });
|
|
79
|
+
* Register a plugin.
|
|
80
|
+
* For plugins with config, call the plugin factory first: registerPlugin(authPlugin({ key: "..." }))
|
|
81
|
+
* Plugins are initialized in dependency order when start() is called.
|
|
123
82
|
*/
|
|
124
|
-
|
|
125
|
-
this.
|
|
126
|
-
return this;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
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() }));
|
|
135
|
-
*/
|
|
136
|
-
registerEvent<T>(name: string, schema: z.ZodType<T>): this {
|
|
137
|
-
this.coreServices.events.register(name, schema);
|
|
138
|
-
return this;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Register multiple event schemas.
|
|
143
|
-
*/
|
|
144
|
-
registerEvents(schemas: Record<string, z.ZodType<any>>): this {
|
|
145
|
-
this.coreServices.events.registerMany(schemas);
|
|
146
|
-
return this;
|
|
147
|
-
}
|
|
148
|
-
|
|
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;
|
|
161
|
-
}
|
|
162
|
-
|
|
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
|
-
}
|
|
170
|
-
|
|
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;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Register multiple rate limit rules.
|
|
192
|
-
*/
|
|
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;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
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
|
-
* });
|
|
212
|
-
*/
|
|
213
|
-
registerJob<T = any, R = any>(name: string, definition: JobDefinition<T, R>): this {
|
|
214
|
-
this.coreServices.jobs.registerJob(name, definition);
|
|
215
|
-
return this;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Register multiple jobs at once.
|
|
220
|
-
*/
|
|
221
|
-
registerJobs(jobs: Record<string, JobDefinition>): this {
|
|
222
|
-
this.coreServices.jobs.registerJobs(jobs);
|
|
83
|
+
registerPlugin(plugin: ConfiguredPlugin): this {
|
|
84
|
+
this.manager.register(plugin);
|
|
223
85
|
return this;
|
|
224
86
|
}
|
|
225
87
|
|
|
226
88
|
/**
|
|
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
|
-
* });
|
|
89
|
+
* Add a router to handle RPC routes.
|
|
235
90
|
*/
|
|
236
|
-
|
|
237
|
-
this.
|
|
91
|
+
use(router: IRouter): this {
|
|
92
|
+
this.routers.push(router);
|
|
238
93
|
return this;
|
|
239
94
|
}
|
|
240
95
|
|
|
241
96
|
/**
|
|
242
|
-
*
|
|
97
|
+
* Get plugin services (for testing or advanced use cases).
|
|
243
98
|
*/
|
|
244
|
-
registerCronTasks(tasks: Record<string, CronTaskDefinition>): this {
|
|
245
|
-
this.coreServices.cron.registerTasks(tasks);
|
|
246
|
-
return this;
|
|
247
|
-
}
|
|
248
|
-
|
|
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;
|
|
255
|
-
}
|
|
256
|
-
this.routers.push(prefixOrRouter);
|
|
257
|
-
return prefixOrRouter;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
99
|
getServices(): any {
|
|
261
100
|
return this.manager.getServices();
|
|
262
101
|
}
|
|
263
102
|
|
|
264
|
-
getDb(): CoreServices["db"] {
|
|
265
|
-
return this.manager.getCore().db;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
103
|
/**
|
|
269
|
-
*
|
|
270
|
-
* Useful for debugging and documentation generation.
|
|
104
|
+
* Get the database instance.
|
|
271
105
|
*/
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
};
|
|
106
|
+
getDb(): CoreServices["db"] {
|
|
107
|
+
return this.manager.getCore().db;
|
|
334
108
|
}
|
|
335
109
|
|
|
110
|
+
// Resolve middleware runtime from plugins
|
|
336
111
|
private resolveMiddleware(name: string): MiddlewareRuntime<any> | undefined {
|
|
337
112
|
for (const plugin of this.manager.getPlugins()) {
|
|
338
|
-
//
|
|
339
|
-
const
|
|
340
|
-
if (
|
|
341
|
-
return
|
|
113
|
+
// Middleware is resolved and stored in _resolvedMiddleware during plugin init
|
|
114
|
+
const resolved = (plugin as any)._resolvedMiddleware as Record<string, MiddlewareRuntime<any>> | undefined;
|
|
115
|
+
if (resolved && resolved[name]) {
|
|
116
|
+
return resolved[name];
|
|
342
117
|
}
|
|
343
118
|
}
|
|
344
119
|
return undefined;
|
|
345
120
|
}
|
|
346
121
|
|
|
122
|
+
// Execute middleware chain, then call final handler
|
|
347
123
|
private async executeMiddlewareChain(
|
|
348
124
|
req: Request,
|
|
349
125
|
ctx: ServerContext,
|
|
350
126
|
stack: MiddlewareDefinition[],
|
|
351
127
|
finalHandler: () => Promise<Response>
|
|
352
128
|
): Promise<Response> {
|
|
129
|
+
// Build chain from end to start (last middleware wraps first)
|
|
353
130
|
let next = finalHandler;
|
|
354
131
|
|
|
355
132
|
for (let i = stack.length - 1; i >= 0; i--) {
|
|
@@ -371,41 +148,257 @@ export class AppServer {
|
|
|
371
148
|
return next();
|
|
372
149
|
}
|
|
373
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Get core services (for advanced use cases).
|
|
153
|
+
*/
|
|
374
154
|
getCore(): CoreServices {
|
|
375
155
|
return this.coreServices;
|
|
376
156
|
}
|
|
377
157
|
|
|
378
|
-
/**
|
|
379
|
-
|
|
380
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Get the internal route map for adapter introspection.
|
|
160
|
+
*/
|
|
161
|
+
getRouteMap(): Map<string, RouteDefinition> {
|
|
162
|
+
return this.routeMap;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Check if a route name is registered.
|
|
167
|
+
*/
|
|
168
|
+
hasRoute(routeName: string): boolean {
|
|
169
|
+
return this.routeMap.has(routeName);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Initialize server without starting HTTP server.
|
|
174
|
+
* Used by adapters (e.g., SvelteKit) that manage their own HTTP server.
|
|
175
|
+
*/
|
|
176
|
+
async initialize(): Promise<void> {
|
|
177
|
+
const { logger } = this.coreServices;
|
|
178
|
+
|
|
179
|
+
await this.manager.migrate();
|
|
180
|
+
await this.manager.init();
|
|
181
|
+
|
|
182
|
+
this.coreServices.cron.start();
|
|
183
|
+
this.coreServices.jobs.start();
|
|
184
|
+
logger.info("Background services started (cron, jobs)");
|
|
185
|
+
|
|
381
186
|
for (const router of this.routers) {
|
|
382
187
|
for (const route of router.getRoutes()) {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
188
|
+
if (this.routeMap.has(route.name)) {
|
|
189
|
+
logger.warn(`Duplicate route detected`, { route: route.name });
|
|
190
|
+
}
|
|
191
|
+
this.routeMap.set(route.name, route);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
logger.info(`Loaded ${this.routeMap.size} RPC routes`);
|
|
195
|
+
logger.info("Server initialized (adapter mode)");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Handle a single API request. Used by adapters.
|
|
200
|
+
* Returns null if the route is not found.
|
|
201
|
+
*/
|
|
202
|
+
async handleRequest(
|
|
203
|
+
req: Request,
|
|
204
|
+
routeName: string,
|
|
205
|
+
ip: string,
|
|
206
|
+
options?: { corsHeaders?: Record<string, string> }
|
|
207
|
+
): Promise<Response | null> {
|
|
208
|
+
const { logger } = this.coreServices;
|
|
209
|
+
const corsHeaders = options?.corsHeaders ?? {};
|
|
210
|
+
|
|
211
|
+
const route = this.routeMap.get(routeName);
|
|
212
|
+
if (!route) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const type = route.handler || "typed";
|
|
217
|
+
|
|
218
|
+
// First check core handlers
|
|
219
|
+
let handler = Handlers[type as keyof typeof Handlers];
|
|
220
|
+
|
|
221
|
+
// If not found, check plugin handlers
|
|
222
|
+
if (!handler) {
|
|
223
|
+
for (const config of this.manager.getPlugins()) {
|
|
224
|
+
if (config.handlers && config.handlers[type]) {
|
|
225
|
+
handler = config.handlers[type] as any;
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!handler) {
|
|
232
|
+
logger.error("Handler not found", { handler: type, route: routeName });
|
|
233
|
+
return Response.json(
|
|
234
|
+
{ error: "HANDLER_NOT_FOUND", message: "Handler not found" },
|
|
235
|
+
{ status: 500, headers: corsHeaders }
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Build context
|
|
240
|
+
const ctx: ServerContext = {
|
|
241
|
+
db: this.coreServices.db,
|
|
242
|
+
plugins: this.manager.getServices(),
|
|
243
|
+
core: this.coreServices,
|
|
244
|
+
errors: this.coreServices.errors,
|
|
245
|
+
config: this.coreServices.config,
|
|
246
|
+
ip,
|
|
247
|
+
requestId: crypto.randomUUID(),
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// Get middleware stack
|
|
251
|
+
const middlewareStack = route.middleware || [];
|
|
252
|
+
|
|
253
|
+
// Final handler
|
|
254
|
+
const finalHandler = async () => {
|
|
255
|
+
const response = await handler.execute(req, route, route.handle, ctx);
|
|
256
|
+
// Add CORS headers if provided
|
|
257
|
+
if (Object.keys(corsHeaders).length > 0 && response instanceof Response) {
|
|
258
|
+
const newHeaders = new Headers(response.headers);
|
|
259
|
+
Object.entries(corsHeaders).forEach(([k, v]) => newHeaders.set(k, v));
|
|
260
|
+
return new Response(response.body, {
|
|
261
|
+
status: response.status,
|
|
262
|
+
statusText: response.statusText,
|
|
263
|
+
headers: newHeaders,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
return response;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
if (middlewareStack.length > 0) {
|
|
271
|
+
return await this.executeMiddlewareChain(req, ctx, middlewareStack, finalHandler);
|
|
272
|
+
} else {
|
|
273
|
+
return await finalHandler();
|
|
274
|
+
}
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (error instanceof HttpError) {
|
|
277
|
+
logger.warn("HTTP error thrown", {
|
|
278
|
+
route: routeName,
|
|
279
|
+
status: error.status,
|
|
280
|
+
code: error.code,
|
|
281
|
+
message: error.message,
|
|
282
|
+
});
|
|
283
|
+
return Response.json(error.toJSON(), {
|
|
284
|
+
status: error.status,
|
|
285
|
+
headers: corsHeaders,
|
|
386
286
|
});
|
|
387
287
|
}
|
|
288
|
+
throw error;
|
|
388
289
|
}
|
|
389
|
-
return routes;
|
|
390
290
|
}
|
|
391
291
|
|
|
392
|
-
|
|
292
|
+
/**
|
|
293
|
+
* Call a route directly without HTTP (for SSR).
|
|
294
|
+
* This bypasses the HTTP layer and calls the route handler directly.
|
|
295
|
+
*
|
|
296
|
+
* @param routeName - The route name (e.g., "api.counter.get")
|
|
297
|
+
* @param input - The input data for the route
|
|
298
|
+
* @param ip - Client IP address (optional, defaults to "127.0.0.1")
|
|
299
|
+
* @returns The route handler result
|
|
300
|
+
*/
|
|
301
|
+
async callRoute<TOutput = any>(
|
|
302
|
+
routeName: string,
|
|
303
|
+
input: any,
|
|
304
|
+
ip: string = "127.0.0.1"
|
|
305
|
+
): Promise<TOutput> {
|
|
393
306
|
const { logger } = this.coreServices;
|
|
394
307
|
|
|
395
|
-
|
|
396
|
-
if (
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
308
|
+
const route = this.routeMap.get(routeName);
|
|
309
|
+
if (!route) {
|
|
310
|
+
throw new Error(`Route "${routeName}" not found`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Build context
|
|
314
|
+
const ctx: ServerContext = {
|
|
315
|
+
db: this.coreServices.db,
|
|
316
|
+
plugins: this.manager.getServices(),
|
|
317
|
+
core: this.coreServices,
|
|
318
|
+
errors: this.coreServices.errors,
|
|
319
|
+
config: this.coreServices.config,
|
|
320
|
+
ip,
|
|
321
|
+
requestId: crypto.randomUUID(),
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// Validate input if schema exists
|
|
325
|
+
if (route.input) {
|
|
326
|
+
const result = route.input.safeParse(input);
|
|
327
|
+
if (!result.success) {
|
|
328
|
+
throw new HttpError(400, "VALIDATION_ERROR", result.error.message);
|
|
329
|
+
}
|
|
330
|
+
input = result.data;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Execute through middleware chain if present
|
|
334
|
+
const middlewareStack = route.middleware || [];
|
|
335
|
+
|
|
336
|
+
const finalHandler = async () => {
|
|
337
|
+
return route.handle(input, ctx);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
if (middlewareStack.length > 0) {
|
|
342
|
+
// Create a fake request for middleware compatibility
|
|
343
|
+
const fakeReq = new Request("http://localhost/" + routeName, {
|
|
344
|
+
method: "POST",
|
|
345
|
+
body: JSON.stringify(input),
|
|
346
|
+
});
|
|
347
|
+
const response = await this.executeMiddlewareChain(
|
|
348
|
+
fakeReq,
|
|
349
|
+
ctx,
|
|
350
|
+
middlewareStack,
|
|
351
|
+
async () => {
|
|
352
|
+
const result = await finalHandler();
|
|
353
|
+
// Return as Response for middleware chain, we'll extract later
|
|
354
|
+
return Response.json(result);
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
// Extract result from Response
|
|
358
|
+
if (response instanceof Response) {
|
|
359
|
+
return response.json();
|
|
360
|
+
}
|
|
361
|
+
return response;
|
|
362
|
+
} else {
|
|
363
|
+
return await finalHandler();
|
|
364
|
+
}
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if (error instanceof HttpError) {
|
|
367
|
+
logger.warn("Route error (SSR)", {
|
|
368
|
+
route: routeName,
|
|
369
|
+
status: error.status,
|
|
370
|
+
code: error.code,
|
|
371
|
+
});
|
|
372
|
+
// Re-throw as a proper error for SSR
|
|
373
|
+
throw error;
|
|
374
|
+
}
|
|
375
|
+
throw error;
|
|
400
376
|
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Start the server.
|
|
381
|
+
* This will:
|
|
382
|
+
* 1. Run all plugin migrations
|
|
383
|
+
* 2. Initialize all plugins in dependency order
|
|
384
|
+
* 3. Start cron and jobs services
|
|
385
|
+
* 4. Start the HTTP server
|
|
386
|
+
*/
|
|
387
|
+
async start() {
|
|
388
|
+
const { logger } = this.coreServices;
|
|
401
389
|
|
|
390
|
+
// 1. Run migrations
|
|
402
391
|
await this.manager.migrate();
|
|
392
|
+
|
|
393
|
+
// 2. Initialize plugins
|
|
403
394
|
await this.manager.init();
|
|
404
395
|
|
|
396
|
+
// 3. Start background services
|
|
405
397
|
this.coreServices.cron.start();
|
|
406
398
|
this.coreServices.jobs.start();
|
|
407
399
|
logger.info("Background services started (cron, jobs)");
|
|
408
400
|
|
|
401
|
+
// 4. Build route map
|
|
409
402
|
for (const router of this.routers) {
|
|
410
403
|
for (const route of router.getRoutes()) {
|
|
411
404
|
if (this.routeMap.has(route.name)) {
|
|
@@ -416,109 +409,133 @@ export class AppServer {
|
|
|
416
409
|
}
|
|
417
410
|
logger.info(`Loaded ${this.routeMap.size} RPC routes`);
|
|
418
411
|
|
|
412
|
+
// 5. Start HTTP server
|
|
419
413
|
Bun.serve({
|
|
420
414
|
port: this.port,
|
|
421
415
|
fetch: async (req, server) => {
|
|
422
416
|
const url = new URL(req.url);
|
|
417
|
+
|
|
418
|
+
// Extract client IP
|
|
423
419
|
const ip = extractClientIP(req, server.requestIP(req)?.address);
|
|
424
420
|
|
|
425
|
-
|
|
426
|
-
|
|
421
|
+
// Handle SSE endpoint
|
|
422
|
+
if (url.pathname === "/sse" && req.method === "GET") {
|
|
423
|
+
return this.handleSSE(req, ip);
|
|
427
424
|
}
|
|
428
425
|
|
|
429
|
-
|
|
430
|
-
|
|
426
|
+
// We only allow POST for RPC routes
|
|
427
|
+
if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
|
|
431
428
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
429
|
+
// Extract action from URL path (e.g., "auth.login")
|
|
430
|
+
const actionName = url.pathname.slice(1);
|
|
435
431
|
|
|
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
|
-
},
|
|
432
|
+
const route = this.routeMap.get(actionName);
|
|
433
|
+
if (route) {
|
|
434
|
+
const type = route.handler || "typed";
|
|
435
|
+
|
|
436
|
+
// First check core handlers
|
|
437
|
+
let handler = Handlers[type as keyof typeof Handlers];
|
|
438
|
+
|
|
439
|
+
// If not found, check plugin handlers
|
|
440
|
+
if (!handler) {
|
|
441
|
+
for (const config of this.manager.getPlugins()) {
|
|
442
|
+
if (config.handlers && config.handlers[type]) {
|
|
443
|
+
handler = config.handlers[type] as any;
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
454
446
|
}
|
|
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
|
-
}
|
|
447
|
+
}
|
|
465
448
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
449
|
+
if (handler) {
|
|
450
|
+
// Build context with core services and IP
|
|
451
|
+
const ctx: ServerContext = {
|
|
452
|
+
db: this.coreServices.db,
|
|
453
|
+
plugins: this.manager.getServices(),
|
|
454
|
+
core: this.coreServices,
|
|
455
|
+
errors: this.coreServices.errors, // Convenience access
|
|
456
|
+
config: this.coreServices.config,
|
|
457
|
+
ip,
|
|
458
|
+
requestId: crypto.randomUUID(),
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// Get middleware stack for this route
|
|
462
|
+
const middlewareStack = route.middleware || [];
|
|
463
|
+
|
|
464
|
+
// Final handler execution
|
|
465
|
+
const finalHandler = async () => {
|
|
466
|
+
return await handler.execute(req, route, route.handle, ctx);
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// Execute middleware chain, then handler - with HttpError handling
|
|
470
|
+
try {
|
|
471
|
+
if (middlewareStack.length > 0) {
|
|
472
|
+
return await this.executeMiddlewareChain(req, ctx, middlewareStack, finalHandler);
|
|
473
|
+
} else {
|
|
474
|
+
return await finalHandler();
|
|
475
|
+
}
|
|
476
|
+
} catch (error) {
|
|
477
|
+
// Handle HttpError (thrown via ctx.errors.*)
|
|
478
|
+
if (error instanceof HttpError) {
|
|
479
|
+
logger.warn("HTTP error thrown", {
|
|
480
|
+
route: actionName,
|
|
481
|
+
status: error.status,
|
|
482
|
+
code: error.code,
|
|
483
|
+
message: error.message,
|
|
484
|
+
});
|
|
485
|
+
return Response.json(error.toJSON(), { status: error.status });
|
|
486
|
+
}
|
|
487
|
+
// Re-throw unknown errors
|
|
488
|
+
throw error;
|
|
472
489
|
}
|
|
490
|
+
} else {
|
|
491
|
+
logger.error("Handler not found", { handler: type, route: actionName });
|
|
492
|
+
return new Response("Handler Not Found", { status: 500 });
|
|
473
493
|
}
|
|
474
494
|
}
|
|
475
495
|
|
|
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
|
-
}
|
|
496
|
+
return new Response("Not Found", { status: 404 });
|
|
511
497
|
}
|
|
512
498
|
});
|
|
513
499
|
|
|
514
500
|
logger.info(`Server running at http://localhost:${this.port}`);
|
|
515
501
|
}
|
|
516
502
|
|
|
517
|
-
|
|
503
|
+
/**
|
|
504
|
+
* Handle SSE (Server-Sent Events) connections.
|
|
505
|
+
* Used by both standalone server and adapters.
|
|
506
|
+
*/
|
|
507
|
+
handleSSE(req: Request, ip: string): Response {
|
|
508
|
+
const url = new URL(req.url);
|
|
509
|
+
const channels = url.searchParams.get("channels")?.split(",").filter(Boolean) || [];
|
|
510
|
+
const lastEventId = req.headers.get("last-event-id") || undefined;
|
|
511
|
+
|
|
512
|
+
const { client, response } = this.coreServices.sse.addClient({ lastEventId });
|
|
513
|
+
|
|
514
|
+
// Subscribe to requested channels
|
|
515
|
+
for (const channel of channels) {
|
|
516
|
+
this.coreServices.sse.subscribe(client.id, channel);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Clean up when connection closes
|
|
520
|
+
req.signal.addEventListener("abort", () => {
|
|
521
|
+
this.coreServices.sse.removeClient(client.id);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
return response;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Gracefully shutdown the server.
|
|
529
|
+
* Stops background services and closes SSE connections.
|
|
530
|
+
*/
|
|
531
|
+
async shutdown() {
|
|
518
532
|
const { logger } = this.coreServices;
|
|
519
533
|
logger.info("Shutting down server...");
|
|
520
534
|
|
|
535
|
+
// Stop SSE (closes all client connections)
|
|
521
536
|
this.coreServices.sse.shutdown();
|
|
537
|
+
|
|
538
|
+
// Stop background services
|
|
522
539
|
await this.coreServices.jobs.stop();
|
|
523
540
|
await this.coreServices.cron.stop();
|
|
524
541
|
|