Skip to content

Commit fe1f626

Browse files
authored
[feat] Full Electron Support with Transparent Relay & Secure IPC Bridge (#19)
* init: electron demo * feat(kkrpc): add Electron support with transparent relay Add complete Electron integration for kkrpc with: - Electron IPC adapters (main ↔ renderer) via kkrpc/electron-ipc - Utility process adapters (main ↔ worker) via kkrpc/electron - Event-driven createRelay() for transparent message forwarding - Full electron-demo with 3 communication patterns Key changes: - New adapters: ElectronIpcMainIO, ElectronIpcRendererIO, ElectronUtilityProcessIO, ElectronUtilityProcessChildIO - Extended IoInterface with optional onMessage callback for event-driven adapters - Rewrote relay.ts to use event callbacks instead of blocking while loops - Added 47 new files including documentation, examples, and implementation plans The relay architecture enables main process to act as transparent byte pipe without parsing JSON or knowing API details. Renderer can now call external Node.js processes directly through createRelay(ipcIO, nodeIO). * feat(electron)!: refactor to dependency injection pattern for version-agnostic Electron support Refactored `createSecureIpcBridge` to use dependency injection instead of direct Electron imports. This removes Electron as a peer dependency, making kkrpc compatible with any Electron version without version conflicts. BREAKING CHANGE: `createSecureIpcBridge` API changed to require `ipcRenderer` parameter Before: createSecureIpcBridge({ channelPrefix: "kkrpc-" }) After: import { ipcRenderer } from "electron" createSecureIpcBridge({ ipcRenderer, channelPrefix: "kkrpc-" }) - Added `IpcRendererInterface` type for minimal Electron API surface - Updated all documentation and examples with new usage pattern - Exported `createSecureIpcBridge` from `kkrpc/electron-ipc` entry point - Fixed TypeScript errors in http.test.ts and nats.ts adapter * ci: optimize test execution to run only kkrpc package tests Filter test execution to run only kkrpc package tests instead of all packages, reducing CI build time and avoiding unnecessary test runs for unaffected packages. * fix(node-io): resolve pending read on stream error to prevent hanging RPC channel
1 parent c297ebf commit fe1f626

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

64 files changed

+11296
-148
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ jobs:
3838
# pnpm --filter kkrpc exec playwright install
3939
pnpx playwright install --with-deps
4040
pnpm build
41-
pnpm test
41+
pnpm test -F kkrpc
4242
- name: Stop test services
4343
if: always()
4444
run: |

.journal/2026-02-02.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Journal Entry - 2026-02-02
2+
3+
## 11:20 - Secure IPC Bridge Architecture Decision
4+
5+
### Core Decision/Topic
6+
7+
Refactored `createSecureIpcBridge` to use dependency injection pattern instead of directly importing from "electron" package. This eliminates the need for kkrpc to depend on Electron as a peer dependency.
8+
9+
### Options Considered
10+
11+
1. **Option A: Keep Electron as peerDependency (>=20.0.0)**
12+
13+
- Pros: Simple, works out of the box
14+
- Cons: Forces non-Electron users to install Electron; version conflicts with newer/older Electron versions
15+
16+
2. **Option B: Use optional peerDependency**
17+
18+
- Pros: Non-Electron users won't be forced to install
19+
- Cons: Still version-locked; future Electron API changes could break kkrpc
20+
21+
3. **Option C: Dependency Injection Pattern (Chosen)**
22+
- Pros: Version agnostic, works with any Electron version, zero dependency overhead
23+
- Cons: Slightly more verbose API (user must import electron themselves)
24+
25+
### Final Decision & Rationale
26+
27+
**Chosen: Option C - Dependency Injection**
28+
29+
The key insight is that kkrpc only needs a minimal interface from Electron's `ipcRenderer`:
30+
31+
```ts
32+
interface IpcRendererInterface {
33+
send(channel: string, ...args: unknown[]): void
34+
on(channel: string, listener: (event: unknown, ...args: unknown[]) => void): void
35+
off(channel: string, listener: (event: unknown, ...args: unknown[]) => void): void
36+
}
37+
```
38+
39+
By accepting `ipcRenderer` as a parameter instead of importing it:
40+
41+
- Users control their own Electron version
42+
- No peer dependency warnings for non-Electron users
43+
- Future-proof against Electron API changes
44+
- Follows the principle of "composition over inheritance"
45+
46+
### Key Changes Made
47+
48+
| File | Change |
49+
| -------------------------------------------- | ---------------------------------------------------------------------------------- |
50+
| `src/adapters/electron-ipc-preload.ts` | Refactored to accept `ipcRenderer` as parameter; added `IpcRendererInterface` type |
51+
| `package.json` | Removed `electron` from `peerDependencies` entirely |
52+
| `examples/electron-demo/electron/preload.ts` | Updated to new API: `createSecureIpcBridge({ ipcRenderer, channelPrefix })` |
53+
| `docs/examples/electron.md` | Updated documentation with new usage pattern |
54+
| `README.md` | Updated Electron preload examples |
55+
56+
### API Change
57+
58+
**Before:**
59+
60+
```ts
61+
import { createSecureIpcBridge } from "kkrpc/electron-ipc"
62+
63+
createSecureIpcBridge({ channelPrefix: "kkrpc-" })
64+
```
65+
66+
**After:**
67+
68+
```ts
69+
import { contextBridge, ipcRenderer } from "electron"
70+
import { createSecureIpcBridge } from "kkrpc/electron-ipc"
71+
72+
const securedIpcRenderer = createSecureIpcBridge({
73+
ipcRenderer,
74+
channelPrefix: "kkrpc-"
75+
})
76+
77+
contextBridge.exposeInMainWorld("electron", {
78+
ipcRenderer: securedIpcRenderer
79+
})
80+
```
81+
82+
### Future Considerations
83+
84+
- The `IpcRendererInterface` should remain stable even if Electron changes their API
85+
- If Electron makes breaking changes to ipcRenderer, users can adapt without waiting for kkrpc updates
86+
- Consider documenting this pattern for other framework integrations (e.g., if we add Tauri-specific helpers)
87+
88+
---
89+
90+
## 11:45 - TypeScript Error Fixes
91+
92+
### Core Decision/Topic
93+
94+
Fixed 3 TypeScript errors that appeared during `bun tsc --noEmit`:
95+
96+
1. `__tests__/http.test.ts:8` - `Server` generic requires type argument
97+
2. `src/adapters/nats.ts:67` - Invalid option `rewait` (should be `reconnectTimeWait`)
98+
3. `src/adapters/electron-ipc-preload.ts:1` - Cannot find module 'electron'
99+
100+
### Key Changes
101+
102+
| File | Fix |
103+
| -------------------------------------- | ------------------------------------------------------------- |
104+
| `__tests__/http.test.ts` | Changed `let server: Server` to `let server: Server<unknown>` |
105+
| `src/adapters/nats.ts` | Fixed typo: `rewait: 1000``reconnectTimeWait: 1000` |
106+
| `src/adapters/electron-ipc-preload.ts` | Resolved by removing electron import (see above refactor) |
107+
108+
### Verification
109+
110+
-`bun tsc --noEmit` passes
111+
- ✅ 119 tests pass

.mcp.json

Lines changed: 0 additions & 11 deletions
This file was deleted.

.sisyphus/boulder.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"active_plan": "/Users/hk/Dev/kkrpc/.sisyphus/plans/implement-event-driven-relay.md",
3+
"started_at": "2026-02-02T02:09:46.201Z",
4+
"session_ids": ["ses_3eda811e5ffecNE7cB2t1fexby"],
5+
"plan_name": "implement-event-driven-relay",
6+
"status": "in_progress",
7+
"tasks_completed": 3,
8+
"tasks_total": 6
9+
}
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
# Better Relay Design - Event-Driven Approach
2+
3+
## Problems with Current Implementation
4+
5+
### 1. Proxy is Too Ugly
6+
7+
```typescript
8+
// Current - user has to write this:
9+
stdio: new Proxy({} as StdioWorkerAPI, {
10+
get: (_, method: string) => {
11+
return (...args: any[]) => {
12+
return (stdioAPI as any)[method](...args)
13+
}
14+
}
15+
})
16+
```
17+
18+
**Bad**: Users shouldn't write this complexity.
19+
20+
### 2. While Loop is Blocking
21+
22+
```typescript
23+
// Current relay - brute force polling
24+
while (!destroyed) {
25+
const msg = await a.read() // Blocks forever
26+
await b.write(msg)
27+
}
28+
```
29+
30+
**Bad**: Wastes CPU, not event-driven.
31+
32+
---
33+
34+
## Better Solution: Event-Driven IoInterface
35+
36+
### Core Insight
37+
38+
The essence of relay is: **connect two adapters' read/write streams**.
39+
40+
When adapter A receives a message → write to adapter B
41+
When adapter B receives a message → write to adapter A
42+
43+
### Design: Add onMessage Hook to IoInterface
44+
45+
```typescript
46+
// Extend IoInterface with optional callback
47+
export interface IoInterface {
48+
name: string
49+
read(): Promise<string | IoMessage | null>
50+
write(message: string | IoMessage): Promise<void>
51+
52+
// NEW: Event-driven message handling
53+
onMessage?: (message: string | IoMessage) => void | Promise<void>
54+
55+
capabilities?: IoCapabilities
56+
destroy?(): void
57+
signalDestroy?(): void
58+
}
59+
```
60+
61+
### Relay Implementation (Clean!)
62+
63+
```typescript
64+
// packages/kkrpc/src/relay.ts
65+
export function createRelay(a: IoInterface, b: IoInterface): Relay {
66+
// When A receives message → forward to B
67+
a.onMessage = async (msg) => {
68+
await b.write(msg)
69+
}
70+
71+
// When B receives message → forward to A
72+
b.onMessage = async (msg) => {
73+
await a.write(msg)
74+
}
75+
76+
return {
77+
destroy: () => {
78+
a.onMessage = undefined
79+
b.onMessage = undefined
80+
a.destroy?.()
81+
b.destroy?.()
82+
}
83+
}
84+
}
85+
```
86+
87+
### User API (Super Clean!)
88+
89+
```typescript
90+
// main.ts - no Proxy, no method enumeration!
91+
import { createRelay } from "kkrpc"
92+
93+
// Just pipe the adapters together
94+
const relay = createRelay(
95+
new ElectronIpcMainIO(ipcMain, win.webContents), // From Renderer
96+
new NodeIo(stdioProcess.stdout!, stdioProcess.stdin!) // To Worker
97+
)
98+
99+
// That's it! Main doesn't know any API methods.
100+
// Messages flow transparently: Renderer ↔ Worker
101+
```
102+
103+
---
104+
105+
## Adapter Modifications Needed
106+
107+
Each adapter needs to call `onMessage` when data arrives:
108+
109+
### Example: NodeIo
110+
111+
```typescript
112+
export class NodeIo implements IoInterface {
113+
onMessage?: (msg: string) => void | Promise<void>
114+
115+
constructor(stdout: ReadableStream, stdin: WritableStream) {
116+
// ... existing setup ...
117+
118+
// NEW: Use event-driven approach instead of blocking read()
119+
this.stdout.on("data", (chunk: Buffer) => {
120+
// Buffer and process lines (existing logic)
121+
const messages = this.bufferString(chunk.toString())
122+
for (const msg of messages) {
123+
// If onMessage is set, use it (event-driven)
124+
// Otherwise fall back to queue for read() compatibility
125+
if (this.onMessage) {
126+
this.onMessage(msg)
127+
} else {
128+
this.messageQueue.push(msg)
129+
this.resolveRead?.(msg)
130+
}
131+
}
132+
})
133+
}
134+
135+
// read() now returns queued messages or waits
136+
async read(): Promise<string | null> {
137+
if (this.messageQueue.length > 0) {
138+
return this.messageQueue.shift()!
139+
}
140+
// If onMessage is set, read() shouldn't be called
141+
// But we keep it for backward compatibility
142+
return new Promise((resolve) => {
143+
this.resolveRead = resolve
144+
})
145+
}
146+
}
147+
```
148+
149+
### Example: ElectronIpcMainIO
150+
151+
```typescript
152+
export class ElectronIpcMainIO implements IoInterface {
153+
onMessage?: (msg: string | IoMessage) => void | Promise<void>
154+
155+
constructor(ipcMain: IpcMain, webContents: WebContents) {
156+
// ... existing setup ...
157+
158+
ipcMain.on(this.channelName, (_event, message) => {
159+
if (this.onMessage) {
160+
// Event-driven mode
161+
this.onMessage(message)
162+
} else {
163+
// Traditional queue mode
164+
this.messageQueue.push(message)
165+
this.resolveRead?.(message)
166+
}
167+
})
168+
}
169+
}
170+
```
171+
172+
---
173+
174+
## Benefits
175+
176+
| Aspect | Before (Proxy) | After (Event-Driven Relay) |
177+
| ------------------- | ------------------------ | ---------------------------- |
178+
| **User Code** | 8 lines of ugly Proxy | 1 line: `createRelay(a, b)` |
179+
| **Performance** | While loop polling | Event-driven, zero CPU waste |
180+
| **API Knowledge** | Main still knows methods | Main knows **nothing** |
181+
| **Maintainability** | Add method → update Main | Add method → no change |
182+
| **Type Safety** | Any types | Fully typed via endpoints |
183+
184+
---
185+
186+
## Migration Strategy
187+
188+
1. **Add `onMessage` to IoInterface** (backward compatible - optional)
189+
2. **Update adapters** to support event-driven mode
190+
3. **Implement new relay** using `onMessage`
191+
4. **Deprecate old approaches**
192+
193+
---
194+
195+
## Advanced: Router Pattern
196+
197+
With this design, we can also build routers easily:
198+
199+
```typescript
200+
// Route messages based on path prefix
201+
const router = createRouter({
202+
"math.": mathWorkerIO,
203+
"db.": dbWorkerIO,
204+
"fs.": fsWorkerIO
205+
})
206+
207+
// "math.add" → math worker
208+
// "db.query" → db worker
209+
```
210+
211+
---
212+
213+
## Summary
214+
215+
**The key insight**: Instead of Main creating RPCChannel and proxying methods, just **pipe the raw adapters together**.
216+
217+
- **Before**: Renderer → Main RPC → Main proxies → Worker RPC → Worker
218+
- **After**: Renderer → Relay → Worker (Main just passes bytes!)
219+
220+
This is the true "transparent relay" you envisioned!

0 commit comments

Comments
 (0)