Claude Skill

desktop-ipc-electron

Type-safe Electron IPC patterns with typed channels, electron-trpc, MessagePort, and utility process communication

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download agents-inc-skills-dist_plugins_desktop-ipc-electron_skills_desktop-ipc-electron-3a51ef5.zip · 17 KB
Part of agents-inc/skills — 130 skills

Install

skills CLI npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/desktop-ipc-electron/skills/desktop-ipc-electron
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
Git 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, 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:




<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>

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.

No comments yet.

Reviews (0)

No reviews yet.

Related