@donkeylabs/server 1.1.16 → 1.1.18

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 CHANGED
@@ -130,7 +130,7 @@ await api.users.create({ email, name });
130
130
  | `.html()` | htmx partials |
131
131
 
132
132
  ## Core Services (ctx.core)
133
- `ctx.core.logger`, `ctx.core.cache`, `ctx.core.jobs`, `ctx.core.events`, `ctx.core.rateLimiter`, `ctx.core.sse`
133
+ `ctx.core.logger`, `ctx.core.cache`, `ctx.core.jobs`, `ctx.core.events`, `ctx.core.rateLimiter`, `ctx.core.sse`, `ctx.core.processes`, `ctx.core.workflows`
134
134
 
135
135
  ## Error Handling
136
136
  ```ts
@@ -150,4 +150,4 @@ bun --bun tsc --noEmit # Type check
150
150
  `get_project_info`, `create_plugin`, `add_migration`, `add_service_method`, `create_router`, `add_route`, `generate_types`, `list_plugins`, `scaffold_feature`
151
151
 
152
152
  ## Detailed Docs
153
- See `docs/` for: handlers, middleware, database, plugins, testing, jobs, cron, sse, workflows, router, errors, sveltekit-adapter.
153
+ See `docs/` for: handlers, middleware, database, plugins, testing, jobs, external-jobs, processes, cron, sse, workflows, router, errors, sveltekit-adapter.
@@ -11,6 +11,9 @@ Core services are foundational utilities automatically available to all plugins
11
11
  | [Events](events.md) | Pub/sub event system | In-memory |
12
12
  | [Cron](cron.md) | Scheduled recurring tasks | In-memory |
13
13
  | [Jobs](jobs.md) | Background job queue | In-memory |
14
+ | [External Jobs](external-jobs.md) | Jobs in any language | SQLite |
15
+ | [Processes](processes.md) | Long-running daemons | SQLite |
16
+ | [Workflows](workflows.md) | Multi-step orchestration | In-memory |
14
17
  | [SSE](sse.md) | Server-Sent Events | In-memory |
15
18
  | [RateLimiter](rate-limiter.md) | Request throttling | In-memory |
16
19
  | [Errors](errors.md) | HTTP error factories | - |
@@ -0,0 +1,531 @@
1
+ # Processes Service
2
+
3
+ The Processes service manages long-running daemon processes that communicate with the server via typed events. Unlike Jobs (which have a defined end), Processes can run indefinitely - perfect for services like FFmpeg encoders, file watchers, or background workers.
4
+
5
+ ## Overview
6
+
7
+ Processes provide:
8
+ - Long-running daemon management (start, stop, restart)
9
+ - Typed event communication from process to server
10
+ - Automatic heartbeat monitoring
11
+ - Connection resilience with auto-reconnection
12
+ - Metadata passing to spawned processes
13
+ - Cross-platform support (Unix sockets / TCP on Windows)
14
+
15
+ ## Architecture
16
+
17
+ ```
18
+ ┌─────────────────────────────────────────────────────────────────┐
19
+ │ @donkeylabs/server │
20
+ │ │
21
+ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
22
+ │ │ Processes │────▶│ Events │────▶│ SSE │───────┼──▶ Client
23
+ │ │ Service │ │ Service │ │ Service │ │
24
+ │ └─────────────┘ └─────────────┘ └─────────────┘ │
25
+ │ │ │
26
+ │ │ spawn + Unix socket/TCP │
27
+ │ ▼ │
28
+ │ ┌─────────────────────────────────────────────────────┐ │
29
+ │ │ Process Socket Server │ │
30
+ │ │ - Listens for connections │ │
31
+ │ │ - Receives typed events │ │
32
+ │ │ - Heartbeat monitoring │ │
33
+ │ └─────────────────────────────────────────────────────┘ │
34
+ └─────────────────────────────────────────────────────────────────┘
35
+
36
+ │ bidirectional (Unix socket / TCP)
37
+
38
+ ┌───────────────┐
39
+ │ Wrapper Script│ (TypeScript/Node)
40
+ │ - ProcessClient│
41
+ │ - Heartbeat │
42
+ │ - Typed events │
43
+ └───────────────┘
44
+
45
+ │ spawns/controls
46
+
47
+ ┌───────────────┐
48
+ │ Actual Process│ (FFmpeg, Python, etc.)
49
+ └───────────────┘
50
+ ```
51
+
52
+ ## Quick Start
53
+
54
+ ### 1. Define a Process
55
+
56
+ ```typescript
57
+ import { z } from "zod";
58
+
59
+ // In your server setup
60
+ server.getCore().processes.define("video-encoder", {
61
+ // Command to run (your wrapper script)
62
+ command: "bun",
63
+ args: ["./workers/video-encoder.ts"],
64
+
65
+ // Working directory
66
+ cwd: "./workers",
67
+
68
+ // Environment variables
69
+ env: {
70
+ NODE_ENV: "production",
71
+ },
72
+
73
+ // Typed events the process can emit
74
+ events: {
75
+ progress: z.object({
76
+ percent: z.number(),
77
+ fps: z.number().optional(),
78
+ currentFrame: z.number().optional(),
79
+ }),
80
+ error: z.object({
81
+ message: z.string(),
82
+ code: z.string().optional(),
83
+ }),
84
+ complete: z.object({
85
+ outputPath: z.string(),
86
+ duration: z.number(),
87
+ }),
88
+ },
89
+
90
+ // Heartbeat configuration
91
+ heartbeatTimeout: 30000, // 30 seconds
92
+ });
93
+ ```
94
+
95
+ ### 2. Write the Wrapper Script
96
+
97
+ ```typescript
98
+ // workers/video-encoder.ts
99
+ import { ProcessClient } from "@donkeylabs/server/process-client";
100
+
101
+ // Connect using environment variables (auto-configured by server)
102
+ const client = await ProcessClient.connect();
103
+
104
+ // Access metadata passed during spawn
105
+ const { inputPath, outputPath, options } = client.metadata;
106
+
107
+ console.log(`Starting encode: ${inputPath} -> ${outputPath}`);
108
+
109
+ // Spawn FFmpeg and monitor progress
110
+ const ffmpeg = Bun.spawn([
111
+ "ffmpeg", "-i", inputPath,
112
+ "-c:v", "libx264",
113
+ "-preset", options.preset ?? "medium",
114
+ outputPath
115
+ ], {
116
+ stderr: "pipe",
117
+ });
118
+
119
+ // Parse FFmpeg output for progress
120
+ const reader = ffmpeg.stderr.getReader();
121
+ const decoder = new TextDecoder();
122
+
123
+ while (true) {
124
+ const { done, value } = await reader.read();
125
+ if (done) break;
126
+
127
+ const output = decoder.decode(value);
128
+
129
+ // Parse frame/fps from FFmpeg output
130
+ const frameMatch = output.match(/frame=\s*(\d+)/);
131
+ const fpsMatch = output.match(/fps=\s*([\d.]+)/);
132
+
133
+ if (frameMatch) {
134
+ // Emit typed progress event
135
+ await client.emit("progress", {
136
+ percent: calculatePercent(parseInt(frameMatch[1])),
137
+ fps: fpsMatch ? parseFloat(fpsMatch[1]) : undefined,
138
+ currentFrame: parseInt(frameMatch[1]),
139
+ });
140
+ }
141
+ }
142
+
143
+ // Wait for process to complete
144
+ const exitCode = await ffmpeg.exited;
145
+
146
+ if (exitCode === 0) {
147
+ // Emit completion event
148
+ await client.emit("complete", {
149
+ outputPath,
150
+ duration: Date.now() - startTime,
151
+ });
152
+ } else {
153
+ // Emit error event
154
+ await client.emit("error", {
155
+ message: `FFmpeg exited with code ${exitCode}`,
156
+ code: `EXIT_${exitCode}`,
157
+ });
158
+ }
159
+
160
+ // Disconnect when done
161
+ client.disconnect();
162
+ ```
163
+
164
+ ### 3. Spawn the Process
165
+
166
+ ```typescript
167
+ // In a route handler or service
168
+ const process = await ctx.core.processes.spawn("video-encoder", {
169
+ // Metadata passed to the wrapper
170
+ metadata: {
171
+ inputPath: "/uploads/video.mp4",
172
+ outputPath: "/outputs/video-encoded.mp4",
173
+ options: { preset: "fast" },
174
+ },
175
+ });
176
+
177
+ console.log(`Spawned process: ${process.id}`);
178
+ ```
179
+
180
+ ### 4. Listen for Events
181
+
182
+ ```typescript
183
+ // Subscribe to process events
184
+ ctx.core.events.on("process.video-encoder.progress", (data) => {
185
+ console.log(`Encoding: ${data.percent}% at ${data.fps} fps`);
186
+
187
+ // Broadcast to SSE clients
188
+ ctx.core.sse.broadcast(`encode:${data.processId}`, "progress", data);
189
+ });
190
+
191
+ ctx.core.events.on("process.video-encoder.complete", (data) => {
192
+ console.log(`Encoding complete: ${data.outputPath}`);
193
+ });
194
+
195
+ ctx.core.events.on("process.video-encoder.error", (data) => {
196
+ console.error(`Encoding error: ${data.message}`);
197
+ });
198
+ ```
199
+
200
+ ## ProcessClient API
201
+
202
+ The `ProcessClient` is used inside wrapper scripts to communicate with the server.
203
+
204
+ ### Connecting
205
+
206
+ ```typescript
207
+ import { ProcessClient } from "@donkeylabs/server/process-client";
208
+
209
+ // Auto-connect using environment variables (recommended)
210
+ const client = await ProcessClient.connect();
211
+
212
+ // Or with custom options
213
+ const client = await ProcessClient.connect({
214
+ heartbeatInterval: 5000, // Send heartbeat every 5s (default)
215
+ reconnectInterval: 2000, // Retry connection every 2s
216
+ maxReconnectAttempts: 30, // Max reconnection attempts
217
+ });
218
+ ```
219
+
220
+ ### Properties
221
+
222
+ ```typescript
223
+ // Process ID assigned by server
224
+ client.processId; // "proc_abc123"
225
+
226
+ // Metadata passed during spawn
227
+ client.metadata; // { inputPath: "...", outputPath: "..." }
228
+
229
+ // Connection status
230
+ client.connected; // true | false
231
+ ```
232
+
233
+ ### Methods
234
+
235
+ ```typescript
236
+ // Emit a typed event to the server
237
+ await client.emit("progress", { percent: 50, fps: 30 });
238
+
239
+ // Disconnect when done
240
+ client.disconnect();
241
+ ```
242
+
243
+ ### Environment Variables
244
+
245
+ The server automatically sets these environment variables when spawning:
246
+
247
+ | Variable | Description |
248
+ |----------|-------------|
249
+ | `DONKEYLABS_PROCESS_ID` | Unique process identifier |
250
+ | `DONKEYLABS_SOCKET_PATH` | Unix socket path (Linux/macOS) |
251
+ | `DONKEYLABS_TCP_PORT` | TCP port (Windows) |
252
+ | `DONKEYLABS_METADATA` | JSON-encoded metadata |
253
+
254
+ ### Manual Configuration
255
+
256
+ If you need manual control:
257
+
258
+ ```typescript
259
+ import { createProcessClient } from "@donkeylabs/server/process-client";
260
+
261
+ const client = createProcessClient({
262
+ processId: "custom-id",
263
+ socketPath: "/tmp/my-socket.sock",
264
+ // OR for Windows:
265
+ // tcpPort: 49152,
266
+ metadata: { custom: "data" },
267
+ });
268
+
269
+ await client.connect();
270
+ ```
271
+
272
+ ## Process Definition
273
+
274
+ ```typescript
275
+ interface ProcessDefinition {
276
+ /** Command to execute */
277
+ command: string;
278
+
279
+ /** Command arguments */
280
+ args?: string[];
281
+
282
+ /** Working directory */
283
+ cwd?: string;
284
+
285
+ /** Environment variables */
286
+ env?: Record<string, string>;
287
+
288
+ /** Typed events the process can emit */
289
+ events?: Record<string, ZodSchema>;
290
+
291
+ /** Heartbeat timeout in ms (default: 30000) */
292
+ heartbeatTimeout?: number;
293
+
294
+ /** Auto-restart on crash (default: false) */
295
+ autoRestart?: boolean;
296
+
297
+ /** Max restart attempts (default: 3) */
298
+ maxRestarts?: number;
299
+ }
300
+ ```
301
+
302
+ ## Processes Service API
303
+
304
+ ```typescript
305
+ interface Processes {
306
+ /** Define a process type */
307
+ define(name: string, config: ProcessDefinition): void;
308
+
309
+ /** Spawn a new process instance */
310
+ spawn(name: string, options?: SpawnOptions): Promise<ManagedProcess>;
311
+
312
+ /** Get a running process by ID */
313
+ get(processId: string): ManagedProcess | undefined;
314
+
315
+ /** Get all running processes */
316
+ getAll(): ManagedProcess[];
317
+
318
+ /** Get processes by name */
319
+ getByName(name: string): ManagedProcess[];
320
+
321
+ /** Stop a process */
322
+ stop(processId: string, signal?: NodeJS.Signals): Promise<void>;
323
+
324
+ /** Stop all processes */
325
+ stopAll(signal?: NodeJS.Signals): Promise<void>;
326
+ }
327
+ ```
328
+
329
+ ### SpawnOptions
330
+
331
+ ```typescript
332
+ interface SpawnOptions {
333
+ /** Metadata passed to the process */
334
+ metadata?: Record<string, any>;
335
+
336
+ /** Override environment variables */
337
+ env?: Record<string, string>;
338
+
339
+ /** Override working directory */
340
+ cwd?: string;
341
+ }
342
+ ```
343
+
344
+ ### ManagedProcess
345
+
346
+ ```typescript
347
+ interface ManagedProcess {
348
+ /** Unique process ID */
349
+ id: string;
350
+
351
+ /** Process definition name */
352
+ name: string;
353
+
354
+ /** Current status */
355
+ status: ProcessStatus;
356
+
357
+ /** OS process ID */
358
+ pid: number;
359
+
360
+ /** Spawn timestamp */
361
+ startedAt: Date;
362
+
363
+ /** Last heartbeat timestamp */
364
+ lastHeartbeat: Date;
365
+
366
+ /** Metadata passed during spawn */
367
+ metadata: Record<string, any>;
368
+ }
369
+
370
+ type ProcessStatus = "starting" | "running" | "stopping" | "stopped" | "crashed";
371
+ ```
372
+
373
+ ## Events
374
+
375
+ The server emits these events for process lifecycle:
376
+
377
+ | Event | Data | Description |
378
+ |-------|------|-------------|
379
+ | `process.spawned` | `{ processId, name }` | Process started |
380
+ | `process.connected` | `{ processId, name }` | Client connected |
381
+ | `process.{name}.{event}` | Event data | Custom process event |
382
+ | `process.heartbeat` | `{ processId, name }` | Heartbeat received |
383
+ | `process.stale` | `{ processId, name, timeSince }` | No heartbeat |
384
+ | `process.stopped` | `{ processId, name, exitCode }` | Process stopped |
385
+ | `process.crashed` | `{ processId, name, error }` | Process crashed |
386
+
387
+ ### Listening Examples
388
+
389
+ ```typescript
390
+ // All progress events from video-encoder processes
391
+ ctx.core.events.on("process.video-encoder.progress", (data) => {
392
+ console.log(`Process ${data.processId}: ${data.percent}%`);
393
+ });
394
+
395
+ // Any process crash
396
+ ctx.core.events.on("process.crashed", (data) => {
397
+ console.error(`Process ${data.name} crashed: ${data.error}`);
398
+ });
399
+ ```
400
+
401
+ ## SSE Integration
402
+
403
+ Broadcast process events to clients:
404
+
405
+ ```typescript
406
+ // Server setup
407
+ ctx.core.events.on("process.video-encoder.progress", (data) => {
408
+ ctx.core.sse.broadcast(`encode:${data.processId}`, "progress", {
409
+ percent: data.percent,
410
+ fps: data.fps,
411
+ });
412
+ });
413
+
414
+ // Route for SSE subscription
415
+ router.route("subscribe").sse({
416
+ channels: (input) => [`encode:${input.processId}`],
417
+ });
418
+ ```
419
+
420
+ ```svelte
421
+ <!-- Client -->
422
+ <script lang="ts">
423
+ import { api } from "$lib/api";
424
+
425
+ let progress = $state(0);
426
+
427
+ $effect(() => {
428
+ const unsubscribe = api.sse.subscribe(
429
+ ["encoding.subscribe", { processId }],
430
+ {
431
+ onProgress: (data) => {
432
+ progress = data.percent;
433
+ },
434
+ }
435
+ );
436
+
437
+ return unsubscribe;
438
+ });
439
+ </script>
440
+
441
+ <progress value={progress} max="100">{progress}%</progress>
442
+ ```
443
+
444
+ ## Heartbeat Monitoring
445
+
446
+ The ProcessClient automatically sends heartbeats. If heartbeats stop:
447
+
448
+ 1. After `heartbeatTimeout`: Server emits `process.stale` event
449
+ 2. After `2 * heartbeatTimeout`: Process considered crashed
450
+
451
+ ```typescript
452
+ // Monitor stale processes
453
+ ctx.core.events.on("process.stale", async (data) => {
454
+ console.warn(`Process ${data.processId} is stale`);
455
+
456
+ // Optionally restart
457
+ await ctx.core.processes.stop(data.processId);
458
+ await ctx.core.processes.spawn(data.name, { metadata: data.metadata });
459
+ });
460
+ ```
461
+
462
+ ## Reconnection
463
+
464
+ If the server restarts, running processes will attempt to reconnect:
465
+
466
+ 1. ProcessClient detects disconnection
467
+ 2. Retries connecting every `reconnectInterval` ms
468
+ 3. After `maxReconnectAttempts`, gives up and exits
469
+ 4. Server recreates socket on same path for seamless reconnection
470
+
471
+ Configure reconnection in the wrapper:
472
+
473
+ ```typescript
474
+ const client = await ProcessClient.connect({
475
+ reconnectInterval: 2000, // 2 seconds between attempts
476
+ maxReconnectAttempts: 30, // Try for up to 60 seconds
477
+ });
478
+ ```
479
+
480
+ ## Differences from External Jobs
481
+
482
+ | Feature | Processes | External Jobs |
483
+ |---------|-----------|---------------|
484
+ | Duration | Long-running / forever | Finite task |
485
+ | Completion | Optional | Required |
486
+ | Restart | Auto-restart support | Retry on failure |
487
+ | Use case | Daemons, watchers, encoders | Batch tasks, emails |
488
+
489
+ ## Example: File Watcher
490
+
491
+ ```typescript
492
+ // Define process
493
+ server.getCore().processes.define("file-watcher", {
494
+ command: "bun",
495
+ args: ["./workers/file-watcher.ts"],
496
+ events: {
497
+ fileChanged: z.object({
498
+ path: z.string(),
499
+ event: z.enum(["create", "modify", "delete"]),
500
+ }),
501
+ },
502
+ autoRestart: true,
503
+ });
504
+
505
+ // Wrapper script (workers/file-watcher.ts)
506
+ import { ProcessClient } from "@donkeylabs/server/process-client";
507
+ import { watch } from "fs";
508
+
509
+ const client = await ProcessClient.connect();
510
+ const { watchPath } = client.metadata;
511
+
512
+ console.log(`Watching: ${watchPath}`);
513
+
514
+ watch(watchPath, { recursive: true }, async (event, filename) => {
515
+ await client.emit("fileChanged", {
516
+ path: filename,
517
+ event: event === "rename" ? "create" : "modify",
518
+ });
519
+ });
520
+
521
+ // Keep process running
522
+ process.on("SIGTERM", () => client.disconnect());
523
+ ```
524
+
525
+ ## Best Practices
526
+
527
+ 1. **Always disconnect** - Call `client.disconnect()` before process exits
528
+ 2. **Handle signals** - Listen for SIGTERM/SIGINT for graceful shutdown
529
+ 3. **Use typed events** - Define event schemas for type safety
530
+ 4. **Monitor heartbeats** - Set appropriate timeout for your use case
531
+ 5. **Keep wrappers thin** - Business logic should be in the actual process
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "1.1.16",
3
+ "version": "1.1.18",
4
4
  "type": "module",
5
5
  "description": "Type-safe plugin system for building RPC-style APIs with Bun",
6
6
  "main": "./src/index.ts",
@@ -26,6 +26,10 @@
26
26
  "types": "./src/generator/index.ts",
27
27
  "import": "./src/generator/index.ts"
28
28
  },
29
+ "./process-client": {
30
+ "types": "./src/process-client.ts",
31
+ "import": "./src/process-client.ts"
32
+ },
29
33
  "./context": {
30
34
  "types": "./context.d.ts"
31
35
  },
package/src/core/audit.ts CHANGED
@@ -115,12 +115,10 @@ export class KyselyAuditAdapter implements AuditAdapter {
115
115
  this.db = db as Kysely<Database>;
116
116
  this.retentionDays = config.retentionDays ?? 90;
117
117
 
118
- // Start cleanup timer
118
+ // Start cleanup timer (don't run immediately - tables may not exist yet before migrations)
119
119
  if (this.retentionDays > 0) {
120
120
  const interval = config.cleanupInterval ?? 86400000; // 24 hours
121
121
  this.cleanupTimer = setInterval(() => this.runCleanup(), interval);
122
- // Run cleanup on startup
123
- this.runCleanup();
124
122
  }
125
123
  }
126
124
 
@@ -238,7 +236,9 @@ export class KyselyAuditAdapter implements AuditAdapter {
238
236
  if (numDeleted > 0) {
239
237
  console.log(`[Audit] Cleaned up ${numDeleted} old audit entries`);
240
238
  }
241
- } catch (err) {
239
+ } catch (err: any) {
240
+ // Silently ignore "no such table" errors - table may not exist yet before migrations run
241
+ if (err?.message?.includes("no such table")) return;
242
242
  console.error("[Audit] Cleanup error:", err);
243
243
  }
244
244
  }
package/src/core/index.ts CHANGED
@@ -169,6 +169,15 @@ export {
169
169
  type ProcessAdapter,
170
170
  } from "./process-adapter-sqlite";
171
171
 
172
+ // Process Client - for use in wrapper scripts
173
+ export {
174
+ ProcessClient,
175
+ type ProcessClient as ProcessClientType,
176
+ type ProcessClientConfig,
177
+ connect as connectProcess,
178
+ createProcessClient,
179
+ } from "./process-client";
180
+
172
181
  export {
173
182
  KyselyProcessAdapter,
174
183
  type KyselyProcessAdapterConfig,
@@ -51,12 +51,10 @@ export class KyselyJobAdapter implements JobAdapter {
51
51
  this.db = db as Kysely<Database>;
52
52
  this.cleanupDays = config.cleanupDays ?? 7;
53
53
 
54
- // Start cleanup timer
54
+ // Start cleanup timer (don't run immediately - tables may not exist yet before migrations)
55
55
  if (this.cleanupDays > 0) {
56
56
  const interval = config.cleanupInterval ?? 3600000; // 1 hour
57
57
  this.cleanupTimer = setInterval(() => this.cleanup(), interval);
58
- // Run cleanup on startup
59
- this.cleanup();
60
58
  }
61
59
  }
62
60
 
@@ -277,7 +275,9 @@ export class KyselyJobAdapter implements JobAdapter {
277
275
  if (numDeleted > 0) {
278
276
  console.log(`[Jobs] Cleaned up ${numDeleted} old jobs`);
279
277
  }
280
- } catch (err) {
278
+ } catch (err: any) {
279
+ // Silently ignore "no such table" errors - table may not exist yet before migrations run
280
+ if (err?.message?.includes("no such table")) return;
281
281
  console.error("[Jobs] Cleanup error:", err);
282
282
  }
283
283
  }