Move src/shared/ipc/errors.ts to src/shared/ipc/errors/index.ts so each migrated domain can host its own error-code map as a sibling errors/<domain>.ts — value-importable by both processes and zod-free. The @shared/ipc/errors barrel path is unchanged, so every importer and test resolves identically (typecheck + IPC tests green).
Document the IpcErrorCode usage convention that previously lived only in code comments: the framework-code single source of truth, the open (string & {}) tail for domain codes, where domain codes belong (errors/, not schemas/, since the renderer may only import type from schemas), and why they are imported directly rather than aggregated through a barrel.
7.2 KiB
IpcApi Usage
Two recurring tasks: adding a request route (R→M call) and adding an event (M→R push). A new request changes 2 places (schema + handler); a new event changes 1 contract plus its emit and subscribe sites. Preload and the channel enum never change.
Add a Request Route
1. Declare the schema (src/shared/ipc/schemas/<domain>.ts)
import { z } from 'zod'
import { defineRoute } from '../define'
export const windowRequestSchemas = {
// route: dot snake_case; payload fields stay camelCase
'window.set_minimum_size': defineRoute({
input: z.object({ width: z.number().int().positive(), height: z.number().int().positive() }),
output: z.void()
})
}
Register it in the composition (src/shared/ipc/schemas/index.ts):
export const ipcRequestSchemas = {
...windowRequestSchemas
} satisfies Record<string, RouteDef>
2. Implement the handler (src/main/ipc/handlers/<domain>.ts)
import type { IpcHandlersFor } from '@shared/ipc/types'
import type { windowRequestSchemas } from '@shared/ipc/schemas/window'
export const windowHandlers: IpcHandlersFor<typeof windowRequestSchemas> = {
// input is the parsed type; ctx.senderId is the caller WindowId (omit ctx if unused)
'window.set_minimum_size': async ({ width, height }, { senderId }) => {
if (senderId != null) application.get('WindowManager').setMinimumSize(senderId, width, height)
}
}
Register it (src/main/ipc/handlers/index.ts):
export const ipcHandlers: IpcHandlersFor<IpcRequestSchemas> = {
...windowHandlers
}
Miss a declared route → compile error. Add a handler for an undeclared route → compile error.
3. Call it from the renderer
import { ipcApi } from '@renderer/ipc'
await ipcApi.request('window.set_minimum_size', { width: 800, height: 600 })
const info = await ipcApi.request('app.get_info') // void input → no second argument
route is completed/checked against IpcRoute; input/output types follow from it. On failure the call rejects with an IpcError (its code lets you branch).
4. Surface a typed error (optional)
To signal a failure the renderer must branch on, throw an IpcError with a domain code — IpcApiService serializes it into { ok: false, error } and the renderer facade rebuilds the IpcError and rejects. Do not throw the framework codes (VALIDATION_FAILED / ROUTE_NOT_FOUND / FORBIDDEN_SENDER / INTERNAL) by hand — the router owns those, and any uncaught non-IpcError throw is normalized to INTERNAL for you. See the error model for the framework-vs-domain-code rule and why codes live under errors/, not schemas/.
Put the domain's codes in @shared/ipc/errors/<domain>.ts as an as const map, and import it directly on both sides (no barrel — there is no aggregated errors/index.ts export of domain codes):
// src/shared/ipc/errors/file.ts — the domain's code map (zod-free, value-importable by both processes)
export const fileErrorCodes = { FILE_NOT_FOUND: 'FILE_NOT_FOUND' } as const
// main handler (src/main/ipc/handlers/file.ts)
import { IpcError } from '@shared/ipc/errors'
import { fileErrorCodes } from '@shared/ipc/errors/file'
'file.read_doc': async ({ path }) => {
if (!(await exists(path))) {
// reference the constant, not a literal; machine-readable detail rides in `data`
throw new IpcError(fileErrorCodes.FILE_NOT_FOUND, `No file at ${path}`, { path })
}
return read(path)
}
// renderer — branch on the rebuilt IpcError's `code` using the same constant
import { IpcError } from '@shared/ipc/errors'
import { fileErrorCodes } from '@shared/ipc/errors/file'
try {
await ipcApi.request('file.read_doc', { path })
} catch (e) {
if (e instanceof IpcError && e.code === fileErrorCodes.FILE_NOT_FOUND) showMissing((e.data as { path: string }).path)
else throw e
}
Add an Event
1. Declare the contract (Event block of schemas/<domain>.ts)
export type WindowEventSchemas = {
'window.maximized_changed': { maximized: boolean }
}
Register it in the composition (schemas/index.ts):
export type IpcEventSchemas = WindowEventSchemas & AppEventSchemas
2. Emit from a main service
// to all windows
application.get('IpcApiService').broadcast('window.maximized_changed', { maximized: true })
// to one window (e.g. the caller, by its WindowId)
application.get('IpcApiService').send(windowId, 'window.maximized_changed', { maximized: true })
3. Subscribe in the renderer
import { useIpcOn } from '@renderer/ipc/useIpcOn'
useIpcOn('window.maximized_changed', ({ maximized }) => setMax(maximized)) // cleanup is automatic
Outside React, use the imperative form:
const unsubscribe = ipcApi.on('window.maximized_changed', (p) => { /* ... */ })
Handler: Pure Function vs Service Delegate
| Capability | Where the handler lives |
|---|---|
| Stateless (app info, font list) | Pure function directly in handlers/ — no service needed |
| Stateful (MCP / Knowledge / Window) | Handler in handlers/, delegating via application.get('XxxService').method(); business logic and resource lifecycle stay in the service |
The handlers/ directory is the single audited list of every main capability the renderer can reach.
Testing
Test the handler, not the schema. A per-domain schema is a thin structural contract — a TS type's runtime mirror — so asserting that z.boolean() rejects a string, or that z.infer yields boolean, only re-tests zod. The contract is already locked three ways:
- compile-time
IpcHandlersFor<typeof schemas>— every route needs a handler, no extras; z.inferdrives the handler signature and the renderer call types — a mismatch is a compile error;- the single framework type test (
src/shared/ipc/__tests__/schema.types.test.ts) exercises the reusableIpcHandlersForgeneric once.
So unit-test the handler (src/main/ipc/handlers/__tests__/<domain>.test.ts) for real behavior — senderId routing, null-window fallback, service delegation — and do not add a per-domain schemas/__tests__. Business validation belongs in the handler/service, not the schema, so a schema with custom logic worth testing effectively never arises; if a genuine custom .refine predicate ever appears, test that predicate as a plain function rather than through the schema.
High-Frequency / Topic Streams
Token streams and file-tree mutations do not go through broadcast. The owning service keeps a listener registry (preserving its batching) and directs send(windowId, …) per topic to attached windows — avoiding the O(windows × frequency) fan-out of broadcasting a hot event. See the migration guide (class B).
The two directions diverge under load:
- M→R high-frequency stays in IpcApi — its transport is already one-way
webContents.send, so frequency costs no extra round-trip; just use directedsend+ batching (above). - R→M high-frequency (per-frame, e.g. tab-drag window moves) gets no such luck — R→M is
invoke/handle, so the rare per-frame channel may leave IpcApi via the escape hatch. See the migration guide.