Process of developing a Node.js library for easily registering namespaced API’s through Electron’s IPC feature.
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. Theapi
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 theconfig.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 READMEdebug
- 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)
- Printsobj
to the console if thedebug
constructor parameter was truegenerateMeta()
- Generates an array of meta objects describing the individual entries on the api object.register(ipcMain)
- Registers all the api methods to the providedipcMain
instance.getInvoker(ipcRenderer)
- Returns an object that has the same structure as the providedapi
object, but with augmented methods that handle callingipcRenderer.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/propertyvalue
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;
}