@soederpop/luca 0.0.26 → 0.0.29

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.
@@ -3,6 +3,7 @@ import { FeatureStateSchema, FeatureEventsSchema } from '../../schemas/base.js'
3
3
  import { Feature } from "../feature.js";
4
4
  import { NodeContainer } from "../container.js";
5
5
  import { Server, Socket } from "net";
6
+ import { existsSync } from "fs";
6
7
 
7
8
  /**
8
9
  * Zod schema for the IpcSocket feature state.
@@ -11,18 +12,40 @@ import { Server, Socket } from "net";
11
12
  export const IpcStateSchema = FeatureStateSchema.extend({
12
13
  /** The current mode of the IPC socket - either 'server' or 'client' */
13
14
  mode: z.enum(['server', 'client']).optional().describe('The current mode of the IPC socket - either server or client'),
15
+ /** The socket path this instance is listening on or connected to */
16
+ socketPath: z.string().optional().describe('The socket path this instance is bound to'),
14
17
  })
15
18
  export type IpcState = z.infer<typeof IpcStateSchema>
16
19
 
17
20
  export const IpcEventsSchema = FeatureEventsSchema.extend({
18
21
  connection: z.tuple([
22
+ z.string().describe('The client ID assigned to the connection'),
19
23
  z.any().describe('The connected net.Socket instance'),
20
- ]).describe('Emitted on the server when a new client connects'),
24
+ ]).describe('Emitted on the server when a new client connects (clientId, socket)'),
25
+ disconnection: z.tuple([
26
+ z.string().describe('The client ID that disconnected'),
27
+ ]).describe('Emitted on the server when a client disconnects'),
21
28
  message: z.tuple([
22
29
  z.any().describe('The parsed JSON message object received over the socket'),
23
- ]).describe('Emitted when a complete JSON message is received (server or client)'),
30
+ z.string().optional().describe('The client ID of the sender (server mode only)'),
31
+ ]).describe('Emitted when a complete JSON message is received (data, clientId?)'),
24
32
  })
25
33
 
