@donkeylabs/server 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/processes.md CHANGED
@@ -269,6 +269,11 @@ client.connected; // true | false
269
269
  // Emit a typed event to the server
270
270
  await client.emit("progress", { percent: 50, fps: 30 });
271
271
 
272
+ // Register a handler for server-sent messages (alternative to onMessage in connect options)
273
+ client.onMessage((message) => {
274
+ console.log("Received from server:", message);
275
+ });
276
+
272
277
  // Disconnect when done
273
278
  client.disconnect();
274
279
  ```
@@ -318,9 +323,12 @@ interface ProcessDefinition {
318
323
  /** Environment variables */
319
324
  env?: Record<string, string>;
320
325
 
321
- /** Typed events the process can emit */
326
+ /** Typed events the process can emit (validated at runtime) */
322
327
  events?: Record<string, ZodSchema>;
323
328
 
329
+ /** Typed command schemas for messages sent TO the process via send() (validated at runtime) */
330
+ commands?: Record<string, ZodSchema>;
331
+
324
332
  /** Heartbeat timeout in ms (default: 30000) */
325
333
  heartbeatTimeout?: number;
326
334
 
@@ -358,6 +366,12 @@ interface Processes {
358
366
  /** Get processes by name */
359
367
  getByName(name: string): ManagedProcess[];
360
368
 
369
+ /** Send a message to a running process (validated against command schemas if defined) */
370
+ send(processId: string, message: any): Promise<boolean>;
371
+
372
+ /** Get all registered process definitions */
373
+ getDefinitions(): Map<string, ProcessDefinition>;
374
+
361
375
  /** Stop a process */
362
376
  stop(processId: string, signal?: NodeJS.Signals): Promise<void>;
363
377
 
@@ -563,6 +577,134 @@ ctx.core.events.on("process.stats", ({ processId, name, stats }) => {
563
577
  });
564
578
  ```
565
579
 
580
+ ## Server-to-Process Communication
581
+
582
+ The server can send messages to running processes via `ctx.core.processes.send()`. The ProcessClient receives these messages through the `onMessage` callback.
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
+
629
+ ### Sending Messages from Server
630
+
631
+ ```typescript
632
+ // In a route handler or service
633
+ await ctx.core.processes.send(processId, {
634
+ type: "subscribe",
635
+ channel: "live-scores",
636
+ });
637
+
638
+ await ctx.core.processes.send(processId, {
639
+ type: "config_update",
640
+ settings: { maxConnections: 100 },
641
+ });
642
+ ```
643
+
644
+ ### Receiving Messages in Worker
645
+
646
+ ```typescript
647
+ // Option 1: In connect options
648
+ const client = await ProcessClient.connect({
649
+ onMessage: (message) => {
650
+ switch (message.type) {
651
+ case "subscribe":
652
+ subscribeToChannel(message.channel);
653
+ break;
654
+ case "config_update":
655
+ applyConfig(message.settings);
656
+ break;
657
+ }
658
+ },
659
+ });
660
+
661
+ // Option 2: Register handler after connecting
662
+ const client = await ProcessClient.connect();
663
+ client.onMessage((message) => {
664
+ console.log("Received:", message);
665
+ });
666
+ ```
667
+
668
+ ### Example: WebSocket Daemon with Server Commands
669
+
670
+ ```typescript
671
+ // Server: define and spawn the WebSocket daemon
672
+ server.getCore().processes.define("ws-daemon", {
673
+ command: "bun",
674
+ args: ["./workers/ws-daemon.ts"],
675
+ events: {
676
+ ready: z.object({ port: z.number() }),
677
+ clientCount: z.object({ count: z.number() }),
678
+ },
679
+ });
680
+
681
+ const processId = await ctx.core.processes.spawn("ws-daemon", {
682
+ metadata: { port: 8080 },
683
+ });
684
+
685
+ // Server: send commands to the daemon
686
+ await ctx.core.processes.send(processId, {
687
+ type: "broadcast",
688
+ message: "Server maintenance in 5 minutes",
689
+ });
690
+
691
+ // Worker: ws-daemon.ts
692
+ import { ProcessClient } from "@donkeylabs/server/process-client";
693
+
694
+ const client = await ProcessClient.connect({
695
+ onMessage: (message) => {
696
+ if (message.type === "broadcast") {
697
+ // Broadcast to all connected WebSocket clients
698
+ for (const ws of connections) {
699
+ ws.send(JSON.stringify({ type: "announcement", text: message.message }));
700
+ }
701
+ }
702
+ },
703
+ });
704
+
705
+ client.emit("ready", { port: client.metadata.port });
706
+ ```
707
+
566
708
  ## Heartbeat Monitoring
567
709
 
568
710
  The ProcessClient automatically sends heartbeats. If heartbeats stop:
@@ -651,3 +793,4 @@ process.on("SIGTERM", () => client.disconnect());
651
793
  3. **Use typed events** - Define event schemas for type safety
652
794
  4. **Monitor heartbeats** - Set appropriate timeout for your use case
653
795
  5. **Keep wrappers thin** - Business logic should be in the actual process
796
+ 6. **Use onMessage for commands** - Register `onMessage` to receive server commands for stateful workers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "2.2.0",
3
+ "version": "2.4.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",
@@ -8,7 +8,11 @@
8
8
  * ```ts
9
9
  * import { ProcessClient } from "@donkeylabs/server/process-client";
10
10
  *
11
- * const client = await ProcessClient.connect();
11
+ * const client = await ProcessClient.connect({
12
+ * onMessage: (message) => {
13
+ * if (message.type === "subscribe") { ... }
14
+ * },
15
+ * });
12
16
  *
13
17
  * // Access metadata passed during spawn
14
18
  * const { inputPath, outputPath } = client.metadata;
@@ -83,6 +87,8 @@ export interface ProcessClientConfig {
83
87
  maxReconnectAttempts?: number;
84
88
  /** Stats emission configuration */
85
89
  stats?: StatsConfig;
90
+ /** Callback for messages sent from the server via ctx.core.processes.send() */
91
+ onMessage?: (message: any) => void | Promise<void>;
86
92
  }
87
93
 
88
94
  export interface ProcessClient {
@@ -94,6 +100,8 @@ export interface ProcessClient {
94
100
  readonly connected: boolean;
95
101
  /** Emit a typed event to the server */
96
102
  emit(event: string, data?: Record<string, any>): Promise<boolean>;
103
+ /** Register a handler for messages sent from the server via ctx.core.processes.send() */
104
+ onMessage(handler: (message: any) => void | Promise<void>): void;
97
105
  /** Disconnect from the server */
98
106
  disconnect(): void;
99
107
  }
@@ -120,6 +128,7 @@ class ProcessClientImpl implements ProcessClient {
120
128
  private reconnectAttempts = 0;
121
129
  private isDisconnecting = false;
122
130
  private _connected = false;
131
+ private messageHandler?: (message: any) => void | Promise<void>;
123
132
 
124
133
  // For CPU percentage calculation
125
134
  private lastCpuUsage?: NodeJS.CpuUsage;
@@ -134,6 +143,7 @@ class ProcessClientImpl implements ProcessClient {
134
143
  this.reconnectInterval = config.reconnectInterval ?? 2000;
135
144
  this.maxReconnectAttempts = config.maxReconnectAttempts ?? 30;
136
145
  this.statsConfig = config.stats ?? { enabled: false };
146
+ if (config.onMessage) this.messageHandler = config.onMessage;
137
147
  }
138
148
 
139
149
  get connected(): boolean {
@@ -221,9 +231,22 @@ class ProcessClientImpl implements ProcessClient {
221
231
  }
222
232
 
223
233
  private handleServerMessage(message: any): void {
224
- // Server can send messages to the process (e.g., "stop", "config update")
225
- // For now, just log them
226
- console.log(`[ProcessClient] Received from server:`, message);
234
+ if (this.messageHandler) {
235
+ try {
236
+ const result = this.messageHandler(message);
237
+ if (result instanceof Promise) {
238
+ result.catch((err) => {
239
+ console.error(`[ProcessClient] Error in onMessage handler:`, err);
240
+ });
241
+ }
242
+ } catch (err) {
243
+ console.error(`[ProcessClient] Error in onMessage handler:`, err);
244
+ }
245
+ }
246
+ }
247
+
248
+ onMessage(handler: (message: any) => void | Promise<void>): void {
249
+ this.messageHandler = handler;
227
250
  }
228
251
 
229
252
  private scheduleReconnect(): void {
@@ -412,6 +435,14 @@ export function createProcessClient(config: ProcessClientConfig): ProcessClient
412
435
  * const client = await ProcessClient.connect({
413
436
  * stats: { enabled: true, interval: 2000 }
414
437
  * });
438
+ *
439
+ * // With server message handling
440
+ * const client = await ProcessClient.connect({
441
+ * onMessage: (message) => {
442
+ * if (message.type === "subscribe") { ... }
443
+ * if (message.type === "config_update") { ... }
444
+ * },
445
+ * });
415
446
  * ```
