Clean inter-process communication in Electron

Published on December 29, 2018 under Blog

Recently, I was working on an Electron app and I had to write a module for inter-process communication (IPC), namely between the main process ("backend") and the renderer ("browser window" or "frontend"). This is a pretty common concern for Electron developers - they often need to perform some logic in the main process and report the result to the renderer.

As a huge fan of JetBrains IDEs, I always try to write code that can be easily interpreted by my IDE, giving me access to auto-completion, type-checking, and many other goodies. I also like to write code that is easy to read and easy to maintain. In this article, I'll talk about how I achieved both of these things for Electron IPC.

A couple of notes before we start:

  1. I'll explain how one would implement that solution step-by-step. If you want quick answers, you should scroll to the end of the article.
  2. I'll drop some obvious constructs in my code examples to make them more concise. It's up to you to adapt said examples to your application.
  3. I'll be talking about IPC with a single main process and a single renderer. It's up to you to extend this strategy to multiple renderers.
  4. I'll focus on asynchronous logic using promises. It shouldn't be hard to make this strategy work for synchronous requests.

Problem statement

Let's take a look at the naive way of handling IPC in Electron. Consider the two code snippets below.

// In your main process (e.g. main.js)
const {ipcMain} = require('electron');
const backendData  = {foo: 42, bar: 322};

ipcMain.on('get-backend-value', (event, key) => {
    console.log(`Main process receved ${key}.`); // prints "foo"
    const value = backendData[key];
    event.sender.send('response', value);
});
// In your renderer (e.g. <script> tag inside index.html)
const {ipcRenderer} = require('electron');

ipcRenderer.on('response', (event, value) => {
    console.log(`Renderer received ${value}.`); // prints "42"
});
ipcRenderer.send('get-backend-value', 'foo');

As you can see, we had to write quite a lot of code to exchange a couple of simple messages. Additionally, we had to write some IPC logic for both the main process and the renderer, which feels like code duplication (or logic duplication, if you like). If we'd add different types of messages and requests, this code would quickly become a nightmare to maintain. Our goal is to produce a simple solution that solves all of these problems.

Solution overview

Let's start by looking at the brief overview of our solution. This is our desired outcome (compressed for clarity):

// Main process
const IpcModule = require('./IpcModule');
const backendData  = {foo: 42, bar: 322};
const ipc = new IpcModule({mode: 'server', backendData});

// This part is identical for BOTH main process and renderer:
ipc.init();
ipc.getBackendValue({key: 'foo'})
    .then(value => console.log(`Renderer received ${value}.`)); // prints "42"
// Renderer
const IpcModule = require('./IpcModule');
const ipc = new IpcModule({mode: 'client'});

// This part is identical for BOTH main process and renderer:
ipc.init();
ipc.getBackendValue({key: 'foo'})
    .then(value => console.log(`Renderer received ${value}.`)); // prints "42"
// IpcModule.js (our class)
class IpcModule {
    /**
     * @param {object} data
     * @param {string} data.mode
     * @param {object} [data.backendData]
     */
    constructor(data) {
        this.mode = data.mode;
        if (this.mode === 'server') this.backendData = data.backendData;
    }
    
    // Some magic functions, explained later
    init() {/*...*/} 
    _prepareActiveMethod(data) {/*...*/} 
    _preparePassiveMethod(data) {/*...*/} 
    
    /**
     * @alias IpcModule.getBackendValue
     * @param {object} data
     * @param {string} data.key
     */
    server_getBackendValue(data) {
        console.log(`Main process receved ${key}.`); // prints "foo"
        return this.backendData[data.key];
    }
}
module.exports = IpcModule;

That might look like a lot of code, but let's think about which parts we actually need to define for our key-value exchange. It turns out there are only two:

// From main process and renderer:
ipc.getBackendValue({key: 'foo'})
    .then(value => console.log(`Renderer received ${value}.`)); // prints "42"