34
+ /** Tracks a pending request awaiting a reply */
35
+ type PendingRequest = {
36
+ resolve: (value: any) => void
37
+ reject: (reason: any) => void
38
+ timer: ReturnType<typeof setTimeout>
39
+ }
40
+
41
+ /** Metadata for a connected client */
42
+ type ClientInfo = {
43
+ socket: Socket
44
+ id: string
45
+ name?: string
46
+ connectedAt: number
47
+ }
48
+
26
49
  /**
27
50
  * IpcSocket Feature - Inter-Process Communication via Unix Domain Sockets
28
51
  *
@@ -31,53 +54,41 @@ export const IpcEventsSchema = FeatureEventsSchema.extend({
31
54
  * file system-based socket connections.
32
55
  *
33
56
  * **Key Features:**
34
- * - Dual-mode operation: server and client functionality
35
- * - JSON message serialization/deserialization
36
- * - Multiple client connection support (server mode)
37
- * - Event-driven message handling
38
- * - Automatic socket cleanup and management
39
- * - Broadcast messaging to all connected clients
40
- * - Lock file management for socket paths
41
- *
42
- * **Communication Pattern:**
43
- * - Messages are automatically JSON-encoded with unique IDs
44
- * - Both server and client emit 'message' events for incoming data
45
- * - Server can broadcast to all connected clients
46
- * - Client maintains single connection to server
47
- *
48
- * **Socket Management:**
49
- * - Automatic cleanup of stale socket files
50
- * - Connection tracking and management
51
- * - Graceful shutdown procedures
52
- * - Lock file protection against conflicts
53
- *
54
- * **Usage Examples:**
55
- *
56
- * **Server Mode:**
57
+ * - Hub-and-spoke: one server, many named clients with identity tracking
58
+ * - Targeted messaging: sendTo(clientId), broadcast(msg, excludeId)
59
+ * - Request/reply: ask() + reply() with timeout-based correlation
60
+ * - Auto-reconnect: clients reconnect with exponential backoff
61
+ * - Stale socket detection: probeSocket() before listen()
62
+ * - Clean shutdown: stopServer() removes socket file
63
+ *
64
+ * **Server (Hub):**
57
65
  * ```typescript
58
66
  * const ipc = container.feature('ipcSocket');
59
- * await ipc.listen('/tmp/myapp.sock', true); // removeLock=true
60
- *
61
- * ipc.on('connection', (socket) => {
62
- * console.log('Client connected');
67
+ * await ipc.listen('/tmp/hub.sock', true);
68
+ *
69
+ * ipc.on('connection', (clientId, socket) => {
70
+ * console.log('Client joined:', clientId);
63
71
  * });
64
- *
65
- * ipc.on('message', (data) => {
66
- * console.log('Received:', data);
67
- * ipc.broadcast({ reply: 'ACK', original: data });
72
+ *
73
+ * ipc.on('message', (data, clientId) => {
74
+ * console.log(`From ${clientId}:`, data);
75
+ * // Reply to sender, or ask and wait
76
+ * ipc.sendTo(clientId, { ack: true });
68
77
  * });
69
78
  * ```
70
- *
71
- * **Client Mode:**
79
+ *
80
+ * **Client (Spoke):**
72
81
  * ```typescript
73
82
  * const ipc = container.feature('ipcSocket');
74
- * await ipc.connect('/tmp/myapp.sock');
75
- *
83
+ * await ipc.connect('/tmp/hub.sock', { reconnect: true, name: 'worker-1' });
84
+ *
85
+ * // Fire and forget
86
+ * await ipc.send({ type: 'status', ready: true });
87
+ *
88
+ * // Request/reply
76
89
  * ipc.on('message', (data) => {
77
- * console.log('Server says:', data);
90
+ * if (data.requestId) ipc.reply(data.requestId, { result: 42 });
78
91
  * });
79
- *
80
- * await ipc.send({ type: 'request', payload: 'hello' });
81
92
  * ```
82
93
  *
83
94
  * @template T - The state type, defaults to IpcState
@@ -93,41 +104,68 @@ export class IpcSocket<T extends IpcState = IpcState> extends Feature<T> {
93
104
  /** The Node.js net Server instance (when in server mode) */
94
105
  server?: Server;
95
106
 
96
- /** Set of connected client sockets (server mode only) */
97
- protected sockets: Set<Socket> = new Set();
107
+ /** Connected clients keyed by client ID (server mode only) */
108
+ protected clients = new Map<string, ClientInfo>();
109
+
110
+ /** Reverse lookup: socket → clientId */
111
+ private _socketToClient = new WeakMap<Socket, string>();
98
112
 
99
113
  /** Per-socket NDJSON read buffers for accumulating partial lines */
100
114
  private _buffers = new WeakMap<Socket, string>();
101
115
 
116
+ /** Pending request/reply correlation map */
117
+ private _pending = new Map<string, PendingRequest>();
118
+
119
+ /** Default timeout for ask() calls in ms */
120
+ requestTimeoutMs = 10000;
121
+
122
+ /** Reconnection config (client mode) */
123
+ private _reconnect = { enabled: false, attempts: 0, maxAttempts: 10, delayMs: 1000, maxDelayMs: 30000, timer: null as ReturnType<typeof setTimeout> | null }
124
+ private _socketPath?: string;
125
+
102
126
  /**
103
127
  * Attaches the IpcSocket feature to a NodeContainer instance.
104
128
  * Registers the feature and creates an auto-enabled instance.
105
- *
129
+ *
106
130
  * @param container - The NodeContainer to attach to
107
131
  * @returns The container for method chaining
108
132
  */
109
133
  static attach(container: NodeContainer & { ipcSocket?: IpcSocket }) {
110
134
  container.ipcSocket = container.feature("ipcSocket", { enable: true });
111
135
  }
112
-
136
+
113
137
  /**
114
138
  * Checks if the IPC socket is operating in client mode.
115
- *
139
+ *
116
140
  * @returns True if the socket is configured as a client
117
141
  */
