Skip to main content

Electron Shell Example

note

Information in this article is in preview status. Do not use information in this article without being in contact with Future Ordering.

This document describes how to build a shell using Electron that connects to FrontendBridge over ZeroMQ and exposes the plugin API to web content. It is a companion to MESSAGING.md.


Layers

The web page is unaware of the shell. It imports the plugin JS file and calls the exported function — that is all. The plugin communicates exclusively through window.__foopBridge. If the shell changes (e.g. Electron → UWP WebView2), only the __foopBridge implementation needs updating — plugin code does not change.


Part 1 — Shell implementation: wiring window.__foopBridge

This section is for shell developers implementing the Electron shell. It describes how to expose window.__foopBridge to the renderer and connect it to the ZeroMQ transport.


Window security configuration

Set these webPreferences on every BrowserWindow that hosts web content:

new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, // required for contextBridge
nodeIntegration: false, // never expose Node.js to the renderer
sandbox: true, // default since Electron 20 — state explicitly
},
})

contextIsolation: true is required for contextBridge to work. sandbox: true should be stated explicitly even though it is the default, so it does not silently regress if the Electron version changes.


window.__foopBridge API

The shell must expose window.__foopBridge to the renderer via contextBridge before any page code runs. This is the only surface plugin code uses to communicate with the backend.

// preload.ts
contextBridge.exposeInMainWorld('__foopBridge', {

// Send a message to the backend. Fire-and-forget.
sendMessage(msg: object): void,

// Send a message to the backend and await a reply.
// The shell attaches a requestId internally and resolves when the matching response arrives.
sendMessageWithResponse(msg: object): Promise<unknown>,

// Register a listener for messages pushed from the backend.
// Returns a disposable — call dispose() to stop receiving.
addMessageListener(handler: (msg: object) => void): { dispose(): void },

});

IPC channel names

DirectionChannel nameDescription
Renderer → Mainfoop:bridge:sendFire-and-forget message from plugin to backend
Renderer → Mainfoop:bridge:send-with-responsePlugin sends and awaits a reply (requestId generated by main)
Main → Rendererfoop:bridge:messageBackend pushes a message to all listeners in the renderer

IPC implementation

sendMessage — fire-and-forget (renderer → main → ZeroMQ)

// main.ts
ipcMain.handle('foop:bridge:send', async (_event, payload: { msg: { type: string; data: unknown } }) => {
const { type, data } = payload.msg;
await publishToZeroMQ('JavascriptResultMessage', {
functionName: type,
argumentsJson: JSON.stringify(data),
});
});

// preload.ts
sendMessage(msg: object): void {
ipcRenderer.invoke('foop:bridge:send', { msg }).catch(console.error);
},

sendMessageWithResponse — request/response (renderer → main → ZeroMQ → main → renderer)

// main.ts
ipcMain.handle('foop:bridge:send-with-response', async (_event, payload: { msg: { type: string; data: unknown }; requestId: string }) => {
const { msg, requestId } = payload;
const { type, data } = msg;
await publishToZeroMQ('JavascriptResultMessage', {
functionName: type,
argumentsJson: JSON.stringify(data),
requestId,
});
// The reply arrives as an InvokeJavascriptMessage with the same requestId.
// Main process must match it and push it back to the renderer via foop:bridge:message.
});

// preload.ts
sendMessageWithResponse(msg: object): Promise<unknown> {
const requestId = crypto.randomUUID();
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Response timeout')), 5000);
const listener = (_event: Electron.IpcRendererEvent, payload: { msg: object; requestId: string }) => {
if (payload.requestId === requestId) {
clearTimeout(timeout);
ipcRenderer.removeListener('foop:bridge:message', listener);
resolve(payload.msg);
}
};
ipcRenderer.on('foop:bridge:message', listener);
ipcRenderer.invoke('foop:bridge:send-with-response', { msg, requestId });
});
},

addMessageListener — backend pushes to renderer (ZeroMQ → main → renderer)

The main process cannot call the renderer via invoke. It uses webContents.send to push messages in, and the renderer's listeners handle them.

// main.ts — called when InvokeJavascriptMessage arrives from ZeroMQ
// requestId is passed for sendMessageWithResponse matching; stripped before delivery to handlers.
function pushMessageToRenderer(win: BrowserWindow, msg: { type: string; data: unknown }, requestId?: string): void {
win.webContents.send('foop:bridge:message', { msg, requestId });
}