// From IpcModule.js:
server_getBackendValue(data) {
    console.log(`Main process receved ${key}.`); // prints "foo"
    return this.backendData[data.key];
}

That's right! To define a new inter-process exchange, we just need to add a new method to IpcModule class. Then, we can call this method directly - from both the main process and renderer code, and the result we'll get will be the same! This approach avoids any possible code duplication.

Note that we don't even need to use electron IPC functions to define our new method. This is possible thanks to the "magic" functions init(), _prepareActiveMethod() and _preparePassiveMethod(), which will be described below.

Another useful outcome is that this code can be parsed by IntelliSense, thanks to JSDoc comments. This means that you can type ipc. in your renderer code and getBackendValue method will come up as one of the auto-complete options. Don't be surprised that the method we use, getBackendValue, has a different name to the method we defined, server_getBackendValue. This is intentional and will also be explained later.

Solution implementation

Dependencies

The only package we'll need is electorn-promise-ipc. As the name implies, it gives a nice promise-based abstraction for IPC in Electron. With this package, our naive IPC code becomes this:

// Main process
const promiseIpc = require('electron-promise-ipc');
const backendData  = {foo: 42, bar: 322};
 
promiseIpc.on('get-backend-value', key => {
   console.log(`Main process received ${key}`); // prints "foo"
   return backendData[key];
});
// Renderer
const promiseIpc = require('electron-promise-ipc');

promiseIpc.send('get-backend-value', 'foo')
    .then(arg => console.log(`Renderer received ${value}.`)); // prints "42"

This is definitely a huge improvement over the naive approach, but there is still much left to do.

