@donkeylabs/server 0.6.4 → 1.0.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.
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Kysely Process Adapter
3
+ *
4
+ * Implements the ProcessAdapter interface using Kysely for the shared app database.
5
+ * This replaces the standalone SqliteProcessAdapter that used a separate .donkeylabs/processes.db file.
6
+ */
7
+
8
+ import type { Kysely } from "kysely";
9
+ import type { ProcessAdapter } from "./process-adapter-sqlite";
10
+ import type { ProcessStatus, ManagedProcess, ProcessConfig } from "./processes";
11
+
12
+ export interface KyselyProcessAdapterConfig {
13
+ /** Auto-cleanup stopped processes older than N days (default: 7, 0 to disable) */
14
+ cleanupDays?: number;
15
+ /** Cleanup interval in ms (default: 3600000 = 1 hour) */
16
+ cleanupInterval?: number;
17
+ }
18
+
19
+ // Table type for Kysely
20
+ interface ProcessesTable {
21
+ id: string;
22
+ name: string;
23
+ pid: number | null;
24
+ socket_path: string | null;
25
+ tcp_port: number | null;
26
+ status: string;
27
+ config: string;
28
+ metadata: string | null;
29
+ created_at: string;
30
+ started_at: string | null;
31
+ stopped_at: string | null;
32
+ last_heartbeat: string | null;
33
+ restart_count: number;
34
+ consecutive_failures: number;
35
+ error: string | null;
36
+ }
37
+
38
+ interface Database {
39
+ __donkeylabs_processes__: ProcessesTable;
40
+ }
41
+
42
+ export class KyselyProcessAdapter implements ProcessAdapter {
43
+ private db: Kysely<Database>;
44
+ private cleanupTimer?: ReturnType<typeof setInterval>;
45
+ private cleanupDays: number;
46
+
47
+ constructor(db: Kysely<any>, config: KyselyProcessAdapterConfig = {}) {
48
+ this.db = db as Kysely<Database>;
49
+ this.cleanupDays = config.cleanupDays ?? 7;
50
+
51
+ // Start cleanup timer
52
+ if (this.cleanupDays > 0) {
53
+ const interval = config.cleanupInterval ?? 3600000; // 1 hour
54
+ this.cleanupTimer = setInterval(() => this.cleanup(), interval);
55
+ // Run cleanup on startup
56
+ this.cleanup();
57
+ }
58
+ }
59
+
60
+ async create(process: Omit<ManagedProcess, "id">): Promise<ManagedProcess> {
61
+ const id = `proc_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
62
+
63
+ await this.db
64
+ .insertInto("__donkeylabs_processes__")
65
+ .values({
66
+ id,
67
+ name: process.name,
68
+ pid: process.pid ?? null,
69
+ socket_path: process.socketPath ?? null,
70
+ tcp_port: process.tcpPort ?? null,
71
+ status: process.status,
72
+ config: JSON.stringify(process.config),
73
+ metadata: process.metadata ? JSON.stringify(process.metadata) : null,
74
+ created_at: process.createdAt.toISOString(),
75
+ started_at: process.startedAt?.toISOString() ?? null,
76
+ stopped_at: process.stoppedAt?.toISOString() ?? null,
77
+ last_heartbeat: process.lastHeartbeat?.toISOString() ?? null,
78
+ restart_count: process.restartCount ?? 0,
79
+ consecutive_failures: process.consecutiveFailures ?? 0,
80
+ error: process.error ?? null,
81
+ })
82
+ .execute();
83
+
84
+ return { ...process, id };
85
+ }
86
+
87
+ async get(processId: string): Promise<ManagedProcess | null> {
88
+ const row = await this.db
89
+ .selectFrom("__donkeylabs_processes__")
90
+ .selectAll()
91
+ .where("id", "=", processId)
92
+ .executeTakeFirst();
93
+
94
+ if (!row) return null;
95
+ return this.rowToProcess(row);
96
+ }
97
+
98
+ async update(processId: string, updates: Partial<ManagedProcess>): Promise<void> {
99
+ const updateData: Partial<ProcessesTable> = {};
100
+
101
+ if (updates.pid !== undefined) {
102
+ updateData.pid = updates.pid;
103
+ }
104
+ if (updates.socketPath !== undefined) {
105
+ updateData.socket_path = updates.socketPath;
106
+ }
107
+ if (updates.tcpPort !== undefined) {
108
+ updateData.tcp_port = updates.tcpPort;
109
+ }
110
+ if (updates.status !== undefined) {
111
+ updateData.status = updates.status;
112
+ }
113
+ if (updates.config !== undefined) {
114
+ updateData.config = JSON.stringify(updates.config);
115
+ }
116
+ if (updates.metadata !== undefined) {
117
+ updateData.metadata = updates.metadata ? JSON.stringify(updates.metadata) : null;
118
+ }
119
+ if (updates.startedAt !== undefined) {
120
+ updateData.started_at = updates.startedAt?.toISOString() ?? null;
121
+ }
122
+ if (updates.stoppedAt !== undefined) {
123
+ updateData.stopped_at = updates.stoppedAt?.toISOString() ?? null;
124
+ }
125
+ if (updates.lastHeartbeat !== undefined) {
126
+ updateData.last_heartbeat = updates.lastHeartbeat?.toISOString() ?? null;
127
+ }
128
+ if (updates.restartCount !== undefined) {
129
+ updateData.restart_count = updates.restartCount;
130
+ }
131
+ if (updates.consecutiveFailures !== undefined) {
132
+ updateData.consecutive_failures = updates.consecutiveFailures;
133
+ }
134
+ if (updates.error !== undefined) {
135
+ updateData.error = updates.error;
136
+ }
137
+
138
+ if (Object.keys(updateData).length === 0) return;
139
+
140
+ await this.db
141
+ .updateTable("__donkeylabs_processes__")
142
+ .set(updateData)
143
+ .where("id", "=", processId)
144
+ .execute();
145
+ }
146
+
147
+ async delete(processId: string): Promise<boolean> {
148
+ // Check if exists first since BunSqliteDialect doesn't report numDeletedRows properly
149
+ const exists = await this.db
150
+ .selectFrom("__donkeylabs_processes__")
151
+ .select("id")
152
+ .where("id", "=", processId)
153
+ .executeTakeFirst();
154
+
155
+ if (!exists) return false;
156
+
157
+ await this.db
158
+ .deleteFrom("__donkeylabs_processes__")
159
+ .where("id", "=", processId)
160
+ .execute();
161
+
162
+ return true;
163
+ }
164
+
165
+ async getByName(name: string): Promise<ManagedProcess[]> {
166
+ const rows = await this.db
167
+ .selectFrom("__donkeylabs_processes__")
168
+ .selectAll()
169
+ .where("name", "=", name)
170
+ .orderBy("created_at", "desc")
171
+ .execute();
172
+
173
+ return rows.map((r) => this.rowToProcess(r));
174
+ }
175
+
176
+ async getRunning(): Promise<ManagedProcess[]> {
177
+ const rows = await this.db
178
+ .selectFrom("__donkeylabs_processes__")
179
+ .selectAll()
180
+ .where((eb) =>
181
+ eb.or([eb("status", "=", "running"), eb("status", "=", "spawning")])
182
+ )
183
+ .execute();
184
+
185
+ return rows.map((r) => this.rowToProcess(r));
186
+ }
187
+
188
+ async getOrphaned(): Promise<ManagedProcess[]> {
189
+ const rows = await this.db
190
+ .selectFrom("__donkeylabs_processes__")
191
+ .selectAll()
192
+ .where((eb) =>
193
+ eb.or([
194
+ eb("status", "=", "running"),
195
+ eb("status", "=", "spawning"),
196
+ eb("status", "=", "orphaned"),
197
+ ])
198
+ )
199
+ .execute();
200
+
201
+ return rows.map((r) => this.rowToProcess(r));
202
+ }
203
+
204
+ private rowToProcess(row: ProcessesTable): ManagedProcess {
205
+ return {
206
+ id: row.id,
207
+ name: row.name,
208
+ pid: row.pid ?? undefined,
209
+ socketPath: row.socket_path ?? undefined,
210
+ tcpPort: row.tcp_port ?? undefined,
211
+ status: row.status as ProcessStatus,
212
+ config: JSON.parse(row.config) as ProcessConfig,
213
+ metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
214
+ createdAt: new Date(row.created_at),
215
+ startedAt: row.started_at ? new Date(row.started_at) : undefined,
216
+ stoppedAt: row.stopped_at ? new Date(row.stopped_at) : undefined,
217
+ lastHeartbeat: row.last_heartbeat ? new Date(row.last_heartbeat) : undefined,
218
+ restartCount: row.restart_count ?? 0,
219
+ consecutiveFailures: row.consecutive_failures ?? 0,
220
+ error: row.error ?? undefined,
221
+ };
222
+ }
223
+
224
+ /** Clean up old stopped/crashed processes */
225
+ private async cleanup(): Promise<void> {
226
+ if (this.cleanupDays <= 0) return;
227
+
228
+ try {
229
+ const cutoff = new Date();
230
+ cutoff.setDate(cutoff.getDate() - this.cleanupDays);
231
+
232
+ const result = await this.db
233
+ .deleteFrom("__donkeylabs_processes__")
234
+ .where((eb) =>
235
+ eb.or([
236
+ eb("status", "=", "stopped"),
237
+ eb("status", "=", "crashed"),
238
+ eb("status", "=", "dead"),
239
+ ])
240
+ )
241
+ .where("stopped_at", "<", cutoff.toISOString())
242
+ .execute();
243
+
244
+ const numDeleted = Number(result[0]?.numDeletedRows ?? 0);
245
+ if (numDeleted > 0) {
246
+ console.log(`[Processes] Cleaned up ${numDeleted} old process records`);
247
+ }
248
+ } catch (err) {
249
+ console.error("[Processes] Cleanup error:", err);
250
+ }
251
+ }
252
+
253
+ /** Stop the adapter and cleanup timer */
254
+ stop(): void {
255
+ if (this.cleanupTimer) {
256
+ clearInterval(this.cleanupTimer);
257
+ this.cleanupTimer = undefined;
258
+ }
259
+ }
260
+ }
@@ -0,0 +1,352 @@
1
+ /**
2
+ * WebSocket Core Service
3
+ *
4
+ * Provides bidirectional real-time communication using Bun's native WebSocket support.
5
+ * This is an in-memory service (no persistence needed) for high-frequency messaging.
6
+ */
7
+
8
+ import type { ServerWebSocket } from "bun";
9
+
10
+ // ============================================
11
+ // Types
12
+ // ============================================
13
+
14
+ export interface WebSocketClient {
15
+ id: string;
16
+ socket: ServerWebSocket<WebSocketData>;
17
+ channels: Set<string>;
18
+ metadata?: Record<string, any>;
19
+ connectedAt: Date;
20
+ lastMessageAt?: Date;
21
+ }
22
+
23
+ export interface WebSocketData {
24
+ clientId: string;
25
+ }
26
+
27
+ export interface WebSocketMessage {
28
+ event: string;
29
+ data: any;
30
+ channel?: string;
31
+ }
32
+
33
+ export type WebSocketMessageHandler = (
34
+ clientId: string,
35
+ event: string,
36
+ data: any
37
+ ) => void | Promise<void>;
38
+
39
+ // ============================================
40
+ // Service Interface
41
+ // ============================================
42
+
43
+ export interface WebSocketService {
44
+ /** Broadcast a message to all clients in a channel */
45
+ broadcast(channel: string, event: string, data: any): void;
46
+ /** Broadcast a message to all connected clients */
47
+ broadcastAll(event: string, data: any): void;
48
+ /** Send a message to a specific client */
49
+ send(clientId: string, event: string, data: any): boolean;
50
+ /** Subscribe a client to a channel */
51
+ subscribe(clientId: string, channel: string): boolean;
52
+ /** Unsubscribe a client from a channel */
53
+ unsubscribe(clientId: string, channel: string): boolean;
54
+ /** Register a message handler */
55
+ onMessage(handler: WebSocketMessageHandler): void;
56
+ /** Get all clients in a channel */
57
+ getClients(channel?: string): string[];
58
+ /** Get client count */
59
+ getClientCount(channel?: string): number;
60
+ /** Check if a client is connected */
61
+ isConnected(clientId: string): boolean;
62
+ /** Get client metadata */
63
+ getClientMetadata(clientId: string): Record<string, any> | undefined;
64
+ /** Set client metadata */
65
+ setClientMetadata(clientId: string, metadata: Record<string, any>): boolean;
66
+ /** Handle an incoming WebSocket connection (for server integration) */
67
+ handleUpgrade(
68
+ clientId: string,
69
+ socket: ServerWebSocket<WebSocketData>,
70
+ metadata?: Record<string, any>
71
+ ): void;
72
+ /** Handle a client disconnection */
73
+ handleClose(clientId: string): void;
74
+ /** Handle an incoming message */
75
+ handleMessage(clientId: string, message: string | Buffer): void;
76
+ /** Close all connections and cleanup */
77
+ shutdown(): void;
78
+ }
79
+
80
+ // ============================================
81
+ // Configuration
82
+ // ============================================
83
+
84
+ export interface WebSocketConfig {
85
+ /** Maximum clients per channel (default: unlimited) */
86
+ maxClientsPerChannel?: number;
87
+ /** Ping interval in ms to keep connections alive (default: 30000) */
88
+ pingInterval?: number;
89
+ /** Maximum message size in bytes (default: 1MB) */
90
+ maxMessageSize?: number;
91
+ }
92
+
93
+ // ============================================
94
+ // Implementation
95
+ // ============================================
96
+
97
+ class WebSocketServiceImpl implements WebSocketService {
98
+ private clients = new Map<string, WebSocketClient>();
99
+ private channels = new Map<string, Set<string>>(); // channel -> Set<clientId>
100
+ private messageHandlers: WebSocketMessageHandler[] = [];
101
+ private pingTimer?: ReturnType<typeof setInterval>;
102
+ private maxClientsPerChannel: number;
103
+ private pingInterval: number;
104
+ private maxMessageSize: number;
105
+
106
+ constructor(config: WebSocketConfig = {}) {
107
+ this.maxClientsPerChannel = config.maxClientsPerChannel ?? Infinity;
108
+ this.pingInterval = config.pingInterval ?? 30000;
109
+ this.maxMessageSize = config.maxMessageSize ?? 1024 * 1024; // 1MB
110
+
111
+ // Start ping timer
112
+ this.startPingTimer();
113
+ }
114
+
115
+ broadcast(channel: string, event: string, data: any): void {
116
+ const channelClients = this.channels.get(channel);
117
+ if (!channelClients) return;
118
+
119
+ const message = JSON.stringify({ event, data, channel });
120
+
121
+ for (const clientId of channelClients) {
122
+ const client = this.clients.get(clientId);
123
+ if (client) {
124
+ try {
125
+ client.socket.send(message);
126
+ } catch {
127
+ // Client may have disconnected
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ broadcastAll(event: string, data: any): void {
134
+ const message = JSON.stringify({ event, data });
135
+
136
+ for (const client of this.clients.values()) {
137
+ try {
138
+ client.socket.send(message);
139
+ } catch {
140
+ // Client may have disconnected
141
+ }
142
+ }
143
+ }
144
+
145
+ send(clientId: string, event: string, data: any): boolean {
146
+ const client = this.clients.get(clientId);
147
+ if (!client) return false;
148
+
149
+ try {
150
+ client.socket.send(JSON.stringify({ event, data }));
151
+ return true;
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ subscribe(clientId: string, channel: string): boolean {
158
+ const client = this.clients.get(clientId);
159
+ if (!client) return false;
160
+
161
+ // Check max clients per channel
162
+ let channelClients = this.channels.get(channel);
163
+ if (!channelClients) {
164
+ channelClients = new Set();
165
+ this.channels.set(channel, channelClients);
166
+ }
167
+
168
+ if (channelClients.size >= this.maxClientsPerChannel) {
169
+ return false;
170
+ }
171
+
172
+ channelClients.add(clientId);
173
+ client.channels.add(channel);
174
+ return true;
175
+ }
176
+
177
+ unsubscribe(clientId: string, channel: string): boolean {
178
+ const client = this.clients.get(clientId);
179
+ if (!client) return false;
180
+
181
+ client.channels.delete(channel);
182
+
183
+ const channelClients = this.channels.get(channel);
184
+ if (channelClients) {
185
+ channelClients.delete(clientId);
186
+ if (channelClients.size === 0) {
187
+ this.channels.delete(channel);
188
+ }
189
+ }
190
+
191
+ return true;
192
+ }
193
+
194
+ onMessage(handler: WebSocketMessageHandler): void {
195
+ this.messageHandlers.push(handler);
196
+ }
197
+
198
+ getClients(channel?: string): string[] {
199
+ if (channel) {
200
+ const channelClients = this.channels.get(channel);
201
+ return channelClients ? Array.from(channelClients) : [];
202
+ }
203
+ return Array.from(this.clients.keys());
204
+ }
205
+
206
+ getClientCount(channel?: string): number {
207
+ if (channel) {
208
+ return this.channels.get(channel)?.size ?? 0;
209
+ }
210
+ return this.clients.size;
211
+ }
212
+
213
+ isConnected(clientId: string): boolean {
214
+ return this.clients.has(clientId);
215
+ }
216
+
217
+ getClientMetadata(clientId: string): Record<string, any> | undefined {
218
+ return this.clients.get(clientId)?.metadata;
219
+ }
220
+
221
+ setClientMetadata(clientId: string, metadata: Record<string, any>): boolean {
222
+ const client = this.clients.get(clientId);
223
+ if (!client) return false;
224
+ client.metadata = { ...client.metadata, ...metadata };
225
+ return true;
226
+ }
227
+
228
+ handleUpgrade(
229
+ clientId: string,
230
+ socket: ServerWebSocket<WebSocketData>,
231
+ metadata?: Record<string, any>
232
+ ): void {
233
+ const client: WebSocketClient = {
234
+ id: clientId,
235
+ socket,
236
+ channels: new Set(),
237
+ metadata,
238
+ connectedAt: new Date(),
239
+ };
240
+
241
+ this.clients.set(clientId, client);
242
+ console.log(`[WebSocket] Client connected: ${clientId}`);
243
+ }
244
+
245
+ handleClose(clientId: string): void {
246
+ const client = this.clients.get(clientId);
247
+ if (!client) return;
248
+
249
+ // Remove from all channels
250
+ for (const channel of client.channels) {
251
+ const channelClients = this.channels.get(channel);
252
+ if (channelClients) {
253
+ channelClients.delete(clientId);
254
+ if (channelClients.size === 0) {
255
+ this.channels.delete(channel);
256
+ }
257
+ }
258
+ }
259
+
260
+ this.clients.delete(clientId);
261
+ console.log(`[WebSocket] Client disconnected: ${clientId}`);
262
+ }
263
+
264
+ handleMessage(clientId: string, message: string | Buffer): void {
265
+ const client = this.clients.get(clientId);
266
+ if (!client) return;
267
+
268
+ client.lastMessageAt = new Date();
269
+
270
+ // Check message size
271
+ const messageStr = typeof message === "string" ? message : message.toString();
272
+ if (messageStr.length > this.maxMessageSize) {
273
+ console.warn(`[WebSocket] Message too large from ${clientId}`);
274
+ return;
275
+ }
276
+
277
+ try {
278
+ const parsed = JSON.parse(messageStr) as WebSocketMessage;
279
+ const { event, data } = parsed;
280
+
281
+ // Handle built-in events
282
+ if (event === "subscribe" && data?.channel) {
283
+ this.subscribe(clientId, data.channel);
284
+ return;
285
+ }
286
+ if (event === "unsubscribe" && data?.channel) {
287
+ this.unsubscribe(clientId, data.channel);
288
+ return;
289
+ }
290
+ if (event === "ping") {
291
+ this.send(clientId, "pong", { timestamp: Date.now() });
292
+ return;
293
+ }
294
+
295
+ // Call registered handlers
296
+ for (const handler of this.messageHandlers) {
297
+ try {
298
+ handler(clientId, event, data);
299
+ } catch (err) {
300
+ console.error(`[WebSocket] Message handler error:`, err);
301
+ }
302
+ }
303
+ } catch {
304
+ console.warn(`[WebSocket] Invalid message from ${clientId}`);
305
+ }
306
+ }
307
+
308
+ shutdown(): void {
309
+ // Stop ping timer
310
+ if (this.pingTimer) {
311
+ clearInterval(this.pingTimer);
312
+ this.pingTimer = undefined;
313
+ }
314
+
315
+ // Close all connections
316
+ for (const client of this.clients.values()) {
317
+ try {
318
+ client.socket.close(1000, "Server shutting down");
319
+ } catch {
320
+ // Already closed
321
+ }
322
+ }
323
+
324
+ this.clients.clear();
325
+ this.channels.clear();
326
+ this.messageHandlers = [];
327
+
328
+ console.log("[WebSocket] Service shutdown complete");
329
+ }
330
+
331
+ private startPingTimer(): void {
332
+ this.pingTimer = setInterval(() => {
333
+ const now = Date.now();
334
+
335
+ for (const client of this.clients.values()) {
336
+ try {
337
+ client.socket.send(JSON.stringify({ event: "ping", data: { timestamp: now } }));
338
+ } catch {
339
+ // Client may have disconnected
340
+ }
341
+ }
342
+ }, this.pingInterval);
343
+ }
344
+ }
345
+
346
+ // ============================================
347
+ // Factory Function
348
+ // ============================================
349
+
350
+ export function createWebSocket(config?: WebSocketConfig): WebSocketService {
351
+ return new WebSocketServiceImpl(config);
352
+ }