v0.0.1 · MIT · Electron >=20
Type-safe IPC for Electron. Define once. Infer everywhere.
Write the contract once in shared code and every arg, return value and event payload is inferred across main, preload and renderer — no duplicated types, no casts.
- Runtime dependencies
- 0
- Renderer entry · brotli
- 505 B
- Runtime exports
- 11
- Contract → processes
- 1 → 3
Published package: 50,097 B across 20 files · all figures measured from dist/ at build time
01 / The contract
Write the shape down once.
A token carries its channel path at runtime and its types at compile time. defineApi stamps the paths; TypeScript keeps the literals. This is the only place your IPC surface is described.
import { defineApi, defineEvent, defineInvoke } from '@cc-heart/electron-use-ipc'
export interface User {
id: string
name: string
}
/**
* Single source of truth: define the contract once.
* Every arg / return / payload type flows from here to main, preload, renderer.
*/
export const api = defineApi({
invoke: {
getUser: defineInvoke<[string], User>(),
listUsers: defineInvoke<[], User[]>(),
createUser: defineInvoke<[string], User>(),
},
events: {
'user:updated': defineEvent<User>(),
'server:shutdown': defineEvent<void>(),
},
})
import { registerMain } from '@cc-heart/electron-use-ipc/main'
import { api, type User } from './shared/api'
const users = new Map<string, User>()
// `main` is captured by closures below; declare first so handlers can emit.
const main = registerMain(api, {
invoke: {
// (id: string) => Promise<User> — inferred from the token
getUser: async (id) => {
const u = users.get(id)
if (!u) throw new Error(`no user ${id}`)
return u
},
// () => Promise<User[]>
listUsers: async () => [...users.values()],
// (name: string) => Promise<User>
createUser: async (name) => {
const u: User = { id: crypto.randomUUID(), name }
users.set(u.id, u)
// typed event broadcast: payload must be User
main.emit(api.events['user:updated'], u)
return u
},
},
})
// void event: no payload allowed
main.emit(api.events['server:shutdown'])
main.dispose()
The handler's parameters are not annotated — they are inferred from the token. Change getUser's return type in shared/api.ts and this file stops compiling until it matches.
02 / The consumers
Every other side infers from it.
Preload forwards tokens without knowing what they mean. The renderer gets call signatures, return types and event payloads straight from the same definition.
import { exposeBridge } from '@cc-heart/electron-use-ipc/preload'
import { api } from './shared/api'
// exposes window.api with the typed bridge
exposeBridge(api)
import { useEvent, useInvoke } from '@cc-heart/electron-use-ipc/renderer'
import { api } from './shared/api'
/* ---- request / response hook ---- */
const { data, loading, call } = useInvoke(api.invoke.getUser)
// call: (id: string) => Promise<User> ✅ inferred
// data: Signal<User | undefined> ✅ inferred
call('1').then(() => {
const u = data.get() // User | undefined
console.log(u?.name)
})
data.subscribe((u) => {
// u: User | undefined
})
/* ---- immediate call (args still typed) ---- */
const list = useInvoke(api.invoke.listUsers, { immediate: [] })
// list.data: Signal<User[] | undefined>
/* ---- event hook ---- */
const updated = useEvent(api.events['user:updated'])
// updated: Signal<User | undefined> ✅ inferred
updated.subscribe((u) => {
if (u) console.log('updated:', u.name)
})
/* ---- compile-time type checks (uncomment to see errors) ---- */
// call(123) // ❌ Argument of type 'number'...
// main.emit(api.events['user:updated']) // ❌ void-typed event still works, but
// // a non-void event without payload errors. (void events allow no-arg emit)
// updated.subscribe((u: number) => {}) // ❌ u is User | undefined
No casts, no as User, no duplicated interfaces. call('1') is typed (id: string) => Promise<User> purely from api.invoke.getUser.
03 / The difference
Instead of casts, compile errors.
Hand-wired IPC has three places to drift and a cast at the end to hide it. With a contract, a wrong argument is a build failure rather than a runtime surprise.
/**
* BEFORE — the same feature wired by hand.
*
* This file is illustrative content for the landing page, not
* compiled. It is kept as a real file so it highlights from
* source like every other snippet.
*
* Three files, three chances to drift: the channel name is a
* bare string, the payload types are restated at each boundary,
* and nothing connects them.
*/
// ── main.ts ─────────────────────────
import { ipcMain } from 'electron'
ipcMain.handle('get-user', async (_event, id) => {
// `id` is `any` here — the cast is a promise, not a check
return db.users.get(id as string)
})
// ── preload.ts ──────────────────────
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('api', {
getUser: (id: string) => ipcRenderer.invoke('get-user', id),
onUserUpdated: (cb: (user: User) => void) =>
ipcRenderer.on('user:updated', (_e, user) => cb(user)),
})
// ── renderer.ts ─────────────────────
interface User {
id: string
name: string
}
// cast it and hope
const user = (await window.api.getUser('1')) as User
// `any` all the way down
window.api.onUserUpdated((u: any) => console.log(u.name))
// Negative test: each @ts-expect-error must be "used" (i.e. a real error occurs).
// If tsc passes, the types are BOTH correct (positive cases) AND strict
// (these bad cases really error). If an @ts-expect-error becomes unused,
// that line leaked `any`.
import { useEvent, useInvoke } from '@cc-heart/electron-use-ipc/renderer'
import { api } from './shared/api'
const { call } = useInvoke(api.invoke.getUser)
// @ts-expect-error number is not assignable to string
call(123)
const updated = useEvent(api.events['user:updated'])
// @ts-expect-error payload is User | undefined, not number
updated.subscribe((u: number) => {})
The right-hand file is a real test in this repository. Each @ts-expect-error must be used — if a bad call ever stopped erroring, tsc would fail on the unused directive.
04 / Properties
Small, and framework-agnostic.
The renderer entry imports neither Electron nor a UI framework. It exposes signals, so binding it to React, Vue, Solid or plain DOM is a few lines each.
End-to-end inference
One defineApi() call types every argument, return value and payload across all three processes. No duplicated interfaces, no casts.
Hook-style signals
useInvoke() and useEvent() hand back a tiny reactive signal — data, error and loading — that any framework can bind to.
Zero runtime dependencies
The published package depends on nothing. The signal primitive is a Set and three closures, inlined into the bundle.
Framework-agnostic core
The renderer entry never imports Electron or a UI framework. Adapters for React, Vue and Solid are a handful of lines each.
05 / Reference
The whole surface.
Eleven runtime exports and no dependencies. Nine of them are the API you will actually call — the other two resolve channel names, which you only need if you are writing your own transport.
| Function | Signature | Side | Purpose |
|---|---|---|---|
| defineApi | defineApi(schema) | shared | Stamps channel paths onto tokens while preserving literal types. |
| defineInvoke | defineInvoke<Args, Return>() | shared | Type token for a request/response method. |
| defineEvent | defineEvent<Payload>() | shared | Type token for a one-way main → renderer event. |
| registerMain | registerMain(api, { invoke }) | main | Registers handlers; returns { emit, dispose }. |
| exposeBridge | exposeBridge(api, name?) | preload | Exposes a typed window[name] through contextBridge. |
| useInvoke | useInvoke(token, opts?) | renderer | Hook returning { data, error, loading, call } signals. |
| useEvent | useEvent(token) | renderer | Hook returning Signal<Payload | undefined> for pushed events. |
| invoke | invoke(token, ...args) | renderer | One-shot typed call, no reactive state. |
| signal | signal(initial) | core | The framework-agnostic reactive primitive behind every hook. |