Separate class for IPC (attempt #1)

Now we're going to create a new ES6 class that's going to hold all of our IPC logic. As you've seen in the overview, we want to include this file in both the main process and the renderer code to avoid code duplication.

We'll try to mimic what we had in the solution overview section, but you will notice that it looks much closer to the code from the previous section:

// IpcModule.js (attempt #1)
const promiseIpc = require('electron-promise-ipc');

class IpcModule {
    constructor(data) {
        this.mode = data.mode;
        if (this.mode === 'server') this.backendData = data.backendData;
    }
    
    init() {
        if (this.mode === 'server') {
            promiseIpc.on('get-backend-value', data => {
               console.log(`Main process received ${data.key}`); // prints "foo"
               return this.backendData[key];
            });
        }
    }

    getBackendValue(data) {
        if (this.mode === 'server') {
            return Promise.resolve(this.backendData[data.key]);
        } else {
            // this.mode === 'client'
            return promiseIpc.send('get-backend-value', data);
        }
    }
}
module.exports = IpcModule;
// After we initialize 'ipc = new IpcModule(...)', we can use
// this method in BOTH the main process and renderer code:
ipc.getBackendValue({key: 'foo'})
    .then(value => console.log(`Renderer received ${value}.`)); // prints "42"

Sure, this class can do what we need, but the code looks very dirty - there are a lot of if statements and our business logic is spread between init() and getBackendValue().

A better class for IPC (attempt #2)

We will rewrite the class from attempt #1 to remove some of the code duplication. First of all, we'll add a prefix to our method name. The prefix is either server_ or client_.

If the prefix is server_, we know that the body of the method should be executed in the main process. For example, let's call our method server_getCpuUsage(). If you call ipc.server_getCpuUsage() from the main process, we just run the method as-is. If you call ipc.server_getCpuUsage() from the renderer, we make a request to the main process to send us the result. This logic is reversed if the prefix is client_.

To make our method call look the same in both main process and renderer, we'll introduce a method wrapper. That is, if the original function was called client_getCpuUsage() or server_getCpuUsage, we create a wrapper method called just getCpuUsage(). This wrapper method will also incorporate the logic described in the paragraph above.

Putting all of this together:

// IpcModule.js (attempt #2)
class IpcModule {
    constructor(data) {/*...*/} // same as before
    
    init() {
        const method = 'server_getBackendValue';
        
        // Extract prefix and prepare alias name
        let methodMode;
        if (method.startsWith('server_') methodMode = 'server';
        if (method.startsWith('client_') methodMode = 'client';
        let alias = method.replace(`${methodMode}_`, '');
        
        if (methodMode === this.mode) { // Can execute logic locally
        
            // Create a simple alias method
            this[alias] = this[method];
            // Create a listener for remote requests
            promiseIpc.on(alias, this[method].bind(this));
            
        } else { // Need to make a remote request
        
            // Create a wrapper method that makes a request
            this[alias] = function() {
                return promiseIpc.send(alias, ...arguments);
            };
            
        }
    }

    server_getBackendValue(data) {
        console.log(`Main process receved ${key}.`); // prints "foo"
        return this.backendData[data.key];
    }
}
module.exports = IpcModule;

Okay, we got rid of code duplicaiton and the solution is already looking quite good. The final thing we need to address is how we get the method names inside init(): Right now we have a hardcoded constant called method - we can definitely better than this.

Best class for IPC (final attempt)

To get the list of method names automatically, we'll use the Object.getOwnPropertyNames() function (check this MDN link for more info). We'll also add a way to check that we're not accidentally redefining the same alias twice, and we'll extract alias-creation into separate functions.

Finally, we'll add a lot of JSDoc comments to make sure our IDE can understand what's going on. To make sure our IDE picks up the new alias method, we'll add @alias IpcModule.getBackendValue to our server_getBackendValue() definition.

The final code for the IpcModule class can be seen below. It should be fully functional.

// IpcModule.js (final)
const promiseIpc = require('electron-promise-ipc');

class IpcModule {
    /**
     * @param {object} data
     * @param {string} data.mode
     * @param {object} [data.backendData]
     */
    constructor(data) {
        this.mode = data.mode;
        this.otherMode = data.mode === 'server' ? 'client' : 'server';
        if (this.mode === 'server') this.backendData = data.backendData;
    }

    init() {
        const takenNames = {};
        const methodNames = Object.getOwnPropertyNames(IpcModule.prototype);
        for (const methodName of methodNames) {
            let methodMode;

            // Determine whether we even need to prepare this method
            if (methodName.startsWith(`${this.mode}_`)) methodMode = this.mode;
            else if (methodName.startsWith(`${this.otherMode}_`)) methodMode = this.otherMode;
            else continue;

            // Check that we're not redefining a method multiple times
            const alias = methodName.replace(`${methodMode}_`, '');
            if (takenNames[alias])
                throw new Error(`Tried to redefine method ${alias} in IpcModule!`);
            takenNames[alias] = true;

            // Define method as passive or active depending on the mode
            const data = {methodName, alias};
            if (methodMode === this.mode) this._prepareActiveMethod(data);
            else this._preparePassiveMethod(data);
        }
    }

    /**
     * @param {object} data
     * @param {string} data.methodName
     * @param {string} data.alias
     */
    _prepareActiveMethod(data) {
        this[data.alias] = this[data.methodName];
        promiseIpc.on(data.alias, this[data.methodName].bind(this));
    }

    /**
     * @param {object} data
     * @param {string} data.alias
     */
    _preparePassiveMethod(data) {
        this[data.alias] = function () {
            return promiseIpc.send(data.alias, ...arguments);
        };
    }

    /**
     * @alias IpcModule.getBackendValue
     * @param {object} data
     * @param {string} data.key
     */
    server_getBackendValue(data) {
        console.log(`Main process receved ${key}.`); // prints "foo"
        return this.backendData[data.key];
    }
}

module.exports = IpcModule;

This class can now be used the same way it was used in the Solution Overview section. It's worth noting that you could replace prefixes with decorators if you wanted. In my case, prefixes worked just fine since they didn't require any additional compilation.


End of Article

Timur Kuzhagaliyev Author

I'm a computer science graduate from UCL & Caltech, working as a systems engineer at Jump Trading. Before Jump, I was doing computer vision for Video Quality Analysis team at Amazon.