Electron interprocess communication

This post originally began as something quick to help people who were beginning to work with Electorn, but as I kept writing — I kept learning neat histories and tidbits that I also thought were worth sharing. This post is still focused on Electron, but I think it takes some fun twists and turns. Exploring the references and recommended materials is well worth your time, in my opinion.


When I began working with Electron, it wasn't clear to me the roles that the renderer or the main process played and how or why I would communicate between them. As it turns out, understanding these two processes are the basis for building any Electron application. This post will look at interprocess communication, models of interprocess communication, and its application in Electron. Finally, we'll end with a basic game of interprocess communication ping pong.

What is interprocess communication

Interprocess communication (IPC) isn't something specific to Electron but rather a concept that finds its origin in prior to even Unix's birth. It's not clear when the term Interprocess Communication was coined, but the idea of passing data between one program or process dates back to 1964 [1] on a typewritten piece of paper where Douglas McIlroy describes what would become Unix pipes in the third edition of Unix in 1973.[2]

We should have some ways of coupling programs like garden hose--screw in another segment when it becomes when it becomes necessary to massage data in another way.

He also uses the expression, "for buggering around with" which I've never heard but fully appreciate.

We can get a glimpse of McIlroy's vision and IPC using employing Ken Thompson brought together in "one feverish night" [3] by using the pipeline operator (|) to take the output of one program and direct it to another. A command that you're probably familiar with is ls which is short for list. We can combine the output of ls with another program like grep, a search by regex tool, using pipes.

ls | grep .js

Pipes are just one form of IPC in unix systems and there are many more such as signals, message queues, semaphores, and shared memory[4]. Windows also has pipes and many of its own IPC implementations, so it should be no surprise that the browser has its own as well.

If you've used the browser's postMessage API, then you've facilitated communication between different browser windows windows using Cross-document Messaging which isn't in seperate processes (as of now), but does share similar characteristics with IPC.

Communication in Electron follows a different pattern that can be found in both peripheral and software architectures — a bus[5] and a pipe-like API, the browser's Channel Messaging API. The channel messaging API "...allows two separate scripts running in different browsing contexts attached to the same document ... to communicate directly, passing messages between one another through two-way channels (or pipes) with a port at each end." [6]

Like most good ideas in Computer Science, the good ones are recycled and pipes are no exception as we see the Channel Messaging API modeling a near 60-year old idea.

IPC in Electron

In Electron, there are two processes that we are concerned with: main and renderer. IPC for these processes are facilitated through ipcMain (main process) and ipcRenderer (renderer process).

You can't go far with Electron's IPC without talking about Node's EventEmitter class and since this has been covered elsewhere, I'm going to continue focusing on the Electron's IPC implementation.

In my opinion, there are two architectures at play:

  1. The event system: The message-bus/pub-sub like nature of Electron's ipcMain and ipcRenderer inherit from Node's EventEmitter class.
  2. The mechanism for sending data: A unix-like message passing system managed by the Channel API's MessageChannel that facilitates sending

The event system allows us to publish messages to a channel by listening for data emitted to a channel. A channel is a string that ipcMain and ipcRenderer use to emit and receive events/data on.

This, generally, follows this form:

// Receiving a message // // EventEmitter: ipcMain / ipcRenderer EventEmitter.on("string", function callback(event, messsage) {}); // Sending a message // // EventEmitter: win.webContents / ipcRenderer EventEmitter.send("string", "mydata");

Pay careful attention that send is not bound on ipcMain and instead is bound to win.webContents. See the docs for more info.

Also, it goes without saying that you can only send a message from a process's respective handler. So, if you're in the renderer process, you should be using ipcRenderer.send to send messages to the main process and using win.webContents.send to send messages from the main process to renderer process(es).

Basics of an electron program

In an Electron project, the package.json specifies a JavaScript file that Electron treats as your main process. The below package.json will expect an index.js file to exist in the current directory since the start script's context is the current directory.

{ "name": "basic", "version": "0.0.0", "main": "index.js", "scripts": { "start": "electron ." }, "dependencies": { "electron": "^8.2.5" } }

Creating the renderer process

Inside of the main process, you can create an instance of BrowserWindow which will run its own render process. Creating a basic render process follows these steps:

  • Create a hidden browser window
  • Load an HTML file
  • Show the window once ready-to-show is emitted from the BrowserWindow instance.

Here is a minimal main process that creates a browser window

const { app, BrowserWindow, ipcMain } = require("electron"); const path = require("path"); const url = require("url"); function onAppReady() { // Create the window const mainWindow = new BrowserWindow({ height: 800, width: 800, show: false, }); // Load main.html in current directory mainWindow.loadURL( url.format({ pathname: path.join(__dirname, "main.html"), protocol: "file", slashes: true, }) ); // Once electron 'ready-to-show' is emitted, show mainWindow mainWindow.once("ready-to-show", function showMainWindow() { mainWindow.show(); }); } app.on("ready", onAppReady);

Communicating between the main process and renderer process

Communication between the main and renderer process is surprisingly easy. I think most use-cases will be from renderer to the main process, so I'll be focusing on that.

To send from the renderer to the main process you can use the send method from the ipcRenderer module. It takes two arguments a channel as a string and an array of arguments as the second parameter.

Though it should be noted that the docs indicate an array, I've found this to also work with just strings or integers.

ipcRenderer.send(channel, ...args)
  • channel: String
  • ...args: any[]

Send an asynchronous message to the main process via channel, along with arguments. Arguments will be serialized with the structured clone algorithm, just like postMessage, so prototype chains will not be included. Sending Functions, Promises, Symbols, WeakMaps, or WeakSets will throw an exception.

Inside the renderer process:

const { ipcRenderer } = require("electron"); document.addEventListener("DOMContentLoaded", function () { ipcRenderer.send("my_channel", "my data"); });

And, in your main process you can receive this:

app.on("ready", function onReady() { // Code to load renderer process omitted here. Refer to above // Receive message ipcMain.on("my_channel", function (event, arg) { // prints "my data" console.log(arg); }); });

Some notes:

  • I like to use enums or objects that are shared across the renderer and the main process to prevent any errors.
const IPC_EVENTS = { READY: "ready", READY_TO_SHOW: "ready-to-show", };
  • The "my data" string could be an object, array, or integer, but there are limitations. Do take heed to the documentation excerpt from above for send:
    • Sending Functions, Promises, Symbols, WeakMaps, or WeakSets will throw an exception.

IPC ping pong

Taking everything we've learned here, we can build a basic game of ping/pong message passing. You can find the code for this demo here

output3

Addendum

I'd like to thank all of the Electron contributors for all of their work over the years. Visual Studio Code has been one of my favorite developments over the past five years and it's only possible with Electron. As a result of Electron, using Linux as a daily driver is now effortless.

I'd especially like to thank Cheng Zhao for his work on Electron and for also taking time to look over this. One thing he did note was the addition of the MessagePort API's coming in Electron v10. For a description of the upcoming additions, take a look at Jeremy Apthorp's pull request (#22404) where he goes over the changes. You can also view the proposal here.

Recommended Materials


References

1 - McIlroy, Douglas (October 11, 1964). The Origin of Unix Pipes

2 - Research Unix. (n.d.).

3 - McIlroy, Douglas (n.d.). A Research UNIX Reader: Annotated Excerpts from the Programmer’s Manual, 1971-1986, pg. 9

4 - Rusling, David (1996). Interprocess Communication Mechanisms

5 - PCMag Encyclopedia: Bus

6 - message-channel-main.md contributors as of commit 1d15839 (April 2, 2020) - Message Channel Main

© Nick Olinger 2023