Skip to content

IPC channels

All inter-process communication between the renderer and the main process goes through a typed, centrally-registered set of channels declared in shared/ipc.ts. There are two kinds:

KindDirectionConstantUsage
Invokerenderer → main → rendererIPC_CHANNELS.Xone-shot request / response, awaited Promise
Eventmain → renderer (one-way)IPC_EVENTS.Xbroadcast push (streaming, lifecycle notifications)

The renderer talks to both via the preload bridge:

import { IPC_CHANNELS, IPC_EVENTS } from '@shared/ipc'
// invoke (request / response)
const result = await window.electronAPI.invoke(IPC_CHANNELS.DB_QUERY, id, sql)
// subscribe (one-way push)
const off = window.electronAPI.on(IPC_EVENTS.AI_CHAT_EVENT, (event) => { … })
off() // unsubscribe

Never use a string literal at a call site. The CI test tests/unit/ipc-channels-coverage.test.ts scans the source tree for string-literal invoke() / on() calls and fails the build if it finds one that isn’t a known channel — and a forgotten constant is a clear regression signal.

src/preload/index.ts is the wire boundary — the only file that touches ipcRenderer — and the renderer that loads it is created with sandbox: true (src/main/index.ts). That is a hard constraint on what this one file, and everything it imports, is allowed to be.

A sandboxed preload does not get Node. It runs inside Chromium’s sandbox with a polyfilled require that resolves electron and a small set of shims (events, timers, url) and nothing else. Import a Node builtin and it throws module not found: node:crypto while the script is loading, which aborts it before the last line runs:

contextBridge.exposeInMainWorld('electronAPI', electronAPI)

