Software 2022 10 13 eipc-api - Electron IPC API Generator
Post
Cancel

eipc-api - Electron IPC API Generator

Process of developing a Node.js library for easily registering namespaced API’s through Electron’s IPC feature.

  1. eipc-api - Electron IPC API Generator
  2. Before/After
    1. Without eipc-api package
      1. electron.js
      2. preload.js
      3. index.html
      4. renderer.js
    2. With eipc-api package
      1. api.js
      2. electron.js
      3. preload.js
      4. index.html
      5. renderer.js
      6. Benefits
  3. How it works
    1. IpcApi class:
      1. Constructor
      2. Methods
        1. generateMeta()
        2. register(ipcMain)
        3. getInvoker(ipcRenderer)

eipc-api - Electron IPC API Generator

When creating electron apps, you have two main process that are needed for the application to work: the main process, and the renderer process. The main process runs in a Node.js environment and has access to all the Node.js native APIs. The renderer process runs in the browser environment, and so does not have access to any of Node’s native APIs. How, then, do we communicate between these two processes to accomplish something like displaying the contents of a directory on the local file system? The answer is to use Electron’s IPC (inter-process communication) functionality, which allows you to send and receive messages between the main and renderer processes.

This package was created to simplify the process of registering two way inter-process communication for electron apps. Registering IPC invokers and handlers in electron is not difficult, but it takes a good amount of boilerplate code, so I wanted to simplify that process, reducing the amount of code and adding some quality of life features.

This library allows you to define a namespaced API by supplying a javascript object containing objects (namespaces) and functions (handlers), and then takes care of automatically registering the invoker and handler functions, and passing back the return data. It also allows you to provide static values which will be provided to the render process as part of the API object. The project README contains a guide on how to use the package, so this article will focus mainly on the implementation details.

Before/After

In this section I will give an example of how this package cleans up boilerplate code and improves maintainability:

Without eipc-api package

Without this package, it is a pretty involved process to register IPC invokers/handlers, especially for projects exposing many ipc methods. The following code is adapted from the example code in Electron’s documentation on two-way process communication.

electron.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const { app, BrowserWindow, ipcMain, dialog } = require('electron')
const path = require('path')

// HANDLER FUNCTION
function openFolder() {
  const filePaths = dialog.showOpenDialogSync({
    properties: ['openDirectory'],
  });
  return filePaths;
}

function createWindow () {
  const mainWindow = new BrowserWindow({
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    }
  })
  mainWindow.loadFile('index.html')
}

