--- sidebar_label: 'Example: Electron Shell' sidebar_position: 3 --- # 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](./messaging.md). --- ## Layers ```mermaid flowchart TD WP["web page\n(knows nothing about the shell —\nloads and calls plugins, nothing else)"] PC["plugin code\n(communicates with the shell\nvia window.__foopBridge only)"] SH["shell main process"] FB["FrontendBridge"] WP <-->|"loads / runs"| PC PC <-->|"window.__foopBridge\n(injected via contextBridge\nbefore the page loads)"| SH SH <-->|"ZeroMQ"| FB ``` 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: ```typescript 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. ```typescript // 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, // 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 | Direction | Channel name | Description | |---|---|---| | Renderer → Main | `foop:bridge:send` | Fire-and-forget message from plugin to backend | | Renderer → Main | `foop:bridge:send-with-response` | Plugin sends and awaits a reply (requestId generated by main) | | Main → Renderer | `foop:bridge:message` | Backend pushes a message to all listeners in the renderer | --- ## IPC implementation ### `sendMessage` — fire-and-forget (renderer → main → ZeroMQ) ```typescript // 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) ```typescript // 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 { 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. ```typescript // 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](../frontend-plugins/plugins-intro.md) and the section on [adding services from a plugin](../frontend-plugins/services/services-overview.md). --- ## 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. ```js // 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: ```js 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. ```typescript // 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/ / / .js ``` The web page loads them as: ```js 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. --- ## Navigation events 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: ```typescript // 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 ```typescript // 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`.