Skip to content

Instantly share code, notes, and snippets.

@Igloczek
Created March 13, 2026 17:17
Show Gist options
  • Select an option

  • Save Igloczek/4ab0b81b1554425b82bd441954390d98 to your computer and use it in GitHub Desktop.

Select an option

Save Igloczek/4ab0b81b1554425b82bd441954390d98 to your computer and use it in GitHub Desktop.
Electron IPC Bridge Wrapper (typed, invoke + event)

Electron IPC Bridge (less pain, more type safety)

Electron’s IPC is powerful, but the ergonomics are rough. You end up wiring:

  • ipcMain.handle + ipcRenderer.invoke for requests
  • ipcRenderer.on for push events
  • a bunch of duplicated string names and mismatched payloads

This bridge wraps that into one small, typed layer.

What this gives you

  • One source of truth for event names + payload types
  • Typed API in the renderer: window.electron.someAction(...)
  • Same API for calls and events:
    • pass a payload to invoke (request/response)
    • pass a callback to subscribe (push event)

How it works (short version)

  1. You define events in a single events.ts file.
  2. Types are generated from that list.
  3. The preload script exposes a typed window.electron API.
  4. The main process uses a small event bus:
    • if it’s an invoke call, it routes and resolves the promise
    • if it’s a push event, it broadcasts to renderer windows

The good parts

  • No stringly‑typed mess across main/preload/renderer
  • Single API for both invoke + subscribe
  • Strong typing without writing extra boilerplate
  • Easy to scan: all IPC surface area is in one file

Tradeoffs

  • This is a convention layer, not magic. You still need to think about payloads.
  • You’re opinionated about event naming (kebab‑case in config, camelCase in renderer).
  • Handlers are async by default. If you want sync IPC, this isn’t for that.
  • You’ll want to keep events.ts tidy as the app grows.

Where this fits

If you’re annoyed by Electron’s default IPC shape and want:

  • a simple API in the renderer
  • typed payloads without plumbing
  • fewer “where does this event live?” moments
import { contextBridge, ipcRenderer } from "electron"
import events from "./events"
import type { ElectronBridge } from "./types"
function kebabToCamel(str: string): string {
return str.replace(/-./g, (x) => x[1].toUpperCase())
}
const api = {} as ElectronBridge
try {
Object.keys(events).forEach((eventName) => {
const methodName = kebabToCamel(eventName)
api[methodName] = (payloadOrCallback: unknown) => {
if (typeof payloadOrCallback === "function") {
const callback = (_event, payload) => payloadOrCallback(payload)
ipcRenderer.on(eventName, callback)
return () => ipcRenderer.removeListener(eventName, callback)
}
return ipcRenderer.invoke(eventName, payloadOrCallback)
}
})
contextBridge.exposeInMainWorld("electron", api)
} catch (error) {
console.error("Error exposing API to renderer", error)
}
import { ipcMain } from "electron"
import mitt from "mitt"
import { randomUUID } from "node:crypto"
import events from "./events"
import type { WebContents } from "electron"
import type { Emitter } from "mitt"
type EventsMap = {
[K in keyof typeof events]: (typeof events)[K]["payload"]
}
export const eventBus = mitt<EventsMap>()
const pendingIpcCalls = new Map<string, (value: unknown) => void>()
const renderers = new Set<WebContents>()
const originalOn = eventBus.on.bind(eventBus) as Emitter<EventsMap>["on"]
const originalEmit = eventBus.emit.bind(eventBus) as <
Key extends keyof EventsMap,
>(
type: Key,
payload?: EventsMap[Key],
) => void
eventBus.emit = ((
type: keyof typeof events,
payload:
| { callId: string; payload: unknown }
| EventsMap[keyof typeof events],
) => {
if (typeof payload === "object" && payload && "callId" in payload) {
return originalEmit(type, payload)
}
renderers.forEach((renderer) => {
renderer.send(type, payload)
})
return originalEmit(type, payload)
}) as <Key extends keyof EventsMap>(type: Key, payload?: EventsMap[Key]) => void
eventBus.on = ((
type: keyof EventsMap,
handler: (payload: any) => Promise<unknown>,
) => {
originalOn(type, async (payload: unknown) => {
try {
if (payload && typeof payload === "object" && "callId" in payload) {
const { callId, payload: actualPayload } = payload as {
callId: string
payload: unknown
}
const result = await handler(actualPayload)
const resolve = pendingIpcCalls.get(callId)
if (resolve) {
resolve(result)
pendingIpcCalls.delete(callId)
}
return result
}
return await handler(payload)
} catch (error) {
if (payload && typeof payload === "object" && "callId" in payload) {
const { callId } = payload as { callId: string }
const resolve = pendingIpcCalls.get(callId)
if (resolve) {
resolve(error)
pendingIpcCalls.delete(callId)
}
}
throw error
}
})
}) as any
export async function initEventBus() {
Object.keys(events).forEach((eventName: keyof typeof events) => {
ipcMain.handle(eventName, (event, payload) => {
renderers.add(event.sender)
const callId = randomUUID()
return new Promise((resolve) => {
pendingIpcCalls.set(callId, resolve)
eventBus.emit(eventName, { callId, payload })
})
})
})
}
export default {
"get-user": { payload: {} as { userId: string } },
"user-updated": { payload: {} as { userId: string; name: string } },
"open-url": { payload: "" as string },
} as const
import { initEventBus, eventBus } from "./event-bus"
await initEventBus()
eventBus.on("get-user", async ({ userId }) => {
return { userId, name: "Ada" }
})
eventBus.on("open-url", async (url) => {
// do something with the URL
})
eventBus.emit("user-updated", { userId: "u1", name: "Ada" })
const user = await window.electron.getUser({ userId: "u1" })
const unsubscribe = window.electron.userUpdated((payload) => {
console.log("User updated:", payload)
})
window.electron.openUrl("https://example.com")
// later
unsubscribe()
import events from "./events"
export type EventPayloads = {
[K in keyof typeof events]: (typeof events)[K]["payload"]
}
export type CamelCase<S extends string> =
S extends `${infer P1}-${infer P2}${infer P3}`
? `${Lowercase<P1>}${Uppercase<P2>}${CamelCase<P3>}`
: Lowercase<S>
export type EventName = keyof EventPayloads
export type ElectronBridge = {
[K in EventName as CamelCase<K>]: {
(payload: EventPayloads[K]): Promise<unknown>
(callback: (payload: EventPayloads[K]) => void): void
}
}
declare global {
interface Window {
electron: ElectronBridge
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment