@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 +144 -1
- package/package.json +1 -1
- package/src/core/process-client.ts +37 -4
- package/src/core/processes.ts +55 -1
- package/src/core.ts +20 -1
- package/src/index.ts +2 -0
- package/src/server.ts +24 -1
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
|
@@ -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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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;
|
package/src/core/processes.ts
CHANGED
|
@@ -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
|
-
|
|
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
package/src/server.ts
CHANGED
|
@@ -855,7 +855,30 @@ export class AppServer {
|
|
|
855
855
|
}
|
|
856
856
|
}
|
|
857
857
|
|
|
858
|
-
|
|
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
|
/**
|