@donkeylabs/server 1.1.17 → 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 +2 -2
- package/docs/core-services.md +3 -0
- package/docs/processes.md +531 -0
- package/package.json +5 -1
- package/src/core/audit.ts +4 -4
- package/src/core/index.ts +9 -0
- package/src/core/job-adapter-kysely.ts +4 -4
- package/src/core/process-client.ts +349 -0
- package/src/core/processes.ts +45 -2
- package/src/core/workflow-adapter-kysely.ts +4 -4
- package/src/core.ts +22 -0
- package/src/index.ts +18 -1
- package/src/process-client.ts +19 -0
- package/src/server.ts +343 -5
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.
|
package/docs/core-services.md
CHANGED
|
@@ -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.
|
|
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
|
}
|