The way Electron works is pretty simple. There are two different layers – the main process and the renderer process(es). There’s always only one main process, which is the entry point of your Electron application. There can be any number of renderer processes, which are responsible for rendering your application.
Communication between these layers is usually done via IPC (interprocess communication). That may sound complicated, but is just a fancy name for an asynchronous request-response pattern.
What happens behind the scenes for the communication between the renderer and main process is basically just event dispatching. For example, let’s say your application should show information regarding the system it is run on. This can be done with a simple command, uname -a
, which shows your kernel version. But your application itself cannot execute commands, so it needs the main process. Within Electron applications, your application has access to the renderer process (ipcRenderer). Here’s what’s going to happen:
ipcRenderer
to emit an event to the main process. These events are called channels within ElectronUltimately this entire process can just be seen as a simple request-response pattern, a bit like HTTP – just asynchronous. We’re going to request something via a certain channel and receive the response to that on a certain channel.
Thanks to TypeScript we can abstract this entire logic into a cleanly separated and properly encapsulated application, where we dedicate entire classes for single channels within the main process and utilize promises for making easier asynchronous requests. Again, this sounds a lot more complicated than it actually is!
The first thing we need to do is to bootstrap our Electron application with TypeScript. Our package.json
is just:
{ "name": "electron-ts", "version": "1.0.0", "description": "Yet another Electron application", "scripts": { "build": "tsc", "watch": "tsc -w", "start": "npm run build && electron ./dist/electron/main.js", "test": "echo \"Error: no test specified\" && exit 1" }, "author": "Kevin Hirczy <https://nehalist.io>", "license": "MIT", "devDependencies": { "electron": "^7.1.5", "typescript": "^3.7.3" } }
The next thing we’re going to add is our Typescript configuration, tsconfig.json
:
{ "compilerOptions": { "target": "es5", "noImplicitAny": true, "sourceMap": true, "moduleResolution": "node", "outDir": "dist", "baseUrl": "." }, "include": [ "src/**/*" ] }
Our source files will live within the src
directory, everything will be built into a dist
directory. We’re going to split the src
directory into two separate directories, one for Electron and one for our application. The entire directory structure will look something like this:
src/ app/ electron/ shared/ index.html package.json tsconfig.json
Our index.html
will be the file loaded by Electron and is pretty simple (for now):
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Hello World!</title> <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"/> </head> <body> Hello there! </body> </html>
The first file we’re going to implement is the main file for Electron. This file will implement a Main
class which is responsible for initializing our Electron application:
// src/electron/main.ts import {app, BrowserWindow, ipcMain} from 'electron'; class Main { private mainWindow: BrowserWindow; public init() { app.on('ready', this.createWindow); app.on('window-all-closed', this.onWindowAllClosed); app.on('activate', this.onActivate); } private onWindowAllClosed() { if (process.platform !== 'darwin') { app.quit(); } } private onActivate() { if (!this.mainWindow) { this.createWindow(); } } private createWindow() { this.mainWindow = new BrowserWindow({ height: 600, width: 800, title: `Yet another Electron Application`, webPreferences: { nodeIntegration: true // makes it possible to use `require` within our index.html } }); this.mainWindow.webContents.openDevTools(); this.mainWindow.loadFile('../../index.html'); } } // Here we go! (new Main()).init();
Running npm start
should now start your Electron application and show your index.html
:
The next thing we’re going to implement is how our IPC channels are handled.
Following SoC we’re going to implement one class per channel. These classes will be responsible for incoming requests. In the example above we’d have a SystemInfoChannel
which is responsible for gathering system data. If you’d like to work with certain tools, let’s say control virtual machines with Vagrant, you’d have a VagrantChannel
, and so on.
Every channel is going to have a name and a method for handling incoming requests – so we create an interface for that:
// src/electron/IPC/IpcChannelInterface.ts import {IpcMainEvent} from 'electron'; export interface IpcChannelInterface { getName(): string; handle(event: IpcMainEvent, request: any): void; }
There’s one thing that stands out, any
. Type-hinting any
is a design flaw in many cases – and we’re not going to live with a design flaw. So let’s take a few moments to think about what type request
really is.
Requests are sent from our renderer process. There are two things that might be relevant to know when sending requests:
Both of them are optional – but we can now create an interface for sending requests. This interface will be shared between Electron and our application:
export interface IpcRequest { responseChannel?: string; params?: string[]; }
Now we can go back to our IpcChannelInterface
and add a proper type for our request
:
handle(event: IpcMainEvent, request: IpcRequest): void;
The next thing we need to take care of is how channels are added to our main process. The easiest way is to add an array of channels to our init
method of our Main
class. These channels will then be registered by our ipcMain
process:
public init(ipcChannels: IpcChannelInterface[]) { app.on('ready', this.createWindow); app.on('window-all-closed', this.onWindowAllClosed); app.on('activate', this.onActivate); this.registerIpcChannels(ipcChannels); }
While the registerIpcChannels
method is just one line:
private registerIpcChannels(ipcChannels: IpcChannelInterface[]) { ipcChannels.forEach(channel => ipcMain.on(channel.getName(), (event, request) => channel.handle(event, request))); }
What’s happening here is that channels passed to our init
method will be registered to our main process and handled by their responding channel classes. To make that easier to follow let’s quickly implement a class for our system info from the example above:
// src/electron/IPC/SystemInfoChannel.ts import {IpcChannelInterface} from "./IpcChannelInterface"; import {IpcMainEvent} from 'electron'; import {IpcRequest} from "../../shared/IpcRequest"; import {execSync} from "child_process"; export class SystemInfoChannel implements IpcChannelInterface { getName(): string { return 'system-info'; } handle(event: IpcMainEvent, request: IpcRequest): void { if (!request.responseChannel) { request.responseChannel = `${this.getName()}_response`; } event.sender.send(request.responseChannel, { kernel: execSync('uname -a').toString() }); } }
By adding an instance of this class to our init
call of our Main
class we have now registered our first channel handler:
(new Main()).init([ new SystemInfoChannel() ]);
Now every time a request happens on the system-info
channel the SystemInfoChannel
will take care of it and handle it properly by responding (on the responseChannel
) with the kernel version.
Here is what we’ve done so far visualized:
Looks good so far, but we’re still missing the part where our application actually does stuff – like sending a request for gathering our kernel version.
To make use of our clean main process’ IPC architecture we need to implement some logic within our application. For the sake of simplicity, our user interface will simply have a button for sending a request to the main process which will return our kernel version.
All of our IPC-related logic will be placed within a simple service – the IpcService
class:
// src/app/IpcService.ts export class IpcService { }
The first thing we need to do when using this class is to make sure we can access the ipcRenderer
.
In case you’re wondering why we need to do that, it’s because if someone opens the index.html
file directly there’s no ipcRenderer
available.
Let’s add a method which properly initializes our ipcRenderer
:
private ipcRenderer?: IpcRenderer; private initializeIpcRenderer() { if (!window || !window.process || !window.require) { throw new Error(`Unable to require renderer process`); } this.ipcRenderer = window.require('electron').ipcRenderer; }
This method will be called when we try to request something from our main process – which is the next method we need to implement:
public send<T>(channel: string, request: IpcRequest = {}): Promise<T> { // If the ipcRenderer is not available try to initialize it if (!this.ipcRenderer) { this.initializeIpcRenderer(); } // If there's no responseChannel let's auto-generate it if (!request.responseChannel) { request.responseChannel = `${channel}_response_${new Date().getTime()}` } const ipcRenderer = this.ipcRenderer; ipcRenderer.send(channel, request); // This method returns a promise which will be resolved when the response has arrived. return new Promise(resolve => { ipcRenderer.once(request.responseChannel, (event, response) => resolve(response)); }); }
Using generics makes it possible for us to get information about what we’re going to get back from our request – otherwise, it would be unknown and we would have to be a wizard in terms of casting to get proper information about what types we’re really dealing with. Don’t get me wrong here; being a wizard is awesome – but having no type information is not.
Resolving the promise from our send
method when the response arrives makes it possible to make use of the async/await
syntax. By using once
instead of on
on our ipcRenderer
we make sure to not listen for additional events on this specific channel.
Our entire IpcService
should look like this by now:
// src/app/IpcService.ts import {IpcRenderer} from 'electron'; import {IpcRequest} from "../shared/IpcRequest"; export class IpcService { private ipcRenderer?: IpcRenderer; public send<T>(channel: string, request: IpcRequest): Promise<T> { // If the ipcRenderer is not available try to initialize it if (!this.ipcRenderer) { this.initializeIpcRenderer(); } // If there's no responseChannel let's auto-generate it if (!request.responseChannel) { request.responseChannel = `${channel}_response_${new Date().getTime()}` } const ipcRenderer = this.ipcRenderer; ipcRenderer.send(channel, request); // This method returns a promise which will be resolved when the response has arrived. return new Promise(resolve => { ipcRenderer.once(request.responseChannel, (event, response) => resolve(response)); }); } private initializeIpcRenderer() { if (!window || !window.process || !window.require) { throw new Error(`Unable to require renderer process`); } this.ipcRenderer = window.require('electron').ipcRenderer; } }
Now that we have created an architecture within our main process for handling incoming requests and implemented a service to send such services we are now ready to put everything together!
The first thing we want to do is to extend our index.html
to include a button for requesting our information and a place to show it:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Hello World!</title> <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"/> </head> <body> <button id="request-os-info">Request OS Info</button> <div id="os-info"></div> <script> require('./dist/app/app.js'); </script> </body> </html>
The app.js
required doesn’t exist yet – so let’s create it. Keep in mind that the referenced path is the built file – but we’re going to implement the TypeScript file (which lives in src/app/
)!
// src/app/app.ts import {IpcService} from "./IpcService"; const ipc = new IpcService(); document.getElementById('request-os-info').addEventListener('click', async () => { const t = await ipc.send<{ kernel: string }>('system-info'); document.getElementById('os-info').innerHTML = t.kernel; });
And et voilà – we’re done! It might seem unimpressive at first, but by clicking on the button now a request is sent from our renderer process to our main process, which delegates the request to the responsible channel class and ultimately responds with our kernel version.
Of course, things like error handling and such needs to be done here – but this concept allows for a very clean and easy-to-follow communication strategy for Electron apps.
The entire source code for this approach can be found on GitHub.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowThe useReducer React Hook is a good alternative to tools like Redux, Recoil, or MobX.
Node.js v22.5.0 introduced a native SQLite module, which is is similar to what other JavaScript runtimes like Deno and Bun already have.
Understanding and supporting pinch, text, and browser zoom significantly enhances the user experience. Let’s explore a few ways to do so.
Playwright is a popular framework for automating and testing web applications across multiple browsers in JavaScript, Python, Java, and C#. […]
2 Replies to "Electron IPC Response/Request architecture with TypeScript"
this is unbelievable. thank you!
It’s really good information in this post!