118
142
  get isClient() {
119
143
  return this.state.get('mode') === 'client'
120
144
  }
121
-
145
+
122
146
  /**
123
147
  * Checks if the IPC socket is operating in server mode.
124
- *
148
+ *
125
149
  * @returns True if the socket is configured as a server
126
150
  */
127
151
  get isServer() {
128
152
  return this.state.get('mode') === 'server'
129
153
  }
130
154
 
155
+ /**
156
+ * Returns the number of currently connected clients (server mode).
157
+ */
158
+ get clientCount() {
159
+ return this.clients.size
160
+ }
161
+
162
+ /**
163
+ * Returns info about all connected clients (server mode).
164
+ */
165
+ get connectedClients(): Array<{ id: string; name?: string; connectedAt: number }> {
166
+ return Array.from(this.clients.values()).map(({ id, name, connectedAt }) => ({ id, name, connectedAt }))
167
+ }
168
+
131
169
  /**
132
170
  * Starts the IPC server listening on the specified socket path.
133
171
  *
@@ -176,8 +214,12 @@ export class IpcSocket<T extends IpcState = IpcState> extends Feature<T> {
176
214
  async listen(socketPath: string, removeLock = false): Promise<Server> {
177
215
  socketPath = this.container.paths.resolve(socketPath)
178
216
 
179
- if (this.container.fs.exists(socketPath)) {
180
- if(removeLock) {
217
+ if (existsSync(socketPath)) {
218
+ if (removeLock) {
219
+ const alive = await this.probeSocket(socketPath)
220
+ if (alive) {
221
+ throw new Error(`Socket ${socketPath} is already in use by a live process`)
222
+ }
181
223
  await this.container.fs.rm(socketPath)
182
224
  } else {
183
225
  throw new Error('Lock already exists')
@@ -189,24 +231,33 @@ export class IpcSocket<T extends IpcState = IpcState> extends Feature<T> {
189
231
  }
190
232
 
191
233
  this.state.set('mode', 'server')
234
+ this.state.set('socketPath', socketPath)
235
+ this._socketPath = socketPath
192
236
 
193
237
  if (this.server) {
194
238
  throw new Error("An IPC server is already running.");
195
239
  }
196
240
 
197
241
  this.server = new Server((socket) => {
198
- this.sockets.add(socket);
242
+ const clientId = this.container.utils.uuid()
243
+ const clientInfo: ClientInfo = { socket, id: clientId, connectedAt: Date.now() }
244
+ this.clients.set(clientId, clientInfo)
245
+ this._socketToClient.set(socket, clientId)
199
246
  this._buffers.set(socket, '');
200
247
 
248
+ // Send the client its assigned ID
249
+ socket.write(JSON.stringify({ type: '__ipc:welcome', clientId }) + '\n')
250
+
201
251
  socket.on("close", () => {
202
- this.sockets.delete(socket);
252
+ this.clients.delete(clientId);
253
+ this.emit('disconnection', clientId)
203
254
  });
204
255
 
205
256
  socket.on('data', (chunk) => {
206
257
  this._handleChunk(socket, chunk)
207
258
  })
208
259
 
209
- this.emit('connection', socket)
260
+ this.emit('connection', clientId, socket)
210
261
  });
211
262
 
212
263
  this.server.listen(socketPath);
@@ -246,6 +297,10 @@ export class IpcSocket<T extends IpcState = IpcState> extends Feature<T> {
246
297
  }
247
298
 
248
299
  this.server.close((err) => {
300
+ // Clean up socket file
301
+ if (this._socketPath && existsSync(this._socketPath)) {
302
+ try { this.container.fs.rm(this._socketPath) } catch {}
303
+ }
249
304
  if (err) {
250
305
  reject(err);
251
306
  } else {
@@ -253,8 +308,12 @@ export class IpcSocket<T extends IpcState = IpcState> extends Feature<T> {
253
308
  }
254
309
  });
255
310
 
256
- this.sockets.forEach((socket) => socket.destroy());
257
- this.sockets.clear();
311
+ for (const { socket } of this.clients.values()) {
312
+ socket.destroy()
313
+ }
314
+ this.clients.clear();
315
+ this._pending.forEach(({ timer }) => clearTimeout(timer))
316
+ this._pending.clear()
258
317
  this.server = undefined;
259
318
  });
260
319
  }
@@ -273,129 +332,151 @@ export class IpcSocket<T extends IpcState = IpcState> extends Feature<T> {
273
332
 
274
333
  /**
275
334
  * Broadcasts a message to all connected clients (server mode only).
276
- *
277
- * This method sends a JSON-encoded message with a unique ID to every client
278
- * currently connected to the server. Each message is automatically wrapped
279
- * with metadata including a UUID for tracking.
280
- *
281
- * **Message Format:**
282
- * Messages are automatically wrapped in the format:
283
- * ```json
284
- * {
285
- * "data": <your_message>,
286
- * "id": "<uuid>"
287
- * }
288
- * ```
289
- *
290
- * @param message - The message object to broadcast to all clients
335
+ *
336
+ * @param message - The message object to broadcast
337
+ * @param exclude - Optional client ID to exclude from broadcast
291
338
  * @returns This instance for method chaining
292
- *
293
- * @example
294
- * ```typescript
295
- * // Broadcast to all connected clients
296
- * ipc.broadcast({
297
- * type: 'notification',
298
- * message: 'Server is shutting down in 30 seconds',
299
- * timestamp: Date.now()
300
- * });
301
- *
302
- * // Chain multiple operations
303
- * ipc.broadcast({ status: 'ready' })
304
- * .broadcast({ time: new Date().toISOString() });
305
- * ```
306
339
  */
