@soederpop/luca 0.0.25 → 0.0.28
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/examples/assistant-with-process-manager.md +84 -0
- package/docs/examples/websocket-ask-and-reply-example.md +128 -0
- package/docs/window-manager-fix.md +249 -0
- package/package.json +1 -1
- package/src/agi/features/assistant.ts +75 -13
- package/src/agi/features/docs-reader.ts +25 -1
- package/src/bootstrap/generated.ts +215 -1
- package/src/cli/build-info.ts +2 -2
- package/src/clients/websocket.ts +76 -1
- package/src/command.ts +75 -0
- package/src/commands/describe.ts +29 -1089
- package/src/container-describer.ts +1098 -0
- package/src/container.ts +11 -0
- package/src/helper.ts +29 -2
- package/src/introspection/generated.agi.ts +1315 -611
- package/src/introspection/generated.node.ts +1168 -552
- package/src/introspection/generated.web.ts +9 -1
- package/src/node/features/content-db.ts +17 -0
- package/src/node/features/fs.ts +18 -0
- package/src/node/features/ipc-socket.ts +370 -180
- package/src/node/features/process-manager.ts +316 -49
- package/src/node/features/window-manager.ts +843 -235
- package/src/scaffolds/generated.ts +1 -1
- package/src/server.ts +40 -0
- package/src/servers/express.ts +2 -0
- package/src/servers/mcp.ts +1 -0
- package/src/servers/socket.ts +89 -0
- package/src/web/clients/socket.ts +22 -6
- package/test/websocket-ask.test.ts +101 -0
|
@@ -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
|
-
|
|
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
|
-
* -
|
|
35
|
-
* -
|
|
36
|
-
* -
|
|
37
|
-
* -
|
|
38
|
-
* -
|
|
39
|
-
* -
|
|
40
|
-
*
|
|
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/
|
|
60
|
-
*
|
|
61
|
-
* ipc.on('connection', (socket) => {
|
|
62
|
-
* console.log('Client
|
|
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(
|
|
67
|
-
*
|
|
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
|
|
79
|
+
*
|
|
80
|
+
* **Client (Spoke):**
|
|
72
81
|
* ```typescript
|
|
73
82
|
* const ipc = container.feature('ipcSocket');
|
|
74
|
-
* await ipc.connect('/tmp/
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
97
|
-
protected
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
*
|
|
278
|
-
*
|
|
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
|
-
|
|
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
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
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
|
-
|
|
349
|
-
const
|
|
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
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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
|
}
|