IPC (Conveyor)
The typed, Zod-validated IPC layer between renderer and main.
Conveyor is the template's typed IPC system. The renderer never touches Node or
ipcRenderer directly — it calls typed methods on window.conveyor, and every
request and response is Zod-validated on the main-process side. There is no
codegen: TypeScript infers channel types from the schema map.
The four parts
Everything lives under lib/conveyor/, plus one wrapper in lib/main/.
| Part | Runs in | Responsibility |
|---|---|---|
schemas/ | Shared | Zod tuples for each channel's args and return, plus main→renderer eventSchemas. Single source of truth for types. |
api/ | Preload/renderer | Thin client classes that call this.invoke('channel', ...args). Composed into one conveyor object. |
handlers/ | Main | The real implementations, registered with handle(). |
conveyor.d.ts | Renderer | Declares window.conveyor so components get full IntelliSense. |
The eight domains wired today are app, file, logs, notification, secure, shortcut, update, and window:
// lib/conveyor/api/index.ts
export const conveyor = {
app: new AppApi(electronAPI),
file: new FileApi(electronAPI),
logs: new LogsApi(electronAPI),
notification: new NotificationApi(electronAPI),
secure: new SecureApi(electronAPI),
shortcut: new ShortcutApi(electronAPI),
update: new UpdateApi(electronAPI),
window: new WindowApi(electronAPI),
}Using it from the renderer
Prefer the useConveyor hook in components — pass a domain to grab just that
client:
import { useConveyor } from '@/app/hooks/use-conveyor'
function ThemeToggle() {
const { getSettings, setSettings } = useConveyor('app')
const enableDark = async () => {
await setSettings({ theme: 'dark' })
}
// ...
}Outside React you can reach the same methods on the global object:
const version = await window.conveyor.app.version()
await window.conveyor.window.windowMinimize()Events (main → renderer)
Request/response covers most needs, but some things are pushed from main: app
intents (deep links, tray navigation) and live update status. Those use
eventSchemas in event-schema.ts and are also validated before the listener
runs. Subscribe through the API client, which returns an unsubscribe function:
const { onStatus } = useConveyor('update')
useEffect(() => {
const off = onStatus((status) => {
// status is a validated UpdateStatus union member
})
return off
}, [onStatus])event-schema.ts also holds the navigation route allow-list
(navigationRouteSchema) — the finite set of routes a deep link or tray click
may target. Anything outside it is rejected before the renderer sees it. See
Deep Links.
Add a channel end to end
Say you want a file-read channel. Work outward from the schema.
Define the schema
Add a Zod tuple for the arguments and the return value.
// lib/conveyor/schemas/file-schema.ts
import { z } from 'zod'
export const fileIpcSchema = {
'file-read': {
args: z.tuple([z.string()]),
return: z.string(),
},
}Register the schema
Spread it into the central ipcSchemas map so the validators and types pick it
up.
// lib/conveyor/schemas/index.ts
import { fileIpcSchema } from './file-schema'
export const ipcSchemas = {
...windowIpcSchema,
...appIpcSchema,
...fileIpcSchema, // add here
} as constAdd the API method
Extend the domain's client class. invoke is fully typed against the schema.
// lib/conveyor/api/file-api.ts
import { ConveyorApi } from '@/lib/preload/shared'
export class FileApi extends ConveyorApi {
readFile = (path: string) => this.invoke('file-read', path)
}If this is a brand-new domain, also add it to the conveyor object in
api/index.ts; window.conveyor types update automatically.
Implement the handler
Use handle() from lib/main/shared.ts. It validates the incoming args, runs
your function, then validates the return value.
// lib/conveyor/handlers/file-handler.ts
import { readFile } from 'node:fs/promises'
import { handle } from '@/lib/main/shared'
export const registerFileHandlers = () => {
handle('file-read', (_event, path) => readFile(path, 'utf-8'))
}Register in the main process
Call the registrar during startup in lib/main/main.ts, alongside the others.
import { registerFileHandlers } from '@/lib/conveyor/handlers/file-handler'
registerFileHandlers()Use it
const contents = await window.conveyor.file.readFile('/path/to/file')Where validation fires
handle() is the choke point. Both directions are checked:
// lib/main/shared.ts (abridged)
ipcMain.handle(channel, async (event, ...args) => {
const validatedArgs = validateArgs(channel, args) // renderer → main
const result = await handler(event, ...validatedArgs)
return validateReturn(channel, result) // main → renderer
})If the renderer sends the wrong shape, validateArgs throws before your handler
runs; the error is logged with the channel name and the rejected promise
surfaces in the renderer. If a handler returns the wrong shape, validateReturn
throws — which catches contract drift during development rather than shipping a
malformed payload to the UI. The preload client deliberately skips validation;
main is the single trusted validator.
Handlers that need the calling window use getRequestingWindow(event), which
throws if the request didn't come from a live application window.
Security posture
Never import or expose ipcRenderer in app code. Add a schema, an API method,
and a handler instead, so the boundary stays typed and runtime-validated.
The renderer runs sandboxed with contextIsolation: true and
nodeIntegration: false (see Architecture). The
preload bridge exposes only the conveyor object through contextBridge — no
raw Node, no ipcRenderer, no ad hoc channels. Because every payload is Zod
tuples with explicit bounds, a compromised or buggy renderer can't smuggle an
unexpected shape past the boundary.
