Electron.js Example
The Electron.js example demonstrates a multi-process Command IPC architecture for building extensible, performant Electron.js apps. This approach is inspired by James Long’s blog post on architecting Electron apps to avoid blocking the main/renderer process for long-running tasks. It uses Electron’s MessagePorts for efficient IPC between processes.
Architecture
Section titled “Architecture”flowchart TB
subgraph Main["Main Process"]
MainReg["CommandRegistry"]
end
subgraph Worker["Worker Process"]
subgraph WorkPreload["Preload"]
WorkReg["CommandRegistry"]
end
WorkClient["CommandClient"]
WorkCmds["Background Tasks"]
WorkPreload --> WorkClient
WorkCmds --> WorkClient
end
subgraph Renderer["Renderer Process"]
subgraph RendPreload["Preload"]
RendReg["CommandRegistry"]
end
RendClient["CommandClient"]
React["React Frontend"]
RendPreload --> RendClient
React --> RendClient
end
subgraph Sandbox["Sandbox Process"]
subgraph SandPreload["Preload"]
SandReg["CommandRegistry"]
end
SandClient["CommandClient"]
SandCmds["Isolated Execution"]
SandPreload --> SandClient
SandCmds --> SandClient
end
MainReg <-->|MessagePortChannel| WorkPreload
MainReg <-->|MessagePortChannel| RendPreload
MainReg <-->|MessagePortChannel| SandPreload
WorkPreload <-->|MessagePortChannel| RendPreload
WorkPreload <-->|MessagePortChannel| SandPreload
Overview
Section titled “Overview”This example implements a reference architecture where:
- A background worker process provides the core backend functionality of the app
- A sandbox process runs in a secure sandboxed window for isolated execution
- The renderer process provides the main UI visible to the user
- Each process registers commands on start-up, which can be called from any other process
- A preload script shared by all windows provides an API to register commands and route requests
- Processes can emit events that other processes can listen for to update their local state
- Child processes connect directly to the worker for frequently called commands, reducing routing overhead
This architecture makes it easy to extend the app with new commands, split tasks to different processes for performance, and forms the basis of an extension model similar to VSCode.
How it Works
Section titled “How it Works”Channel Setup
Section titled “Channel Setup”When a window loads, the main process creates a MessageChannelMain and sends one port to the window while keeping the other for its own channel:
// Main process creates channels when window loadswindow.webContents.on('did-finish-load', () => { const { port1, port2 } = new MessageChannelMain()
// Send port1 to the window's preload script window.webContents.postMessage('REGISTER_NEW_CHANNEL', 'main', [port1])
// Register port2 with the main CommandRegistry const channel = new MessagePortMainChannel(id, port2) CommandsRegistryMain.registerChannel(channel)})Preload Script
Section titled “Preload Script”Each window shares a preload script that creates its own CommandRegistry and exposes it to the renderer via contextBridge. The preload listens for channel registration events from the main process:
// Preload creates a CommandRegistry with schemasconst commandRegistry = new CommandRegistry({ routerChannel: 'main', schemas: { commands: AppCommandSchema, events: AppEventSchema },})
// Expose registry methods to renderer as window.commandscontextBridge.exposeInMainWorld('commands', { executeCommand: commandRegistry.executeCommand.bind(commandRegistry), registerCommand: commandRegistry.registerCommand.bind(commandRegistry), onEvent: commandRegistry.onEvent.bind(commandRegistry), // ...})
// Listen for new channels from main processipcRenderer.on('REGISTER_NEW_CHANNEL', (event, id: string) => { const [port] = event.ports const channel = new MessagePortChannel(id, port) commandRegistry.registerChannel(channel)})CommandClient
Section titled “CommandClient”The CommandClient in the core package provides a type-safe wrapper around window.commands. It also includes an isReady() method that waits for specified channels to be registered before executing commands:
// CommandClient wraps window.commands for type-safe accessexport const CommandClient = { executeCommand: async (command, ...args) => { return await window.commands.executeCommand(command, ...args) },
isReady: async (channels: ChannelID[]): Promise<void> => { // Polls until all specified channels are registered // Useful for waiting on background processes to connect },}Direct Process Connections
Section titled “Direct Process Connections”For performance, child processes can connect directly to each other. For example, the renderer can have a direct channel to the worker process, bypassing the main process for frequently called commands:
// Main process sets up direct worker-to-renderer channelconst { port1, port2 } = new MessageChannelMain()workerWindow.webContents.postMessage('REGISTER_NEW_CHANNEL', 'renderer', [port1])rendererWindow.webContents.postMessage('REGISTER_NEW_CHANNEL', 'worker', [port2])Project Structure
Section titled “Project Structure”examples/electron/├── package.json├── tsconfig.base.json└── packages/ ├── core/ # Shared types and schemas │ └── src/ │ └── schemas/ ├── main/ # Electron main process │ └── src/ │ └── services/ ├── frontend/ # Electron renderer (React) │ └── src/ ├── worker/ # Background worker process │ └── src/ └── sandbox/ # Sandboxed execution environment └── src/Running the Example
Section titled “Running the Example”yarn start:examples-electron