@isdk/tool-electron 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.
package/docs/README.md ADDED
@@ -0,0 +1,465 @@
1
+ **@isdk/tool-electron**
2
+
3
+ ***
4
+
5
+ # @isdk/tool-electron
6
+
7
+ > ✨ **Electron-native IPC Transport for the `ToolFunc` Framework**
8
+ > Build decoupled, type-safe, real-time Electron apps with RPC tools and Pub/Sub events over IPC.
9
+
10
+ [πŸ‡¨πŸ‡³ δΈ­ζ–‡ζ–‡ζ‘£](_media/README.cn.md) | 🌐 English
11
+
12
+ [![npm version](https://img.shields.io/npm/v/@isdk/tool-electron.svg?style=flat-square)](https://www.npmjs.com/package/@isdk/tool-electron)
13
+ [![Vitest Tests](https://img.shields.io/badge/tests-vitest-green?style=flat-square)](https://vitest.dev/)
14
+ [![TypeScript](https://img.shields.io/badge/types-TypeScript-blue?style=flat-square)](https://www.typescriptlang.org/)
15
+ [![License: MIT](https://img.shields.io/badge/license-MIT-purple?style=flat-square)](LICENSE)
16
+
17
+ ```bash
18
+ npm install @isdk/tool-electron
19
+ ```
20
+
21
+ Built on [`@isdk/tool-func`](https://github.com/isdk/ai-tools) β€” Define reusable, self-documenting functions.
22
+
23
+ ## 🌟 Features
24
+
25
+ Designed to pair with `@isdk/tool-rpc` / `@isdk/tool-event`. Define your business logic once as tools, then call them from the renderer like local methods β€” no HTTP required.
26
+
27
+ * βœ… **Zero network overhead** β€” uses Electron IPC
28
+ * βœ… **RPC Tools over IPC** β€” Call server-defined functions from renderer
29
+ * βœ… **Real-time Event Bus** β€” Bidirectional Pub/Sub with auto session management
30
+ * βœ… **Unified error model** via `@isdk/common-error`
31
+ * βœ… **AbortSignal support** β€” cancel waiting on the client
32
+ * βœ… **Safe Preload Bridge** β€” Securely expose APIs via `contextBridge` with injectable `Bridge`, `ServerIpcMain`, `PubSubBridge`, and `ServerPubSubIpcMain` types
33
+ * βœ… **Dynamic Namespaces** β€” Run multiple isolated tool/event buses via URI scheme (`electron://my-app`)
34
+ * βœ… **URI Scheme Routing** β€” `apiUrl` follows URI convention; `electron://` routes to IPC, `http://` routes to HTTP; auto-registered with `RpcTransportManager`
35
+
36
+ ---
37
+
38
+ ## πŸ”’ Secure Bridge Pattern
39
+
40
+ When `contextIsolation` is enabled (the default since Electron 12), the renderer process has no direct access to Node.js or Electron APIs. The recommended approach is to:
41
+
42
+ 1. **Preload** β€” Expose a minimal `invoke`-only bridge via `contextBridge`
43
+ 2. **Main process** β€” Optionally inject a custom `ipcMain`-like object for testing or custom setups
44
+ 3. **Renderer** β€” Create transport instances with the injected bridge
45
+
46
+ ### 1. Main Process (Server)
47
+
48
+ ```ts
49
+ // main.ts
50
+ import { ServerTools } from '@isdk/tool-rpc';
51
+ import { EventServer } from '@isdk/tool-event';
52
+ import {
53
+ IpcServerToolTransport,
54
+ ElectronServerPubSubTransport,
55
+ } from '@isdk/tool-electron';
56
+
57
+ // Register a tool
58
+ ServerTools.register({
59
+ name: 'getUser',
60
+ func: async ({ id }: { id: string }) => ({ id, name: 'Alice' }),
61
+ });
62
+
63
+ // Mount RPC with the URI 'electron://my-app'
64
+ const server = new IpcServerToolTransport({ apiUrl: 'electron://my-app' });
65
+ server.addDiscoveryHandler('electron://my-app', () => ServerTools.toJSON());
66
+ server.addRpcHandler('electron://my-app');
67
+ await server.start();
68
+
69
+ // Setup event bus
70
+ EventServer.setPubSubTransport(
71
+ new ElectronServerPubSubTransport('my-app-events'),
72
+ );
73
+ EventServer.get()?.register();
74
+ ```
75
+
76
+ > **Testing tip:** Pass a mock `ipcMain` object via the `ipcMain` option:
77
+ >
78
+ > ```ts
79
+ > import { IpcServerToolTransport } from '@isdk/tool-electron';
80
+ > const server = new IpcServerToolTransport({
81
+ > apiUrl: 'electron://test',
82
+ > // Works with any test framework β€” wrap with vi.fn() / jest.fn() / sinon.spy() as needed
83
+ > ipcMain: { handle: () => {}, removeHandler: () => {} },
84
+ > });
85
+ > ```
86
+
87
+ ---
88
+
89
+ ### 2. Preload Script (Secure Bridge)
90
+
91
+ The preload script is the **only** place with access to `ipcRenderer`. Expose only what's needed:
92
+
93
+ ```ts
94
+ // preload.ts
95
+ import { contextBridge, ipcRenderer } from 'electron';
96
+
97
+ // Expose a minimal invoke bridge to the renderer
98
+ export type ElectronIpcBridge = {
99
+ invoke: (channel: string, ...args: any[]) => Promise<any>;
100
+ };
101
+
102
+ contextBridge.exposeInMainWorld('electronIpc', {
103
+ invoke: (channel: string, ...args: any[]) =>
104
+ ipcRenderer.invoke(channel, ...args),
105
+ } satisfies ElectronIpcBridge);
106
+ ```
107
+
108
+ ---
109
+
110
+ ### 3. Renderer Process (Client)
111
+
112
+ In the renderer, create the transport using the bridge exposed by the preload:
113
+
114
+ ```ts
115
+ // renderer.ts
116
+ import {
117
+ IpcClientToolTransport,
118
+ type Bridge,
119
+ } from '@isdk/tool-electron';
120
+
121
+ // The bridge exposed by preload.ts
122
+ const bridge: Bridge = (window as any).electronIpc;
123
+
124
+ // Create the transport with the injectable bridge
125
+ const transport = new IpcClientToolTransport('electron://my-app', { bridge });
126
+
127
+ // Mount and load tool definitions
128
+ await transport.loadApis();
129
+
130
+ // Call a remote tool
131
+ const result = await transport._fetch('getUser', { id: '42' });
132
+ console.log(result); // { id: '42', name: 'Alice' }
133
+ ```
134
+
135
+ ---
136
+
137
+ ### ⚑ Full Preload + Pub/Sub Example
138
+
139
+ Here's a complete end-to-end example showing both RPC and Pub/Sub with the injectable bridge types:
140
+
141
+ **main.ts (server)**
142
+
143
+ ```ts
144
+ import { ServerTools } from '@isdk/tool-rpc';
145
+ import {
146
+ IpcServerToolTransport,
147
+ ElectronServerPubSubTransport,
148
+ } from '@isdk/tool-electron';
149
+
150
+ // Register a tool
151
+ ServerTools.register({
152
+ name: 'getUser',
153
+ func: async ({ id }: { id: string }) => ({ id, name: 'Alice' }),
154
+ });
155
+
156
+ // RPC transport
157
+ const rpcServer = new IpcServerToolTransport({ apiUrl: 'electron://my-app' });
158
+ rpcServer.addDiscoveryHandler('electron://my-app', () => ServerTools.toJSON());
159
+ rpcServer.addRpcHandler('electron://my-app');
160
+ await rpcServer.start();
161
+
162
+ // Pub/Sub transport
163
+ const pubSubServer = new ElectronServerPubSubTransport('electron://my-app-events');
164
+ pubSubServer.listen();
165
+
166
+ // πŸ’‘ Testing: inject a mock ipcMain via ServerPubSubIpcMain
167
+ // import type { ServerPubSubIpcMain } from '@isdk/tool-electron';
168
+ // const mockIpcMain: ServerPubSubIpcMain = {
169
+ // on: () => {},
170
+ // removeAllListeners: () => {},
171
+ // };
172
+ // const pubSubServer = new ElectronServerPubSubTransport('electron://test', { ipcMain: mockIpcMain });
173
+ ```
174
+
175
+ **preload.ts**
176
+
177
+ ```ts
178
+ import { contextBridge, ipcRenderer } from 'electron';
179
+
180
+ const electronIpc = {
181
+ invoke: (ch: string, ...args: any[]) => ipcRenderer.invoke(ch, ...args),
182
+ on: (ch: string, listener: (event: any, ...args: any[]) => void) => {
183
+ ipcRenderer.on(ch, listener);
184
+ },
185
+ off: (ch: string, listener: (event: any, ...args: any[]) => void) => {
186
+ ipcRenderer.removeListener(ch, listener);
187
+ },
188
+ send: (ch: string, ...args: any[]) => ipcRenderer.send(ch, ...args),
189
+ };
190
+
191
+ contextBridge.exposeInMainWorld('electronIpc', electronIpc);
192
+ ```
193
+
194
+ **renderer.ts**
195
+
196
+ ```ts
197
+ import {
198
+ IpcClientToolTransport,
199
+ ElectronClientPubSubTransport,
200
+ type Bridge,
201
+ type PubSubBridge,
202
+ } from '@isdk/tool-electron';
203
+
204
+ const bridge: Bridge = (window as any).electronIpc;
205
+ const pubsubBridge: PubSubBridge = (window as any).electronIpc;
206
+
207
+ // Create RPC transport
208
+ const rpc = new IpcClientToolTransport('electron://my-app', { bridge });
209
+ await rpc.loadApis();
210
+
211
+ // Create Pub/Sub transport with the same bridge
212
+ const pubsub = new ElectronClientPubSubTransport('electron://my-app-events', {
213
+ bridge: pubsubBridge,
214
+ });
215
+ const stream = pubsub.connect('electron://my-app-events');
216
+
217
+ stream.on('server-event', (data) => {
218
+ console.log('Received:', data);
219
+ });
220
+ ```
221
+
222
+ ---
223
+
224
+ ## πŸš€ Quick Start (Simple Setup)
225
+
226
+ If you don't need `contextIsolation` or want a minimal setup:
227
+
228
+ ### 1. Main Process
229
+
230
+ ```ts
231
+ // main.ts
232
+ import { ServerTools } from '@isdk/tool-rpc';
233
+ import {
234
+ IpcServerToolTransport,
235
+ } from '@isdk/tool-electron';
236
+
237
+ ServerTools.register({
238
+ name: 'greet',
239
+ func: async ({ name }: { name: string }) => `Hello, ${name}!`,
240
+ });
241
+
242
+ const server = new IpcServerToolTransport({ apiUrl: 'electron://my-app' });
243
+ server.addDiscoveryHandler('electron://my-app', () => ServerTools.toJSON());
244
+ server.addRpcHandler('electron://my-app');
245
+ await server.start();
246
+ ```
247
+
248
+ ### 2. Preload Script
249
+
250
+ Use the same preload script as the [Secure Bridge Pattern](#2-preload-script-secure-bridge) above:
251
+
252
+ ```ts
253
+ // preload.ts β€” same as the Secure Bridge Pattern example
254
+ import { contextBridge, ipcRenderer } from 'electron';
255
+
256
+ contextBridge.exposeInMainWorld('electronIpc', {
257
+ invoke: (ch: string, ...args: any[]) => ipcRenderer.invoke(ch, ...args),
258
+ });
259
+ ```
260
+
261
+ ### 3. Renderer Process
262
+
263
+ ```ts
264
+ // renderer.ts
265
+ import { IpcClientToolTransport } from '@isdk/tool-electron';
266
+
267
+ const transport = new IpcClientToolTransport('electron://my-app', {
268
+ bridge: (window as any).electronIpc,
269
+ });
270
+ const defs = await transport.loadApis();
271
+ const result = await transport._fetch('greet', { name: 'World' });
272
+ console.log(result); // "Hello, World!"
273
+ ```
274
+
275
+ ---
276
+
277
+ ## πŸ”„ Architecture
278
+
279
+ ```mermaid
280
+ graph LR
281
+ subgraph "Main Process"
282
+ A[ServerTools] --> B[IpcServer]
283
+ C[EventServer] --> D[PubSub Server]
284
+ B -->|ipcMain.handle| E[(IPC Channel)]
285
+ D -->|ipcMain.on/send| E
286
+ end
287
+
288
+ subgraph "Preload (contextBridge)"
289
+ P["electronIpc.invoke"] -->|ipcRenderer.invoke| E
290
+ end
291
+
292
+ subgraph "Renderer Process"
293
+ F[IpcClientToolTransport] -->|bridge.invoke| P
294
+ G[ElectronClientPubSubTransport] -->|ipcRenderer.on/send| E
295
+ end
296
+ ```
297
+
298
+ The preload script acts as a security boundary β€” the renderer never imports `electron` directly.
299
+
300
+ ---
301
+
302
+ ## βš™οΈ Advanced
303
+
304
+ ### Custom ServerIpcMain (Testing / Custom IPC)
305
+
306
+ Inject a mock or custom `ipcMain`-like object for testing:
307
+
308
+ ```ts
309
+ import { IpcServerToolTransport, type ServerIpcMain } from '@isdk/tool-electron';
310
+
311
+ // Plain no-op mocks β€” wrap with vitest/jest/sinon spies for assertions
312
+ const mockIpcMain: ServerIpcMain = {
313
+ handle: (_channel, _handler) => {},
314
+ removeHandler: (_channel) => {},
315
+ };
316
+
317
+ const server = new IpcServerToolTransport({
318
+ apiUrl: 'electron://test',
319
+ ipcMain: mockIpcMain,
320
+ });
321
+ ```
322
+
323
+ ### Error Handling
324
+
325
+ `_fetch` throws a `CommonError` directly when the server returns an error response. No need to call `toObject()` first β€” errors are thrown immediately:
326
+
327
+ ```ts
328
+ import { CommonError } from '@isdk/common-error';
329
+
330
+ try {
331
+ const result = await transport._fetch('getUser', { id: 'invalid' });
332
+ } catch (err: any) {
333
+ if (err instanceof CommonError) {
334
+ console.error(`Error [${err.code}]: ${err.message}`, err.data);
335
+ }
336
+ }
337
+ ```
338
+
339
+ > **Note:** The `toObject()` method is still available for backward compatibility, but when used on a successful response it simply passes the data through. For new code, just use `await transport._fetch(...)` directly.
340
+
341
+ ### Timeout & Cancellation
342
+
343
+ ```ts
344
+ const ctrl = new AbortController();
345
+ setTimeout(() => ctrl.abort(), 5000);
346
+
347
+ try {
348
+ const result = await transport._fetch('slowTool', params, undefined, undefined, {
349
+ signal: ctrl.signal,
350
+ });
351
+ } catch (err: any) {
352
+ if (err.name === 'AbortError') {
353
+ console.log('Cancelled or timed out');
354
+ }
355
+ }
356
+ ```
357
+
358
+ ### Dynamic Namespaces
359
+
360
+ ```ts
361
+ // Switch to a different namespace at runtime
362
+ transport.setApiUrl('electron://my-app-v2');
363
+ await transport.loadApis();
364
+ ```
365
+
366
+ ---
367
+
368
+ ## πŸ“¦ API
369
+
370
+ ### API URL Convention
371
+
372
+ All IPC transports use URI-formatted `apiUrl` values with the `electron://` scheme:
373
+
374
+ ```ts
375
+ // Server: options object
376
+ const server = new IpcServerToolTransport({ apiUrl: 'electron://my-app' });
377
+
378
+ // Client: first argument
379
+ const client = new IpcClientToolTransport('electron://my-app', { bridge });
380
+
381
+ // PubSub: constructor argument
382
+ const pubsub = new ElectronServerPubSubTransport('electron://my-app');
383
+ ```
384
+
385
+ The `electron` scheme is auto-registered with `RpcTransportManager` on import,
386
+ so `RpcTransportManager.instance.getClient('electron://my-app')` works automatically.
387
+ The host component is extracted as the IPC channel namespace:
388
+ `'electron://my-app'` β†’ channels `my-app:discover`, `pubsub-downstream:my-app`, etc.
389
+
390
+ ### Exported Types (Bridge Pattern)
391
+
392
+ | Type | Description | Methods | Used By |
393
+ |------|-------------|---------|---------|
394
+ | `Bridge` | Client-side RPC bridge. Inject into `IpcClientToolTransport`. | `invoke(channel, ...args): Promise<any>` | `IpcClientToolTransport` |
395
+ | `ServerIpcMain` | Server-side RPC bridge. Inject into `IpcServerToolTransport`. | `handle(channel, handler)`, `removeHandler(channel)` | `IpcServerToolTransport` |
396
+ | `PubSubBridge` | Client-side Pub/Sub bridge. Inject into `ElectronClientPubSubTransport`. | `on(channel, listener)`, `off(channel, listener)`, `send(channel, ...args)` | `ElectronClientPubSubTransport` |
397
+ | `ServerPubSubIpcMain` | Server-side Pub/Sub bridge. Inject into `ElectronServerPubSubTransport`. | `on(channel, listener)`, `removeAllListeners(channel?)` | `ElectronServerPubSubTransport` |
398
+
399
+ All four types are exported from the package root:
400
+
401
+ ```ts
402
+ import {
403
+ type Bridge,
404
+ type ServerIpcMain,
405
+ type PubSubBridge,
406
+ type ServerPubSubIpcMain,
407
+ } from '@isdk/tool-electron';
408
+ ```
409
+
410
+ ### Exported Classes
411
+
412
+ | Class | Process | Purpose |
413
+ |-------|---------|---------|
414
+ | `IpcClientToolTransport` | Renderer | RPC transport for calling server tools |
415
+ | `IpcServerToolTransport` | Main | RPC transport for registering and serving tools |
416
+ | `ElectronClientPubSubTransport` | Renderer | Pub/Sub transport for receiving/sending events |
417
+ | `ElectronServerPubSubTransport` | Main | Pub/Sub transport for broadcasting events |
418
+ | `ServerTools` | Main | Tool registry (re-exported from `@isdk/tool-rpc`) |
419
+ | `EventServer` | Main | Event server (re-exported from `@isdk/tool-event`) |
420
+
421
+ ---
422
+
423
+ ## πŸ§ͺ Testing
424
+
425
+ Run unit tests with mocked Electron IPC:
426
+
427
+ ```bash
428
+ npm test # run once
429
+ npm run test:watch # dev mode
430
+ npm run coverage # generate report
431
+ ```
432
+
433
+ Mocks: `test/mocks/electron.ts`
434
+
435
+ ---
436
+
437
+ ## πŸ“š Docs
438
+
439
+ - [ToolFunc Core](https://github.com/isdk/ai-tools/tree/main/packages/tool-func)
440
+ - [RPC Transports Guide](https://github.com/isdk/ai-tools/tree/main/packages/tool-rpc)
441
+ - [Events Guide](https://github.com/isdk/ai-tools/tree/main/packages/tool-event)
442
+
443
+ ---
444
+
445
+ ## 🀝 Contributing
446
+
447
+ We ❀️ contributions!
448
+
449
+ 1. Fork β†’ `git clone`
450
+ 2. Create branch β†’ `git checkout -b feat/your-feature`
451
+ 3. Commit β†’ `git commit -m 'feat: add XYZ'`
452
+ 4. Push β†’ `git push origin feat/your-feature`
453
+ 5. Open PR πŸŽ‰
454
+
455
+ Please ensure tests pass and types are clean.
456
+
457
+ ---
458
+
459
+ ## πŸ“œ License
460
+
461
+ MIT Β© [ISDK](https://github.com/isdk) β€” See [LICENSE](LICENSE)
462
+
463
+ ---
464
+
465
+ > πŸ’‘ **Pro Tip**: Use `EventServer.forward([...events])` to auto-relay global events to all connected clients!