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:
| Kind | Direction | Constant | Usage |
|---|---|---|---|
| Invoke | renderer → main → renderer | IPC_CHANNELS.X | one-shot request / response, awaited Promise |
| Event | main → renderer (one-way) | IPC_EVENTS.X | broadcast 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() // unsubscribeNever 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.
Adding a new invoke channel
Section titled “Adding a new invoke channel”It’s a three-step edit, all in shared/ipc.ts:
-
Add the channel definition to the
IpcChannelMaptype. Be precise about theargstuple and thereturntype — these are what the renderer actually sees throughwindow.electronAPI.invoke().interface IpcChannelMap {// …'db:explain-query': {args: [profileId: string, sql: string]return: { plan: string; cost: number }}} -
Add the matching constant to
IPC_CHANNELS. Follow the existingSCREAMING_SNAKE_CASEconvention; 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<string, IpcChannel>The
satisfiesclause makes TypeScript reject any value that isn’t a key ofIpcChannelMap— so a typo here is a compile-time error, not a runtime mystery. -
Implement the handler. Pick the right file under
src/main/ipc/based on the domain prefix:connections:*→ipc/connections.tsdb:*→ipc/db.tsexport:*/import:*→ipc/export-import.tsplugins:*→ipc/plugins.tssettings:*→ipc/settings.tsdialog:*→ipc/dialog.tskeyring:*→ipc/keyring.tsmcp:*→ipc/mcp.tsmigration:*→ipc/migration.tsapp:*→ipc/app.ts
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 }})handleis the wrapper defined inipc/context.ts. It’s typed byIpcChannelMapso the handler’sargsandreturnmust match — if you forget a field or get a type wrong, the build fails. -
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.
Adding a new broadcast event
Section titled “Adding a new broadcast event”Broadcasts go the other way: main → renderer. They don’t return a value.
-
Add the event to
IpcEventMapinshared/ipc.ts:export interface IpcEventMap {// …'db:long-query-progress': [payload: { profileId: string; pct: number }]} -
Add the constant to
IPC_EVENTS:export const IPC_EVENTS = {// …DB_LONG_QUERY_PROGRESS: 'db:long-query-progress'} as const satisfies Record<string, IpcEvent> -
Emit it from main. Inside a plugin use
ctx.broadcast(...); in the IPC handlers, useBrowserWindow.getAllWindows().forEach(w => w.webContents.send(IPC_EVENTS.X, payload)). -
Subscribe in the renderer:
const off = window.electronAPI.on(IPC_EVENTS.DB_LONG_QUERY_PROGRESS, ({ profileId, pct }) => {// …})// off() to unsubscribe
Guard rails
Section titled “Guard rails”| Check | Where | What breaks if you skip a step |
|---|---|---|
| Compile-time channel registration | IPC_CHANNELS uses satisfies Record<string, IpcChannel> | Typo → build fail |
| Compile-time map coverage | tests/unit/ipc-channels-coverage.test.ts has Exclude<IpcChannel, ChannelValues> | Forgot a constant → build fail |
| Runtime call-site audit | Same test scans source for string-literal invoke/on calls | Hand-rolled string literal → test fail |
| Renderer typing | window.electronAPI.invoke<K>() | Wrong args / wrong return → build fail |
| Handler typing | handle: Handle in ipc/context.ts | Wrong args / wrong return → build fail |
Picking a channel name
Section titled “Picking a channel name”- 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-queryis better thandb:eq.