Electron – 3 Methods for Inter Process Communications (IPC)

The purpose of this article is to describe the primary methods for an electron application to communicate with desktop resources and make them available to be rendered in the browser process.

Introduction

Electron is an exciting technology that allows web developers to use their HTML, CSS, and JavaScript skills to create desktop applications. Electron also produces cross platform builds meaning that the application that you make with electron can be built and distributed on Windows, Mac, and Linux. No need to learn Windows Forms, nor Windows Presentation Foundation. No need to learn Swift and AppleScript in Xcode.

Under the hood Electron is a Chromium based browser that get’s distributed with your HTML, CSS, and JavaScript code. When electron creates a window it is essentially creating a Chrome browser window, and loading your HTML file, and then things just work like you would normally expect on a web application. The thing to consider though is that there is not a server hosting your HTML and therefore this is essentially the same thing as loading resources using the file:/// protocol instead of the http:// protocol. That being said though, you are welcome to have the initial page load be an HTTP server if you wish, but then you will need to start considering how to make updates to the HTTP site, and the electron desktop application at the same time, or in ways that do not introduce breaking changes to either the website, or your desktop application. But I will save that discussion for later. The main purpose of this article is to discuss how to get your processes to communicate with each other in electron.

Electron has 3 main entry points / processes:

  • The main process
    • This is analogous to the executable file produced by a traditional desktop application. The primary responsibility of this process is to create, and manage windows. It also has access to the operating system resources. This code is similar to the code you use to create Node.js Command Line Interfaces or Web Server Applications.
  • The preload process
    • The preload process is essentially JavaScript that is loaded on to any created window. Any code placed here will be available to the downstream renderer process. Essentially the loaded window will have its own JavaScript resources + the resources injected via this process. You can control which windows even receive a preload, and even which preload they will receive.
  • The renderer process
    • The renderer process is your standard JavaScript that works in the browser. Your HTML file will need to have a script tag to the primary renderer javascript file, and things work from there. This process has access to the Window JavaScript object like you would expect to do in any front end code project. Out of the box it supports vanilla JavaScript, but you can include other popular frameworks such as REACT or VUE here.

As the title of this article suggests, we are focusing on communicating between these processes (Inter Process Communications or IPC).

The Electron API includes two methods out of the box to facilitate this communication called: ipcMain, and ipcRenderer. For security purposes the Electron API documentation recommends not allowing the renderer process to have direct access to the nodejs api, and allowing that code to remain in the main process. Furthermore, they also don’t recommend that the renderer have direct access to ipcRender, rather that you should have a preload script that only exposes some communications between the main process and the renderer process.

Starting from a new Project

For this demonstration I will be creating a new project using Electron Forge which has minimal boilerplate to start from where a default main, preload, and HTML file are built for us. I will also be using TypeScript since that some times adds some complications to a standard JavaScript implementation, and most everyone these days are using TypeScript to be compiled in to JavaScript.

Go to the parent directory of where you would like your project code to be and execute the following command:

npm init electron-app@latest electron_ipc -- --template=webpack-typescript
This will create a new folder in the directory that you executed from called “electron_ipc”.

Open this folder in your favorite IDE like Visual Studio Code.

Add the ipcMain code

There are 2 main methods that are exposed on the ipcMain API from electron: “on”, and “handle”. The “on” method can be used both for synchronous and asynchronous communication depending on how you use the properties of the callback function. The “handle” method is asynchronous and, in my opinion, very similar to a Nodejs Express route handler. We will get more in to that as we go through the different processes, and once we have completed the code of the processes we can discuss their impacts and use cases. There is not a one size fits all and is why they have these options available to developers.

In the src/index.ts file (which is the main process in this boiler plate that we created from electron forge) update the first line to import ipcMain from ‘electron’ by having the first line read as follows:

import { app, BrowserWindow, ipcMain } from 'electron';
And for now let’s add some code that will handle requests from the renderer process

