@donkeylabs/server 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/processes.md CHANGED
@@ -323,9 +323,12 @@ interface ProcessDefinition {
323
323
  /** Environment variables */
324
324
  env?: Record<string, string>;
325
325
 
326
- /** Typed events the process can emit */
326
+ /** Typed events the process can emit (validated at runtime) */
327
327
  events?: Record<string, ZodSchema>;
328
328
 
329
+ /** Typed command schemas for messages sent TO the process via send() (validated at runtime) */
330
+ commands?: Record<string, ZodSchema>;
331
+
329
332
  /** Heartbeat timeout in ms (default: 30000) */
330
333
  heartbeatTimeout?: number;
331
334
 
@@ -363,9 +366,12 @@ interface Processes {
363
366
  /** Get processes by name */
364
367
  getByName(name: string): ManagedProcess[];
365
368
 
366
- /** Send a message to a running process */
369
+ /** Send a message to a running process (validated against command schemas if defined) */
367
370
  send(processId: string, message: any): Promise<boolean>;
368
371
 
372
+ /** Get all registered process definitions */
373
+ getDefinitions(): Map<string, ProcessDefinition>;
374
+
369
375
  /** Stop a process */
370
376
  stop(processId: string, signal?: NodeJS.Signals): Promise<void>;
371
377
 
@@ -575,6 +581,51 @@ ctx.core.events.on("process.stats", ({ processId, name, stats }) => {
575
581
 
576
582
  The server can send messages to running processes via `ctx.core.processes.send()`. The ProcessClient receives these messages through the `onMessage` callback.
577
583
 
584
+ ### Typed Commands
585
+
586
+ Define command schemas to get runtime validation on `send()`. If a command fails validation, `send()` returns `false` and the message is not sent.
587
+
588
+ ```typescript
589
+ import { z } from "zod";
590
+
591
+ ctx.core.processes.register({
592
+ name: "ws-daemon",
593
+ config: {
594
+ command: "bun",
595
+ args: ["./workers/ws-daemon.ts"],
596
+ },
597
+ events: {
598
+ ready: z.object({ port: z.number() }),
599
+ clientCount: z.object({ count: z.number() }),
600
+ },
601
+ // Commands are validated before sending to the process
602
+ commands: {
603
+ subscribe: z.object({
604
+ type: z.literal("subscribe"),
605
+ channel: z.string(),
606
+ }),
607
+ configUpdate: z.object({
608
+ type: z.literal("configUpdate"),
609
+ settings: z.record(z.any()),
610
+ }),
611
+ },
612
+ });
613
+
614
+ // Valid command - sends successfully
615
+ await ctx.core.processes.send(processId, {
616
+ type: "subscribe",
617
+ channel: "live-scores",
618
+ }); // true
619
+
620
+ // Invalid command - returns false, message not sent
621
+ await ctx.core.processes.send(processId, {
622
+ type: "subscribe",
623
+ channel: 12345, // schema requires string
624
+ }); // false
625
+ ```
626
+
627
+ Commands without schemas are not validated (passthrough). The `type` field of the message is used to match against the command schema name.
628
+
578
629
  ### Sending Messages from Server
579
630
 
580
631
  ```typescript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "type": "module",
5
5
  "description": "Type-safe plugin system for building RPC-style APIs with Bun",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Core Event Map
3
+ *
4
+ * Single source of truth for all core service event types.
5
+ * These events are emitted by workflows, jobs, processes, cron, and logs.
6
+ * They are always available in EventRegistry without code generation.
7
+ */
8
+
9
+ import type { ProcessStats } from "./processes";
10
+ import type { PersistentLogEntry } from "./logs";
11
+
12
+ export interface CoreEventMap {
13
+ // Workflow events
14
+ "workflow.started": { instanceId: string; workflowName: string; input: any };
15
+ "workflow.completed": { instanceId: string; output: any };
16
+ "workflow.failed": { instanceId: string; workflowName: string; error: string };
17
+ "workflow.cancelled": { instanceId: string; workflowName: string };
18
+ "workflow.progress": { instanceId: string; progress: number; currentStep: string; completedSteps: number; totalSteps: number };
19
+ "workflow.event": { instanceId: string; workflowName: string; event: string; data: any };
20
+ "workflow.step.started": { instanceId: string; stepName: string; stepType: string };
21
+ "workflow.step.completed": { instanceId: string; stepName: string; output: any };
22
+ "workflow.step.failed": { instanceId: string; stepName: string; error: string; attempts: number };
23
+ "workflow.step.poll": { instanceId: string; stepName: string; pollCount: number; done: boolean; result: any };
24
+ "workflow.step.loop": { instanceId: string; stepName: string; loopCount: number; target: string };
25
+ "workflow.step.retry": { instanceId: string; stepName: string; attempt: number; maxAttempts: number; delay: number };
26
+ "workflow.watchdog.stale": { instanceId: string; reason: string; timeoutMs: number };
27
+ "workflow.watchdog.killed": { instanceId: string; reason: string; timeoutMs: number };
28
+
29
+ // Job events
30
+ "job.completed": { jobId: string; name: string; result: any };
31
+ "job.failed": { jobId: string; name: string; error: string; attempts?: number; stack?: string };
32
+ "job.stale": { jobId: string; name: string; timeSinceHeartbeat: number };
33
+ "job.reconnected": { jobId: string; name: string };
34
+ "job.lost": { jobId: string; name: string };
35
+ "job.event": { jobId: string; name: string; event: string; data?: any };
36
+ "job.external.spawned": { jobId: string; name: string };
37
+ "job.external.progress": { jobId: string; name: string; percent: number; message: string; data: any };
38
+ "job.external.log": { jobId: string; name: string; level: string; message: string; data?: any };
39
+ "job.watchdog.stale": { jobId: string; name: string; timeSinceHeartbeat: number };
40
+ "job.watchdog.killed": { jobId: string; name: string; reason: string };
41
+
42
+ // Process events
43
+ "process.spawned": { processId: string; name: string; pid: number };
44
+ "process.stopped": { processId: string; name: string };
45
+ "process.crashed": { processId: string; name: string; exitCode: number | null };
46
+ "process.restarted": { oldProcessId: string; newProcessId: string; name: string; attempt: number };
47
+ "process.reconnected": { processId: string; name: string; pid: number };
48
+ "process.stats": { processId: string; name: string; stats: ProcessStats };
49
+ "process.limits_exceeded": { processId: string; name: string; reason: string; limit: number; value?: number };
50
+ "process.heartbeat_missed": { processId: string; name: string };
51
+ "process.event": { processId: string; name: string; event: string; data: any };
52
+ "process.message": { processId: string; name: string; message: any };
53
+ "process.watchdog.stale": { processId: string; name: string; reason: string; timeoutMs: number };
54
+ "process.watchdog.killed": { processId: string; name: string; reason: string; value?: number };
55
+
56
+ // Cron events
57
+ "cron.event": { taskId: string; name: string; event: string; data?: any };
58
+
59
+ // Log events
60
+ "log.created": PersistentLogEntry;
61
+ }
62
+
63
+ /**
64
+ * Serializable core event definitions for CLI type generation.
65
+ * Maps event name to TypeScript type string (used by `donkeylabs generate`).
66
+ */
67
+ export const CORE_EVENT_DEFINITIONS: Record<string, string> = {
68
+ // Workflow events
69
+ "workflow.started": "{ instanceId: string; workflowName: string; input: any }",
70
+ "workflow.completed": "{ instanceId: string; output: any }",
71
+ "workflow.failed": "{ instanceId: string; workflowName: string; error: string }",
72
+ "workflow.cancelled": "{ instanceId: string; workflowName: string }",
73
+ "workflow.progress": "{ instanceId: string; progress: number; currentStep: string; completedSteps: number; totalSteps: number }",
74
+ "workflow.event": "{ instanceId: string; workflowName: string; event: string; data: any }",
75
+ "workflow.step.started": "{ instanceId: string; stepName: string; stepType: string }",
76
+ "workflow.step.completed": "{ instanceId: string; stepName: string; output: any }",
77
+ "workflow.step.failed": "{ instanceId: string; stepName: string; error: string; attempts: number }",
78
+ "workflow.step.poll": "{ instanceId: string; stepName: string; pollCount: number; done: boolean; result: any }",
79
+ "workflow.step.loop": "{ instanceId: string; stepName: string; loopCount: number; target: string }",
80
+ "workflow.step.retry": "{ instanceId: string; stepName: string; attempt: number; maxAttempts: number; delay: number }",
81
+ "workflow.watchdog.stale": "{ instanceId: string; reason: string; timeoutMs: number }",
82
+ "workflow.watchdog.killed": "{ instanceId: string; reason: string; timeoutMs: number }",
83
+
84
+ // Job events
85
+ "job.completed": "{ jobId: string; name: string; result: any }",
86
+ "job.failed": "{ jobId: string; name: string; error: string; attempts?: number; stack?: string }",
87
+ "job.stale": "{ jobId: string; name: string; timeSinceHeartbeat: number }",
88
+ "job.reconnected": "{ jobId: string; name: string }",
89
+ "job.lost": "{ jobId: string; name: string }",
90
+ "job.event": "{ jobId: string; name: string; event: string; data?: any }",
91
+ "job.external.spawned": "{ jobId: string; name: string }",
92
+ "job.external.progress": "{ jobId: string; name: string; percent: number; message: string; data: any }",
93
+ "job.external.log": "{ jobId: string; name: string; level: string; message: string; data?: any }",
94
+ "job.watchdog.stale": "{ jobId: string; name: string; timeSinceHeartbeat: number }",
95
+ "job.watchdog.killed": "{ jobId: string; name: string; reason: string }",
96
+
97
+ // Process events
98
+ "process.spawned": "{ processId: string; name: string; pid: number }",
99
+ "process.stopped": "{ processId: string; name: string }",
100
+ "process.crashed": "{ processId: string; name: string; exitCode: number | null }",
101
+ "process.restarted": "{ oldProcessId: string; newProcessId: string; name: string; attempt: number }",
102
+ "process.reconnected": "{ processId: string; name: string; pid: number }",
103
+ "process.stats": "{ processId: string; name: string; stats: { cpu: { user: number; system: number; percent: number }; memory: { rss: number; heapTotal: number; heapUsed: number; external: number }; uptime: number } }",
104
+ "process.limits_exceeded": "{ processId: string; name: string; reason: string; limit: number; value?: number }",
105
+ "process.heartbeat_missed": "{ processId: string; name: string }",
106
+ "process.event": "{ processId: string; name: string; event: string; data: any }",
107
+ "process.message": "{ processId: string; name: string; message: any }",
108
+ "process.watchdog.stale": "{ processId: string; name: string; reason: string; timeoutMs: number }",
109
+ "process.watchdog.killed": "{ processId: string; name: string; reason: string; value?: number }",
110
+
111
+ // Cron events
112
+ "cron.event": "{ taskId: string; name: string; event: string; data?: any }",
113
+
114
+ // Log events
115
+ "log.created": "{ id: string; timestamp: Date; level: string; message: string; source: string; sourceId?: string; tags?: string[]; data?: Record<string, any>; context?: Record<string, any> }",
116
+ };
package/src/core/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  // Core Services - Re-export all services
2
2
 
3
+ export { type CoreEventMap, CORE_EVENT_DEFINITIONS } from "./core-events";
4
+
3
5
  export {
4
6
  type Logger,
5
7
  type LogLevel,
@@ -136,6 +136,19 @@ export interface ProcessDefinition {
136
136
  * ```
137
137
  */
138
138
  events?: Record<string, import("zod").ZodType<any>>;
139
+ /**
140
+ * Command schemas for messages sent TO the process via send().
141
+ * Commands are validated at runtime using safeParse() before sending.
142
+ *
143
+ * @example
144
+ * ```ts
145
+ * commands: {
146
+ * subscribe: z.object({ channel: z.string() }),
147
+ * configUpdate: z.object({ settings: z.record(z.any()) }),
148
+ * }
149
+ * ```
150
+ */
151
+ commands?: Record<string, import("zod").ZodType<any>>;
139
152
  /** Called when a message is received from the process */
140
153
  onMessage?: (process: ManagedProcess, message: any) => void | Promise<void>;
141
154
  /** Called when the process crashes unexpectedly */
@@ -209,6 +222,8 @@ export interface Processes {
209
222
  getRunning(): Promise<ManagedProcess[]>;
210
223
  /** Send a message to a process via socket */
211
224
  send(processId: string, message: any): Promise<boolean>;
225
+ /** Get all registered process definitions */
226
+ getDefinitions(): Map<string, ProcessDefinition>;
212
227
  /** Start the service (recovery, monitoring) */
213
228
  start(): void;
214
229
  /** Shutdown the service and all managed processes */
@@ -552,9 +567,32 @@ export class ProcessesImpl implements Processes {
552
567
  }
553
568
 
554
569
  async send(processId: string, message: any): Promise<boolean> {
570
+ // Validate message against command schemas if defined
571
+ if (message && typeof message === "object" && message.type) {
572
+ const proc = await this.adapter.get(processId);
573
+ if (proc) {
574
+ const definition = this.definitions.get(proc.name);
575
+ if (definition?.commands) {
576
+ const commandSchema = definition.commands[message.type];
577
+ if (commandSchema) {
578
+ const result = commandSchema.safeParse(message);
579
+ if (!result.success) {
580
+ console.warn(
581
+ `[Processes] Command validation failed for '${message.type}' on ${proc.name}: ${result.error.message}`
582
+ );
583
+ return false;
584
+ }
585
+ }
586
+ }
587
+ }
588
+ }
555
589
  return this.socketServer.send(processId, message);
556
590
  }
557
591
 
592
+ getDefinitions(): Map<string, ProcessDefinition> {
593
+ return this.definitions;
594
+ }
595
+
558
596
  start(): void {
559
597
  // Recover orphaned processes
560
598
  if (this.autoRecoverOrphans) {
@@ -684,7 +722,23 @@ export class ProcessesImpl implements Processes {
684
722
  // Handle typed event messages from ProcessClient.emit()
685
723
  if (type === "event" && message.event) {
686
724
  const eventName = message.event as string;
687
- const eventData = message.data ?? {};
725
+ let eventData = message.data ?? {};
726
+
727
+ // Validate event data against schema if defined
728
+ const definition = this.definitions.get(proc.name);
729
+ if (definition?.events) {
730
+ const eventSchema = definition.events[eventName];
731
+ if (eventSchema) {
732
+ const result = eventSchema.safeParse(eventData);
733
+ if (!result.success) {
734
+ console.warn(
735
+ `[Processes] Event validation failed for '${eventName}' on ${proc.name}: ${result.error.message}`
736
+ );
737
+ return; // Drop invalid events
738
+ }
739
+ eventData = result.data;
740
+ }
741
+ }
688
742
 
689
743
  // Emit to events service as "process.<name>.<event>"
690
744
  await this.emitEvent(`process.${proc.name}.${eventName}`, {
package/src/core.ts CHANGED
@@ -18,6 +18,7 @@ import type { WebSocketService } from "./core/websocket";
18
18
  import type { Storage } from "./core/storage";
19
19
  import type { Logs } from "./core/logs";
20
20
  import type { Health } from "./core/health";
21
+ import type { CoreEventMap } from "./core/core-events";
21
22
 
22
23
  // ============================================
23
24
  // Auto-detect caller module for plugin define()
@@ -71,7 +72,26 @@ export type EventSchemas = Record<string, z.ZodType<any>>;
71
72
  * ```
72
73
  */
73
74
  // eslint-disable-next-line @typescript-eslint/no-empty-interface
74
- export interface EventRegistry {}
75
+ export interface EventRegistry extends CoreEventMap {}
76
+
77
+ /**
78
+ * Registry for process type definitions.
79
+ * Augment this interface to add typed process commands/events:
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * declare module "@donkeylabs/server" {
84
+ * interface ProcessRegistry {
85
+ * "video-encoder": {
86
+ * events: { progress: { percent: number }; complete: { outputPath: string } };
87
+ * commands: { subscribe: { channel: string }; configUpdate: { settings: Record<string, any> } };
88
+ * };
89
+ * }
90
+ * }
91
+ * ```
92
+ */
93
+ // eslint-disable-next-line @typescript-eslint/no-empty-interface
94
+ export interface ProcessRegistry {}
75
95
 
76
96
  /**
77
97
  * Define server-level events with Zod schemas.
@@ -240,7 +260,7 @@ type ExtractServices<T extends readonly (keyof PluginRegistry)[] | undefined> =
240
260
  T extends readonly []
241
261
  ? {}
242
262
  : T extends readonly (infer K)[]
243
- ? K extends keyof PluginRegistry
263
+ ? [K] extends [keyof PluginRegistry]
244
264
  ? { [P in K]: PluginRegistry[P] extends { service: infer S } ? S : unknown }
245
265
  : {}
246
266
  : {};
package/src/index.ts CHANGED
@@ -51,7 +51,9 @@ export {
51
51
  defineEvents,
52
52
  type EventRegistry,
53
53
  type EventSchemas,
54
+ // Registries
54
55
  type PluginRegistry,
56
+ type ProcessRegistry,
55
57
  type PluginHandlerRegistry,
56
58
  type PluginMiddlewareRegistry,
57
59
  type CoreServices,
@@ -110,6 +112,9 @@ export {
110
112
  type EventMetadata,
111
113
  } from "./core/index";
112
114
 
115
+ // Core event types
116
+ export { type CoreEventMap, CORE_EVENT_DEFINITIONS } from "./core/core-events";
117
+
113
118
  // Health checks
114
119
  export {
115
120
  type Health,
package/src/server.ts CHANGED
@@ -56,6 +56,7 @@ import {
56
56
  import { createHealth, createDbHealthCheck, type HealthConfig } from "./core/health";
57
57
  import type { AdminConfig } from "./admin";
58
58
  import { zodSchemaToTs } from "./generator/zod-to-ts";
59
+ import { CORE_EVENT_DEFINITIONS } from "./core/core-events";
59
60
 
60
61
  export interface TypeGenerationConfig {
61
62
  /** Output path for generated client types (e.g., "./src/lib/api.ts") */
@@ -855,7 +856,30 @@ export class AppServer {
855
856
  }
856
857
  }
857
858
 
858
- console.log(JSON.stringify({ routes }));
859
+ // Collect process definitions with their schemas
860
+ const processes = [];
861
+ const definitions = this.coreServices.processes.getDefinitions();
862
+ for (const [name, def] of definitions) {
863
+ const events: Record<string, string> = {};
864
+ if (def.events) {
865
+ for (const [eventName, schema] of Object.entries(def.events)) {
866
+ events[eventName] = zodSchemaToTs(schema);
867
+ }
868
+ }
869
+ const commands: Record<string, string> = {};
870
+ if (def.commands) {
871
+ for (const [cmdName, schema] of Object.entries(def.commands)) {
872
+ commands[cmdName] = zodSchemaToTs(schema);
873
+ }
874
+ }
875
+ processes.push({
876
+ name,
877
+ events: Object.keys(events).length > 0 ? events : undefined,
878
+ commands: Object.keys(commands).length > 0 ? commands : undefined,
879
+ });
880
+ }
881
+
882
+ console.log(JSON.stringify({ routes, processes, coreEvents: CORE_EVENT_DEFINITIONS }));
859
883
  }
860
884
 
861
885
  /**