@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.
@@ -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
- // Emit generic message event
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 { AppServer, type ServerConfig } from "./server";
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 { type ServerContext as AppContext } from "@donkeylabs/server";
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
- * Stops background services and closes SSE connections.
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
  }