Between lines 21 and 23 in the index.ts file add the following code (between the mainWindow being created and the mainWindow loading the HTML file)

ipcMain.on('asyncPing', (event, args) => {
    console.log("asyncPing received");
    event.sender.send('asyncPong', 'asyncPong');
  });

  ipcMain.on('syncPing', (event, args) => {
    console.log('syncPing received');
    event.returnValue = 'syncPong';
  });

  ipcMain.handle('handlePing', (event, args) => {
    console.log('handlePing received');
    return 'handlePong';
  });

  ipcMain.handle('handlePingWithError', () => {
    throw new Error("Something Went Wrong");
  });
In total the index.ts file should look like this
import { app, BrowserWindow, ipcMain } from 'electron';
// This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack
// plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on
// whether you're running in development or production).
declare const MAIN_WINDOW_WEBPACK_ENTRY: string;
declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string;

// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (require('electron-squirrel-startup')) {
  app.quit();
}

const createWindow = (): void => {
  // Create the browser window.
  const mainWindow = new BrowserWindow({
    height: 600,
    width: 800,
    webPreferences: {
      preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY,
    },
  });

  ipcMain.on('asyncPing', (event, args) => {
    console.log("asyncPing received");
    event.sender.send('asyncPong', 'asyncPong');
  });

  ipcMain.on('syncPing', (event, args) => {
    console.log('syncPing received');
    event.returnValue = 'syncPong';
  });

  ipcMain.handle('handlePing', (event, args) => {
    console.log('handlePing received');
    return 'handlePong';
  });

  ipcMain.handle('handlePingWithError', () => {
    throw new Error("Something Went Wrong");
  });

  // and load the index.html of the app.
  mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY);

  // Open the DevTools.
  mainWindow.webContents.openDevTools();
};

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', createWindow);

// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('activate', () => {
  // On OS X it's common to re-create a window in the app when the
  // dock icon is clicked and there are no other windows open.
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow();
  }
});

// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and import them here.
Let’s review what we have done so far. We now have two methods of “on” from ipcMain which will respond to either “asyncPing” or “syncPing”. For the asyncPing we are using the event property of the callback function and grabbing the sender property to send a message back to the process that sent the message. On the other hand with the “syncPing” method we are setting the returnValue of the event property of the call back to a specific value. The advantage to doing things asynchronously is that the browser / renderer can just simply request something to be done, and is free to either wait for a response or do nothing. But the point is the browser will not be “locked” waiting for a response. On the other hand the “syncPing” is almost like calling that method as a function and it will wait until the return value is set before it allows the browser process to think of doing anything else. The advantage to that though is that the method that called that function will certainly get the correct value, where as in the asynchronous example it will rely on a different function to handle the response of the message, and this “disconnect” might not be desired. Though you can have the payloads of the messages be specific enough that it won’t matter.

We also did two flavors of “handle”. One without an error and one with an error. The one without an error is using the standard return statement. But just because it is a standard return statement does not mean that it is operating synchronously. Rather it is sending a response back, and on the renderer process / browser window will still be able to do anything until the response comes back. This is similar to making an HTTP Request from the browser to a server using fetch, axios, or even plain old XMLHttpRequest.

Add the preload code

As mentioned earlier Electron’s documentation suggests to not directly expose these ipc methods to the renderer process, and rather only expose some of them through a prerender process. Luckily the boilerplate that we started with already has the files in place and being loaded. Since we need to share this information with the renderer process, and we are using typescript let’s create an interface so that we know the renderer and preload processes will have the same API information.

To the src folder create a new file called “IElectronAPI.ts” and give it the following code:

export interface IElectronAPI {
    asyncPing: () => void
    syncPing: () => string
    handlePing: () => Promise<string>
    handlePingWithError: () => Promise<string>
}
As you can see here, we are just making a “contract” for which methods we expect the API to have and what their return values to be.

Update the “src/preload.ts” file with the following code:

