DEV Community

Cover image for Electron IPC Response/Request architecture with TypeScript
Brian Neville-O'Neill
Brian Neville-O'Neill

Posted on • Originally published at blog.logrocket.com on

Electron IPC Response/Request architecture with TypeScript

Written by Kevin Hirczy✏️

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.

ipc 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:

  1. Your application will make use of the ipcRenderer to emit an event to the main process. These events are called channels within Electron
  2. If the main process registered a proper event listener (which listens for the event which was just dispatched) it’s capable of running proper code for this event
  3. After everything is done the main process can emit yet another event for the result (in our case, the kernel version)
  4. Now the entire workflow happens the other way around, the renderer process needs to implement a listener for the event dispatched in the main process
  5. When the renderer process receives the proper event containing our desired information the UI can now show the information

Ultimately 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!

LogRocket Free Trial Banner

Bootstrapping an Electron application with TypeScript

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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/**/*"
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Running npm start should now start your Electron application and show your index.html:

hello world

The next thing we’re going to implement is how our IPC channels are handled.

Channel handling

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;
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. We need to know that our channel may accept some parameters
  2. We need to know which channel to use for the response

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[];
}
Enter fullscreen mode Exit fullscreen mode

Now we can go back to our IpcChannelInterface and add a proper type for our request:

handle(event: IpcMainEvent, request: IpcRequest): void;
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)));
}
Enter fullscreen mode Exit fullscreen mode

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() });
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
]);
Enter fullscreen mode Exit fullscreen mode

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:

renderer process

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.

Sending requests from our application

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 {
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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));
  });
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

Putting everything together

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>
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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.

main ui

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.


Editor's note: Seeing something wrong with this post? You can find the correct version here.

Plug: LogRocket, a DVR for web apps

 
LogRocket Dashboard Free Trial Banner
 
LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
 
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.
 
Try it for free.


The post Electron IPC Response/Request architecture with TypeScript appeared first on LogRocket Blog.

Top comments (0)