electron-ipc-module
    Preparing search index...

    electron-ipc-module

    electron-ipc-module

    npm CI License

    Modular, type-safe IPC for Electron. Declare handlers in the main process, load them with lifecycle management, and auto-generate a typed preload bridge for the renderer.

    • Compact API for ipcMain.handle, handleOnce, on, and once, with automatic channel prefixing (profile:get, profile:save, …)
    • Cancellable stream channels: yield from a generator in main, iterate in the renderer
    • Typed renderer events via reply, sender.send, and senderFrame.send
    • Container to load, unload, and observe multiple IPC modules, with channel-collision detection and transactional rollback
    • Rollup/Vite plugin that generates a typed ipcRenderer bridge from *.ipc.ts files
    • Generated types model the structured clone boundary, so an unserializable payload fails to compile where the channel is declared
    • Runtime authorization and payload-validation hooks, and a standalone generate/check/watch CLI
    npm install electron-ipc-module
    
    Requirement Range
    Node.js >=22.5.0
    Electron >=12 peer; majors 41–43 build-tested in CI
    TypeScript >=5.0.0 <7 peer
    Modules ESM only

    typescript is a real peer dependency, not a build-time convenience: the generator, the CLI, and the Rollup plugin load the compiler at runtime to analyse your IPC modules, so bundlers that transpile TypeScript without installing it — Vite among them — would otherwise fail at first run.

    See compatibility and stability for what CI actually verifies and the SemVer contract.

    // main/ipc/profile.ipc.ts
    import { createIpcHelpers, defineIpcModule } from "electron-ipc-module";

    type ProfileEvents = {
    "profile-updated": [profile: { id: string; name: string }];
    };

    const { handle, listen } = createIpcHelpers<ProfileEvents>();

    export function createProfileIpc(service: ProfileService) {
    return defineIpcModule("profile", {
    get: handle((_event, id: string) => service.get(id)),

    save: handle(async (event, input: { id: string; name: string }) => {
    const profile = await service.save(input);
    event.sender.send("profile-updated", profile);
    return profile;
    }),

    "open-editor": listen(() => {
    service.openEditor();
    }),
    });
    }

    This registers:

    • profile:get → ipcRenderer.invoke
    • profile:save → ipcRenderer.invoke
    • profile:open-editor → ipcRenderer.send

    Return plain data from handlers. Class instances and functions do not survive the IPC boundary — see what survives the boundary.

    import { createIpcContainer } from "electron-ipc-module";
    import { createProfileIpc } from "./ipc/profile.ipc.js";

    const ipc = createIpcContainer();

    await ipc.loadAll({
    profile: createProfileIpc(profileService),
    });
    // rollup.config.js
    import ipcBridge from "electron-ipc-module/rollup-plugin";

    export default {
    plugins: [
    ipcBridge({
    ipcDir: "./main/ipc",
    outFile: "./main/generated/ipc-bridge.ts",
    tsconfig: "./tsconfig.preload.json",
    }),
    ],
    };

    Commit the generated file — see generator CLI for why, and how to keep it honest in CI.

    // main/preload.ts
    import { contextBridge } from "electron";
    import { bridge } from "./generated/ipc-bridge.js";

    contextBridge.exposeInMainWorld("ipc", bridge);

    This file must be built to a single CommonJS file. Electron sandboxes renderers by default, and a sandboxed preload cannot load ESM or resolve modules. Read preload constraints before wiring up your build — getting it wrong leaves window.ipc undefined at runtime.

    The generated bridge is the single source of truth for the renderer's API, so derive the global from it rather than hand-writing a duplicate:

    // renderer/ipc.d.ts
    import type { bridge } from "../main/generated/ipc-bridge.js";

    declare global {
    interface Window {
    ipc: typeof bridge;
    }
    }

    Both of these are boilerplate the generator can write instead — see expose.

    const profile = await window.ipc.profile.get("abc-123");
    window.ipc.profile.onProfileUpdated((profile) => {
    console.log("updated", profile);
    });

    onProfileUpdated returns an unsubscribe function — call it when the component unmounts.

    The generated bridge is a preload-side file, and preload scripts have loader rules of their own. They are Electron's rules, not this package's, but they decide how you build the generated file. Get them wrong and the preload fails to load with SyntaxError: Cannot use import statement outside a module, leaving a renderer whose window.ipc is undefined.

    • Sandboxed preloads are CommonJS-only. Electron sandboxes renderers by default since v20. A sandboxed preload runs in a restricted loader with no module resolution: it must be a single self-contained CommonJS file. Emitting ESM — which is what tsc produces in a "type": "module" package — does not work, and neither does splitting the bridge into a separate file the preload imports at runtime. Bundle the preload.
    • electron stays external. The sandbox shim provides electron (plus events, timers, and url). Mark it external in your bundler rather than trying to inline it. The generated bridge imports nothing else at runtime — its Serializable import is type-only and erased at build time — so no other module needs resolving.
    • ESM preloads require opting out of the sandbox. Electron supports an ESM preload only with sandbox: false and an .mjs extension. That trades a real security boundary for a build convenience — prefer bundling to CommonJS.
    • contextIsolation: true and nodeIntegration: false are assumed by the generated bridge and are the defaults. The runtime does not verify them; see security model.

    example/ builds its preload exactly this way — tsc for compilation, then a small Rollup config to bundle — and runs under sandbox: true.

    example/ is a small, runnable Electron application with one typed IPC module. It shows an invocation, renderer-to-main messages, main-to-renderer events, generated bridge methods, and context-isolated preload exposure.

    cd example
    pnpm install
    pnpm start
    Export Description
    defineIpcModule(prefix, channels, options?) Register a group of IPC channels
    createIpcEmitter<TEvents>(source?) Send typed events from independent main-process producers
    createIpcHelpers<TEmit>() Create typed handle / listen / stream helpers
    defineIpcEvents<TEvents>() Declare an emitted-event map for the bridge
    defineChannel(type, fn) Extension point for wrapper authors; prefer the preset helpers
    handle, listen, stream Default untyped helpers
    handleOnce, listenOnce Process-scoped one-shot helpers; the first call from any window wins
    createIpcContainer() Load, unload, and observe IPC modules
    IpcAuthorizationError Thrown when authorize returns false
    IpcValidationError Thrown when a schema validate entry rejects
    IpcChannelCollisionError Thrown on a duplicate physical channel name
    IpcContainerDisposedError Thrown by lifecycle calls after dispose()
    IpcObserverError Reported on error when an observer threw

    The exported types, and why the generator's types live on a separate entry point, are documented in compatibility and stability.

    Typed events. Pass an event map to createIpcHelpers<TEmit>() to type event.reply, event.sender.send, and event.senderFrame?.send — in stream callbacks too. Emitted events are not prefixed by defineIpcModule.

    Alternatively, declare an event map with defineIpcEvents<TEvents>() and export it from the *.ipc.ts file. The bridge plugin reads the type argument to generate typed on<Event> / once<Event> listeners in the renderer — useful when a module emits events without wiring them through createIpcHelpers:

    type StatusEvents = { "status-changed": [online: boolean] };
    export const statusEvents = defineIpcEvents<StatusEvents>();
    // -> bridge.status.onStatusChanged((online) => { ... })

    For timers, jobs, file watchers, and other producers that run independently of an incoming IPC call, pair a module event declaration with a standalone emitter. The module declaration is what lets bridge generation create the renderer listener:

    // jobs.ipc.ts
    import { defineIpcEvents, defineIpcModule } from "electron-ipc-module";

    export type JobEvents = { "job-completed": [jobId: string] };
    const channels = {};

    export const registerJobsIpc = defineIpcModule("jobs", channels, { eventPrefix: true });
    export const jobEvents = defineIpcEvents<JobEvents>();
    // -> bridge.jobs.onJobCompleted((jobId) => { ... })

    The independent producer takes its prefix from the module itself:

    // jobs-producer.ts
    import { createIpcEmitter } from "electron-ipc-module";
    import { registerJobsIpc, type JobEvents } from "./jobs.ipc.js";

    const jobs = createIpcEmitter<JobEvents>(registerJobsIpc);

    setInterval(() => jobs.emit("job-completed", "nightly-report"), 60_000);
    // sends `jobs:job-completed` to every live window

    // Send only to one renderer.
    jobs.emitTo(window.webContents, "job-completed", "on-demand-report");

    Standalone emitters do not declare bridge listeners. Export defineIpcEvents<TEvents>() from the corresponding *.ipc.ts module so the bridge generates the matching on* / once* helpers.

    Passing the register function takes that module's own resolved eventPrefix, so renaming it cannot leave the emitter sending to a channel the bridge no longer listens on. A literal prefix — createIpcEmitter<JobEvents>("jobs") — still works for producers with no module to point at, but has to be kept in step by hand.

    emit reaches every window, hidden ones included, with no filtering, so do not broadcast payloads that only one renderer should see. Both methods ignore destroyed webContents, and emitTo accepts a handler's event.sender without prefixing the channel twice.

    Cleanup. defineIpcModule accepts an optional ready hook. If registration fails, already-registered channels are rolled back automatically.

    defineIpcModule("profile", channels, {
    ready: async (ipc) => {
    return () => {
    // optional module cleanup on unload
    };
    },
    });

    Authorization and runtime validation. Types protect callers at compile time; these hooks protect the actual main-process boundary. Returning false from authorize rejects the call with IpcAuthorizationError.

    A validate entry is either a callback that throws to reject the payload, or any Standard Schema — Zod, Valibot, ArkType, and others implement it, and the package depends only on @standard-schema/spec, which is types with no runtime. A schema receives the full argument array and its parsed output replaces the arguments the channel callback receives, so coercion carries through instead of being validated and then discarded. Failures reject with IpcValidationError, which carries the schema's issues.

    validate keys are checked against the channels you declared, and a schema's output is checked against the channel callback's parameters — so a renamed channel or a schema that drifts from its handler is a compile error, not a guard that silently stops matching.

    defineIpcModule("profile", channels, {
    authorize: (event) => event.senderFrame?.url.startsWith("app://") === true,
    validate: {
    // A schema: `save` is called with the parsed tuple.
    save: z.tuple([profileInputSchema]),
    // Or a callback, when a check needs the event or the channel context.
    rename: (args, event) => {
    if (typeof args[0] !== "string") throw new TypeError("expected a name");
    },
    },
    // listen/listenOnce failures are not returned to the renderer — hook or log them
    onListenerError: (error, context) => {
    console.error(`IPC listener failed on ${context.channel}`, error);
    },
    });

    Callback validators only inspect the original arguments: their return value is discarded. Standard Schema validators are different—their parsed output replaces the callback arguments, so coercion and field stripping carry through to the handler.

    For handle channels, rejected promises propagate back through ipcRenderer.invoke. For fire-and-forget listen channels, failures are caught and passed to onListenerError (or logged) so they never become unhandled rejections. The full per-stage behavior is in the error contract.

    Event namespacing. Set eventPrefix: true to turn emitted event channels such as updated into profile:updated. The generated API remains bridge.profile.onUpdated(...). A string may be supplied for a custom physical prefix.

    defineIpcModule("profile", channels, { eventPrefix: true });
    

    Streaming. A stream channel's callback returns an async iterable — usually an async generator — or a plain iterable, and every value it yields reaches the renderer as one chunk, in order:

    import { createIpcHelpers, defineIpcModule } from "electron-ipc-module";
    const { handle, stream } = createIpcHelpers<Events>();

    defineIpcModule("export", {
    run: stream(async function* (event, input: string) {
    for (const chunk of chunks) {
    if (event.signal.aborted) return;
    yield await renderChunk(chunk);
    }
    }),
    });

    The bridge method returns an IpcStream<T>: next(), return(), and cancel(). It is not itself an AsyncIterable, because contextBridge drops symbol-keyed properties — a [Symbol.asyncIterator] would type-check in the renderer and then be missing there. Wrap it to for await:

    const run = window.ipc.export.run("in.mp4");
    for await (const chunk of { [Symbol.asyncIterator]: () => run }) {
    render(chunk); // `break` cancels the stream in main
    }

    // Or stop it from anywhere else, such as a Cancel button.
    run.cancel();
    • event.signal is per invocation in a stream callback, and aborts when the renderer cancels — cancel(), or break/return out of for await — as well as when its WebContents is destroyed. Either way main stops sending and calls the iterator's return(), so a generator's finally runs. For an async generator that takes effect at its next yield, so pass event.signal into long awaits rather than only checking it between chunks.
    • A throw before the first yield or mid-stream rejects the renderer's next() after the chunks already sent, in the same Error invoking remote method '<channel>': <error> shape as a failed invoke. authorize and validate apply exactly as they do to handle — validate sees the call's arguments — and a rejection fails the first next(). The full behavior is in the error contract.
    • Arguments and yielded chunks are checked with Serializable<T> where you declare the channel, the same as a handler's arguments and return value.
    • Reading event.signal needs a global AbortController, Electron 15 or newer. A stream that never reads it still stops on cancel and destruction on the Electron 12 floor.

    A stream derives three physical channels from <prefix>:<key>. All three are reported by getChannels, and covered by collision detection, so a key such as run:cancel beside a stream named run is rejected:

    Channel Direction Carries
    export:run renderer → main, invoke (id, ...args); resolves once authorize and validate pass
    export:run:cancel renderer → main, send (id); ignored unless it comes from the window that started the stream
    export:run:event main → renderer (id, "chunk", value) per yield, then (id, "end") or (id, "error", message)

    Each call carries a random id chosen by the generated bridge, and every message is filtered by it, so concurrent streams on one channel — from one window or several — stay independent. Sends are dropped once the window is destroyed, like every other send path. Unloading a module does not stop its in-flight streams, the same as an in-flight invoke.

    Container.

    const ipc = createIpcContainer();

    await ipc.load("profile", createProfileIpc(service));
    await ipc.loadAll({ profile, settings });

    ipc.on("loaded", (name, channels) => {});
    ipc.on("unloaded", (name) => {});
    ipc.on("error", (name, error) => {});

    await ipc.unload("profile");
    await ipc.unloadAll();

    Reads: has(name), getChannels(name), and the names, allChannels, and size getters.

    All lifecycle mutations (load, loadAll, unload, unloadAll, and dispose) are asynchronous and run through one FIFO queue in invocation order. Reads report only committed state and do not wait for that queue.

    • load(name, register) unloads a committed module with the same name before it starts the replacement registration. If replacement fails, the old module stays unloaded.
    • loadAll(entries) is insert-only and transactional. It rejects before registration if any supplied name is loaded, then registers entries in object iteration order. Its result is Record<string, string[]>, preserving each module name. A failure rolls back every earlier entry from that batch.
    • unload(name) waits for earlier calls, returns false for an unknown name, or removes the module and returns true.
    • unloadAll() waits for earlier calls, attempts every loaded module in insertion order, and leaves the container reusable.
    • dispose() waits for earlier calls, unloads everything, and is terminal and idempotent. Repeated calls return the same result. Other lifecycle calls requested after dispose() reject with IpcContainerDisposedError; read methods remain available and report the final committed state.
    • Physical channel names must be unique across loaded modules, regardless of whether they are handlers or listeners. The incoming registration is cleaned up and rejected with IpcChannelCollisionError. Duplicate channels returned within one registration are rejected the same way.

    Because the queue is global, overlap has no special race behavior: load(); unload() loads and then unloads, two loads replace in call order, and no operation can interleave with a loadAll batch or dispose.

    Exceptions thrown by loaded, unloaded, or error listeners are isolated from the lifecycle operation and reported as IpcObserverError — see observer exceptions.

    Three things that are Electron's behavior rather than this package's, and are worth knowing before you design around them.

    Electron serializes IPC payloads with the structured clone algorithm, which does not carry JavaScript semantics across intact. The generated bridge wraps every parameter and return type in Serializable<T> so the renderer's types describe what it actually receives, not what the main process returned:

    In the main process In the renderer
    Function, symbol, Promise, WeakMap, WeakSet — anywhere inside Nothing: the whole payload is rejected
    Class instance carrying methods Nothing: rejected, because the type cannot prove it is safe
    Class instance with only data Plain object; instanceof is false
    Buffer Uint8Array — Electron converts it
    Subclass of Error, Date, or a typed array The base type, without added fields or methods
    Date, RegExp, Map, Set, Error, ArrayBuffer, typed arrays Preserved, prototype included
    Getter Flattened to the value it evaluated to at send time

    Rejection is all-or-nothing. Structured clone does not drop the offending member and deliver the rest — it throws DataCloneError and the invoke() rejects before any result arrives. So Serializable<T> resolves to IpcUncloneable<T> for the entire payload rather than mapping one property to never. That propagates out of arrays, tuples, Map, Set, and nested objects, and a union is only as cloneable as its least cloneable member.

    The check runs where you declare the channel, not only in the generated bridge:

    class Session {
    constructor(public id: string) {}
    isExpired(): boolean {
    return false;
    }
    }

    defineIpcModule("session", {
    // Type 'IpcUncloneable<Session>' is not assignable to type 'ChannelDef'.
    current: handle(async (): Promise<Session> => new Session("s1")),
    });

    Return plain data, or map to a DTO inside the handler. Arguments are checked the same way in both directions; a listen callback's return value is exempt, since it is never sent back.

    A class with methods is rejected even though a prototype method would in fact be dropped silently rather than throwing — a type cannot distinguish an own function property from a prototype one, and the safe reading is the one that never surprises you at runtime.

    Serializable<T> and IpcUncloneable<T> are exported from the root if you want them in your own wrappers.

    ipcMain is global, so the first call from any window consumes the channel: later invokes reject with "No handler registered", and later sends are dropped silently. In a multi-window app use handle/listen and track one-shot state yourself.

    Work that produces results over time, and that the renderer may want to stop, is a stream channel: each yield is the progress, cancel() or a break stops it, and event.signal aborts on a cancel and on the window closing alike:

    defineIpcModule("export", {
    run: stream(async function* (event, input: string) {
    for (const chunk of chunks) {
    if (event.signal.aborted) return;
    yield await renderChunk(chunk);
    }
    }),
    });

    When the work has to stay a single invoke — one result at the end — use a second IPC channel for explicit user intent, such as a Cancel button, and send progress as typed events:

    // main
    const cancelled = new Set<string>();

    defineIpcModule("export", {
    start: handle(async (event, jobId: string) => {
    for (const chunk of chunks) {
    if (cancelled.has(jobId)) return { cancelled: true };
    event.sender.send("export-progress", jobId, chunk.index / chunks.length);
    }
    return { cancelled: false };
    }),
    cancel: listen((_event, jobId: string) => {
    cancelled.add(jobId);
    }),
    });

    Sending to a window that has closed is safe on its own: event.sender.send, event.senderFrame?.send, and event.reply all drop the event once the target is destroyed, rather than throwing Object has been destroyed in main. An invoke that outlives its caller settles normally.

    That keeps the process alive; it does not stop the work. handle callbacks also receive event.signal, which represents the lifetime of the WebContents that made the call. It starts active and aborts once that window (or other WebContents) is destroyed. Check it before expensive work so a closed window does not leave a job running:

    defineIpcModule("export", {
    start: handle(async (event, jobId: string) => {
    for (const chunk of chunks) {
    if (event.signal.aborted) return { cancelled: true };
    const result = await renderChunk(chunk);
    if (event.signal.aborted) return { cancelled: true };
    event.sender.send("export-progress", jobId, result);
    }
    return { cancelled: false };
    }),
    });

    Polling aborted only cancels work the handler drives itself. A child process, a file lock, or an open socket keeps running after the window is gone unless the signal reaches it. It is a real AbortSignal, so pass it to anything that accepts one instead of checking it by hand:

    defineIpcModule("export", {
    start: handle(async (event, input: string) => {
    // Node kills the child when the window closes.
    const { stdout } = await execFile("ffmpeg", ["-i", input, "out.mp4"], {
    signal: event.signal,
    });
    return stdout;
    }),
    });

    fs/promises, fetch, stream.pipeline, events.once, and most third-party clients take a signal option the same way.

    For a resource with no signal option, release it from an abort listener — and remove that listener when the operation finishes:

    const release = handle(async (event, path: string) => {
    const lock = await acquireLock(path);
    const onAbort = () => void lock.release();
    event.signal.addEventListener("abort", onAbort);
    try {
    return await useLock(lock);
    } finally {
    event.signal.removeEventListener("abort", onAbort);
    await lock.release();
    }
    });

    The finally is not optional. One signal is shared by every invocation from a sender and lives as long as the window, so a listener added per call and never removed accumulates for the window's lifetime and keeps whatever its closure captures — the lock, the child process handle — reachable that whole time. Options that take a signal clean up after themselves; hand-registered listeners do not.

    The signal is cooperative: aborting it does not terminate the handler, select an error, or settle the renderer promise automatically. It tracks destruction of the WebContents and nothing else — a reload or an in-place navigation abandons the pending invocation without aborting, because the WebContents itself survives. One read-only signal is shared by every invocation from the same sender, and it stays valid after the handler settles. It is built the first time a handler reads it, so it needs a global AbortController — Electron 15 or newer; reading it on an older runtime throws, while handlers that never touch it keep working on the package's Electron 12 peer floor. Use a stream, or the two-channel pattern above, when cancellation must also work while the renderer is still alive.

    Analyzes *.ipc.ts files and generates a typed bridge for the renderer.

    Option Default Description
    ipcDir ./src/ipc Directory or glob of IPC module files
    outFile ./src/generated/ipc-bridge.ts Generated TypeScript output
    tsconfig ./tsconfig.json TypeScript config used for static analysis
    logger labelled console logger Where progress and analyzer warnings are sent
    expose not set Global key to expose the bridge under

    logger takes any LoggerLike — Pick<Console, "debug" \| "info" \| "warn" \| "error" \| "log">, exported from the root. Supply one to route generator output into a build tool's own reporter, to silence it in a watch loop, or to see the per-module debug detail the default logger drops. The default prints info and above; a supplied logger receives every level.

    Exposing the bridge automatically

    Set expose to have the generator write step 4 and step 5 for you:

    ipcBridge({ expose: "ipc" });
    

    The generated file then imports contextBridge, ends with contextBridge.exposeInMainWorld("ipc", bridge), and declares Window["ipc"] as typeof bridge. Both come from the one key, so the exposed name and the type the renderer sees cannot drift apart — a mismatch that still type-checks and shows up only as an undefined window.ipc at runtime.

    Two things to keep in mind:

    • The renderer's tsconfig must include the generated file for the Window declaration to reach it. Nothing else changes: the file is still a preload artifact and still has to be bundled per the preload constraints.
    • check compares against what the current options produce, so pass --expose there too or CI reports the committed bridge as stale.

    The key has to be a new global identifier: ipc is fine, while my-ipc, name, innerWidth, and other names declared by the installed TypeScript DOM/ES libraries fail generation. Electron also rejects any runtime-specific global rather than overwriting it.

    Leave expose unset to keep exposing the bridge yourself — useful when the preload wraps or filters it before handing it to the renderer.

    Naming conventions

    Source Generated API
    profile.ipc.ts bridge.profile
    channel "get-all" bridge.profile.getAll()
    event "profile-updated" bridge.profile.onProfileUpdated(...)
    stream channel "run" bridge.profile.run(...), an IpcStream<T>

    What gets type-checked

    The generator reads types from the compiler, so a broken type in an IPC source — or in anything it imports — could put a wrong signature in the bridge. Those errors abort generation, and nothing is written.

    Errors anywhere else in the tsconfig project are ignored. They cannot reach the bridge, and failing on them would mean the half-written file you are in the middle of stops the build with an error from the bridge generator. Point tsconfig at your application's config if that is convenient; scoping it to the IPC sources is no longer needed to keep generation quiet.

    Configuration and compiler-option errors still abort, since those describe the project rather than any one file.

    Static analysis tips

    • Use *.ipc.ts file names
    • One defineIpcModule per file. The bridge is grouped into one entry named after the file, so a second module in the same file has nowhere to go. Generation fails rather than emitting the first and dropping the rest, which would register both on ipcMain while the renderer only ever saw one.
    • Prefer a plain object literal in defineIpcModule(...)
    • Avoid spreads in the channels object for complete bridge typing
    • Use a string literal for the module prefix so build-time and runtime channel names cannot diverge

    The plugin also implements Vite's compatible plugin API, so the same electron-ipc-module/rollup-plugin import works in a Vite config.

    Scaffold a project — the template picker covers React, Vue, Svelte, Solid, and vanilla, in JS or TS:

    npm create @quick-start/electron@latest
    

    Then add the plugin to the preload build. The bridge is a preload artifact, so putting it under renderer generates nothing useful:

    // electron.vite.config.ts
    import { defineConfig, externalizeDepsPlugin } from "electron-vite";
    import ipcBridge from "electron-ipc-module/rollup-plugin";

    export default defineConfig({
    main: { plugins: [externalizeDepsPlugin()] },
    preload: {
    plugins: [
    externalizeDepsPlugin(),
    ipcBridge({
    ipcDir: "src/main/ipc",
    outFile: "src/preload/generated/ipc-bridge.ts",
    tsconfig: "tsconfig.node.json",
    }),
    ],
    },
    renderer: {/* your framework plugin */},
    });

    Point tsconfig at tsconfig.node.json, not the root tsconfig.json: the scaffold's root config is a solution file with "files": [], so the generator would find no IPC sources to analyse.

    electron-vite dev regenerates the bridge when a *.ipc.ts file changes. This needs electron-ipc-module@>=1.0.2 — earlier versions registered only the IPC directory as a watch target, and Vite's build watcher ignores directories passed to addWatchFile, so the bridge generated once and then went stale until the next cold start. Creating a new *.ipc.ts file is still only picked up on the next start of dev.

    The same generator can be used without Rollup:

    npx electron-ipc-module generate \
    --ipc-dir ./main/ipc \
    --out-file ./main/generated/ipc-bridge.ts \
    --tsconfig ./tsconfig.preload.json

    npx electron-ipc-module generate --expose ipc
    npx electron-ipc-module generate --watch
    npx electron-ipc-module generate --quiet
    npx electron-ipc-module check

    check does not write files and exits non-zero when the generated bridge is stale. --quiet drops progress output while still printing analyzer warnings and errors — a warning means the generated bridge is incompletely typed, which is not the kind of thing a quiet flag should hide. The programmatic generator is exported from electron-ipc-module/generator.

    Commit the generated bridge. It is the renderer's entire API surface, so keeping it in version control makes every change to it show up in review — a new channel reaching the renderer is exactly the diff a reviewer should see, and it is invisible if the file is produced during the build. It also means a fresh clone type-checks before anyone runs the generator.

    Then run check in CI to guarantee the committed file still matches the *.ipc.ts sources:

    - run: npx electron-ipc-module check
    

    example/generated/ipc-bridge.ts follows exactly that convention.

    • Context isolation required. The generated bridge is meant to be exposed via contextBridge.exposeInMainWorld in a preload script (see step 4); it assumes contextIsolation: true and nodeIntegration: false on the BrowserWindow. The runtime does not check these settings itself.
    • Keep the sandbox on. sandbox: true is the default and the preload constraints are written around keeping it that way. Disabling it to avoid bundling your preload trades a process-level security boundary for a build shortcut.
    • No arbitrary channel exposure. The bridge is generated statically at build time from the *.ipc.ts files found in ipcDir — the renderer only ever gets invoke/send wrappers for channels you explicitly declared with defineIpcModule, and stream wrappers whose derived channel names are literals written at generation time. There is no generic ipcRenderer.invoke/.send/.on passthrough, so the renderer cannot reach an arbitrary or future main-process channel.
    • Main process still validates input. Channel prefixing and typed bridges prevent name collisions and typos, not payload attacks. Types are erased at runtime and a compromised renderer can send anything to a declared channel. Use the authorize and validate hooks (or equivalent checks inside handlers) before touching the filesystem, network, or other privileged APIs.

    SECURITY.md records what counts as a vulnerability here and how to report one privately.

    main/
    ipc/
    profile.ipc.ts
    settings.ipc.ts
    generated/
    ipc-bridge.ts # generated, committed, verified by `check` in CI
    main.ts
    preload.ts # bundled to a single CommonJS file — see preload constraints
    renderer/
    ipc.d.ts # declares window.ipc from `typeof bridge`

    MIT © Adel Terki