@donkeylabs/server 1.1.17 → 1.1.19
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/CLAUDE.md +6 -2
- package/docs/core-services.md +10 -0
- package/docs/lifecycle-hooks.md +348 -0
- package/docs/processes.md +531 -0
- package/docs/services.md +256 -0
- package/package.json +5 -1
- package/src/core/audit.ts +4 -4
- package/src/core/index.ts +9 -0
- package/src/core/job-adapter-kysely.ts +4 -4
- package/src/core/process-client.ts +349 -0
- package/src/core/processes.ts +45 -2
- package/src/core/workflow-adapter-kysely.ts +4 -4
- package/src/core.ts +22 -0
- package/src/index.ts +18 -1
- package/src/process-client.ts +19 -0
- package/src/server.ts +343 -5
package/src/core/processes.ts
CHANGED
|
@@ -84,6 +84,21 @@ export interface ManagedProcess {
|
|
|
84
84
|
export interface ProcessDefinition {
|
|
85
85
|
name: string;
|
|
86
86
|
config: Omit<ProcessConfig, "args"> & { args?: string[] };
|
|
87
|
+
/**
|
|
88
|
+
* Event schemas this process can emit.
|
|
89
|
+
* Events are automatically emitted to ctx.core.events as "process.<name>.<event>"
|
|
90
|
+
* and broadcast to SSE channel "process:<processId>".
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```ts
|
|
94
|
+
* events: {
|
|
95
|
+
* progress: z.object({ percent: z.number(), fps: z.number() }),
|
|
96
|
+
* complete: z.object({ outputPath: z.string() }),
|
|
97
|
+
* error: z.object({ message: z.string() }),
|
|
98
|
+
* }
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
events?: Record<string, import("zod").ZodType<any>>;
|
|
87
102
|
/** Called when a message is received from the process */
|
|
88
103
|
onMessage?: (process: ManagedProcess, message: any) => void | Promise<void>;
|
|
89
104
|
/** Called when the process crashes unexpectedly */
|
|
@@ -261,7 +276,7 @@ export class ProcessesImpl implements Processes {
|
|
|
261
276
|
// Create Unix socket
|
|
262
277
|
const { socketPath, tcpPort } = await this.socketServer.createSocket(process.id);
|
|
263
278
|
|
|
264
|
-
// Build environment with socket info
|
|
279
|
+
// Build environment with socket info and metadata
|
|
265
280
|
const env: Record<string, string> = {
|
|
266
281
|
...config.env,
|
|
267
282
|
DONKEYLABS_PROCESS_ID: process.id,
|
|
@@ -272,6 +287,9 @@ export class ProcessesImpl implements Processes {
|
|
|
272
287
|
if (tcpPort) {
|
|
273
288
|
env.DONKEYLABS_TCP_PORT = tcpPort.toString();
|
|
274
289
|
}
|
|
290
|
+
if (options?.metadata) {
|
|
291
|
+
env.DONKEYLABS_METADATA = JSON.stringify(options.metadata);
|
|
292
|
+
}
|
|
275
293
|
|
|
276
294
|
// Spawn the process
|
|
277
295
|
const proc = Bun.spawn([config.command, ...(config.args || [])], {
|
|
@@ -508,7 +526,32 @@ export class ProcessesImpl implements Processes {
|
|
|
508
526
|
return;
|
|
509
527
|
}
|
|
510
528
|
|
|
511
|
-
//
|
|
529
|
+
// Handle typed event messages from ProcessClient.emit()
|
|
530
|
+
if (type === "event" && message.event) {
|
|
531
|
+
const eventName = message.event as string;
|
|
532
|
+
const eventData = message.data ?? {};
|
|
533
|
+
|
|
534
|
+
// Emit to events service as "process.<name>.<event>"
|
|
535
|
+
await this.emitEvent(`process.${proc.name}.${eventName}`, {
|
|
536
|
+
processId,
|
|
537
|
+
name: proc.name,
|
|
538
|
+
event: eventName,
|
|
539
|
+
data: eventData,
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// Broadcast to SSE channel "process:<processId>"
|
|
543
|
+
if (this.events) {
|
|
544
|
+
// SSE broadcast happens through events - listeners can forward to SSE
|
|
545
|
+
await this.emitEvent("process.event", {
|
|
546
|
+
processId,
|
|
547
|
+
name: proc.name,
|
|
548
|
+
event: eventName,
|
|
549
|
+
data: eventData,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Emit generic message event (for raw messages)
|
|
512
555
|
await this.emitEvent("process.message", {
|
|
513
556
|
processId,
|
|
514
557
|
name: proc.name,
|
|
@@ -46,12 +46,10 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
46
46
|
this.db = db as Kysely<Database>;
|
|
47
47
|
this.cleanupDays = config.cleanupDays ?? 30;
|
|
48
48
|
|
|
49
|
-
// Start cleanup timer
|
|
49
|
+
// Start cleanup timer (don't run immediately - tables may not exist yet before migrations)
|
|
50
50
|
if (this.cleanupDays > 0) {
|
|
51
51
|
const interval = config.cleanupInterval ?? 3600000; // 1 hour
|
|
52
52
|
this.cleanupTimer = setInterval(() => this.cleanup(), interval);
|
|
53
|
-
// Run cleanup on startup
|
|
54
|
-
this.cleanup();
|
|
55
53
|
}
|
|
56
54
|
}
|
|
57
55
|
|
|
@@ -240,7 +238,9 @@ export class KyselyWorkflowAdapter implements WorkflowAdapter {
|
|
|
240
238
|
if (numDeleted > 0) {
|
|
241
239
|
console.log(`[Workflows] Cleaned up ${numDeleted} old workflow instances`);
|
|
242
240
|
}
|
|
243
|
-
} catch (err) {
|
|
241
|
+
} catch (err: any) {
|
|
242
|
+
// Silently ignore "no such table" errors - table may not exist yet before migrations run
|
|
243
|
+
if (err?.message?.includes("no such table")) return;
|
|
244
244
|
console.error("[Workflows] Cleanup error:", err);
|
|
245
245
|
}
|
|
246
246
|
}
|
package/src/core.ts
CHANGED
|
@@ -67,6 +67,26 @@ export interface CoreServices {
|
|
|
67
67
|
* Global context interface used in route handlers.
|
|
68
68
|
* The `plugins` property is typed via PluginRegistry augmentation.
|
|
69
69
|
*/
|
|
70
|
+
/**
|
|
71
|
+
* Registry for custom user services.
|
|
72
|
+
* Augment this interface to add typed services:
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```ts
|
|
76
|
+
* // In your app's types file
|
|
77
|
+
* declare module "@donkeylabs/server" {
|
|
78
|
+
* interface ServiceRegistry {
|
|
79
|
+
* nvr: NVR;
|
|
80
|
+
* analytics: AnalyticsService;
|
|
81
|
+
* }
|
|
82
|
+
* }
|
|
83
|
+
*
|
|
84
|
+
* // Now ctx.services.nvr is typed
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
|
88
|
+
export interface ServiceRegistry {}
|
|
89
|
+
|
|
70
90
|
export interface GlobalContext {
|
|
71
91
|
/** Database instance */
|
|
72
92
|
db: Kysely<any>;
|
|
@@ -80,6 +100,8 @@ export interface GlobalContext {
|
|
|
80
100
|
errors: Errors;
|
|
81
101
|
/** Application config */
|
|
82
102
|
config: Record<string, any>;
|
|
103
|
+
/** Custom user-registered services - typed via ServiceRegistry augmentation */
|
|
104
|
+
services: ServiceRegistry & Record<string, any>;
|
|
83
105
|
/** Client IP address */
|
|
84
106
|
ip: string;
|
|
85
107
|
/** Unique request ID */
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
// @donkeylabs/server - Main exports
|
|
2
2
|
|
|
3
3
|
// Server
|
|
4
|
-
export {
|
|
4
|
+
export {
|
|
5
|
+
AppServer,
|
|
6
|
+
type ServerConfig,
|
|
7
|
+
// Lifecycle hooks
|
|
8
|
+
type HookContext,
|
|
9
|
+
type OnReadyHandler,
|
|
10
|
+
type OnShutdownHandler,
|
|
11
|
+
type OnErrorHandler,
|
|
12
|
+
// Custom services
|
|
13
|
+
defineService,
|
|
14
|
+
type ServiceDefinition,
|
|
15
|
+
type ServiceFactory,
|
|
16
|
+
} from "./server";
|
|
5
17
|
|
|
6
18
|
// Router
|
|
7
19
|
export {
|
|
@@ -46,6 +58,8 @@ export {
|
|
|
46
58
|
type InferMiddleware,
|
|
47
59
|
type InferDependencies,
|
|
48
60
|
type EventSchemas,
|
|
61
|
+
// Custom services registry
|
|
62
|
+
type ServiceRegistry,
|
|
49
63
|
} from "./core";
|
|
50
64
|
|
|
51
65
|
// Middleware
|
|
@@ -84,3 +98,6 @@ export {
|
|
|
84
98
|
type WorkflowContext,
|
|
85
99
|
type Workflows,
|
|
86
100
|
} from "./core/workflows";
|
|
101
|
+
|
|
102
|
+
// Test Harness - for plugin testing
|
|
103
|
+
export { createTestHarness } from "./harness";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process Client Entry Point
|
|
3
|
+
*
|
|
4
|
+
* Import this in your wrapper scripts:
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { ProcessClient } from "@donkeylabs/server/process-client";
|
|
7
|
+
*
|
|
8
|
+
* const client = await ProcessClient.connect();
|
|
9
|
+
* client.emit("progress", { percent: 50 });
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
ProcessClient,
|
|
15
|
+
type ProcessClient as ProcessClientType,
|
|
16
|
+
type ProcessClientConfig,
|
|
17
|
+
connect,
|
|
18
|
+
createProcessClient,
|
|
19
|
+
} from "./core/process-client";
|
package/src/server.ts
CHANGED
|
@@ -84,6 +84,103 @@ export interface ServerConfig {
|
|
|
84
84
|
useLegacyCoreDatabases?: boolean;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// =============================================================================
|
|
88
|
+
// LIFECYCLE HOOK TYPES
|
|
89
|
+
// =============================================================================
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Context passed to lifecycle hooks.
|
|
93
|
+
* Provides access to core services, plugin services, the database, and custom services.
|
|
94
|
+
*/
|
|
95
|
+
export interface HookContext {
|
|
96
|
+
/** Database instance (Kysely) */
|
|
97
|
+
db: CoreServices["db"];
|
|
98
|
+
/** Core services (logger, cache, events, jobs, etc.) */
|
|
99
|
+
core: CoreServices;
|
|
100
|
+
/** Plugin services (auth, email, permissions, etc.) */
|
|
101
|
+
plugins: Record<string, any>;
|
|
102
|
+
/** Server configuration */
|
|
103
|
+
config: Record<string, any>;
|
|
104
|
+
/** Custom user-registered services */
|
|
105
|
+
services: Record<string, any>;
|
|
106
|
+
/**
|
|
107
|
+
* Register a custom service at runtime (useful in onReady hooks).
|
|
108
|
+
* Services registered this way are immediately available in ctx.services.
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* ```ts
|
|
112
|
+
* server.onReady(async (ctx) => {
|
|
113
|
+
* const nvr = new NVR(ctx.plugins.auth);
|
|
114
|
+
* await nvr.initialize();
|
|
115
|
+
* ctx.setService("nvr", nvr);
|
|
116
|
+
* });
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
setService: <T>(name: string, service: T) => void;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Handler for onReady hook - called after server is fully initialized
|
|
124
|
+
*/
|
|
125
|
+
export type OnReadyHandler = (ctx: HookContext) => void | Promise<void>;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Handler for onShutdown hook - called when server is shutting down
|
|
129
|
+
*/
|
|
130
|
+
export type OnShutdownHandler = () => void | Promise<void>;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Handler for onError hook - called when an unhandled error occurs
|
|
134
|
+
*/
|
|
135
|
+
export type OnErrorHandler = (error: Error, ctx?: HookContext) => void | Promise<void>;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Factory function for creating a service.
|
|
139
|
+
* Receives the hook context to access plugins, db, etc.
|
|
140
|
+
*/
|
|
141
|
+
export type ServiceFactory<T> = (ctx: HookContext) => T | Promise<T>;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Service definition created by defineService().
|
|
145
|
+
* Contains the name and factory for type-safe service registration.
|
|
146
|
+
*/
|
|
147
|
+
export interface ServiceDefinition<N extends string = string, T = any> {
|
|
148
|
+
readonly name: N;
|
|
149
|
+
readonly factory: ServiceFactory<T>;
|
|
150
|
+
/** Type brand for inference - not used at runtime */
|
|
151
|
+
readonly __type?: T;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Define a custom service for registration with the server.
|
|
156
|
+
* The service will be available as `ctx.services.name` in route handlers.
|
|
157
|
+
* Types are automatically inferred and included in generated types.
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* ```ts
|
|
161
|
+
* // services/nvr.ts
|
|
162
|
+
* export const nvrService = defineService("nvr", async (ctx) => {
|
|
163
|
+
* const nvr = new NVR(ctx.plugins.auth);
|
|
164
|
+
* await nvr.initialize();
|
|
165
|
+
* return nvr;
|
|
166
|
+
* });
|
|
167
|
+
*
|
|
168
|
+
* // server/index.ts
|
|
169
|
+
* server.registerService(nvrService);
|
|
170
|
+
*
|
|
171
|
+
* // In routes - ctx.services.nvr is fully typed
|
|
172
|
+
* handle: async (input, ctx) => {
|
|
173
|
+
* return ctx.services.nvr.getRecordings();
|
|
174
|
+
* }
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
export function defineService<N extends string, T>(
|
|
178
|
+
name: N,
|
|
179
|
+
factory: ServiceFactory<T>
|
|
180
|
+
): ServiceDefinition<N, T> {
|
|
181
|
+
return { name, factory };
|
|
182
|
+
}
|
|
183
|
+
|
|
87
184
|
export class AppServer {
|
|
88
185
|
private port: number;
|
|
89
186
|
private maxPortAttempts: number;
|
|
@@ -93,6 +190,16 @@ export class AppServer {
|
|
|
93
190
|
private coreServices: CoreServices;
|
|
94
191
|
private typeGenConfig?: TypeGenerationConfig;
|
|
95
192
|
|
|
193
|
+
// Lifecycle hooks
|
|
194
|
+
private readyHandlers: OnReadyHandler[] = [];
|
|
195
|
+
private shutdownHandlers: OnShutdownHandler[] = [];
|
|
196
|
+
private errorHandlers: OnErrorHandler[] = [];
|
|
197
|
+
private isShuttingDown = false;
|
|
198
|
+
|
|
199
|
+
// Custom services registry
|
|
200
|
+
private serviceFactories = new Map<string, ServiceFactory<any>>();
|
|
201
|
+
private serviceRegistry: Record<string, any> = {};
|
|
202
|
+
|
|
96
203
|
constructor(options: ServerConfig) {
|
|
97
204
|
// Port priority: explicit config > PORT env var > default 3000
|
|
98
205
|
const envPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
|
|
@@ -183,6 +290,196 @@ export class AppServer {
|
|
|
183
290
|
return this;
|
|
184
291
|
}
|
|
185
292
|
|
|
293
|
+
// ===========================================================================
|
|
294
|
+
// LIFECYCLE HOOKS & SERVICES
|
|
295
|
+
// ===========================================================================
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Register a custom service/dependency that will be available in ctx.services.
|
|
299
|
+
* Services are initialized after plugins but before onReady handlers.
|
|
300
|
+
*
|
|
301
|
+
* Prefer using `defineService()` for automatic type generation:
|
|
302
|
+
* @example
|
|
303
|
+
* ```ts
|
|
304
|
+
* // services/nvr.ts
|
|
305
|
+
* export const nvrService = defineService("nvr", async (ctx) => {
|
|
306
|
+
* const nvr = new NVR(ctx.plugins.auth);
|
|
307
|
+
* await nvr.initialize();
|
|
308
|
+
* return nvr;
|
|
309
|
+
* });
|
|
310
|
+
*
|
|
311
|
+
* // server/index.ts
|
|
312
|
+
* server.registerService(nvrService);
|
|
313
|
+
*
|
|
314
|
+
* // In routes - ctx.services.nvr is fully typed!
|
|
315
|
+
* handle: async (input, ctx) => {
|
|
316
|
+
* return ctx.services.nvr.getRecordings();
|
|
317
|
+
* }
|
|
318
|
+
* ```
|
|
319
|
+
*/
|
|
320
|
+
registerService<N extends string, T>(definition: ServiceDefinition<N, T>): this;
|
|
321
|
+
registerService<T>(name: string, factory: ServiceFactory<T>): this;
|
|
322
|
+
registerService<N extends string, T>(
|
|
323
|
+
nameOrDefinition: string | ServiceDefinition<N, T>,
|
|
324
|
+
factory?: ServiceFactory<T>
|
|
325
|
+
): this {
|
|
326
|
+
if (typeof nameOrDefinition === "string") {
|
|
327
|
+
this.serviceFactories.set(nameOrDefinition, factory!);
|
|
328
|
+
} else {
|
|
329
|
+
this.serviceFactories.set(nameOrDefinition.name, nameOrDefinition.factory);
|
|
330
|
+
}
|
|
331
|
+
return this;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Get the custom services registry.
|
|
336
|
+
*/
|
|
337
|
+
getCustomServices(): Record<string, any> {
|
|
338
|
+
return this.serviceRegistry;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Register a handler to be called when the server is fully initialized.
|
|
343
|
+
* Called after all plugins are initialized and background services are started.
|
|
344
|
+
*
|
|
345
|
+
* @example
|
|
346
|
+
* ```ts
|
|
347
|
+
* server.onReady(async (ctx) => {
|
|
348
|
+
* // Initialize app-specific services
|
|
349
|
+
* await MyService.initialize(ctx.plugins.auth);
|
|
350
|
+
*
|
|
351
|
+
* // Set up event listeners
|
|
352
|
+
* ctx.core.events.on("user.created", handleUserCreated);
|
|
353
|
+
*
|
|
354
|
+
* ctx.core.logger.info("Application ready!");
|
|
355
|
+
* });
|
|
356
|
+
* ```
|
|
357
|
+
*/
|
|
358
|
+
onReady(handler: OnReadyHandler): this {
|
|
359
|
+
this.readyHandlers.push(handler);
|
|
360
|
+
return this;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Register a handler to be called when the server is shutting down.
|
|
365
|
+
* Use this to clean up resources, close connections, etc.
|
|
366
|
+
*
|
|
367
|
+
* @example
|
|
368
|
+
* ```ts
|
|
369
|
+
* server.onShutdown(async () => {
|
|
370
|
+
* await redis.quit();
|
|
371
|
+
* await externalApi.disconnect();
|
|
372
|
+
* console.log("Cleanup complete");
|
|
373
|
+
* });
|
|
374
|
+
* ```
|
|
375
|
+
*/
|
|
376
|
+
onShutdown(handler: OnShutdownHandler): this {
|
|
377
|
+
this.shutdownHandlers.push(handler);
|
|
378
|
+
return this;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Register a global error handler for unhandled errors.
|
|
383
|
+
* Use this for error reporting, logging, or recovery.
|
|
384
|
+
*
|
|
385
|
+
* @example
|
|
386
|
+
* ```ts
|
|
387
|
+
* server.onError(async (error, ctx) => {
|
|
388
|
+
* // Report to error tracking service
|
|
389
|
+
* await Sentry.captureException(error);
|
|
390
|
+
*
|
|
391
|
+
* ctx?.core.logger.error("Unhandled error", { error: error.message });
|
|
392
|
+
* });
|
|
393
|
+
* ```
|
|
394
|
+
*/
|
|
395
|
+
onError(handler: OnErrorHandler): this {
|
|
396
|
+
this.errorHandlers.push(handler);
|
|
397
|
+
return this;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Build the hook context for lifecycle handlers.
|
|
402
|
+
*/
|
|
403
|
+
private getHookContext(): HookContext {
|
|
404
|
+
return {
|
|
405
|
+
db: this.coreServices.db,
|
|
406
|
+
core: this.coreServices,
|
|
407
|
+
plugins: this.manager.getServices(),
|
|
408
|
+
config: this.coreServices.config,
|
|
409
|
+
services: this.serviceRegistry,
|
|
410
|
+
setService: <T>(name: string, service: T) => {
|
|
411
|
+
this.serviceRegistry[name] = service;
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Initialize all registered services.
|
|
418
|
+
* Called after plugins but before onReady handlers.
|
|
419
|
+
*/
|
|
420
|
+
private async initializeServices(): Promise<void> {
|
|
421
|
+
const ctx = this.getHookContext();
|
|
422
|
+
for (const [name, factory] of this.serviceFactories) {
|
|
423
|
+
try {
|
|
424
|
+
this.serviceRegistry[name] = await factory(ctx);
|
|
425
|
+
this.coreServices.logger.debug(`Service initialized: ${name}`);
|
|
426
|
+
} catch (error) {
|
|
427
|
+
this.coreServices.logger.error(`Failed to initialize service: ${name}`, { error });
|
|
428
|
+
await this.handleError(error as Error);
|
|
429
|
+
throw error; // Services are critical, fail startup if they can't init
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (this.serviceFactories.size > 0) {
|
|
433
|
+
this.coreServices.logger.info(`Initialized ${this.serviceFactories.size} custom service(s)`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Run all registered ready handlers.
|
|
439
|
+
*/
|
|
440
|
+
private async runReadyHandlers(): Promise<void> {
|
|
441
|
+
const ctx = this.getHookContext();
|
|
442
|
+
for (const handler of this.readyHandlers) {
|
|
443
|
+
try {
|
|
444
|
+
await handler(ctx);
|
|
445
|
+
} catch (error) {
|
|
446
|
+
this.coreServices.logger.error("Error in onReady handler", { error });
|
|
447
|
+
await this.handleError(error as Error);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Run all registered shutdown handlers (internal helper).
|
|
454
|
+
*/
|
|
455
|
+
private async runShutdownHandlers(): Promise<void> {
|
|
456
|
+
// Run shutdown handlers in reverse order (LIFO)
|
|
457
|
+
for (const handler of [...this.shutdownHandlers].reverse()) {
|
|
458
|
+
try {
|
|
459
|
+
await handler();
|
|
460
|
+
} catch (error) {
|
|
461
|
+
this.coreServices.logger.error("Error in onShutdown handler", { error });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Handle an error using registered error handlers.
|
|
468
|
+
*/
|
|
469
|
+
private async handleError(error: Error): Promise<void> {
|
|
470
|
+
const ctx = this.getHookContext();
|
|
471
|
+
for (const handler of this.errorHandlers) {
|
|
472
|
+
try {
|
|
473
|
+
await handler(error, ctx);
|
|
474
|
+
} catch (handlerError) {
|
|
475
|
+
this.coreServices.logger.error("Error in onError handler", {
|
|
476
|
+
originalError: error.message,
|
|
477
|
+
handlerError: (handlerError as Error).message,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
186
483
|
/**
|
|
187
484
|
* Add a router to handle RPC routes.
|
|
188
485
|
*/
|
|
@@ -594,8 +891,8 @@ export interface Handler<T extends { Input: any; Output: any }> {
|
|
|
594
891
|
handle(input: T["Input"]): T["Output"] | Promise<T["Output"]>;
|
|
595
892
|
}
|
|
596
893
|
|
|
597
|
-
// Re-export server context for model classes
|
|
598
|
-
export {
|
|
894
|
+
// Re-export server context for model classes (type-only to avoid bundling server code)
|
|
895
|
+
export type { ServerContext as AppContext } from "@donkeylabs/server";
|
|
599
896
|
|
|
600
897
|
// ============================================
|
|
601
898
|
// Route Types
|
|
@@ -650,6 +947,10 @@ ${factoryFunction}
|
|
|
650
947
|
}
|
|
651
948
|
logger.info(`Loaded ${this.routeMap.size} RPC routes`);
|
|
652
949
|
logger.info("Server initialized (adapter mode)");
|
|
950
|
+
|
|
951
|
+
// Initialize custom services, then run onReady handlers
|
|
952
|
+
await this.initializeServices();
|
|
953
|
+
await this.runReadyHandlers();
|
|
653
954
|
}
|
|
654
955
|
|
|
655
956
|
/**
|
|
@@ -700,6 +1001,7 @@ ${factoryFunction}
|
|
|
700
1001
|
core: this.coreServices,
|
|
701
1002
|
errors: this.coreServices.errors,
|
|
702
1003
|
config: this.coreServices.config,
|
|
1004
|
+
services: this.serviceRegistry,
|
|
703
1005
|
ip,
|
|
704
1006
|
requestId: crypto.randomUUID(),
|
|
705
1007
|
};
|
|
@@ -774,6 +1076,7 @@ ${factoryFunction}
|
|
|
774
1076
|
core: this.coreServices,
|
|
775
1077
|
errors: this.coreServices.errors,
|
|
776
1078
|
config: this.coreServices.config,
|
|
1079
|
+
services: this.serviceRegistry,
|
|
777
1080
|
ip,
|
|
778
1081
|
requestId: crypto.randomUUID(),
|
|
779
1082
|
};
|
|
@@ -894,7 +1197,7 @@ ${factoryFunction}
|
|
|
894
1197
|
const getEnabledHandlers = ["stream", "sse", "html", "raw"];
|
|
895
1198
|
|
|
896
1199
|
// Check method based on handler type
|
|
897
|
-
if (req.method === "GET" && !getEnabledHandlers.includes(handlerType)) {
|
|
1200
|
+
if (req.method === "GET" && !getEnabledHandlers.includes(handlerType as string)) {
|
|
898
1201
|
return new Response("Method Not Allowed", { status: 405 });
|
|
899
1202
|
}
|
|
900
1203
|
if (req.method !== "GET" && req.method !== "POST") {
|
|
@@ -923,6 +1226,7 @@ ${factoryFunction}
|
|
|
923
1226
|
core: this.coreServices,
|
|
924
1227
|
errors: this.coreServices.errors, // Convenience access
|
|
925
1228
|
config: this.coreServices.config,
|
|
1229
|
+
services: this.serviceRegistry,
|
|
926
1230
|
ip,
|
|
927
1231
|
requestId: crypto.randomUUID(),
|
|
928
1232
|
};
|
|
@@ -978,6 +1282,10 @@ ${factoryFunction}
|
|
|
978
1282
|
// Update the actual port we're running on
|
|
979
1283
|
this.port = currentPort;
|
|
980
1284
|
logger.info(`Server running at http://localhost:${this.port}`);
|
|
1285
|
+
|
|
1286
|
+
// Initialize custom services, then run onReady handlers
|
|
1287
|
+
await this.initializeServices();
|
|
1288
|
+
await this.runReadyHandlers();
|
|
981
1289
|
return;
|
|
982
1290
|
} catch (error) {
|
|
983
1291
|
const isPortInUse =
|
|
@@ -1030,12 +1338,19 @@ ${factoryFunction}
|
|
|
1030
1338
|
|
|
1031
1339
|
/**
|
|
1032
1340
|
* Gracefully shutdown the server.
|
|
1033
|
-
*
|
|
1341
|
+
* Runs shutdown handlers, stops background services, and closes connections.
|
|
1342
|
+
* Safe to call multiple times (idempotent).
|
|
1034
1343
|
*/
|
|
1035
|
-
async shutdown() {
|
|
1344
|
+
async shutdown(): Promise<void> {
|
|
1345
|
+
if (this.isShuttingDown) return;
|
|
1346
|
+
this.isShuttingDown = true;
|
|
1347
|
+
|
|
1036
1348
|
const { logger } = this.coreServices;
|
|
1037
1349
|
logger.info("Shutting down server...");
|
|
1038
1350
|
|
|
1351
|
+
// Run user shutdown handlers first (in reverse order - LIFO)
|
|
1352
|
+
await this.runShutdownHandlers();
|
|
1353
|
+
|
|
1039
1354
|
// Stop SSE (closes all client connections)
|
|
1040
1355
|
this.coreServices.sse.shutdown();
|
|
1041
1356
|
|
|
@@ -1053,4 +1368,27 @@ ${factoryFunction}
|
|
|
1053
1368
|
|
|
1054
1369
|
logger.info("Server shutdown complete");
|
|
1055
1370
|
}
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* Set up graceful shutdown handlers for process signals.
|
|
1374
|
+
* Call this after start() to enable SIGTERM/SIGINT handling.
|
|
1375
|
+
*
|
|
1376
|
+
* @example
|
|
1377
|
+
* ```ts
|
|
1378
|
+
* await server.start();
|
|
1379
|
+
* server.enableGracefulShutdown();
|
|
1380
|
+
* ```
|
|
1381
|
+
*/
|
|
1382
|
+
enableGracefulShutdown(): this {
|
|
1383
|
+
const handleSignal = async (signal: string) => {
|
|
1384
|
+
this.coreServices.logger.info(`Received ${signal}, initiating graceful shutdown...`);
|
|
1385
|
+
await this.shutdown();
|
|
1386
|
+
process.exit(0);
|
|
1387
|
+
};
|
|
1388
|
+
|
|
1389
|
+
process.on("SIGTERM", () => handleSignal("SIGTERM"));
|
|
1390
|
+
process.on("SIGINT", () => handleSignal("SIGINT"));
|
|
1391
|
+
|
|
1392
|
+
return this;
|
|
1393
|
+
}
|
|
1056
1394
|
}
|