app.whenReady().then(() => {
  // REGISTER HANDLER
  ipcMain.handle('dialog:openFolder', openFolder)

  createWindow()
  app.on('activate', function () {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})

app.on('window-all-closed', function () {
  if (process.platform !== 'darwin') app.quit()
})

preload.js

1
2
3
4
5
6
const { contextBridge, ipcRenderer } = require('electron')

// REGISTER INVOKER METHOD(S)
contextBridge.exposeInMainWorld('electronApi', {
  openFolder: () => ipcRenderer.invoke('dialog:openFolder')
})

index.html

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html>
  <head>
    <!-- ... -->
  </head>
  <body>
    <button type="button" id="btn">Open a Folder</button>
    Folder contents: <strong id="folderContents"></strong>
    <script src='./renderer.js'></script>
  </body>
</html>

renderer.js

1
2
3
4
5
6
7
8
const btn = document.getElementById('btn')
const folderContentsElement = document.getElementById('folderContents')

btn.addEventListener('click', () => {
  // CALL IPC METHOD
  const folderContents = window.electronAPI.openFolder();
  folderContentsElement.innerText = folderContents;
})

This is a lot of boilerplate code, and it only increases when you start adding more and more methods. Another downside is having to manually update namespaces in two locations (electron.js and preload.js), providing opportunity for bugs if one file is not kept in sync with the other.

With eipc-api package

The following code will look pretty similar, but will include one additional file that is not included in the previous example: api.js

api.js

In this file we will define all of our handler functions that need to run in the main process, and structure them into an object with namespaces and functions inside those namespaces. Then we will create a new IpcApi object, passing in the api object to the constructor. The IpcApi object will then process the api object and give us easy to use methods that can automatically register handlers on the main thread, as well as provide an invoker object that can be used to invoke these methods from the renderer thread.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const { dialog } = require('electron');
const IpcApi = require('eipc-api');

// HANDLER FUNCTION
function openFolder() {
  const filePaths = dialog.showOpenDialogSync({
    properties: ['openDirectory'],
  });
  return filePaths;
}

const api = {
  dialog: {
    openFolder
  }
}

module.exports = new IpcApi(api);

electron.js

In this file we will now import our api.js module, and call the api.register method, passing in ipcMain as the only argument. api.register() takes care of automatically registering a properly namespaced handler for all of the api methods with a single line of code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')

// IMPORT API MODULE
const api = require('api.js')

function createWindow () {
  const mainWindow = new BrowserWindow({
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    }
  })
  mainWindow.loadFile('index.html')
}

app.whenReady().then(() => {
  // REGISTER HANDLER(S)
  api.register(ipcMain);

  createWindow()
  app.on('activate', function () {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})

app.on('window-all-closed', function () {
  if (process.platform !== 'darwin') app.quit()
})

preload.js

In this file, we now import the api.js module as well. We will call the api.getInvoker method and pass the ipcRenderer as the only argument. Then we expose the returned invoker object to the renderer process.

1
2
3
4
5
6
const { contextBridge, ipcRenderer } = require('electron')
const api = require('api.js')

// REGISTER INVOKER METHOD(S)
const apiInvoker = api.getInvoker(ipcRenderer)
contextBridge.exposeInMainWorld('electronApi', apiInvoker)

index.html

This file is not changed

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html>
  <head>
    ...
  </head>
  <body>
    <button type="button" id="btn">Open a Folder</button>
    Folder contents: <strong id="folderContents"></strong>
    <script src='./renderer.js'></script>
  </body>
</html>

renderer.js

In the render process, we can now call any method from our api object by calling window.api.<namespace>.<method>(). If the method accepts arguments, the invoker will pass the props, and if the handler method returns anything it will be returned by the invoker here as well.

1
2
3
4
5
6
7
8
const btn = document.getElementById('btn')
const folderContentsElement = document.getElementById('folderContents')

btn.addEventListener('click', () => {
  // CALL IPC METHOD
  const folderContents = window.api.dialog.openFolder();
  folderContentsElement.innerText = folderContents;
})

Benefits

It’s not very apparent in this smaller example, but once we have a large number of API methods it becomes very cumbersome to register a new handler and expose a new invoker for each individually, not to mention the issues with having to make sure namespaces are consistent across both. This package allows us to simply add more methods to the object passed to IpcApi, without having to worry about adding code to register them in the electron.js and preload.js files. This decouples all of the api methods from the implementation details of registering them with electrons ipc methods, and allows us to add or modify api methods from a single file. Imagine an api with many methods and namespaces, and how much work would be involved in registering all of the required handlers/invokers:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const api = {
  ns1: {
    method1: function() {},
    method2: function() {},
    method3: function() {},
    method4: function() {},
  },
  ns2: {
    method1: function() {},
    method2: function() {},
    method3: function() {},
    method4: function() {},
  },
  ns3: {
    method1: function() {},
    method2: function() {},
    method3: function() {},
    method4: function() {},
  },
}

In addition to the code maintainability factor, it lets us access the methods on the namespace exactly as they were defined in the api declaration. For example, in our renderer process, any of the following would be valid:

1
2
3
window.api.ns1.method1()
window.api.ns2.method2()
window.api.ns3.method4()

How it works

This section will get into the implementation details of the package, and how it works. All the important stuff happens in the lib/IpcApi.js file.

IpcApi class:

Constructor

The IpcApi class accepts three parameters:

  • api - The api object to parse and generate handlers/invokers for. The api object can also include any number of base properties/methods. Base properties can be passed directly to the renderer process to share static data between the processes. Base methods will be processed just like namespace methods, just without a namespace. By default, base methods will be ignored, and base properties will be ignored unless explicitly specified in the config.includeBaseProperties array.
  • config - optional configuration specifying how to handle methods and properties that are not part of a namespace. The different config options and their default values are explained in the README
  • debug - A boolean value indicating whether the library should print debug messages about it’s operation to the console.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const IpcApi = function(api, config, debug = false) {
    let self = this;
    self.api = api;
    self.config = {
        ...defaultConfig,
        ...config
    }
    self.meta = [];
    self.dbg = debug;
    self.debug = function(obj) {
        if(self.dbg) console.log(obj);
    }

    self.generateMeta = function () {
        ...
    }

    self.register = function (ipcMain) {
        ...
    }

    self.getInvoker = function (ipcRenderer) {
        ...
    }

    self.generateMeta();
    self.debug(self.meta);
}

Methods

There are 4 instance methods defined in this class:

  • debug(obj) - Prints obj to the console if the debug constructor parameter was true
  • generateMeta() - Generates an array of meta objects describing the individual entries on the api object.
  • register(ipcMain) - Registers all the api methods to the provided ipcMain instance.
  • getInvoker(ipcRenderer) - Returns an object that has the same structure as the provided api object, but with augmented methods that handle calling ipcRenderer.invoke(...) and passing params/return values automatically.

generateMeta()

This method is responsible for generating a list of all the object and methods on the api object, and sorting that data in the self.meta instance variable for use by the register() and getInvoker() methods.

We start by getting all of the api object’s keys, and looping through each one of them (line 6-7). Then for each key, we determine if it is a namespace or a base method/property:

  • If it is a namespace, we will loop through the sub-objects keys and generate a meta entry for each of them.
  • If it is an object that’s not a namespace, we will check if it is specified in the config.includeBaseProperties array. If so, we will add it to the meta as a static object, if not we will ignore it.
  • If it is a function, we will check if the includeBaseMethods config option is specified. If so, we include it in the meta as a base method, otherwise it is ignored.
  • If it is not an object or a function, we will again check the config.invludeBaseProperties array, and include/ignore it depending on if it is specified there or not.

Each meta object will consist of the following properties:

  • namepsace - the function/properties namespace if applicable.
  • key - the name of the function/property
  • value the raw value of the function/property
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
self.generateMeta = function () {
    self.debug({
        config,
        api: self.api
    })
    Object.keys(self.api)
    .forEach(key => {
        self.debug(`-- generating meta for ${key}`);
        const propType = typeof(self.api[key])

        if(propType === 'object') { // if property is an object, could be property or namespace
            if(isNamespace(self.api[key])) {
                // if it's a namespace, add meta entry
                self.debug('found namespace, generating meta objects');
                Object.keys(self.api[key]).forEach(subKey => {
                    let meta = {
                        namespace: key,
                        key: subKey,
                        value: self.api[key][subKey]
                    }
                    self.meta.push(meta)
                })
            } else if(self.config.includeBaseProperties.includes(key)) {
                // if it's not a namespace, but key is configured as base property, add meta
                self.debug('found included base property (object)');
                self.meta.push({
                    namespace: null,
                    key: key,
                    value: self.api[key],
                })
            }
        } else { // if not object, property could be function or base property
            if(propType === 'function' && self.config.includeBaseMethods) {
                // if base functions are allowed, add meta
                self.debug('found included base method');
                self.meta.push({
                    namespace: null,
                    key: key,
                    value: self.api[key],
                })
            }
            if(self.config.includeBaseProperties.includes(key)) {
                // if key is configured as base property, add meta
                self.debug('found included base property (non-object)');
                self.meta.push({
                    namespace: null,
                    key: key,
                    value: self.api[key],
                })
            }
        }
    })
}

register(ipcMain)

This function is pretty simple, all it does is loop through each meta entry to find functions that should be included in the exposed api, and if so it registers a namespaced handler on the provided ipcMain object.

1
2
3
4
5
6
7
8
self.register = function (ipcMain) {
    self.meta.forEach(({namespace, key, value}) => {
        if(namespace !== null || typeof(value) === 'function' && self.config.includeBaseMethods) {
            // register namespaced function
            ipcMain.handle(normalizeKey(namespace, key), value);
        }
    })
}

getInvoker(ipcRenderer)

This method is responsible for generating the api invoker object that will be exposed to the renderer process. It loops through each meta object and checks if it is a method or object. If it is a method, we create a wrapper function that accepts a props object and calles ipcRenderer.invoke() with the namespaced method name and the provided props.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
self.getInvoker = function (ipcRenderer) {
    let invoker = {}
    self.debug('generating invoker');
    self.meta.forEach(({namespace, key, value}) => {
        self.debug({namespace, key, value});
        // if namespace is not null, this is a namespaced method
        if(namespace !== null) {
            self.debug('found namespace')
            // create namespace if it does not exist on invoker
            if(invoker[namespace] === undefined) {
                invoker[namespace] = {}
            }
            //generate invoker method
            invoker[namespace][key] = (props) => ipcRenderer.invoke(normalizeKey(namespace, key), props);
        // if namespace is null, it could be an object or
        } else {
            if(typeof(value) === 'function' && self.config.includeBaseMethods) {
                self.debug('found base function')
                // register base function invokers if configured
                invoker[key] = (props) => ipcRenderer.invoke(normalizeKey(namespace, key), props);
            } else if (self.config.includeBaseProperties && self.config.includeBaseProperties.includes(key)) {
                self.debug('found base property')
                // add base properties to the invoker object
                invoker[key] = value;
            }
        }
    })
    return invoker;
}
This post is licensed under CC BY 4.0 by the author.