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/.

conveyor.d.ts (global window.conveyor types)
PartRuns inResponsibility
schemas/SharedZod tuples for each channel's args and return, plus main→renderer eventSchemas. Single source of truth for types.
api/Preload/rendererThin client classes that call this.invoke('channel', ...args). Composed into one conveyor object.
handlers/MainThe real implementations, registered with handle().
conveyor.d.tsRendererDeclares 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 const

Add 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.

On this page