307
- broadcast(message: any) {
308
- this.sockets.forEach((socket) => socket.write(JSON.stringify({
340
+ broadcast(message: any, exclude?: string) {
341
+ const envelope = JSON.stringify({
309
342
  data: message,
310
343
  id: this.container.utils.uuid()
311
- }) + '\n'))
344
+ }) + '\n'
345
+
346
+ for (const [clientId, { socket }] of this.clients) {
347
+ if (clientId === exclude) continue
348
+ if (!socket.writable) continue
349
+ socket.write(envelope)
350
+ }
312
351
 
313
352
  return this
314
353
  }
315
354
 
316
355
  /**
317
- * Sends a message to the server (client mode only).
318
- *
319
- * This method sends a JSON-encoded message with a unique ID to the connected server.
320
- * The message is automatically wrapped with metadata for tracking purposes.
321
- *
322
- * **Message Format:**
323
- * Messages are automatically wrapped in the format:
324
- * ```json
325
- * {
326
- * "data": <your_message>,
327
- * "id": "<uuid>"
328
- * }
329
- * ```
330
- *
331
- * @param message - The message object to send to the server
332
- * @returns Promise that resolves when the message is sent
333
- *
334
- * @throws {Error} When no connection is established
335
- *
336
- * @example
337
- * ```typescript
338
- * // Send a simple message
339
- * await ipc.send({ type: 'ping' });
340
- *
341
- * // Send complex data
342
- * await ipc.send({
343
- * type: 'data_update',
344
- * payload: { users: [...], timestamp: Date.now() }
345
- * });
346
- * ```
356
+ * Sends a message to a specific client by ID (server mode only).
357
+ *
358
+ * @param clientId - The target client ID
359
+ * @param message - The message to send
360
+ * @returns True if the message was sent, false if client not found or not writable
347
361
  */
348
- async send(message: any) {
349
- const id = this.container.utils.uuid()
362
+ sendTo(clientId: string, message: any): boolean {
363
+ const client = this.clients.get(clientId)
364
+ if (!client || !client.socket.writable) return false
365
+
366
+ client.socket.write(JSON.stringify({
367
+ data: message,
368
+ id: this.container.utils.uuid()
369
+ }) + '\n')
370
+
371
+ return true
372
+ }
350
373
 
374
+ /**
375
+ * Fire-and-forget: sends a message to the server (client mode only).
376
+ * For server→client, use sendTo() or broadcast().
377
+ *
378
+ * @param message - The message to send
379
+ */
380
+ async send(message: any) {
351
381
  if(!this._connection) {
352
382
  throw new Error("No connection.")
353
383
  }
354
-
384
+
385
+ const id = this.container.utils.uuid()
355
386
  this._connection.write(JSON.stringify({ id, data: message }) + '\n')
356
387
  }
357
388
 
389
+ /**
390
+ * Sends a message and waits for a correlated reply.
391
+ * Works in both client and server mode.
392
+ *
393
+ * The recipient should call `reply(requestId, response)` to respond.
394
+ *
395
+ * @param message - The message to send
396
+ * @param options - Optional: clientId (server mode target), timeoutMs
397
+ * @returns The reply data
398
+ */
399
+ async ask(message: any, options?: { clientId?: string; timeoutMs?: number }): Promise<any> {
400
+ const requestId = this.container.utils.uuid()
401
+ const timeoutMs = options?.timeoutMs ?? this.requestTimeoutMs
402
+
403
+ return new Promise((resolve, reject) => {
404
+ const timer = setTimeout(() => {
405
+ this._pending.delete(requestId)
406
+ reject(new Error(`IPC ask timed out after ${timeoutMs}ms`))
407
+ }, timeoutMs)
408
+
409
+ this._pending.set(requestId, { resolve, reject, timer })
410
+
411
+ const envelope = JSON.stringify({
412
+ id: requestId,
413
+ data: message,
414
+ requestId,
415
+ }) + '\n'
416
+
417
+ if (this.isServer) {
418
+ const clientId = options?.clientId
419
+ if (!clientId) {
420
+ clearTimeout(timer)
421
+ this._pending.delete(requestId)
422
+ reject(new Error('ask() in server mode requires options.clientId'))
423
+ return
424
+ }
425
+ const client = this.clients.get(clientId)
426
+ if (!client || !client.socket.writable) {
427
+ clearTimeout(timer)
428
+ this._pending.delete(requestId)
429
+ reject(new Error(`Client ${clientId} not found or not writable`))
430
+ return
431
+ }
432
+ client.socket.write(envelope)
433
+ } else {
434
+ if (!this._connection) {
435
+ clearTimeout(timer)
436
+ this._pending.delete(requestId)
437
+ reject(new Error('No connection'))
438
+ return
439
+ }
440
+ this._connection.write(envelope)
441
+ }
442
+ })
443
+ }
444
+
445
+ /**
446
+ * Sends a reply to a previous ask() call, correlated by requestId.
447
+ *
448
+ * @param requestId - The requestId from the incoming message
449
+ * @param data - The reply payload
450
+ * @param clientId - Target client (server mode; for client mode, omit)
451
+ */
452
+ reply(requestId: string, data: any, clientId?: string) {
453
+ const envelope = JSON.stringify({
454
+ id: this.container.utils.uuid(),
455
+ data,
456
+ replyTo: requestId,
457
+ }) + '\n'
458
+
459
+ if (this.isServer && clientId) {
460
+ const client = this.clients.get(clientId)
461
+ if (client?.socket.writable) {
462
+ client.socket.write(envelope)
463
+ }
464
+ } else if (this._connection) {
465
+ this._connection.write(envelope)
466
+ }
467
+ }
468
+
469
+ /** The server-assigned client ID (client mode only) */
470
+ clientId?: string;
471
+
358
472
  /**
359
473
  * Connects to an IPC server at the specified socket path (client mode).
360
- *
361
- * This method establishes a client connection to an existing IPC server.
362
- * Once connected, the client can send messages to the server and receive
363
- * responses. The connection is maintained until explicitly closed or the
364
- * server terminates.
365
- *
366
- * **Connection Behavior:**
367
- * - Sets the socket mode to 'client'
368
- * - Returns existing connection if already connected
369
- * - Automatically handles connection events and cleanup
370
- * - JSON-parses incoming messages and emits 'message' events
371
- * - Cleans up connection reference when socket closes
372
- *
373
- * **Error Handling:**
374
- * - Throws error if already in server mode
375
- * - Rejects promise on connection failures
376
- * - Automatically cleans up on connection close
377
- *
378
- * @param socketPath - The file system path to the server's Unix domain socket
379
- * @returns Promise resolving to the established Socket connection
380
- *
381
- * @throws {Error} When already in server mode or connection fails
382
- *
383
- * @example
384
- * ```typescript
385
- * // Connect to server
386
- * const socket = await ipc.connect('/tmp/myapp.sock');
387
- * console.log('Connected to IPC server');
388
- *
389
- * // Handle incoming messages
390
- * ipc.on('message', (data) => {
391
- * console.log('Server message:', data);
392
- * });
393
- *
394
- * // Send messages
395
- * await ipc.send({ type: 'hello', client_id: 'client_001' });
396
- * ```
474
+ *
475
+ * @param socketPath - Path to the server's Unix domain socket
476
+ * @param options - Optional: reconnect (enable auto-reconnect), name (identify this client)
477
+ * @returns The established Socket connection
397
478
  */
398
- async connect(socketPath: string): Promise<Socket> {
479
+ async connect(socketPath: string, options?: { reconnect?: boolean; name?: string }): Promise<Socket> {
399
480
  if(this.isServer) {
400
481
  throw new Error("Cannot connect on a server socket.")
401
482
  }
@@ -404,19 +485,21 @@ export class IpcSocket<T extends IpcState = IpcState> extends Feature<T> {
404
485
  return this._connection
405
486
  }
406
487
 
407
- const connection : Socket = await new Promise((resolve, reject) => {
408
- const socket = new Socket();
409
- socket.connect(socketPath, () => {
410
- resolve(socket);
411
- });
488
+ this._socketPath = socketPath
489
+ this.state.set('socketPath', socketPath)
490
+
491
+ if (options?.reconnect !== undefined) {
492
+ this._reconnect.enabled = options.reconnect
493
+ }
494
+
495
+ const connection: Socket = await this._doConnect(socketPath)
412
496
 
413
- socket.on("error", (err) => {
414
- reject(err);
415
- });
416
- });
417
-
418
497
  connection.on("close", () => {
419
498
  this._connection = undefined
499
+ this.clientId = undefined
500
+ if (this._reconnect.enabled) {
501
+ this._scheduleReconnect()
502
+ }
420
503
  })
421
504
 
422
505
  this._buffers.set(connection, '');
@@ -424,31 +507,138 @@ export class IpcSocket<T extends IpcState = IpcState> extends Feature<T> {
424
507
  this._handleChunk(connection, chunk)
425
508
  })
426
509
 
427
- return this._connection = connection as Socket
510
+ this._connection = connection
511
+ this._reconnect.attempts = 0
512
+
513
+ // Send identity if a name was provided
514
+ if (options?.name) {
515
+ connection.write(JSON.stringify({
516
+ id: this.container.utils.uuid(),
517
+ data: { type: '__ipc:identify', name: options.name }
518
+ }) + '\n')
519
+ }
520
+
521
+ return connection
522
+ }
523
+
524
+ private _doConnect(socketPath: string): Promise<Socket> {
525
+ return new Promise((resolve, reject) => {
526
+ const socket = new Socket();
527
+ socket.connect(socketPath, () => resolve(socket));
528
+ socket.on("error", (err) => reject(err));
529
+ })
530
+ }
531
+
532
+ private _scheduleReconnect() {
533
+ if (this._reconnect.timer) return
534
+ if (this._reconnect.attempts >= this._reconnect.maxAttempts) {
535
+ this.emit('message', { type: '__ipc:reconnect_failed', attempts: this._reconnect.attempts })
536
+ return
537
+ }
538
+
539
+ const delay = Math.min(
540
+ this._reconnect.delayMs * Math.pow(2, this._reconnect.attempts),
541
+ this._reconnect.maxDelayMs
542
+ )
543
+ this._reconnect.attempts++
544
+
545
+ this._reconnect.timer = setTimeout(async () => {
546
+ this._reconnect.timer = null
547
+ if (!this._socketPath) return
548
+
549
+ try {
550
+ await this.connect(this._socketPath)
551
+ } catch {
552
+ this._scheduleReconnect()
553
+ }
554
+ }, delay)
428
555
  }
429
556
 
430
557
  /**
431
- * Accumulates incoming data into a per-socket buffer and emits
432
- * a `message` event for each complete NDJSON line (newline-delimited JSON).
433
- *
434
- * This handles the common stream framing issues:
435
- * - Partial messages split across multiple `data` events
436
- * - Multiple messages arriving in a single `data` event
437
- * - Malformed lines (silently skipped)
438
- *
439
- * @param socket - The socket the data arrived on
440
- * @param chunk - The raw data chunk
558
+ * Disconnects the client and stops any reconnection attempts.
559
+ */
560
+ disconnect() {
561
+ this._reconnect.enabled = false
562
+ if (this._reconnect.timer) {
563
+ clearTimeout(this._reconnect.timer)
564
+ this._reconnect.timer = null
565
+ }
566
+ if (this._connection) {
567
+ this._connection.destroy()
568
+ this._connection = undefined
569
+ }
570
+ this._pending.forEach(({ timer }) => clearTimeout(timer))
571
+ this._pending.clear()
572
+ }
573
+
574
+ /**
575
+ * Probe an existing socket to see if a live listener is behind it.
576
+ * Attempts a quick connect — if it succeeds, someone is listening.
441
577
  */
578
+ probeSocket(socketPath: string): Promise<boolean> {
579
+ if (!existsSync(socketPath)) return Promise.resolve(false)
580
+ return new Promise<boolean>((resolve) => {
581
+ const probe = new Socket()
582
+ const timer = setTimeout(() => {
583
+ probe.destroy()
584
+ resolve(false)
585
+ }, 500)
586
+
587
+ probe.once('connect', () => {
588
+ clearTimeout(timer)
589
+ probe.destroy()
590
+ resolve(true)
591
+ })
592
+
593
+ probe.once('error', () => {
594
+ clearTimeout(timer)
595
+ probe.destroy()
596
+ resolve(false)
597
+ })
598
+
599
+ probe.connect(socketPath)
600
+ })
601
+ }
602
+
442
603
  private _handleChunk(socket: Socket, chunk: Buffer): void {
443
604
  let buffer = (this._buffers.get(socket) || '') + chunk.toString()
444
605
  const lines = buffer.split('\n')
445
- // Last element is either empty (if chunk ended with \n) or an incomplete line
446
606
  this._buffers.set(socket, lines.pop() || '')
447
607
 
448
608
  for (const line of lines) {
449
609
  if (!line.trim()) continue
450
610
  try {
451
- this.emit('message', JSON.parse(line))
611
+ const parsed = JSON.parse(line)
612
+
613
+ // Handle protocol messages
614
+ if (parsed.type === '__ipc:welcome' && parsed.clientId) {
615
+ this.clientId = parsed.clientId
616
+ this.state.set('mode', 'client')
617
+ continue
618
+ }
619
+
620
+ if (parsed.data?.type === '__ipc:identify' && this.isServer) {
621
+ const clientId = this._socketToClient.get(socket)
622
+ if (clientId) {
623
+ const client = this.clients.get(clientId)
624
+ if (client) client.name = parsed.data.name
625
+ }
626
+ continue
627
+ }
628
+
629
+ // Handle reply correlation
630
+ const replyTo = parsed.replyTo
631
+ if (replyTo && this._pending.has(replyTo)) {
632
+ const pending = this._pending.get(replyTo)!
633
+ this._pending.delete(replyTo)
634
+ clearTimeout(pending.timer)
635
+ pending.resolve(parsed.data)
636
+ continue
637
+ }
638
+
639
+ // Regular message — include sender clientId in server mode
640
+ const clientId = this._socketToClient.get(socket)
641
+ this.emit('message', parsed.data ?? parsed, clientId)
452
642
  } catch {
453
643
  // Malformed JSON line — skip
454
644
  }