// See the Electron documentation for details on how to use preload scripts:
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
import { contextBridge, ipcRenderer } from "electron"
import { IElectronAPI } from "./IElectronAPI"

const electronAPI: IElectronAPI = {
    asyncPing: () => ipcRenderer.send("asyncPing"),
    syncPing: () => ipcRenderer.sendSync("syncPing"),
    handlePing: () => ipcRenderer.invoke("handlePing"),
    handlePingWithError: () => ipcRenderer.invoke("handlePingWithError")
}

contextBridge.exposeInMainWorld('electronAPI', electronAPI)

ipcRenderer.on('asyncPong', (event, args) => {
    console.log("asyncPong received");
    const asyncResponseElement = document.getElementById('asyncPingResponse');
    asyncResponseElement.textContent = args;
})

console.log("preload complete");
Here we are importing the IElectronAPI interface that we created earlier. We then created an electronAPI variable to fulfill the contract of the IElectronAPI interface. And each method is essentially using the ipcRenderer with either send, sendSync, or invoke. The “send” method should be used with ipcMain methods that will use the event.sender.send method in its call back. The “sendSync” methods should be using with ipcMain methods that will be using the event.returnValue method in its call back. The “invoke” method should be used with ipcMain methods that will use the “handle” method and the return or throw an error.

After that we exposed this api in the main world and gave it the label of ‘electronAPI’. Effectively this means that when this code is executed and the renderer process loads window.electronAPI will have the methods that we defined, i.e. window.electronAPI.asyncPing() will now be a method available to the browser window / page. To further understand continue on to the next bit of code where we update the renderer process.

Add the ipcRenderer code

We plan on adding buttons to the HTML code that will execute any of the electronAPI that we exposed via the preload process. Updated your render.js code to the following:

/**
 * This file will automatically be loaded by webpack and run in the "renderer" context.
 * To learn more about the differences between the "main" and the "renderer" context in
 * Electron, visit:
 *
 * https://electronjs.org/docs/latest/tutorial/process-model
 *
 * By default, Node.js integration in this file is disabled. When enabling Node.js integration
 * in a renderer process, please be aware of potential security implications. You can read
 * more about security risks here:
 *
 * https://electronjs.org/docs/tutorial/security
 *
 * To enable Node.js integration in this file, open up `main.js` and enable the `nodeIntegration`
 * flag:
 *
 * ```
 *  // Create the browser window.
 *  mainWindow = new BrowserWindow({
 *    width: 800,
 *    height: 600,
 *    webPreferences: {
 *      nodeIntegration: true
 *    }
 *  });
 * ```
 */

import './index.css';
import { IElectronAPI } from "./IElectronAPI"

declare global {
    interface Window {
        electronAPI: IElectronAPI
    }
}

console.log('👋 This message is being logged by "renderer.js", included via webpack');

const main = async () => {
    const asyncPingButton = document.querySelector("#asyncPingButton");
    asyncPingButton.addEventListener('click', (e) => {
        console.log("Async Ping Clicked");
        window.electronAPI.asyncPing();        
    });

    const syncPingButton = document.querySelector("#syncPingButton");
    syncPingButton.addEventListener('click', (e) => {
        console.log("Sync Ping Clicked");
        const response = window.electronAPI.syncPing();
        console.log(response);
        const responseElement = document.getElementById("syncPingResponse")
        responseElement.textContent = response;
    });

    const handlePingButton = document.querySelector("#handlePingButton");
    handlePingButton.addEventListener('click', (e) => {
        console.log("Handle Ping Clicked");
        window.electronAPI.handlePing().then((result) => {
            console.log(result);
            const responseElement = document.getElementById("handlePingResponse")
            responseElement.textContent = result;
        });
    });

    const handlePingWithErrorButton = document.querySelector("#handlePingWithErrorButton");
    handlePingWithErrorButton.addEventListener('click', (e) => {
        console.log("Handle Ping with Error Clicked");
        window.electronAPI.handlePingWithError()
        .then((result) => {
            console.log("then");
            console.log(result);
            const responseElement = document.getElementById("handlePingWithErrorResponse")
            responseElement.textContent = result;
        })
        .catch((err) => {
            console.log("catch");
            console.log(err);
            const responseElement = document.getElementById("handlePingWithErrorResponse")
            responseElement.textContent = err;
        })
    })
}

