desktop-ipc-electron
Type-safe Electron IPC patterns with typed channels, electron-trpc, MessagePort, and utility process communication
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/desktop-ipc-electron/skills/desktop-ipc-electron
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Electron Type-Safe IPC Patterns
Quick Guide: All Electron IPC flows through a preload script using
contextBridge.exposeInMainWorld(). Make it type-safe by defining a shared channel map that constrains channel names, payloads, and return types across main, preload, and renderer. For end-to-end type safety with minimal boilerplate, useelectron-trpc(tRPC over IPC). For high-throughput streaming or renderer-to-renderer communication, useMessageChannelMain/MessagePort. For CPU-intensive background work, useutilityProcesswithparentPort. Always validate IPC input in the main process -- treat renderer messages as untrusted.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST validate and sanitize ALL data received via IPC in the main process -- treat renderer messages as untrusted input)
(You MUST use contextBridge.exposeInMainWorld() in preload scripts -- never expose ipcRenderer directly)
(You MUST define IPC channel names and payload types in a single shared file -- never use untyped string literals for channel names)
(You MUST use ipcMain.handle() / ipcRenderer.invoke() for request-response IPC -- sendSync blocks the renderer)
(You MUST clean up IPC listeners when components unmount or windows close -- listener leaks cause memory issues and duplicate handlers)
</critical_requirements>
Auto-detection: Electron IPC, ipcMain, ipcRenderer, contextBridge, preload, type-safe IPC, electron-trpc, ipcLink, createIPCHandler, exposeElectronTRPC, MessageChannelMain, MessagePortMain, MessagePort, utilityProcess, parentPort, typed channels, IPC channel map, postMessage, webContents.send, ipcMain.handle, ipcRenderer.invoke
When to use:
- Adding type safety to Electron IPC communication
- Setting up electron-trpc for end-to-end typed IPC
- Defining shared channel/payload types between main and renderer
- Building typed preload APIs with contextBridge
- Using MessagePort for high-throughput or renderer-to-renderer communication
- Implementing utility process IPC for background tasks
- Validating and sanitizing IPC input in main process handlers
When NOT to use:
- Choosing a UI framework for the renderer (use the appropriate framework skill)
- General Electron app setup, packaging, or native APIs (use the Electron framework skill)
- Simple IPC that does not need type safety beyond basic JavaScript
Key patterns covered:
- Shared IPC channel map with typed payloads and return types
- Typed preload API via contextBridge with declaration augmentation
- electron-trpc for end-to-end type safety (queries, mutations, subscriptions)
- Request-response (
handle/invoke) with typed wrappers - Fire-and-forget (
on/send) with typed channels - Main-to-renderer push (
webContents.send) with typed events - MessagePort for high-throughput and renderer-to-renderer communication
- Utility process IPC with
parentPortand MessagePort transfer - IPC input validation and channel allowlisting
Detailed Resources:
- examples/core.md - Shared channel map, typed preload, typed wrappers, declaration augmentation
- examples/electron-trpc.md - electron-trpc setup, queries, mutations, subscriptions
- examples/message-ports.md - MessagePort patterns, renderer-to-renderer, utility process IPC
- reference.md - IPC method quick reference, decision framework, security checklist
<decision_framework>
Decision Framework
Which Type Safety Approach?
How many IPC channels does the app have?
+-- 1-5 channels?
| +-- Shared channel map + typed wrappers (no dependencies)
+-- 5-20 channels?
| +-- Shared channel map works, but electron-trpc adds value
+-- 20+ channels or complex validation?
| +-- electron-trpc (Zod validation + typed client)
+-- Need subscriptions / real-time updates?
+-- electron-trpc subscriptions OR MessagePort
Which IPC Pattern?
Renderer needs a response from main?
+-- YES --> ipcMain.handle() + ipcRenderer.invoke()
Renderer sends data, no response needed?
+-- YES --> ipcMain.on() + ipcRenderer.send()
Main needs to push data to renderer?
+-- YES --> webContents.send() + ipcRenderer.on() (in preload)
Two renderers need to communicate?
+-- YES --> MessagePort (set up via main process)
High-frequency streaming data?
+-- YES --> MessagePort (avoids per-message IPC overhead)
CPU-intensive background work?
+-- YES --> utilityProcess.fork() + parentPort
</decision_framework>
<red_flags>
RED FLAGS
Critical Security Issues:
- Exposing
ipcRendererdirectly viacontextBridgeinstead of wrapping specific channels -- gives renderer full IPC access - Not validating IPC arguments in main process handlers -- path traversal, injection, privilege escalation
- Using
ipcRenderer.sendSync()-- blocks the entire renderer process, causes UI freezes - Accepting arbitrary file paths from renderer without resolving and checking boundaries
Type Safety Issues:
- Using string literals for channel names without a shared type map -- typos become runtime bugs
- Defining IPC types separately in main and renderer -- they will drift apart
- Not augmenting
windowtype with the preload API -- renderer code has no autocompletion - Using
anyfor IPC payloads -- defeats the purpose of typed IPC
Architecture Issues:
- Not cleaning up
ipcRenderer.onlisteners when components unmount -- causes memory leaks and duplicate handlers - Direct renderer-to-renderer communication without going through main or MessagePort -- not possible in Electron
- Putting business logic in the renderer that should live in main
- Using
child_process.fork()instead ofutilityProcess.fork()in Electron apps
electron-trpc Gotchas:
- Forgetting
exposeElectronTRPC()in the preload script -- client silently fails - Not using a transformer (e.g., SuperJSON) when procedures return
Date,Map, orSet-- serialization loses type information - Subscriptions auto-cancel on window navigation -- resubscribe if the page is a SPA that does not reload
- Custom error classes lose properties during IPC serialization -- use plain error objects or error codes
MessagePort Gotchas:
- Ports must be transferred via
postMessage, notsendorinvoke-- the transfer list is a third argument - Main side must call
port.start()explicitly -- forgetting this means no messages flow port.closeevent fires when the remote end is garbage collected -- handle gracefullySharedArrayBufferis NOT reliably supported in Electron across process boundaries due to cross-origin isolation limitations
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST validate and sanitize ALL data received via IPC in the main process -- treat renderer messages as untrusted input)
(You MUST use contextBridge.exposeInMainWorld() in preload scripts -- never expose ipcRenderer directly)
(You MUST define IPC channel names and payload types in a single shared file -- never use untyped string literals for channel names)
(You MUST use ipcMain.handle() / ipcRenderer.invoke() for request-response IPC -- sendSync blocks the renderer)
(You MUST clean up IPC listeners when components unmount or windows close -- listener leaks cause memory issues and duplicate handlers)
Failure to follow these rules will create security vulnerabilities, type mismatches across process boundaries, and memory leaks.
</critical_reminders>
Files (skills)
-
examples
-
core.md 10 KB
# Electron Type-Safe IPC - Core Patterns > Shared channel map, typed wrappers, typed preload, and declaration augmentation. See [electron-trpc.md](electron-trpc.md) for the tRPC approach. See [message-ports.md](message-ports.md) for MessagePort and utility process patterns. --- ## Shared IPC Channel Map The foundation of type-safe IPC: a single file defining all channel names, argument types, and return types. Both main and renderer processes import from this file. ```typescript // shared/ipc-channels.ts /** Request-response channels: renderer calls, main responds with a Promise */ export interface IpcHandleChannels { "file:read": (filePath: string) => { content: string }; "file:write": (filePath: string, content: string) => { success: boolean }; "file:exists": (filePath: string) => boolean; "dialog:open-file": (options: { filters?: Array<{ name: string; extensions: string[] }>; multiSelect?: boolean; }) => string[] | null; "dialog:save-file": (defaultPath?: string) => string | null; "app:get-version": () => string; "app:get-path": (name: "userData" | "documents" | "downloads") => string; "settings:get": <T>(key: string) => T | null; "settings:set": (key: string, value: unknown) => void; } /** Fire-and-forget channels: renderer sends, main does not respond */ export interface IpcSendChannels { "analytics:track": [eventName: string, metadata?: Record<string, unknown>]; "log:error": [message: string, stack?: string]; "log:info": [message: string]; "window:minimize": []; "window:close": []; } /** Main-to-renderer push channels: main sends, renderer listens */ export interface IpcMainToRendererChannels { "update:progress": { percent: number; message: string }; "update:available": { version: string; releaseNotes?: string }; "update:downloaded": { version: string }; "theme:changed": "light" | "dark"; "deep-link:received": { url: string }; } ``` **Why good:** All channel contracts live in one file. Renaming a channel or changing a payload type triggers compile errors everywhere the old contract is used. Channel names use namespace prefixes (`file:`, `app:`, `dialog:`) for organization. --- ## Typed Main Process Handlers Create typed wrappers around `ipcMain.handle` and `ipcMain.on` that constrain channels to the map. ```typescript // main/typed-ipc.ts import { ipcMain } from "electron"; import type { IpcHandleChannels, IpcSendChannels, } from "../shared/ipc-channels"; /** Type-safe ipcMain.handle -- only accepts channels from IpcHandleChannels */ export function typedHandle<C extends keyof IpcHandleChannels>( channel: C, handler: ( event: Electron.IpcMainInvokeEvent, ...args: Parameters<IpcHandleChannels[C]> ) => | ReturnType<IpcHandleChannels[C]> | Promise<ReturnType<IpcHandleChannels[C]>>, ): void { ipcMain.handle(channel, handler as (...args: unknown[]) => unknown); } /** Type-safe ipcMain.on -- only accepts channels from IpcSendChannels */ export function typedOn<C extends keyof IpcSendChannels>( channel: C, handler: (event: Electron.IpcMainEvent, ...args: IpcSendChannels[C]) => void, ): void { ipcMain.on(channel, handler as (...args: unknown[]) => void); } ``` ```typescript // main/handlers.ts -- register all handlers using typed wrappers import { app, dialog } from "electron"; import { typedHandle, typedOn } from "./typed-ipc"; // TypeScript enforces correct return types typedHandle("file:read", async (_event, filePath) => { // filePath is typed as string, return must be { content: string } const content = await fs.readFile(filePath, "utf-8"); return { content }; }); typedHandle("dialog:open-file", async (_event, options) => { // options is typed from the channel map const result = await dialog.showOpenDialog({ properties: options.multiSelect ? ["openFile", "multiSelections"] : ["openFile"], filters: options.filters ?? [], }); return result.canceled ? null : result.filePaths; }); typedHandle("app:get-version", () => app.getVersion()); // Fire-and-forget -- no return value expected typedOn("analytics:track", (_event, eventName, metadata) => { // eventName: string, metadata: Record<string, unknown> | undefined trackEvent(eventName, metadata); }); typedOn("log:error", (_event, message, stack) => { logger.error(message, { stack }); }); ``` **Why good:** typo in channel name = compile error, wrong argument types = compile error, wrong return type = compile error. The wrapper functions are thin -- no runtime overhead beyond a type cast. --- ## Typed Preload Script The preload script bridges main and renderer. Build a generic typed API from the channel map. ```typescript // preload.ts import { contextBridge, ipcRenderer } from "electron"; import type { IpcHandleChannels, IpcSendChannels, IpcMainToRendererChannels, } from "../shared/ipc-channels"; export interface ElectronAPI { invoke: <C extends keyof IpcHandleChannels>( channel: C, ...args: Parameters<IpcHandleChannels[C]> ) => Promise<ReturnType<IpcHandleChannels[C]>>; send: <C extends keyof IpcSendChannels>( channel: C, ...args: IpcSendChannels[C] ) => void; on: <C extends keyof IpcMainToRendererChannels>( channel: C, callback: (data: IpcMainToRendererChannels[C]) => void, ) => () => void; } const api: ElectronAPI = { invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), send: (channel, ...args) => ipcRenderer.send(channel, ...args), on: (channel, callback) => { const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data as never); ipcRenderer.on(channel, listener); // Return unsubscribe function for cleanup return () => { ipcRenderer.removeListener(channel, listener); }; }, }; contextBridge.exposeInMainWorld("electronAPI", api); ``` **Why good:** `invoke`, `send`, and `on` are all constrained to their respective channel maps. The `on` method returns an unsubscribe function, making cleanup trivial in UI framework components. --- ## Window Type Augmentation Augment the global `Window` interface so the renderer gets full autocompletion on `window.electronAPI`. ```typescript // shared/electron-api.d.ts import type { ElectronAPI } from "../preload"; declare global { interface Window { electronAPI: ElectronAPI; } } ``` **Usage in renderer:** ```typescript // renderer/some-component.ts // Full autocompletion: channel names, argument types, return types const { content } = await window.electronAPI.invoke( "file:read", "/path/to/file", ); // ^-- typed as { content: string } window.electronAPI.send("analytics:track", "page-view", { page: "/home" }); // Listener with cleanup const unsubscribe = window.electronAPI.on("update:progress", (data) => { console.log(data.percent); // typed as number }); // Call unsubscribe() when component unmounts ``` --- ## Typed Main-to-Renderer Push Type-safe wrapper for `webContents.send()` so main process pushes are also constrained to the channel map. ```typescript // main/typed-ipc.ts (add to existing file) import type { BrowserWindow } from "electron"; import type { IpcMainToRendererChannels } from "../shared/ipc-channels"; /** Type-safe webContents.send -- only accepts channels from IpcMainToRendererChannels */ export function typedSendToRenderer<C extends keyof IpcMainToRendererChannels>( win: BrowserWindow, channel: C, data: IpcMainToRendererChannels[C], ): void { win.webContents.send(channel, data); } ``` ```typescript // main/updater.ts import { typedSendToRenderer } from "./typed-ipc"; function onUpdateProgress(win: BrowserWindow, percent: number) { typedSendToRenderer(win, "update:progress", { percent, message: `Downloading: ${percent}%`, }); } ``` **Why good:** prevents sending wrong data shape to a channel, prevents typos in channel names --- ## Channel Validation Middleware For defense-in-depth, validate that incoming IPC channels are in the allowed set at runtime. ```typescript // main/ipc-validator.ts import { ipcMain } from "electron"; import type { IpcHandleChannels, IpcSendChannels, } from "../shared/ipc-channels"; const ALLOWED_HANDLE_CHANNELS = new Set<string>([ "file:read", "file:write", "file:exists", "dialog:open-file", "dialog:save-file", "app:get-version", "app:get-path", "settings:get", "settings:set", ] satisfies Array<keyof IpcHandleChannels>); const ALLOWED_SEND_CHANNELS = new Set<string>([ "analytics:track", "log:error", "log:info", "window:minimize", "window:close", ] satisfies Array<keyof IpcSendChannels>); /** * Reject IPC messages on channels not in the allowed set. * Call once during app initialization. */ export function installChannelValidator(): void { const originalHandle = ipcMain.handle.bind(ipcMain); ipcMain.handle = ( channel: string, handler: (...args: unknown[]) => unknown, ) => { if (!ALLOWED_HANDLE_CHANNELS.has(channel)) { throw new Error(`Unregistered IPC handle channel: ${channel}`); } return originalHandle(channel, handler); }; } ``` **Why good:** the `satisfies` assertion ensures the allowlist stays in sync with the type map. If a channel is added to the type map but not the allowlist, TypeScript flags it. --- ## Listener Cleanup Pattern Always clean up IPC listeners to prevent memory leaks. The unsubscribe function from the typed preload makes this straightforward. ```typescript // In a UI framework component (framework-agnostic pattern) function setupListeners() { const unsubProgress = window.electronAPI.on("update:progress", (data) => { updateProgressBar(data.percent); }); const unsubAvailable = window.electronAPI.on("update:available", (data) => { showUpdateBanner(data.version); }); // Return combined cleanup function return () => { unsubProgress(); unsubAvailable(); }; } // Call cleanup when component unmounts or page navigates away const cleanup = setupListeners(); // ... later: cleanup(); ``` **Why good:** each `on` call returns its own unsubscribe function, cleanup is explicit, no stale listeners accumulate across re-mounts or navigation -
electron-trpc.md 9.1 KB
# Electron Type-Safe IPC - electron-trpc > End-to-end type-safe IPC using tRPC over Electron's IPC channels. See [core.md](core.md) for the manual typed channel map approach. See [SKILL.md](../SKILL.md) for when to choose electron-trpc vs manual typing. --- ## Setup Overview electron-trpc consists of three parts: 1. **Main process:** `createIPCHandler` -- routes IPC messages through a tRPC router 2. **Preload script:** `exposeElectronTRPC` -- exposes IPC transport via contextBridge 3. **Renderer:** `ipcLink` -- tRPC link that sends requests over Electron IPC instead of HTTP --- ## Preload Script The preload script must expose electron-trpc's IPC transport. This is a one-liner. ```typescript // preload.ts import { exposeElectronTRPC } from "electron-trpc/main"; // Must run after the preload context is loaded process.once("loaded", () => { exposeElectronTRPC(); }); ``` **Gotcha:** The import path is `electron-trpc/main` even though this runs in the preload script -- the module handles the contextBridge exposure internally. --- ## Router Definition (Main Process) Define your IPC API as a tRPC router with Zod-validated inputs. ```typescript // main/router.ts import { initTRPC } from "@trpc/server"; import { z } from "zod"; import { app, dialog } from "electron"; import fs from "node:fs/promises"; import path from "node:path"; const t = initTRPC.create({ isServer: true }); const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB export const appRouter = t.router({ // Query: read-only operations getVersion: t.procedure.query(() => app.getVersion()), readFile: t.procedure .input(z.object({ filePath: z.string().min(1) })) .query(async ({ input }) => { const resolved = path.resolve(app.getPath("userData"), input.filePath); if (!resolved.startsWith(app.getPath("userData"))) { throw new Error("Access denied"); } const content = await fs.readFile(resolved, "utf-8"); return { content }; }), // Mutation: write operations writeFile: t.procedure .input( z.object({ filePath: z.string().min(1), content: z.string().max(MAX_FILE_SIZE), }), ) .mutation(async ({ input }) => { const resolved = path.resolve(app.getPath("userData"), input.filePath); if (!resolved.startsWith(app.getPath("userData"))) { throw new Error("Access denied"); } await fs.writeFile(resolved, input.content, "utf-8"); return { success: true }; }), openFileDialog: t.procedure .input( z .object({ filters: z .array( z.object({ name: z.string(), extensions: z.array(z.string()) }), ) .optional(), multiSelect: z.boolean().optional(), }) .optional(), ) .mutation(async ({ input }) => { const result = await dialog.showOpenDialog({ properties: input?.multiSelect ? ["openFile", "multiSelections"] : ["openFile"], filters: input?.filters ?? [], }); return result.canceled ? null : result.filePaths; }), }); // Export the router type for use in the renderer export type AppRouter = typeof appRouter; ``` **Why good:** Zod validates input at runtime (protects against compromised renderers), TypeScript validates at compile time (autocompletion in renderer). Adding a new procedure automatically surfaces in the typed client. --- ## IPC Handler Registration (Main Process) Wire the router to Electron's IPC in the main process after `app.whenReady()`. ```typescript // main/index.ts import { app, BrowserWindow } from "electron"; import { createIPCHandler } from "electron-trpc/main"; import { appRouter } from "./router"; import path from "node:path"; let mainWindow: BrowserWindow | null = null; app.whenReady().then(() => { mainWindow = new BrowserWindow({ width: 1200, height: 800, webPreferences: { preload: path.join(__dirname, "preload.js"), // contextIsolation: true (default) // sandbox: true (default) }, }); // Attach the tRPC IPC handler to this window createIPCHandler({ router: appRouter, windows: [mainWindow], }); mainWindow.loadFile("index.html"); }); ``` **Key point:** `createIPCHandler` must receive the window(s) it should listen on. For multi-window apps, pass all windows that need IPC access. --- ## Renderer Client Create a typed tRPC client using `ipcLink` instead of an HTTP link. ```typescript // renderer/trpc-client.ts import { createTRPCProxyClient } from "@trpc/client"; import { ipcLink } from "electron-trpc/renderer"; import type { AppRouter } from "../main/router"; export const trpc = createTRPCProxyClient<AppRouter>({ links: [ipcLink()], }); ``` **Usage:** ```typescript // renderer/app.ts import { trpc } from "./trpc-client"; // Fully typed -- autocompletion on procedure names, input shapes, return types const version = await trpc.getVersion.query(); // ^-- typed as string const { content } = await trpc.readFile.query({ filePath: "config.json" }); // ^-- typed as { content: string } await trpc.writeFile.mutate({ filePath: "config.json", content: "{}" }); const files = await trpc.openFileDialog.mutate({ multiSelect: true }); // ^-- typed as string[] | null ``` **Why good:** Zero boilerplate for typed IPC calls. The renderer never sees channel names, IPC arguments, or `window.electronAPI`. The tRPC client provides the same DX as calling a typed API. --- ## Subscriptions (Real-Time Updates) electron-trpc supports tRPC subscriptions for pushing data from main to renderer. ```typescript // main/router.ts (add to existing router) import { observable } from "@trpc/server/observable"; import { EventEmitter } from "node:events"; const ee = new EventEmitter(); export const appRouter = t.router({ // ... other procedures ... onFileChanged: t.procedure .input(z.object({ watchPath: z.string() })) .subscription(({ input }) => { return observable<{ path: string; event: string }>((emit) => { const watcher = fs.watch(input.watchPath, (eventType, filename) => { emit.next({ path: filename ?? input.watchPath, event: eventType }); }); // Cleanup when subscription ends return () => { watcher.close(); }; }); }), onSettingsChanged: t.procedure.subscription(() => { return observable<{ key: string; value: unknown }>((emit) => { const handler = (data: { key: string; value: unknown }) => { emit.next(data); }; ee.on("settings-changed", handler); return () => { ee.off("settings-changed", handler); }; }); }), }); ``` ```typescript // renderer/app.ts const unsubscribe = trpc.onFileChanged.subscribe( { watchPath: "/some/dir" }, { onData: (change) => { console.log(`File ${change.path} had event: ${change.event}`); }, onError: (err) => { console.error("Subscription error:", err); }, }, ); // Clean up when no longer needed unsubscribe(); ``` **Key points:** - Subscriptions auto-cancel when the window navigates or closes - The `observable` return function is the cleanup callback - If the renderer is a SPA, subscriptions persist until explicitly unsubscribed - Error handling is per-subscription via `onError` --- ## Context for Authentication / Session Pass per-request context (e.g., window ID, session data) via `createContext`. ```typescript // main/index.ts import { createIPCHandler } from "electron-trpc/main"; import { appRouter } from "./router"; createIPCHandler({ router: appRouter, windows: [mainWindow], createContext: ({ event }) => { // event.sender is the WebContents that sent the request return { windowId: event.sender.id, isMainWindow: event.sender === mainWindow?.webContents, }; }, }); ``` ```typescript // main/router.ts import { initTRPC } from "@trpc/server"; interface Context { windowId: number; isMainWindow: boolean; } const t = initTRPC.context<Context>().create({ isServer: true }); export const appRouter = t.router({ getWindowInfo: t.procedure.query(({ ctx }) => { return { windowId: ctx.windowId, isMainWindow: ctx.isMainWindow, }; }), }); ``` --- ## SuperJSON for Complex Types Standard IPC serialization loses `Date`, `Map`, `Set`, and other non-JSON types. Use SuperJSON as a transformer. ```typescript // shared/transformer.ts import superjson from "superjson"; export { superjson }; ``` ```typescript // main/router.ts import { initTRPC } from "@trpc/server"; import { superjson } from "../shared/transformer"; const t = initTRPC.create({ isServer: true, transformer: superjson, }); ``` ```typescript // renderer/trpc-client.ts import { createTRPCProxyClient } from "@trpc/client"; import { ipcLink } from "electron-trpc/renderer"; import { superjson } from "../shared/transformer"; import type { AppRouter } from "../main/router"; export const trpc = createTRPCProxyClient<AppRouter>({ links: [ipcLink()], transformer: superjson, }); ``` **When needed:** procedures that return `Date` objects, `Map`, `Set`, `BigInt`, `RegExp`, or `undefined` values. Without SuperJSON, these are silently converted to strings or lost during serialization. -
message-ports.md 9.8 KB
# Electron Type-Safe IPC - MessagePort & Utility Process > High-throughput communication patterns: MessagePort for streaming and renderer-to-renderer, utilityProcess for background work. See [core.md](core.md) for standard typed IPC. See [SKILL.md](../SKILL.md) for when to choose MessagePort vs standard IPC. --- ## MessagePort: Main to Renderer Use `MessageChannelMain` to create a port pair for high-frequency data transfer that avoids per-message IPC serialization overhead. ```typescript // shared/port-messages.ts -- typed messages for port communication export interface PortRequest { id: string; type: "process-chunk" | "stream-start" | "stream-stop"; payload: unknown; } export interface PortResponse { id: string; type: "chunk-result" | "stream-data" | "stream-end" | "error"; data: unknown; } ``` ```typescript // main/data-channel.ts import { MessageChannelMain } from "electron"; import type { BrowserWindow } from "electron"; import type { PortRequest, PortResponse } from "../shared/port-messages"; export function createDataChannel(win: BrowserWindow): MessagePortMain { const { port1, port2 } = new MessageChannelMain(); // Main keeps port1 for sending/receiving port1.on("message", (event: Electron.MessageEvent) => { const request = event.data as PortRequest; const response = processRequest(request); port1.postMessage(response); }); // CRITICAL: must call start() on main side port1.start(); // Transfer port2 to the renderer via postMessage (not send/invoke) win.webContents.postMessage("data-channel-port", null, [port2]); return port1; } function processRequest(request: PortRequest): PortResponse { switch (request.type) { case "process-chunk": return { id: request.id, type: "chunk-result", data: heavyProcess(request.payload), }; case "stream-start": return { id: request.id, type: "stream-data", data: null }; case "stream-stop": return { id: request.id, type: "stream-end", data: null }; default: return { id: request.id, type: "error", data: "Unknown request type" }; } } ``` ```typescript // preload.ts -- receive the port and expose it import { contextBridge, ipcRenderer } from "electron"; let dataPort: MessagePort | null = null; ipcRenderer.on("data-channel-port", (event) => { const [port] = event.ports; dataPort = port; }); contextBridge.exposeInMainWorld("dataChannel", { onReady: (callback: (port: MessagePort) => void) => { if (dataPort) { callback(dataPort); } else { ipcRenderer.on("data-channel-port", (event) => { callback(event.ports[0]); }); } }, }); ``` ```typescript // renderer/use-data-channel.ts import type { PortRequest, PortResponse } from "../shared/port-messages"; function useDataChannel(): { send: (request: PortRequest) => Promise<PortResponse>; } { let port: MessagePort | null = null; const pending = new Map<string, (response: PortResponse) => void>(); window.dataChannel.onReady((p) => { port = p; port.onmessage = (event: MessageEvent<PortResponse>) => { const resolve = pending.get(event.data.id); if (resolve) { pending.delete(event.data.id); resolve(event.data); } }; }); return { send: (request) => new Promise((resolve) => { pending.set(request.id, resolve); port?.postMessage(request); }), }; } ``` **When to use:** Real-time data feeds (audio/video processing, live charts), large binary transfers, or patterns where the standard `invoke`/`handle` serialization overhead is measurable. **Key differences from standard IPC:** - Uses Structured Clone Algorithm instead of IPC serialization - Ports transferred via `postMessage`, not `send`/`invoke` - `port.start()` required on main side (renderer auto-starts on `message` listener) - `port.close` event fires when the remote end is garbage collected --- ## Renderer-to-Renderer Communication Two renderer windows cannot communicate directly. Route through main using a MessagePort pair. ```typescript // main/renderer-bridge.ts import { MessageChannelMain } from "electron"; import type { BrowserWindow } from "electron"; /** * Create a direct communication channel between two renderer windows. * Each window gets one end of a MessagePort pair. */ export function bridgeRenderers( windowA: BrowserWindow, windowB: BrowserWindow, ): void { const { port1, port2 } = new MessageChannelMain(); // Transfer one port to each window windowA.webContents.postMessage("peer-port", null, [port1]); windowB.webContents.postMessage("peer-port", null, [port2]); } ``` ```typescript // preload.ts ipcRenderer.on("peer-port", (event) => { const [port] = event.ports; contextBridge.exposeInMainWorld("peerChannel", { send: (data: unknown) => port.postMessage(data), onMessage: (callback: (data: unknown) => void) => { port.onmessage = (event) => callback(event.data); }, close: () => port.close(), }); }); ``` **How it works:** Main creates a `MessageChannelMain`, sends one port to each renderer. After setup, the two renderers communicate directly through the port pair -- main is not involved in message routing. **Gotcha:** Both windows must be loaded before transferring ports. Use `webContents.on("did-finish-load")` to ensure readiness. --- ## Utility Process for Background Work `utilityProcess.fork()` spawns a Node.js child process for CPU-intensive work without blocking the main process. ```typescript // shared/worker-messages.ts export interface WorkerRequest { type: "parse-csv" | "compress-file" | "generate-report"; id: string; payload: unknown; } export interface WorkerResponse { type: "result" | "progress" | "error"; id: string; data: unknown; } ``` ```typescript // main/background-worker.ts import { utilityProcess } from "electron"; import type { BrowserWindow } from "electron"; import path from "node:path"; import type { WorkerRequest, WorkerResponse } from "../shared/worker-messages"; export function createBackgroundWorker(mainWindow: BrowserWindow) { const worker = utilityProcess.fork(path.join(__dirname, "worker.js"), [], { serviceName: "background-worker", }); // Forward results from worker to renderer worker.on("message", (response: WorkerResponse) => { if (response.type === "progress") { mainWindow.webContents.send("worker:progress", response); } else { mainWindow.webContents.send("worker:result", response); } }); worker.on("exit", (code) => { if (code !== 0) { mainWindow.webContents.send("worker:error", { message: `Worker exited with code ${code}`, }); } }); return { send: (request: WorkerRequest) => worker.postMessage(request), kill: () => worker.kill(), }; } ``` ```typescript // worker.ts (runs in utility process) import type { WorkerRequest, WorkerResponse } from "../shared/worker-messages"; process.parentPort.on("message", (event: Electron.MessageEvent) => { const request = event.data as WorkerRequest; switch (request.type) { case "parse-csv": { const result = parseLargeCsv(request.payload as string); const response: WorkerResponse = { type: "result", id: request.id, data: result, }; process.parentPort.postMessage(response); break; } case "compress-file": { // Report progress during long operations for (let i = 0; i <= 100; i += 10) { const progress: WorkerResponse = { type: "progress", id: request.id, data: { percent: i }, }; process.parentPort.postMessage(progress); } break; } default: { const error: WorkerResponse = { type: "error", id: request.id, data: `Unknown request type: ${request.type}`, }; process.parentPort.postMessage(error); } } }); ``` **Key points:** - `utilityProcess.fork()` is preferred over `child_process.fork()` in Electron -- uses Chromium's Services API - Communication via `parentPort.postMessage()` / `parentPort.on("message")` - Can only be called after `app.whenReady()` - `serviceName` option labels the process in Electron's `app.getAppMetrics()` - Utility processes have full Node.js access (fs, crypto, etc.) - The `exit` event fires with a code when the process terminates --- ## MessagePort Transfer to Utility Process For direct communication between a renderer and a utility process, transfer a MessagePort. ```typescript // main/direct-worker-channel.ts import { MessageChannelMain, utilityProcess } from "electron"; import type { BrowserWindow } from "electron"; import path from "node:path"; export function createDirectWorkerChannel(win: BrowserWindow): void { const worker = utilityProcess.fork(path.join(__dirname, "worker.js")); const { port1, port2 } = new MessageChannelMain(); // Send port1 to the worker worker.postMessage({ type: "init-port" }, [port1]); // Send port2 to the renderer win.webContents.postMessage("worker-port", null, [port2]); } ``` ```typescript // worker.ts (utility process) process.parentPort.on("message", (event: Electron.MessageEvent) => { if (event.data?.type === "init-port" && event.ports.length > 0) { const port = event.ports[0]; port.on("message", (msgEvent: Electron.MessageEvent) => { // Direct message from renderer const result = processData(msgEvent.data); port.postMessage(result); }); port.start(); } }); ``` **When to use:** When the renderer needs frequent, low-latency communication with a background worker and routing through main would add unnecessary overhead. After the initial setup (which goes through main), all subsequent messages flow directly between the renderer and utility process. **Gotcha:** The utility process must call `port.start()` after receiving the transferred port. The renderer side auto-starts when adding a `message` listener.
-
-
reference.md 4.6 KB
# Electron Type-Safe IPC Reference > Quick-lookup tables, decision frameworks, and security checklist. See [SKILL.md](SKILL.md) for patterns and philosophy. See [examples/](examples/) for full code implementations. --- ## IPC Methods Quick Reference | Pattern | Main API | Preload API | Direction | Returns | | ----------------------- | ------------------------------------ | ------------------------ | ----------------- | --------- | | Request-response | `ipcMain.handle(ch, handler)` | `ipcRenderer.invoke(ch)` | Renderer --> Main | `Promise` | | Fire-and-forget | `ipcMain.on(ch, handler)` | `ipcRenderer.send(ch)` | Renderer --> Main | `void` | | Main pushes to renderer | `webContents.send(ch, data)` | `ipcRenderer.on(ch, cb)` | Main --> Renderer | `void` | | Synchronous (avoid) | `ipcMain.on()` + `event.returnValue` | `sendSync()` (blocks!) | Renderer --> Main | sync | | Port-based | `MessageChannelMain` | `MessagePort` | Bidirectional | `void` | | Utility process | `child.postMessage()` | `parentPort.postMessage` | Main <-> Utility | `void` | --- ## Type Safety Approaches Comparison | Approach | Dependencies | Type Safety Level | Effort | Best For | | --------------------------- | --------------------- | ----------------- | ------ | ---------------------- | | Shared channel map + wraps | None | Compile-time | Low | 1-10 IPC channels | | electron-trpc | `electron-trpc`, tRPC | Compile + runtime | Medium | 10+ channels, complex | | @electron-toolkit/typed-ipc | `@electron-toolkit/*` | Compile-time | Low | Drop-in typed wrappers | --- ## Channel Map Type Patterns | Communication Pattern | Type Shape | Example | | --------------------- | ---------------------------------- | ---------------------------------------------------- | | Request-response | `channel: (...args) => ReturnType` | `"file:read": (path: string) => { content: string }` | | Fire-and-forget | `channel: [...args]` | `"log:error": [message: string, stack?: string]` | | Main-to-renderer push | `channel: PayloadType` | `"update:progress": { percent: number }` | --- ## Port Transfer Methods Standard IPC methods (`send`, `invoke`) **cannot** transfer MessagePort objects. You must use: | Method | Process | Usage | | --------------------------- | ---------------- | -------------------------------- | | `webContents.postMessage()` | Main | Transfer port to renderer | | `ipcRenderer.postMessage()` | Renderer/Preload | Transfer port to main | | `child.postMessage()` | Main | Transfer port to utility process | | `parentPort.postMessage()` | Utility process | Transfer port back to main | --- ## Security Checklist for IPC - [ ] Preload exposes only specific channel wrappers, not raw `ipcRenderer` - [ ] All `ipcMain.handle()` handlers validate argument types - [ ] File path arguments are resolved and checked against allowed directories - [ ] String arguments are length-limited - [ ] Channel names use a namespace prefix (`file:`, `app:`, `dialog:`) - [ ] `ipcRenderer.on()` listeners are cleaned up on component unmount - [ ] No `sendSync` usage (blocks renderer thread) - [ ] `contextIsolation` is `true` (default since Electron 12) - [ ] `nodeIntegration` is `false` (default since Electron 5) - [ ] `shell.openExternal()` validates URLs against an allowlist --- ## electron-trpc Checklist - [ ] `exposeElectronTRPC()` called in preload script - [ ] `createIPCHandler({ router, windows: [win] })` in main process after `app.whenReady()` - [ ] Router exported as `type AppRouter = typeof router` for renderer client - [ ] `createTRPCProxyClient<AppRouter>({ links: [ipcLink()] })` in renderer - [ ] SuperJSON transformer configured if procedures return `Date`, `Map`, or `Set` - [ ] Subscriptions handle auto-cancel on navigation (resubscribe if SPA) --- ## See Also - [Electron IPC Tutorial](https://www.electronjs.org/docs/latest/tutorial/ipc) - [Electron MessagePorts](https://www.electronjs.org/docs/latest/tutorial/message-ports) - [Electron utilityProcess API](https://www.electronjs.org/docs/latest/api/utility-process) - [Electron contextBridge API](https://www.electronjs.org/docs/latest/api/context-bridge) - [electron-trpc documentation](https://electron-trpc.dev/) -
SKILL.md 16.7 KB
--- name: desktop-ipc-electron description: Type-safe Electron IPC patterns with typed channels, electron-trpc, MessagePort, and utility process communication --- # Electron Type-Safe IPC Patterns > **Quick Guide:** All Electron IPC flows through a preload script using `contextBridge.exposeInMainWorld()`. Make it type-safe by defining a shared channel map that constrains channel names, payloads, and return types across main, preload, and renderer. For end-to-end type safety with minimal boilerplate, use `electron-trpc` (tRPC over IPC). For high-throughput streaming or renderer-to-renderer communication, use `MessageChannelMain`/`MessagePort`. For CPU-intensive background work, use `utilityProcess` with `parentPort`. Always validate IPC input in the main process -- treat renderer messages as untrusted. --- <critical_requirements> ## CRITICAL: Before Using This Skill > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST validate and sanitize ALL data received via IPC in the main process -- treat renderer messages as untrusted input)** **(You MUST use `contextBridge.exposeInMainWorld()` in preload scripts -- never expose `ipcRenderer` directly)** **(You MUST define IPC channel names and payload types in a single shared file -- never use untyped string literals for channel names)** **(You MUST use `ipcMain.handle()` / `ipcRenderer.invoke()` for request-response IPC -- `sendSync` blocks the renderer)** **(You MUST clean up IPC listeners when components unmount or windows close -- listener leaks cause memory issues and duplicate handlers)** </critical_requirements> --- **Auto-detection:** Electron IPC, ipcMain, ipcRenderer, contextBridge, preload, type-safe IPC, electron-trpc, ipcLink, createIPCHandler, exposeElectronTRPC, MessageChannelMain, MessagePortMain, MessagePort, utilityProcess, parentPort, typed channels, IPC channel map, postMessage, webContents.send, ipcMain.handle, ipcRenderer.invoke **When to use:** - Adding type safety to Electron IPC communication - Setting up electron-trpc for end-to-end typed IPC - Defining shared channel/payload types between main and renderer - Building typed preload APIs with contextBridge - Using MessagePort for high-throughput or renderer-to-renderer communication - Implementing utility process IPC for background tasks - Validating and sanitizing IPC input in main process handlers **When NOT to use:** - Choosing a UI framework for the renderer (use the appropriate framework skill) - General Electron app setup, packaging, or native APIs (use the Electron framework skill) - Simple IPC that does not need type safety beyond basic JavaScript **Key patterns covered:** - Shared IPC channel map with typed payloads and return types - Typed preload API via contextBridge with declaration augmentation - electron-trpc for end-to-end type safety (queries, mutations, subscriptions) - Request-response (`handle`/`invoke`) with typed wrappers - Fire-and-forget (`on`/`send`) with typed channels - Main-to-renderer push (`webContents.send`) with typed events - MessagePort for high-throughput and renderer-to-renderer communication - Utility process IPC with `parentPort` and MessagePort transfer - IPC input validation and channel allowlisting --- **Detailed Resources:** - [examples/core.md](examples/core.md) - Shared channel map, typed preload, typed wrappers, declaration augmentation - [examples/electron-trpc.md](examples/electron-trpc.md) - electron-trpc setup, queries, mutations, subscriptions - [examples/message-ports.md](examples/message-ports.md) - MessagePort patterns, renderer-to-renderer, utility process IPC - [reference.md](reference.md) - IPC method quick reference, decision framework, security checklist --- <philosophy> ## Philosophy Electron IPC is stringly typed by default -- channel names are plain strings, payloads are `any`, and there is no compile-time guarantee that the main process handler matches what the renderer sends. Type-safe IPC solves this by defining a single source of truth for channel names, argument types, and return types, then threading those types through typed wrapper functions. **Three levels of type safety, pick one:** 1. **Shared channel map + typed wrappers** (DIY) -- define an `IpcChannelMap` interface, create thin typed wrappers around `ipcMain`/`ipcRenderer`. Zero dependencies, full control. 2. **electron-trpc** (library) -- tRPC over Electron IPC. Define a router in main with Zod-validated procedures, get a fully typed client in the renderer. Best DX for complex apps. 3. **MessagePort with typed messages** -- for high-throughput streaming or renderer-to-renderer communication where standard IPC overhead matters. **When to use each:** - **Shared channel map:** Most apps. Simple, no dependencies, covers `handle`/`invoke`, `send`/`on`, and `webContents.send`. - **electron-trpc:** Apps with many IPC endpoints, complex input validation, or subscription needs. Worth the dependency when you have 10+ IPC channels. - **MessagePort:** Real-time data feeds, large binary transfers, or direct renderer-to-renderer communication. Not a replacement for standard IPC -- an addition for specific high-throughput needs. **When NOT to use type-safe IPC:** - Prototyping where speed matters more than safety - Apps with 1-2 trivial IPC calls where the overhead of typed infrastructure is not justified </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: Shared IPC Channel Map Define all channel names, argument types, and return types in a single shared file. Both main and renderer import from this file. ```typescript // shared/ipc-channels.ts export interface IpcHandleChannels { "file:read": (filePath: string) => { content: string }; "file:write": (filePath: string, content: string) => { success: boolean }; "dialog:open": (options: OpenDialogOptions) => string | null; "app:version": () => string; } export interface IpcSendChannels { "analytics:track": [eventName: string, metadata: Record<string, unknown>]; "log:error": [message: string, stack?: string]; } export interface IpcMainToRendererChannels { "update:progress": { percent: number; message: string }; "update:available": { version: string }; "theme:changed": "light" | "dark"; } ``` **Why good:** Single source of truth for all IPC contracts, TypeScript catches mismatches at compile time, channel names are autocompleted See [examples/core.md](examples/core.md) for typed wrappers that consume this map. --- ### Pattern 2: Typed Preload with contextBridge Build a typed preload API from the channel map, then augment `window` so the renderer gets full autocompletion. ```typescript // preload.ts import { contextBridge, ipcRenderer } from "electron"; import type { IpcHandleChannels, IpcSendChannels, } from "../shared/ipc-channels"; type ElectronAPI = { invoke: <C extends keyof IpcHandleChannels>( channel: C, ...args: Parameters<IpcHandleChannels[C]> ) => Promise<ReturnType<IpcHandleChannels[C]>>; send: <C extends keyof IpcSendChannels>( channel: C, ...args: IpcSendChannels[C] ) => void; on: (channel: string, callback: (...args: unknown[]) => void) => () => void; }; contextBridge.exposeInMainWorld("electronAPI", { invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), send: (channel, ...args) => ipcRenderer.send(channel, ...args), on: (channel, callback) => { const listener = (_event: unknown, ...args: unknown[]) => callback(...args); ipcRenderer.on(channel, listener); return () => ipcRenderer.removeListener(channel, listener); }, } satisfies ElectronAPI); ``` ```typescript // shared/electron-api.d.ts -- augment window for renderer autocompletion import type { ElectronAPI } from "../preload"; declare global { interface Window { electronAPI: ElectronAPI; } } ``` **Why good:** renderer gets autocomplete on channel names and typed payloads, `on` returns an unsubscribe function for easy cleanup See [examples/core.md](examples/core.md) for the full pattern with main process typed handlers. --- ### Pattern 3: electron-trpc for End-to-End Type Safety For apps with many IPC endpoints, electron-trpc provides the best developer experience by leveraging tRPC's router pattern. ```typescript // main/router.ts import { initTRPC } from "@trpc/server"; import { z } from "zod"; const t = initTRPC.create({ isServer: true }); export const router = t.router({ readFile: t.procedure .input(z.object({ path: z.string() })) .query(async ({ input }) => { const content = await fs.readFile(input.path, "utf-8"); return { content }; }), saveSettings: t.procedure .input(z.object({ theme: z.enum(["light", "dark"]) })) .mutation(async ({ input }) => { await saveToStore(input); return { success: true }; }), }); export type AppRouter = typeof router; ``` ```typescript // renderer/client.ts import { createTRPCProxyClient } from "@trpc/client"; import { ipcLink } from "electron-trpc/renderer"; import type { AppRouter } from "../main/router"; export const trpc = createTRPCProxyClient<AppRouter>({ links: [ipcLink()], }); // Fully typed -- autocomplete on procedures, typed input/output const result = await trpc.readFile.query({ path: "/some/file.txt" }); ``` **Why good:** Zod validates input at runtime in main, TypeScript validates at compile time in renderer, adding a new procedure auto-surfaces in the client See [examples/electron-trpc.md](examples/electron-trpc.md) for full setup including preload, subscriptions, and context patterns. --- ### Pattern 4: IPC Input Validation Always validate arguments in main process handlers. The renderer can be compromised via XSS -- main process handlers have full Node.js access. ```typescript // main/handlers.ts const ALLOWED_EXTENSIONS = new Set([".txt", ".md", ".json"]); const MAX_CONTENT_LENGTH = 10 * 1024 * 1024; // 10MB ipcMain.handle("file:read", async (_event, filePath: unknown) => { // Type check if (typeof filePath !== "string") { throw new Error("filePath must be a string"); } // Path traversal prevention const resolved = path.resolve(app.getPath("userData"), filePath); if (!resolved.startsWith(app.getPath("userData"))) { throw new Error("Access denied: path outside allowed directory"); } // Extension allowlist const ext = path.extname(resolved); if (!ALLOWED_EXTENSIONS.has(ext)) { throw new Error(`File type not allowed: ${ext}`); } return { content: await fs.readFile(resolved, "utf-8") }; }); ``` **Why good:** validates type, prevents path traversal, restricts file extensions, uses named constants See [examples/core.md](examples/core.md) for a channel validation middleware pattern. --- ### Pattern 5: MessagePort for High-Throughput Communication Use `MessageChannelMain` for streaming data, large transfers, or direct renderer-to-renderer communication. ```typescript // main.ts -- create a port pair and send one end to renderer import { MessageChannelMain } from "electron"; function createDataChannel(win: BrowserWindow): MessagePortMain { const { port1, port2 } = new MessageChannelMain(); win.webContents.postMessage("port-transfer", null, [port2]); port1.start(); return port1; } ``` ```typescript // preload.ts -- receive port and expose to renderer ipcRenderer.on("port-transfer", (event) => { const [port] = event.ports; contextBridge.exposeInMainWorld("dataPort", port); }); ``` **Key points:** ports are transferred via `postMessage` (not `send`/`invoke`), `port.start()` must be called on the main side, renderer side auto-starts when adding a `message` listener. See [examples/message-ports.md](examples/message-ports.md) for renderer-to-renderer and utility process patterns. --- ### Pattern 6: Utility Process IPC Use `utilityProcess.fork()` for CPU-intensive work. Communication flows through `parentPort`. ```typescript // main.ts import { utilityProcess } from "electron"; const worker = utilityProcess.fork(path.join(__dirname, "worker.js")); worker.postMessage({ type: "process-data", payload: largeDataset }); worker.on("message", (result) => { mainWindow.webContents.send("processing-complete", result); }); ``` ```typescript // worker.ts (runs in utility process) process.parentPort.on("message", (event) => { const { type, payload } = event.data; if (type === "process-data") { const result = heavyComputation(payload); process.parentPort.postMessage({ type: "result", data: result }); } }); ``` **Key points:** utility processes have full Node.js access, communicate via `parentPort.postMessage()`, and should be used instead of `child_process.fork()` in Electron apps. See [examples/message-ports.md](examples/message-ports.md) for typed utility process communication. </patterns> --- <decision_framework> ## Decision Framework ### Which Type Safety Approach? ``` How many IPC channels does the app have? +-- 1-5 channels? | +-- Shared channel map + typed wrappers (no dependencies) +-- 5-20 channels? | +-- Shared channel map works, but electron-trpc adds value +-- 20+ channels or complex validation? | +-- electron-trpc (Zod validation + typed client) +-- Need subscriptions / real-time updates? +-- electron-trpc subscriptions OR MessagePort ``` ### Which IPC Pattern? ``` Renderer needs a response from main? +-- YES --> ipcMain.handle() + ipcRenderer.invoke() Renderer sends data, no response needed? +-- YES --> ipcMain.on() + ipcRenderer.send() Main needs to push data to renderer? +-- YES --> webContents.send() + ipcRenderer.on() (in preload) Two renderers need to communicate? +-- YES --> MessagePort (set up via main process) High-frequency streaming data? +-- YES --> MessagePort (avoids per-message IPC overhead) CPU-intensive background work? +-- YES --> utilityProcess.fork() + parentPort ``` </decision_framework> --- <red_flags> ## RED FLAGS **Critical Security Issues:** - Exposing `ipcRenderer` directly via `contextBridge` instead of wrapping specific channels -- gives renderer full IPC access - Not validating IPC arguments in main process handlers -- path traversal, injection, privilege escalation - Using `ipcRenderer.sendSync()` -- blocks the entire renderer process, causes UI freezes - Accepting arbitrary file paths from renderer without resolving and checking boundaries **Type Safety Issues:** - Using string literals for channel names without a shared type map -- typos become runtime bugs - Defining IPC types separately in main and renderer -- they will drift apart - Not augmenting `window` type with the preload API -- renderer code has no autocompletion - Using `any` for IPC payloads -- defeats the purpose of typed IPC **Architecture Issues:** - Not cleaning up `ipcRenderer.on` listeners when components unmount -- causes memory leaks and duplicate handlers - Direct renderer-to-renderer communication without going through main or MessagePort -- not possible in Electron - Putting business logic in the renderer that should live in main - Using `child_process.fork()` instead of `utilityProcess.fork()` in Electron apps **electron-trpc Gotchas:** - Forgetting `exposeElectronTRPC()` in the preload script -- client silently fails - Not using a transformer (e.g., SuperJSON) when procedures return `Date`, `Map`, or `Set` -- serialization loses type information - Subscriptions auto-cancel on window navigation -- resubscribe if the page is a SPA that does not reload - Custom error classes lose properties during IPC serialization -- use plain error objects or error codes **MessagePort Gotchas:** - Ports must be transferred via `postMessage`, not `send` or `invoke` -- the transfer list is a third argument - Main side must call `port.start()` explicitly -- forgetting this means no messages flow - `port.close` event fires when the remote end is garbage collected -- handle gracefully - `SharedArrayBuffer` is NOT reliably supported in Electron across process boundaries due to cross-origin isolation limitations </red_flags> --- <critical_reminders> ## CRITICAL REMINDERS > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST validate and sanitize ALL data received via IPC in the main process -- treat renderer messages as untrusted input)** **(You MUST use `contextBridge.exposeInMainWorld()` in preload scripts -- never expose `ipcRenderer` directly)** **(You MUST define IPC channel names and payload types in a single shared file -- never use untyped string literals for channel names)** **(You MUST use `ipcMain.handle()` / `ipcRenderer.invoke()` for request-response IPC -- `sendSync` blocks the renderer)** **(You MUST clean up IPC listeners when components unmount or windows close -- listener leaks cause memory issues and duplicate handlers)** **Failure to follow these rules will create security vulnerabilities, type mismatches across process boundaries, and memory leaks.** </critical_reminders>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.