416
447
  */
417
448
  export async function connect(options?: {
@@ -420,6 +451,8 @@ export async function connect(options?: {
420
451
  maxReconnectAttempts?: number;
421
452
  /** Enable real-time CPU/memory stats emission */
422
453
  stats?: StatsConfig;
454
+ /** Callback for messages sent from the server via ctx.core.processes.send() */
455
+ onMessage?: (message: any) => void | Promise<void>;
423
456
  }): Promise<ProcessClient> {
424
457
  const processId = process.env.DONKEYLABS_PROCESS_ID;
425
458
  const socketPath = process.env.DONKEYLABS_SOCKET_PATH;
@@ -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
@@ -73,6 +73,25 @@ export type EventSchemas = Record<string, z.ZodType<any>>;
73
73
  // eslint-disable-next-line @typescript-eslint/no-empty-interface
74
74
  export interface EventRegistry {}
75
75
 
76
+ /**
77
+ * Registry for process type definitions.
78
+ * Augment this interface to add typed process commands/events:
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * declare module "@donkeylabs/server" {
83
+ * interface ProcessRegistry {
84
+ * "video-encoder": {
85
+ * events: { progress: { percent: number }; complete: { outputPath: string } };
86
+ * commands: { subscribe: { channel: string }; configUpdate: { settings: Record<string, any> } };
87
+ * };
88
+ * }
89
+ * }
90
+ * ```
91
+ */
92
+ // eslint-disable-next-line @typescript-eslint/no-empty-interface
93
+ export interface ProcessRegistry {}
94
+
76
95
  /**
77
96
  * Define server-level events with Zod schemas.
78
97
  * Events defined here will be typed and available across the app.
@@ -240,7 +259,7 @@ type ExtractServices<T extends readonly (keyof PluginRegistry)[] | undefined> =
240
259
  T extends readonly []
241
260
  ? {}
242
261
  : T extends readonly (infer K)[]
243
- ? K extends keyof PluginRegistry
262
+ ? [K] extends [keyof PluginRegistry]
244
263
  ? { [P in K]: PluginRegistry[P] extends { service: infer S } ? S : unknown }
245
264
  : {}
246
265
  : {};
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,
package/src/server.ts CHANGED
@@ -855,7 +855,30 @@ export class AppServer {
855
855
  }
856
856
  }
857
857
 
858
- console.log(JSON.stringify({ routes }));
858
+ // Collect process definitions with their schemas
859
+ const processes = [];
860
+ const definitions = this.coreServices.processes.getDefinitions();
861
+ for (const [name, def] of definitions) {
862
+ const events: Record<string, string> = {};
863
+ if (def.events) {
864
+ for (const [eventName, schema] of Object.entries(def.events)) {
865
+ events[eventName] = zodSchemaToTs(schema);
866
+ }
867
+ }
868
+ const commands: Record<string, string> = {};
869
+ if (def.commands) {
870
+ for (const [cmdName, schema] of Object.entries(def.commands)) {
871
+ commands[cmdName] = zodSchemaToTs(schema);
872
+ }
873
+ }
874
+ processes.push({
875
+ name,
876
+ events: Object.keys(events).length > 0 ? events : undefined,
877
+ commands: Object.keys(commands).length > 0 ? commands : undefined,
878
+ });
879
+ }
880
+
881
+ console.log(JSON.stringify({ routes, processes }));
859
882
  }
860
883
 
861
884
  /**