main();
Furthermore, update your index.html file to actually have these buttons. The code should be similar to this:
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />    
    <title>Hello World!</title>

  </head>
  <body>
    <h1>💖 Hello World!</h1>
    <p>Welcome to your Electron application.</p>
    <div>
      <button id="asyncPingButton">Ping (Async)</button>
      <div>Async Response:</div>
      <div id="asyncPingResponse"></div>
    </div>
    <div>
      <button id="syncPingButton">Ping (Sync)</button>
      <div>Sync Response:</div>
      <div id="syncPingResponse"></div>
    </div>
    <div>
      <button id="handlePingButton">Ping (Handle)</button>
      <div>Handle Response:</div>
      <div id="handlePingResponse"></div>
    </div>
    <div>
      <button id="handlePingWithErrorButton">Ping (Handle with Error)</button>
      <div>Handle Response:</div>
      <div id="handlePingWithErrorResponse"></div>
    </div>
  </body>
</html>
As you can see here in the HTML file we put buttons, and div’s to output the results of pressing that button. In the renderer javascript file (which gets injected by webpack in this boilerplate) it will seek out the buttons by their id and add a click event listener. Once the button is clicked it will put a response in to the appropriate response div.

When you run “npm start” your application should look like this before clicking on any button.

When you click on “Ping (Async) you should see something like this:
After you clicked each of the buttons you should see something like this:
Furthermore, the console window of your browser should look something like this:
In addition, the console of your terminal where you ran npm start should look like this:
It is interesting to note here that logging messages in the main process will result in them showing up in the terminal, but logging messages in the preload or renderer process will show up in the browsers log.

Conclusion

Electron is an exciting technology that can be used to create cross platform desktop application using web developer skills. For security purposes electron breaks the API out from the main and renderer process, but thanks to their Inter Process Communication (IPC) API. They can communicate with each other in harmony. This particular example only demonstrates things that send messages back and forth. However, there are many use cases for this. For example, let’s say you wanted to have the renderer produce a list of files on the computer that the desktop application is running on. Well the renderer needs some information in order to present that on screen, but it does not have direct access to the file system; however, the main process does. So you could theoretically create an ipcMain method that will respond to a renderer request for a list of files, and that main ipc method could do it’s thing and return the list, and the renderer will do what it needs to with that information. Furthermore, some web APIs that you interact with might not allow the file:/// protocol to be used in the renderer process (think CORS policies, or the API might require HTTPS). In which case you may want to have the main process communicate with the REST API, and then setup ipcMain methods to communicate with the API while the renderer requests the main process to do so.

The possibilities are endless. I hope you enjoy programming with electron as much as I do.

Check out this git repository for more information. https://github.com/woodman231/electron_ipc

About Intertech

Intertech is a Software Development Consulting Firm that provides single and multiple turnkey software development teams, available on your schedule and configured to achieve success as defined by your requirements independently or in co-development with your team. Intertech teams combine proven full-stack, DevOps, Agile-experienced lead consultants with Delivery Management, User Experience, Software Development, and QA experts in Business Process Automation (BPA), Microservices, Client- and Server-Side Web Frameworks of multiple technologies, Custom Portal and Dashboard development, Cloud Integration and Migration (Azure and AWS), and so much more. Each Intertech employee leads with the soft skills necessary to explain complex concepts to stakeholders and team members alike and makes your business more efficient, your data more valuable, and your team better. In addition, Intertech is a trusted partner of more than 4000 satisfied customers and has a 99.70% “would recommend” rating.