So the failure is total and silent. window.electronAPI is simply absent; every ipc.invoke rejects with “Backend unavailable”; the settings hydrate that dismisses the splash never resolves; the app hangs on the splash screen with nothing in the UI saying why. This is not hypothetical — it shipped in 1.8.0 (the trace-id work in #226 added import { randomUUID } from 'node:crypto').

Three things conspired to let it through, which is why the rule is now executable rather than remembered:

  • TypeScript resolves Node builtins fine under tsconfig.node.json.
  • The bundler happily externalises them into a runtime require.
  • The unit suite runs on Node, where node:crypto imports without complaint.

The break exists only at runtime, inside the sandbox. So:

Instead ofUse
node:crypto randomUUIDnewTraceId() from @shared/trace (built on globalThis.crypto)
node:crypto hashing / random bytesglobalThis.crypto.subtle / getRandomValues, or move it to main behind a channel
node:buffer, node:util encodersTextEncoder / TextDecoder
fs, path, os, child_processa main-process handler behind an IPC channel — the preload is a bridge, not a place to do work

A pure @shared/* or relative import is always fine: it is compiled into the preload bundle, not resolved at runtime. An import type is fine for the same reason — it is erased before the bundle exists.

tests/unit/audit/preload-sandbox-safe.test.ts walks the preload’s whole static import graph (through @shared/, not just the entry file) and fails the build on any Node builtin outside the sandbox-safe set, naming the file, the line, and the chain that pulled it in.

Each channel is described in two complementary halves, both in shared/ipc.ts:

  • IpcChannelShapes — an interface keyed by the channel’s constant name (DB_EXPLAIN_QUERY) carrying its args tuple and return type.
  • IPC_CHANNELS — the const object mapping that same constant name to its wire string ('db:explain-query').

The wire string is written exactly once (in IPC_CHANNELS). The renderer-/main-facing IpcChannelMap (keyed by wire string, the shape invoke/handle/the preload bridge consume) is derived by joining the two halves — so the channel name can never be duplicated or drift out of sync.

It’s a two-step edit, all in shared/ipc.ts:

  1. Add the channel’s contract to IpcChannelShapes, keyed by its constant name. Be precise about the args tuple and the return type — these are what the renderer actually sees through window.electronAPI.invoke().

    export interface IpcChannelShapes {
    // …
    DB_EXPLAIN_QUERY: {
    args: [profileId: string, sql: string]
    return: { plan: string; cost: number }
    }
    }
  2. Add the matching constant + wire string to IPC_CHANNELS. Follow the existing SCREAMING_SNAKE_CASE convention; the section comment groups it under the right domain (DB, PLUGINS, AI, …).

    export const IPC_CHANNELS = {
    // …
    DB_EXPLAIN_QUERY: 'db:explain-query',
    } as const satisfies Record<keyof IpcChannelShapes, string>

    The satisfies clause makes TypeScript reject the build unless every IpcChannelShapes key has exactly one constant here (and vice versa) — so a forgotten or mistyped name is a compile-time error, not a runtime mystery.

  3. Implement the handler. Pick the right file under src/main/ipc/ based on the domain prefix:

    ai:* is the exception to this list: the AI assistant is a bundled plugin (src/main/plugins/bundled/ai/), so it registers its own channels through ctx.ipc.handle() (the PluginIpc.handle method) from inside the plugin, not from a file under src/main/ipc/. It takes the same (channel, handler) pair as the core handle wrapper, but the two are not interchangeable: PluginIpc.handle returns a Disposable (dispose it to unregister — e.g. on plugin deactivation) and throws a permission error unless the plugin was granted the ipc capability, whereas the core handle returns void and also traces every call into the activity stream. If the domain you’re adding belongs to a plugin rather than the core app, register it through ctx.ipc.handle(), not handle.

    The handler signature is inferred from IpcChannelMap:

    src/main/ipc/db.ts
    import { IPC_CHANNELS } from '@shared/ipc'
    handle(IPC_CHANNELS.DB_EXPLAIN_QUERY, async (profileId, sql) => {
    const adapter = requireAdapter(profileId)
    const result = await adapter.query(`EXPLAIN ${sql}`)
    return { plan: formatPlan(result.rows), cost: 0 }
    })

    handle is the wrapper defined in ipc/context.ts. It’s typed by IpcChannelMap so the handler’s args and return must match — if you forget a field or get a type wrong, the build fails.

  4. Call it from the renderer:

    import { IPC_CHANNELS } from '@shared/ipc'
    const { plan } = await window.electronAPI.invoke(
    IPC_CHANNELS.DB_EXPLAIN_QUERY,
    profileId,
    sql
    )

No preload/index.ts change is needed: the generic invoke<K>(channel, …args) signature already passes through any channel that’s in the map.

Broadcasts go the other way: main → renderer. They don’t return a value. They follow the same single-source model as channels — payload tuple in IpcEventShapes (keyed by constant name), wire string once in IPC_EVENTS, and IpcEventMap derived from the two.

  1. Add the event’s payload tuple to IpcEventShapes in shared/ipc.ts, keyed by its constant name:

    export interface IpcEventShapes {
    // …
    DB_LONG_QUERY_PROGRESS: [payload: { profileId: string; pct: number }]
    }
  2. Add the constant + wire string to IPC_EVENTS:

    export const IPC_EVENTS = {
    // …
    DB_LONG_QUERY_PROGRESS: 'db:long-query-progress'
    } as const satisfies Record<keyof IpcEventShapes, string>
  3. Emit it from main. Inside a plugin use ctx.broadcast(...); in orchestrator code use the typed broadcast(IPC_EVENTS.X, payload) helper from src/main/ipc/broadcast.ts — never hand-roll a BrowserWindow.getAllWindows() loop (the helper is typed by IpcEventMap, so a wrong payload is a compile error).

  4. Subscribe in the renderer:

    const off = window.electronAPI.on(IPC_EVENTS.DB_LONG_QUERY_PROGRESS, ({ profileId, pct }) => {
    // …
    })
    // off() to unsubscribe
CheckWhereWhat breaks if you skip a step
Single-source key coverageIPC_CHANNELS / IPC_EVENTS use satisfies Record<keyof IpcChannelShapes, string>Constant without a shape (or shape without a constant) → build fail
Compile-time map coveragetests/unit/ipc-channels-coverage.test.ts re-asserts the shape↔constant key sets matchDrift between the two halves → build fail
Call-site single-sourcingtests/unit/audit/ipc-channels-single-sourced.test.ts scans all processes for a raw 'domain:action' literal passed to invoke/on/send/handle/h/broadcast/emitHand-rolled wire string at any call site (renderer or main handle/broadcast) → test fail. Always pass IPC_CHANNELS.X / IPC_EVENTS.X, never the literal
Sandbox-safe preloadtests/unit/audit/preload-sandbox-safe.test.ts walks the preload’s static import graph (including through @shared/) for Node builtinsA sandboxed preload cannot require a Node builtin: it throws at load, contextBridge.exposeInMainWorld never runs, and the app boots with no window.electronAPI — stuck on the splash screen → test fail
Renderer typingwindow.electronAPI.invoke<K>()Wrong args / wrong return → build fail
Handler typinghandle: Handle in ipc/context.tsWrong args / wrong return → build fail
  • Use domain:verb-noun (kebab-case after the colon). Multi-level domains use additional colons: plugins:ui:get-contributions.
  • Pick the domain prefix that already exists rather than inventing a new one — it determines which file the handler goes in.
  • Avoid abbreviations that hide intent. db:explain-query is better than db:eq.