@donkeylabs/server 0.5.1 → 0.6.3

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,521 @@
1
+ /**
2
+ * Process Socket Server
3
+ * Handles bidirectional communication with managed processes via Unix sockets (or TCP on Windows)
4
+ */
5
+
6
+ import { mkdir, rm, readdir, unlink } from "node:fs/promises";
7
+ import { existsSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import type { Server as NetServer, Socket } from "node:net";
10
+ import { createServer as createNetServer, createConnection } from "node:net";
11
+
12
+ // ============================================
13
+ // Types
14
+ // ============================================
15
+
16
+ export interface ProcessMessage {
17
+ type: string;
18
+ processId: string;
19
+ timestamp: number;
20
+ [key: string]: any;
21
+ }
22
+
23
+ export interface ProcessSocketServerOptions {
24
+ /** Directory for Unix sockets */
25
+ socketDir: string;
26
+ /** TCP port range for Windows fallback */
27
+ tcpPortRange: [number, number];
28
+ /** Callback when a message is received */
29
+ onMessage: (message: ProcessMessage) => void;
30
+ /** Callback when a connection is established */
31
+ onConnect?: (processId: string) => void;
32
+ /** Callback when a connection is closed */
33
+ onDisconnect?: (processId: string) => void;
34
+ /** Callback for errors */
35
+ onError?: (error: Error, processId?: string) => void;
36
+ }
37
+
38
+ export interface ProcessSocketServer {
39
+ /** Create a new socket for a process (returns socket path or TCP port) */
40
+ createSocket(processId: string): Promise<{ socketPath?: string; tcpPort?: number }>;
41
+ /** Close a specific process's socket and release reservations */
42
+ closeSocket(processId: string): Promise<void>;
43
+ /** Send a message to a process */
44
+ send(processId: string, message: any): Promise<boolean>;
45
+ /** Get all active process connections */
46
+ getActiveConnections(): string[];
47
+ /** Attempt to reconnect to an existing socket */
48
+ reconnect(processId: string, socketPath?: string, tcpPort?: number): Promise<boolean>;
49
+ /** Reserve a socket path/port for an orphaned process (prevents reuse until released) */
50
+ reserve(processId: string, socketPath?: string, tcpPort?: number): void;
51
+ /** Release reservation for a process (called when process is cleaned up) */
52
+ release(processId: string): void;
53
+ /** Check if a socket path or port is reserved */
54
+ isReserved(socketPath?: string, tcpPort?: number): boolean;
55
+ /** Shutdown all sockets and cleanup */
56
+ shutdown(): Promise<void>;
57
+ /** Clean orphaned socket files from a previous run */
58
+ cleanOrphanedSockets(activeProcessIds: Set<string>): Promise<void>;
59
+ }
60
+
61
+ // ============================================
62
+ // Implementation
63
+ // ============================================
64
+
65
+ export class ProcessSocketServerImpl implements ProcessSocketServer {
66
+ private socketDir: string;
67
+ private tcpPortRange: [number, number];
68
+ private onMessage: (message: ProcessMessage) => void;
69
+ private onConnect?: (processId: string) => void;
70
+ private onDisconnect?: (processId: string) => void;
71
+ private onError?: (error: Error, processId?: string) => void;
72
+
73
+ // Map of processId -> server instance
74
+ private servers = new Map<string, NetServer>();
75
+ // Map of processId -> active client socket
76
+ private clientSockets = new Map<string, Socket>();
77
+ // Map of processId -> socket path
78
+ private socketPaths = new Map<string, string>();
79
+ // Map of processId -> TCP port
80
+ private tcpPorts = new Map<string, number>();
81
+ // Track used TCP ports
82
+ private usedPorts = new Set<number>();
83
+ // Track reserved socket paths (for processes that might reconnect)
84
+ private reservedSocketPaths = new Set<string>();
85
+ // Track reserved TCP ports (for processes that might reconnect)
86
+ private reservedTcpPorts = new Set<number>();
87
+ // Map processId -> reserved socket path (for release by processId)
88
+ private processReservedSocketPath = new Map<string, string>();
89
+ // Map processId -> reserved TCP port (for release by processId)
90
+ private processReservedTcpPort = new Map<string, number>();
91
+
92
+ private isWindows = process.platform === "win32";
93
+
94
+ constructor(options: ProcessSocketServerOptions) {
95
+ this.socketDir = options.socketDir;
96
+ this.tcpPortRange = options.tcpPortRange;
97
+ this.onMessage = options.onMessage;
98
+ this.onConnect = options.onConnect;
99
+ this.onDisconnect = options.onDisconnect;
100
+ this.onError = options.onError;
101
+ }
102
+
103
+ async createSocket(processId: string): Promise<{ socketPath?: string; tcpPort?: number }> {
104
+ // Ensure socket directory exists (only for Unix)
105
+ if (!this.isWindows) {
106
+ await mkdir(this.socketDir, { recursive: true });
107
+ }
108
+
109
+ if (this.isWindows) {
110
+ return this.createTcpServer(processId);
111
+ } else {
112
+ return this.createUnixServer(processId);
113
+ }
114
+ }
115
+
116
+ private async createUnixServer(processId: string): Promise<{ socketPath: string }> {
117
+ const socketPath = join(this.socketDir, `proc_${processId}.sock`);
118
+
119
+ // Check if this socket path is reserved by another process
120
+ if (this.reservedSocketPaths.has(socketPath) && !this.processReservedSocketPath.has(processId)) {
121
+ throw new Error(`Socket path ${socketPath} is reserved by another process`);
122
+ }
123
+
124
+ // Remove existing socket file if it exists
125
+ if (existsSync(socketPath)) {
126
+ await unlink(socketPath);
127
+ }
128
+
129
+ return new Promise((resolve, reject) => {
130
+ const server = createNetServer((socket) => {
131
+ this.handleConnection(processId, socket);
132
+ });
133
+
134
+ server.on("error", (err) => {
135
+ this.onError?.(err, processId);
136
+ reject(err);
137
+ });
138
+
139
+ server.listen(socketPath, () => {
140
+ this.servers.set(processId, server);
141
+ this.socketPaths.set(processId, socketPath);
142
+ resolve({ socketPath });
143
+ });
144
+ });
145
+ }
146
+
147
+ private async createTcpServer(processId: string): Promise<{ tcpPort: number }> {
148
+ const port = await this.findAvailablePort();
149
+
150
+ return new Promise((resolve, reject) => {
151
+ const server = createNetServer((socket) => {
152
+ this.handleConnection(processId, socket);
153
+ });
154
+
155
+ server.on("error", (err) => {
156
+ this.usedPorts.delete(port);
157
+ this.onError?.(err, processId);
158
+ reject(err);
159
+ });
160
+
161
+ server.listen(port, "127.0.0.1", () => {
162
+ this.servers.set(processId, server);
163
+ this.tcpPorts.set(processId, port);
164
+ this.usedPorts.add(port);
165
+ resolve({ tcpPort: port });
166
+ });
167
+ });
168
+ }
169
+
170
+ private async findAvailablePort(): Promise<number> {
171
+ const [minPort, maxPort] = this.tcpPortRange;
172
+
173
+ // Try random ports within range
174
+ for (let i = 0; i < 100; i++) {
175
+ const port = minPort + Math.floor(Math.random() * (maxPort - minPort));
176
+ // Skip if port is already in use or reserved by another process
177
+ if (this.usedPorts.has(port) || this.reservedTcpPorts.has(port)) {
178
+ continue;
179
+ }
180
+ // Check if port is actually available
181
+ const isAvailable = await this.checkPortAvailable(port);
182
+ if (isAvailable) {
183
+ return port;
184
+ }
185
+ }
186
+
187
+ throw new Error(
188
+ `Could not find available port in range ${minPort}-${maxPort}`
189
+ );
190
+ }
191
+
192
+ private checkPortAvailable(port: number): Promise<boolean> {
193
+ return new Promise((resolve) => {
194
+ const server = createNetServer();
195
+ server.once("error", () => resolve(false));
196
+ server.once("listening", () => {
197
+ server.close(() => resolve(true));
198
+ });
199
+ server.listen(port, "127.0.0.1");
200
+ });
201
+ }
202
+
203
+ private handleConnection(processId: string, socket: Socket): void {
204
+ // Store the client socket
205
+ this.clientSockets.set(processId, socket);
206
+ this.onConnect?.(processId);
207
+
208
+ let buffer = "";
209
+
210
+ socket.on("data", (data) => {
211
+ buffer += data.toString();
212
+
213
+ // Process complete messages (newline-delimited JSON)
214
+ const lines = buffer.split("\n");
215
+ buffer = lines.pop() || ""; // Keep incomplete line in buffer
216
+
217
+ for (const line of lines) {
218
+ if (!line.trim()) continue;
219
+
220
+ const message = this.parseMessage(line);
221
+ if (message) {
222
+ this.onMessage(message);
223
+ } else {
224
+ this.onError?.(new Error(`Invalid message: ${line}`), processId);
225
+ }
226
+ }
227
+ });
228
+
229
+ socket.on("error", (err) => {
230
+ this.onError?.(err, processId);
231
+ });
232
+
233
+ socket.on("close", () => {
234
+ this.clientSockets.delete(processId);
235
+ this.onDisconnect?.(processId);
236
+ });
237
+ }
238
+
239
+ private parseMessage(data: string): ProcessMessage | null {
240
+ try {
241
+ const parsed = JSON.parse(data);
242
+ if (!parsed.type || !parsed.processId || typeof parsed.timestamp !== "number") {
243
+ return null;
244
+ }
245
+ return parsed as ProcessMessage;
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+
251
+ async send(processId: string, message: any): Promise<boolean> {
252
+ const socket = this.clientSockets.get(processId);
253
+ if (!socket || socket.destroyed) {
254
+ return false;
255
+ }
256
+
257
+ return new Promise((resolve) => {
258
+ const data = JSON.stringify(message) + "\n";
259
+ socket.write(data, (err) => {
260
+ if (err) {
261
+ this.onError?.(err, processId);
262
+ resolve(false);
263
+ } else {
264
+ resolve(true);
265
+ }
266
+ });
267
+ });
268
+ }
269
+
270
+ async closeSocket(processId: string): Promise<void> {
271
+ // Close client socket
272
+ const clientSocket = this.clientSockets.get(processId);
273
+ if (clientSocket) {
274
+ clientSocket.destroy();
275
+ this.clientSockets.delete(processId);
276
+ }
277
+
278
+ // Close server
279
+ const server = this.servers.get(processId);
280
+ if (server) {
281
+ await new Promise<void>((resolve) => {
282
+ server.close(() => resolve());
283
+ });
284
+ this.servers.delete(processId);
285
+ }
286
+
287
+ // Clean up socket file (Unix only)
288
+ const socketPath = this.socketPaths.get(processId);
289
+ if (socketPath && existsSync(socketPath)) {
290
+ await unlink(socketPath).catch(() => {
291
+ // Ignore errors during cleanup
292
+ });
293
+ }
294
+ this.socketPaths.delete(processId);
295
+
296
+ // Clean up port tracking (TCP)
297
+ const port = this.tcpPorts.get(processId);
298
+ if (port) {
299
+ this.usedPorts.delete(port);
300
+ this.tcpPorts.delete(processId);
301
+ }
302
+
303
+ // Release any reservations for this process
304
+ this.release(processId);
305
+ }
306
+
307
+ getActiveConnections(): string[] {
308
+ return Array.from(this.clientSockets.keys());
309
+ }
310
+
311
+ reserve(processId: string, socketPath?: string, tcpPort?: number): void {
312
+ if (socketPath) {
313
+ this.reservedSocketPaths.add(socketPath);
314
+ this.processReservedSocketPath.set(processId, socketPath);
315
+ }
316
+ if (tcpPort) {
317
+ this.reservedTcpPorts.add(tcpPort);
318
+ this.processReservedTcpPort.set(processId, tcpPort);
319
+ }
320
+ }
321
+
322
+ release(processId: string): void {
323
+ // Release socket path reservation
324
+ const socketPath = this.processReservedSocketPath.get(processId);
325
+ if (socketPath) {
326
+ this.reservedSocketPaths.delete(socketPath);
327
+ this.processReservedSocketPath.delete(processId);
328
+ }
329
+ // Also check socketPaths map (for active processes)
330
+ const activeSocketPath = this.socketPaths.get(processId);
331
+ if (activeSocketPath) {
332
+ this.reservedSocketPaths.delete(activeSocketPath);
333
+ }
334
+
335
+ // Release TCP port reservation
336
+ const tcpPort = this.processReservedTcpPort.get(processId);
337
+ if (tcpPort) {
338
+ this.reservedTcpPorts.delete(tcpPort);
339
+ this.processReservedTcpPort.delete(processId);
340
+ }
341
+ // Also check tcpPorts map (for active processes)
342
+ const activeTcpPort = this.tcpPorts.get(processId);
343
+ if (activeTcpPort) {
344
+ this.reservedTcpPorts.delete(activeTcpPort);
345
+ }
346
+ }
347
+
348
+ isReserved(socketPath?: string, tcpPort?: number): boolean {
349
+ if (socketPath && this.reservedSocketPaths.has(socketPath)) {
350
+ return true;
351
+ }
352
+ if (tcpPort && this.reservedTcpPorts.has(tcpPort)) {
353
+ return true;
354
+ }
355
+ return false;
356
+ }
357
+
358
+ async reconnect(
359
+ processId: string,
360
+ socketPath?: string,
361
+ tcpPort?: number
362
+ ): Promise<boolean> {
363
+ // Check if we already have a connection
364
+ if (this.clientSockets.has(processId)) {
365
+ return true;
366
+ }
367
+
368
+ // For Unix sockets, recreate the server on the same path
369
+ // The external process should be retrying to connect
370
+ if (socketPath && !this.isWindows) {
371
+ try {
372
+ // Remove old socket file if it exists
373
+ if (existsSync(socketPath)) {
374
+ await unlink(socketPath);
375
+ }
376
+
377
+ // Create new server on the same path
378
+ return new Promise((resolve) => {
379
+ const server = createNetServer((socket) => {
380
+ this.handleConnection(processId, socket);
381
+ });
382
+
383
+ server.on("error", (err) => {
384
+ this.onError?.(err, processId);
385
+ resolve(false);
386
+ });
387
+
388
+ server.listen(socketPath, () => {
389
+ this.servers.set(processId, server);
390
+ this.socketPaths.set(processId, socketPath);
391
+ console.log(`[ProcessSocket] Recreated socket for process ${processId} at ${socketPath}`);
392
+ // Return true - the server is ready, external process should reconnect
393
+ resolve(true);
394
+ });
395
+ });
396
+ } catch (err) {
397
+ this.onError?.(err as Error, processId);
398
+ return false;
399
+ }
400
+ }
401
+
402
+ // For TCP, recreate the server on the same port
403
+ if (tcpPort && this.isWindows) {
404
+ try {
405
+ return new Promise((resolve) => {
406
+ const server = createNetServer((socket) => {
407
+ this.handleConnection(processId, socket);
408
+ });
409
+
410
+ server.on("error", (err) => {
411
+ this.onError?.(err, processId);
412
+ resolve(false);
413
+ });
414
+
415
+ server.listen(tcpPort, "127.0.0.1", () => {
416
+ this.servers.set(processId, server);
417
+ this.tcpPorts.set(processId, tcpPort);
418
+ this.usedPorts.add(tcpPort);
419
+ console.log(`[ProcessSocket] Recreated TCP server for process ${processId} on port ${tcpPort}`);
420
+ resolve(true);
421
+ });
422
+ });
423
+ } catch (err) {
424
+ this.onError?.(err as Error, processId);
425
+ return false;
426
+ }
427
+ }
428
+
429
+ return false;
430
+ }
431
+
432
+ async shutdown(): Promise<void> {
433
+ // Close all client sockets
434
+ for (const socket of this.clientSockets.values()) {
435
+ socket.destroy();
436
+ }
437
+ this.clientSockets.clear();
438
+
439
+ // Close all servers
440
+ const closePromises = Array.from(this.servers.values()).map(
441
+ (server) =>
442
+ new Promise<void>((resolve) => {
443
+ server.close(() => resolve());
444
+ })
445
+ );
446
+ await Promise.all(closePromises);
447
+ this.servers.clear();
448
+
449
+ // Clean up socket files
450
+ for (const socketPath of this.socketPaths.values()) {
451
+ if (existsSync(socketPath)) {
452
+ await unlink(socketPath).catch(() => {});
453
+ }
454
+ }
455
+ this.socketPaths.clear();
456
+ this.tcpPorts.clear();
457
+ this.usedPorts.clear();
458
+ }
459
+
460
+ async cleanOrphanedSockets(activeProcessIds: Set<string>): Promise<void> {
461
+ if (this.isWindows) {
462
+ // No socket files to clean on Windows
463
+ return;
464
+ }
465
+
466
+ if (!existsSync(this.socketDir)) {
467
+ return;
468
+ }
469
+
470
+ try {
471
+ const files = await readdir(this.socketDir);
472
+
473
+ for (const file of files) {
474
+ // Match socket files: proc_<processId>.sock
475
+ const match = file.match(/^proc_(.+)\.sock$/);
476
+ if (match) {
477
+ const processId = match[1]!;
478
+ const socketPath = join(this.socketDir, file);
479
+
480
+ // Don't clean if process is active or socket path is reserved
481
+ if (!activeProcessIds.has(processId) && !this.reservedSocketPaths.has(socketPath)) {
482
+ // This socket file doesn't correspond to any active process and isn't reserved
483
+ await unlink(socketPath).catch(() => {});
484
+ }
485
+ }
486
+ }
487
+ } catch {
488
+ // Ignore errors during cleanup
489
+ }
490
+ }
491
+ }
492
+
493
+ // ============================================
494
+ // Factory Function
495
+ // ============================================
496
+
497
+ export interface ProcessSocketConfig {
498
+ /** Directory for Unix sockets (default: /tmp/donkeylabs-processes) */
499
+ socketDir?: string;
500
+ /** TCP port range for Windows fallback (default: [49152, 65535]) */
501
+ tcpPortRange?: [number, number];
502
+ }
503
+
504
+ export function createProcessSocketServer(
505
+ config: ProcessSocketConfig,
506
+ callbacks: {
507
+ onMessage: (message: ProcessMessage) => void;
508
+ onConnect?: (processId: string) => void;
509
+ onDisconnect?: (processId: string) => void;
510
+ onError?: (error: Error, processId?: string) => void;
511
+ }
512
+ ): ProcessSocketServer {
513
+ return new ProcessSocketServerImpl({
514
+ socketDir: config.socketDir ?? "/tmp/donkeylabs-processes",
515
+ tcpPortRange: config.tcpPortRange ?? [49152, 65535],
516
+ onMessage: callbacks.onMessage,
517
+ onConnect: callbacks.onConnect,
518
+ onDisconnect: callbacks.onDisconnect,
519
+ onError: callbacks.onError,
520
+ });
521
+ }