Simplifying IPC in Electron

You have an Electron app with two processes, main and renderer. (For the uninitiated, the main process is responsible for launching new BrowserWindows – renderer processes. Renderer processes run JavaScript defined in the webpages. More here.)

You likely also have some IPC code for communicating between the two processes.

renderer/index.js:

import { ipcRenderer } from 'electron'

await ipcRenderer.invoke('addNumbers', 69)
await ipcRenderer.invoke('subNumbers', 42)

main/index.js:

import { ipcMain } from 'electron'

ipcMain.handle('addNumbers', (event, arg) => {
  return arg + 42
})
ipcMain.handle('subNumbers', (event, arg) => {
  return 69 - arg
})

// window creation etc.

This is fine for two methods but when you have a couple dozen, the IPC logic can easily turn into spaghetti code and you’ll end up writing boilerplate glue code for each method.

What if you could call functions in the main process directly from the renderer process as if you had written them in the renderer process?

Instead of the above boilerplatey IPC logic, all you need to do is move all your functions that you want to access from the renderer process in a separate module, not thinking about IPC, and write a simple RPC layer using Proxy that routes all function calls through IPC under the hood:

renderer/index.ts:

import { ipcRenderer } from 'electron'

export const mainFns = new Proxy({}, {
  get: (target, key) =>
    (...args: any[]) =>
      ipcRenderer.invoke('CALL_EXPOSED_MAIN_FN', { methodName: key, args }),
})

main/exposed-fns.ts:

// this executes in the main process
export default {
  getProcessType: () => process.type,
  addNumbers: (arg: number) => arg + 42,
  subNumbers: (arg: number) => 69 - arg,
}

main/index.ts:

import { app, BrowserWindow, ipcMain } from 'electron'
import mainFns from './exposed-fns'

ipcMain.handle('CALL_EXPOSED_MAIN_FN', (event, { methodName, args }) =>
  mainFns[methodName](...args)
)

// window creation etc.

That’s all. Now you can call all functions defined in main/exposed-fns.ts easily with the mainFns proxy object:

await mainFns.getProcessType() // returns "browser"

Type checking

You can have full type checking by remapping the exported functions to return a promise.

types.ts:

<pre class="wp-block-syntaxhighlighter-code">type AnyFunction = (...args: any[]) => any
type Async = ReturnType extends Promise
  ? F
  : (...args: Parameters) => Promise<ReturnType>

export type Promisified = { [K in keyof T]: T[K] extends AnyFunction ? Async : never }</pre>

renderer/index.ts:

import { ipcRenderer } from 'electron'
import type mainFnsType from '../main/exposed-fns'
import type { Promisified } from '../types'

type ExportedFunctionsType = typeof mainFnsType

export const mainFns = new Proxy({}, {
  get: (target, key) =>
    (...args: any[]) =>
      ipcRenderer.invoke('CALL_EXPOSED_MAIN_FN', { methodName: key, args }),
}) as Promisified

// call main functions seamlessly
mainFns.getProcessType()
  .then(x => console.log('getProcessType', x))

Passing callbacks

With the above, all arguments cloneable with the structured clone algorithm can be passed to the exposed functions. Notable exception is functions which cannot be cloned. Instead, you can pass a reference to them like this.

Calling functions in renderer process from main process

Electron doesn’t have an equivalent of ipcRenderer.invoke for the renderer processes so there’s more logic involved to handle the request and response.

main/renderer-fns-bridge.ts:

import { ipcMain, BrowserWindow } from 'electron'
import type rendererFnsType from '../renderer/exposed-fns'
import type { Promisified } from '../types'

type ExportedFunctionsType = typeof rendererFnsType

export default function getRendererFnsBridge(window: BrowserWindow) {
  const requestQueue = new Map()

  ipcMain.on('EXPOSED_RENDERER_FN_RESULT', (_, { reqID, result, error }) => {
    const promise = requestQueue.get(reqID)
    if (error) promise?.reject(new Error(error.message))
    else promise?.resolve(result)
    requestQueue.delete(reqID)
  })

  let reqID = 0
  const rendererFns = new Proxy({}, {
    get: (target, key) =>
      (...args: any[]) =>
        new Promise((resolve, reject) => {
          requestQueue.set(++reqID, { resolve, reject })
          window.webContents.send('CALL_EXPOSED_RENDERER_FN', {
            reqID,
            methodName: key,
            args,
          })
        }),
  })
  return rendererFns as Promisified
}

renderer/ipc.ts:

import { ipcRenderer } from 'electron'
import rendererFns from './exposed-fns'

ipcRenderer.on('CALL_EXPOSED_RENDERER_FN', async (_, { reqID, methodName, args }) => {
  try {
    const result = await rendererFns[methodName](...args)
    ipcRenderer.send('EXPOSED_RENDERER_FN_RESULT', { reqID, result })
  } catch (err) {
    ipcRenderer.send('EXPOSED_RENDERER_FN_RESULT', { reqID, error: { message: err.message } })
  }
})

main/index.ts:

import getRendererFnsBridge from './renderer-fns-bridge'

// after window is created, call renderer functions seamlessly from main process
const rendererFnsBridge = getRendererFnsBridge(window)

rendererFnsBridge.getProcessType()
  .then(x => console.log('getProcessType', x))

Here’s an example repo implementing all of the above.

This was inspired by electron/remote, check it out if you want to handle more complex cases and proxy entire objects. This RPC implementation is of course not just limited to Electron, you can use it for client-server communication over HTTP and WebSockets too, as an alternative to REST/GraphQL.


Leave a Reply

Discover more from Texts.blog, the blog of Texts.com

Subscribe now to keep reading and get access to the full archive.

Continue reading