@donkeylabs/server 0.5.1 → 0.6.4
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/router.md +93 -0
- package/package.json +2 -2
- package/src/core/index.ts +24 -0
- package/src/core/process-adapter-sqlite.ts +282 -0
- package/src/core/process-socket.ts +521 -0
- package/src/core/processes.ts +758 -0
- package/src/core.ts +2 -0
- package/src/harness.ts +3 -0
- package/src/server.ts +32 -3
package/docs/router.md
CHANGED
|
@@ -304,6 +304,99 @@ router.route("getUser").typed({
|
|
|
304
304
|
|
|
305
305
|
---
|
|
306
306
|
|
|
307
|
+
## Type Generation
|
|
308
|
+
|
|
309
|
+
When using `donkeylabs generate` to create a typed API client, **you must provide explicit `output` schemas if your route returns data**.
|
|
310
|
+
|
|
311
|
+
### Output Schema Rules
|
|
312
|
+
|
|
313
|
+
- **No `output` schema** → Generated type is `void` (handler should return nothing)
|
|
314
|
+
- **With `output` schema** → Generated type matches the schema
|
|
315
|
+
|
|
316
|
+
This enforces explicitness: if you want to return data, you must declare what you're returning.
|
|
317
|
+
|
|
318
|
+
**Without `output` schema (returns void):**
|
|
319
|
+
```ts
|
|
320
|
+
// Handler should NOT return anything
|
|
321
|
+
router.route("delete").typed({
|
|
322
|
+
input: z.object({ id: z.string() }),
|
|
323
|
+
handle: async (input, ctx) => {
|
|
324
|
+
await ctx.plugins.recordings.delete(input.id);
|
|
325
|
+
// No return - this is correct for void output
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
**With `output` schema (returns data):**
|
|
331
|
+
```ts
|
|
332
|
+
// ✅ Generated type will be: Output = Expand<{ recordings: Recording[]; total: number; }>
|
|
333
|
+
router.route("list").typed({
|
|
334
|
+
input: z.object({ page: z.number() }),
|
|
335
|
+
output: z.object({
|
|
336
|
+
recordings: z.array(RecordingSchema),
|
|
337
|
+
total: z.number(),
|
|
338
|
+
}),
|
|
339
|
+
handle: async (input, ctx) => {
|
|
340
|
+
return ctx.plugins.recordings.list(input);
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### Best Practice: Always Define Output Schemas
|
|
346
|
+
|
|
347
|
+
For proper type safety in generated clients:
|
|
348
|
+
|
|
349
|
+
1. **Define Zod schemas for outputs**:
|
|
350
|
+
```ts
|
|
351
|
+
// schemas.ts
|
|
352
|
+
export const RecordingSchema = z.object({
|
|
353
|
+
id: z.string(),
|
|
354
|
+
name: z.string(),
|
|
355
|
+
duration: z.number(),
|
|
356
|
+
createdAt: z.string(),
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
export const RecordingListOutput = z.object({
|
|
360
|
+
recordings: z.array(RecordingSchema),
|
|
361
|
+
total: z.number(),
|
|
362
|
+
page: z.number(),
|
|
363
|
+
});
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
2. **Use them in routes**:
|
|
367
|
+
```ts
|
|
368
|
+
import { RecordingListOutput } from "./schemas";
|
|
369
|
+
|
|
370
|
+
router.route("list").typed({
|
|
371
|
+
input: z.object({ page: z.number().default(1) }),
|
|
372
|
+
output: RecordingListOutput,
|
|
373
|
+
handle: async (input, ctx) => {
|
|
374
|
+
return ctx.plugins.recordings.list(input);
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
3. **Run type generation**:
|
|
380
|
+
```bash
|
|
381
|
+
donkeylabs generate
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
The generated client will have properly typed methods:
|
|
385
|
+
```ts
|
|
386
|
+
// Generated API client
|
|
387
|
+
api.recordings.list({ page: 1 }) // Returns Promise<{ recordings: Recording[]; total: number; page: number; }>
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
### Debugging Missing Types
|
|
391
|
+
|
|
392
|
+
If your generated client shows `Output = Expand<void>` but your handler returns data:
|
|
393
|
+
|
|
394
|
+
1. Add an explicit `output` Zod schema that matches your return type
|
|
395
|
+
2. Run `donkeylabs generate` to regenerate the client
|
|
396
|
+
3. Check the warning logs - routes without output schemas are listed
|
|
397
|
+
|
|
398
|
+
---
|
|
399
|
+
|
|
307
400
|
## Real-World Examples
|
|
308
401
|
|
|
309
402
|
### CRUD Operations
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@donkeylabs/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Type-safe plugin system for building RPC-style APIs with Bun",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
],
|
|
76
76
|
"repository": {
|
|
77
77
|
"type": "git",
|
|
78
|
-
"url": "https://github.com/donkeylabs/
|
|
78
|
+
"url": "https://github.com/donkeylabs-io/donkeylabs"
|
|
79
79
|
},
|
|
80
80
|
"license": "MIT"
|
|
81
81
|
}
|
package/src/core/index.ts
CHANGED
|
@@ -146,3 +146,27 @@ export {
|
|
|
146
146
|
workflow,
|
|
147
147
|
createWorkflows,
|
|
148
148
|
} from "./workflows";
|
|
149
|
+
|
|
150
|
+
export {
|
|
151
|
+
type Processes,
|
|
152
|
+
type ProcessesConfig,
|
|
153
|
+
type ProcessStatus,
|
|
154
|
+
type ProcessConfig,
|
|
155
|
+
type ProcessDefinition,
|
|
156
|
+
type ManagedProcess,
|
|
157
|
+
type SpawnOptions,
|
|
158
|
+
createProcesses,
|
|
159
|
+
} from "./processes";
|
|
160
|
+
|
|
161
|
+
export {
|
|
162
|
+
SqliteProcessAdapter,
|
|
163
|
+
type SqliteProcessAdapterConfig,
|
|
164
|
+
type ProcessAdapter,
|
|
165
|
+
} from "./process-adapter-sqlite";
|
|
166
|
+
|
|
167
|
+
export {
|
|
168
|
+
type ProcessSocketServer,
|
|
169
|
+
type ProcessMessage,
|
|
170
|
+
type ProcessSocketConfig,
|
|
171
|
+
createProcessSocketServer,
|
|
172
|
+
} from "./process-socket";
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in SQLite Process Adapter
|
|
3
|
+
*
|
|
4
|
+
* Provides automatic persistence for managed processes, enabling server restart resilience
|
|
5
|
+
* and orphan recovery without requiring user configuration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Database } from "bun:sqlite";
|
|
9
|
+
import { mkdir } from "node:fs/promises";
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
|
+
import type { ProcessStatus, ManagedProcess, ProcessConfig } from "./processes";
|
|
12
|
+
|
|
13
|
+
export interface SqliteProcessAdapterConfig {
|
|
14
|
+
/** Path to SQLite database file (default: .donkeylabs/processes.db) */
|
|
15
|
+
path?: string;
|
|
16
|
+
/** Auto-cleanup stopped processes older than N days (default: 7, 0 to disable) */
|
|
17
|
+
cleanupDays?: number;
|
|
18
|
+
/** Cleanup interval in ms (default: 3600000 = 1 hour) */
|
|
19
|
+
cleanupInterval?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ProcessAdapter {
|
|
23
|
+
/** Create a new process record */
|
|
24
|
+
create(process: Omit<ManagedProcess, "id">): Promise<ManagedProcess>;
|
|
25
|
+
/** Get a process by ID */
|
|
26
|
+
get(processId: string): Promise<ManagedProcess | null>;
|
|
27
|
+
/** Update a process record */
|
|
28
|
+
update(processId: string, updates: Partial<ManagedProcess>): Promise<void>;
|
|
29
|
+
/** Delete a process record */
|
|
30
|
+
delete(processId: string): Promise<boolean>;
|
|
31
|
+
/** Get all processes by name */
|
|
32
|
+
getByName(name: string): Promise<ManagedProcess[]>;
|
|
33
|
+
/** Get all running processes */
|
|
34
|
+
getRunning(): Promise<ManagedProcess[]>;
|
|
35
|
+
/** Get orphaned processes (status="running" from before crash) */
|
|
36
|
+
getOrphaned(): Promise<ManagedProcess[]>;
|
|
37
|
+
/** Stop the adapter and cleanup timer */
|
|
38
|
+
stop(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class SqliteProcessAdapter implements ProcessAdapter {
|
|
42
|
+
private db: Database;
|
|
43
|
+
private initialized = false;
|
|
44
|
+
private cleanupTimer?: ReturnType<typeof setInterval>;
|
|
45
|
+
private cleanupDays: number;
|
|
46
|
+
|
|
47
|
+
constructor(config: SqliteProcessAdapterConfig = {}) {
|
|
48
|
+
const dbPath = config.path ?? ".donkeylabs/processes.db";
|
|
49
|
+
this.cleanupDays = config.cleanupDays ?? 7;
|
|
50
|
+
|
|
51
|
+
// Ensure directory exists
|
|
52
|
+
this.ensureDir(dbPath);
|
|
53
|
+
|
|
54
|
+
this.db = new Database(dbPath);
|
|
55
|
+
this.init();
|
|
56
|
+
|
|
57
|
+
// Start cleanup timer
|
|
58
|
+
if (this.cleanupDays > 0) {
|
|
59
|
+
const interval = config.cleanupInterval ?? 3600000; // 1 hour
|
|
60
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), interval);
|
|
61
|
+
// Run cleanup on startup
|
|
62
|
+
this.cleanup();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private ensureDir(dbPath: string): void {
|
|
67
|
+
const dir = dirname(dbPath);
|
|
68
|
+
if (dir && dir !== ".") {
|
|
69
|
+
// Sync mkdir for constructor
|
|
70
|
+
try {
|
|
71
|
+
Bun.spawnSync(["mkdir", "-p", dir]);
|
|
72
|
+
} catch {
|
|
73
|
+
// Directory may already exist
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private init(): void {
|
|
79
|
+
if (this.initialized) return;
|
|
80
|
+
|
|
81
|
+
this.db.run(`
|
|
82
|
+
CREATE TABLE IF NOT EXISTS processes (
|
|
83
|
+
id TEXT PRIMARY KEY,
|
|
84
|
+
name TEXT NOT NULL,
|
|
85
|
+
pid INTEGER,
|
|
86
|
+
socket_path TEXT,
|
|
87
|
+
tcp_port INTEGER,
|
|
88
|
+
status TEXT NOT NULL DEFAULT 'stopped',
|
|
89
|
+
config TEXT NOT NULL,
|
|
90
|
+
metadata TEXT,
|
|
91
|
+
created_at TEXT NOT NULL,
|
|
92
|
+
started_at TEXT,
|
|
93
|
+
stopped_at TEXT,
|
|
94
|
+
last_heartbeat TEXT,
|
|
95
|
+
restart_count INTEGER DEFAULT 0,
|
|
96
|
+
consecutive_failures INTEGER DEFAULT 0,
|
|
97
|
+
error TEXT
|
|
98
|
+
)
|
|
99
|
+
`);
|
|
100
|
+
|
|
101
|
+
// Indexes for efficient queries
|
|
102
|
+
this.db.run(`CREATE INDEX IF NOT EXISTS idx_processes_status ON processes(status)`);
|
|
103
|
+
this.db.run(`CREATE INDEX IF NOT EXISTS idx_processes_name ON processes(name)`);
|
|
104
|
+
this.db.run(`CREATE INDEX IF NOT EXISTS idx_processes_name_status ON processes(name, status)`);
|
|
105
|
+
|
|
106
|
+
this.initialized = true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async create(process: Omit<ManagedProcess, "id">): Promise<ManagedProcess> {
|
|
110
|
+
const id = `proc_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
111
|
+
|
|
112
|
+
this.db.run(
|
|
113
|
+
`INSERT INTO processes (
|
|
114
|
+
id, name, pid, socket_path, tcp_port, status, config, metadata,
|
|
115
|
+
created_at, started_at, stopped_at, last_heartbeat,
|
|
116
|
+
restart_count, consecutive_failures, error
|
|
117
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
118
|
+
[
|
|
119
|
+
id,
|
|
120
|
+
process.name,
|
|
121
|
+
process.pid ?? null,
|
|
122
|
+
process.socketPath ?? null,
|
|
123
|
+
process.tcpPort ?? null,
|
|
124
|
+
process.status,
|
|
125
|
+
JSON.stringify(process.config),
|
|
126
|
+
process.metadata ? JSON.stringify(process.metadata) : null,
|
|
127
|
+
process.createdAt.toISOString(),
|
|
128
|
+
process.startedAt?.toISOString() ?? null,
|
|
129
|
+
process.stoppedAt?.toISOString() ?? null,
|
|
130
|
+
process.lastHeartbeat?.toISOString() ?? null,
|
|
131
|
+
process.restartCount ?? 0,
|
|
132
|
+
process.consecutiveFailures ?? 0,
|
|
133
|
+
process.error ?? null,
|
|
134
|
+
]
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
return { ...process, id };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async get(processId: string): Promise<ManagedProcess | null> {
|
|
141
|
+
const row = this.db.query(`SELECT * FROM processes WHERE id = ?`).get(processId) as any;
|
|
142
|
+
if (!row) return null;
|
|
143
|
+
return this.rowToProcess(row);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async update(processId: string, updates: Partial<ManagedProcess>): Promise<void> {
|
|
147
|
+
const sets: string[] = [];
|
|
148
|
+
const values: any[] = [];
|
|
149
|
+
|
|
150
|
+
if (updates.pid !== undefined) {
|
|
151
|
+
sets.push("pid = ?");
|
|
152
|
+
values.push(updates.pid);
|
|
153
|
+
}
|
|
154
|
+
if (updates.socketPath !== undefined) {
|
|
155
|
+
sets.push("socket_path = ?");
|
|
156
|
+
values.push(updates.socketPath);
|
|
157
|
+
}
|
|
158
|
+
if (updates.tcpPort !== undefined) {
|
|
159
|
+
sets.push("tcp_port = ?");
|
|
160
|
+
values.push(updates.tcpPort);
|
|
161
|
+
}
|
|
162
|
+
if (updates.status !== undefined) {
|
|
163
|
+
sets.push("status = ?");
|
|
164
|
+
values.push(updates.status);
|
|
165
|
+
}
|
|
166
|
+
if (updates.config !== undefined) {
|
|
167
|
+
sets.push("config = ?");
|
|
168
|
+
values.push(JSON.stringify(updates.config));
|
|
169
|
+
}
|
|
170
|
+
if (updates.metadata !== undefined) {
|
|
171
|
+
sets.push("metadata = ?");
|
|
172
|
+
values.push(updates.metadata ? JSON.stringify(updates.metadata) : null);
|
|
173
|
+
}
|
|
174
|
+
if (updates.startedAt !== undefined) {
|
|
175
|
+
sets.push("started_at = ?");
|
|
176
|
+
values.push(updates.startedAt?.toISOString() ?? null);
|
|
177
|
+
}
|
|
178
|
+
if (updates.stoppedAt !== undefined) {
|
|
179
|
+
sets.push("stopped_at = ?");
|
|
180
|
+
values.push(updates.stoppedAt?.toISOString() ?? null);
|
|
181
|
+
}
|
|
182
|
+
if (updates.lastHeartbeat !== undefined) {
|
|
183
|
+
sets.push("last_heartbeat = ?");
|
|
184
|
+
values.push(updates.lastHeartbeat?.toISOString() ?? null);
|
|
185
|
+
}
|
|
186
|
+
if (updates.restartCount !== undefined) {
|
|
187
|
+
sets.push("restart_count = ?");
|
|
188
|
+
values.push(updates.restartCount);
|
|
189
|
+
}
|
|
190
|
+
if (updates.consecutiveFailures !== undefined) {
|
|
191
|
+
sets.push("consecutive_failures = ?");
|
|
192
|
+
values.push(updates.consecutiveFailures);
|
|
193
|
+
}
|
|
194
|
+
if (updates.error !== undefined) {
|
|
195
|
+
sets.push("error = ?");
|
|
196
|
+
values.push(updates.error);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (sets.length === 0) return;
|
|
200
|
+
|
|
201
|
+
values.push(processId);
|
|
202
|
+
this.db.run(`UPDATE processes SET ${sets.join(", ")} WHERE id = ?`, values);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async delete(processId: string): Promise<boolean> {
|
|
206
|
+
const result = this.db.run(`DELETE FROM processes WHERE id = ?`, [processId]);
|
|
207
|
+
return result.changes > 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async getByName(name: string): Promise<ManagedProcess[]> {
|
|
211
|
+
const rows = this.db
|
|
212
|
+
.query(`SELECT * FROM processes WHERE name = ? ORDER BY created_at DESC`)
|
|
213
|
+
.all(name) as any[];
|
|
214
|
+
return rows.map((r) => this.rowToProcess(r));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async getRunning(): Promise<ManagedProcess[]> {
|
|
218
|
+
const rows = this.db
|
|
219
|
+
.query(`SELECT * FROM processes WHERE status = 'running' OR status = 'spawning'`)
|
|
220
|
+
.all() as any[];
|
|
221
|
+
return rows.map((r) => this.rowToProcess(r));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async getOrphaned(): Promise<ManagedProcess[]> {
|
|
225
|
+
// Get processes that were running or spawning when server died
|
|
226
|
+
const rows = this.db
|
|
227
|
+
.query(
|
|
228
|
+
`SELECT * FROM processes WHERE status IN ('running', 'spawning', 'orphaned')`
|
|
229
|
+
)
|
|
230
|
+
.all() as any[];
|
|
231
|
+
return rows.map((r) => this.rowToProcess(r));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private rowToProcess(row: any): ManagedProcess {
|
|
235
|
+
return {
|
|
236
|
+
id: row.id,
|
|
237
|
+
name: row.name,
|
|
238
|
+
pid: row.pid ?? undefined,
|
|
239
|
+
socketPath: row.socket_path ?? undefined,
|
|
240
|
+
tcpPort: row.tcp_port ?? undefined,
|
|
241
|
+
status: row.status as ProcessStatus,
|
|
242
|
+
config: JSON.parse(row.config),
|
|
243
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
|
244
|
+
createdAt: new Date(row.created_at),
|
|
245
|
+
startedAt: row.started_at ? new Date(row.started_at) : undefined,
|
|
246
|
+
stoppedAt: row.stopped_at ? new Date(row.stopped_at) : undefined,
|
|
247
|
+
lastHeartbeat: row.last_heartbeat ? new Date(row.last_heartbeat) : undefined,
|
|
248
|
+
restartCount: row.restart_count ?? 0,
|
|
249
|
+
consecutiveFailures: row.consecutive_failures ?? 0,
|
|
250
|
+
error: row.error ?? undefined,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Clean up old stopped/crashed processes */
|
|
255
|
+
private cleanup(): void {
|
|
256
|
+
if (this.cleanupDays <= 0) return;
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const cutoff = new Date();
|
|
260
|
+
cutoff.setDate(cutoff.getDate() - this.cleanupDays);
|
|
261
|
+
|
|
262
|
+
const result = this.db.run(
|
|
263
|
+
`DELETE FROM processes WHERE status IN ('stopped', 'crashed', 'dead') AND stopped_at < ?`,
|
|
264
|
+
[cutoff.toISOString()]
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
if (result.changes > 0) {
|
|
268
|
+
console.log(`[Processes] Cleaned up ${result.changes} old process records`);
|
|
269
|
+
}
|
|
270
|
+
} catch (err) {
|
|
271
|
+
console.error("[Processes] Cleanup error:", err);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Stop the adapter and cleanup timer */
|
|
276
|
+
stop(): void {
|
|
277
|
+
if (this.cleanupTimer) {
|
|
278
|
+
clearInterval(this.cleanupTimer);
|
|
279
|
+
this.cleanupTimer = undefined;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|