// preload.ts
addMessageListener(handler: (msg: object) => void): { dispose(): void } {
const listener = (_event: Electron.IpcRendererEvent, payload: { msg: object; requestId?: string }) => {
if (payload.requestId) return; // response to sendMessageWithResponse — not a pushed event
handler(payload.msg);
};
ipcRenderer.on('foop:bridge:message', listener);
return {
dispose() {
ipcRenderer.removeListener('foop:bridge:message', listener);
},
};
},

Part 2 — Plugin authoring

Note: This section contains only a minimal example to illustrate how the shell wires up to plugin code. For full plugin development guidance - message types, lifecycle, error handling, and conventions - refer to the dedicated plugin documentation and the section on adding services from a plugin.


Plugin format

A plugin is a JS module that exports a single async function as its default export. It takes no arguments — all communication goes through window.__foopBridge, which the shell has already injected by the time the plugin runs.

// my-plugin/1.0.0/my-plugin.js
export default async function() {

// Listen for messages pushed from the backend
window.__foopBridge.addMessageListener((msg) => {
if (msg.type === 'someBackendEvent') {
// handle it
}
});

// Fire-and-forget to the backend
window.__foopBridge.sendMessage({ type: 'pluginReady' });

// Send and await a reply from the backend
const result = await window.__foopBridge.sendMessageWithResponse({
type: 'startPayment',
data: { orderId: 'ord-42', amount: 1500, currencyIsoCode: 'SEK' },
});
}

Page loader

The page loads the plugin and calls it:

const mod = await import('/plugins/my-plugin/1.0.0/my-plugin.js');
await mod.default();

Plugin serving

Serve plugin files from a local Express HTTP server bound to 127.0.0.1. Never bind to 0.0.0.0 — plugins are local-only.

// main.ts
import express from 'express';
import * as path from 'path';

const app = express();
app.use('/plugins', express.static(path.join(__dirname, '..', 'plugins')));

const server = app.listen(3000, '127.0.0.1');
server.on('error', (err) => console.error('[plugins] server error:', err.message));
server.on('listening', () => console.log('[plugins] listening on http://127.0.0.1:3000'));

Plugin files are placed under:

plugins/
<pluginName>/
<version>/
<pluginName>.js

The web page loads them as:

import('/plugins/my-plugin/1.0.0/my-plugin.js')

Add a path-traversal guard if you write a custom router (do not use .. in resolved paths). Use fs.promises.access for async existence checks — fs.existsSync blocks the event loop.


Hook both did-navigate (full-page loads) and did-navigate-in-page (hash/anchor navigations) to send NavigatedToMessage. Both events fire for bridge-triggered navigations too, so suppress the duplicate:

// navigation/handler.ts (excerpt)
let bridgeNavigationsInFlight = 0;

// In handleNavigateTo:
bridgeNavigationsInFlight++;
win.webContents.loadURL(url).then(() => {
bridgeNavigationsInFlight--;
sendNavigatedTo(url, requestId); // with echoed requestId
}).catch(() => { bridgeNavigationsInFlight--; });

// In main.ts did-navigate handler:
win.webContents.on('did-navigate', (_event, url) => {
if (navigationHandler.isBridgeNavigation()) return; // suppress duplicate
sendNavigatedTo(url, null);
});

isBridgeNavigation() returns bridgeNavigationsInFlight > 0. A counter rather than a boolean correctly handles overlapping navigations — a single boolean would reset too early if a second bridge navigation started before the first resolved.

Without the suppression, every bridge-triggered navigation produces two NavigatedToMessage events: one with a requestId (from the handler) and one with null (from did-navigate).


App lifecycle

// Handle macOS: keep process alive after last window closes (standard Mac behaviour)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});

// Cleanup on quit — will-quit fires after all windows close successfully.
// Use will-quit (not before-quit) because before-quit can be cancelled
// by a beforeunload dialog in the renderer.
app.on('will-quit', () => {
teardownPluginIpc();
transport.close();
});

Key constraints

  • Functions cannot cross IPC. Plugin handlers must stay in the renderer. Only serializable data (JSON) crosses the IPC boundary.
  • The web page knows nothing about the shell. It only loads plugins and calls the exported function. All shell communication goes through window.__foopBridge.
  • window.__foopBridge is the portability boundary. Swapping Electron for UWP WebView2 means reimplementing __foopBridge — plugin code does not change.
  • Shell → renderer uses push (webContents.send), not invoke. Only the renderer can initiate an invoke; the shell pushes via webContents.send and the renderer's addMessageListener handlers fire.
  • requestIds are managed by the shell, not the plugin. Plugins call sendMessageWithResponse without knowing about requestIds — the shell generates, attaches, and matches them internally.
  • ipcMain.handle registrations must be cleaned up. Calling ipcMain.handle on an already-registered channel throws. Keep a reference to the teardown function and call it on will-quit.