Skip to main content

FrontendBridge Messaging

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 the messages exchanged between FrontendBridge and the Shell over the ZeroMQ pub/sub bus. It is the primary reference for external integrators.

Code examples use TypeScript for shell/transport code and JavaScript for plugin and page loader code (plugins are plain ES modules with no build step).


Vocabulary

TermDefinition
shellThe Electron app (or equivalent) that hosts and displays the web page. May not literally be Electron, but is treated as such throughout this document.
FrontendBridgeThe .NET module running inside the FO.OP host process that is the other party in all communication described here. It manages navigation state, settings, and plugin invocation on the backend side.
WebViewThe embedded browser component inside the shell that loads and renders the web page. The shell navigates the WebView in response to bridge commands and reports URL changes back to the bridge.
pluginA JavaScript module loaded by the web page at runtime via dynamic import(). Plugins communicate with the backend exclusively through window.__foopBridge and have no knowledge of the underlying ZeroMQ transport.
requestIdA UUID generated by the sender of a request message. The receiver echoes it back in the response, allowing the original sender to match the response to its outstanding request.

Transport

The bus uses ZeroMQ pub/sub brokered through an XSUB/XPUB proxy running inside the FO.OP host process. The ports are fixed and cannot be changed.

Ports

PortAddressPurpose
42421tcp://127.0.0.1:42421Publish — connect your PUB socket here to send messages
42422tcp://127.0.0.1:42422Subscribe — connect your SUB socket here to receive messages

Both ports are bound to 127.0.0.1. No authentication or encryption is used or expected.

Concurrency

Exactly one shell should be connected at a time. Multiple shells are not prevented by the transport but are unsupported and will produce undefined behaviour.

Wire frame layout

Every message on the bus uses three frames when received and two frames when sent:

Sending (shell → bus):    [frame1: topic (string)] [frame2: JSON (string)]
Receiving (bus → shell): [frame1: topic (string)] [frame2: seqId (4-byte LE int32)] [frame3: JSON (string)]

The proxy inserts the sequence ID frame automatically. The sequence ID increments per topic and starts at 1. Implementors must read this frame but may discard it — its purpose is internal to the proxy.

Connecting from the Shell

Install the ZeroMQ bindings:

npm install zeromq

Create one SUB socket (receive) and one PUB socket (send):

import * as zmq from 'zeromq';

const sub = new zmq.Subscriber();
const pub = new zmq.Publisher();

sub.connect('tcp://127.0.0.1:42422'); // receive from XPUB port
pub.connect('tcp://127.0.0.1:42421'); // send to XSUB port

Reconnection

If the ZeroMQ connection is lost and re-established, the shell should send a GetDesiredUrlMessage to resync URL state. The bridge may have changed the desired URL while the shell was disconnected. The bridge is fully stateless with respect to connections — it will simply receive the message as a normal request.

Topic reference

TopicDirectionWhen
NavigateToMessageBridge → ShellAlways subscribe
DesiredUrlResponseMessageBridge → ShellAfter sending GetDesiredUrlMessage (or always)
InvokeJavascriptMessageBridge → ShellAlways subscribe
NavigatedToMessageShell → BridgeEvery time the WebView URL changes
GetDesiredUrlMessageShell → BridgeOn startup or reconnect
JavascriptResultMessageShell → BridgeAfter JS invocation, or on unsolicited JS events

Subscribing to a topic

Subscribe by the exact message type name (the string value in the messageType field). Then read three frames per message:

sub.subscribe('NavigateToMessage');
sub.subscribe('DesiredUrlResponseMessage');
sub.subscribe('InvokeJavascriptMessage');

for await (const [topicFrame, seqIdFrame, jsonFrame] of sub) {
const topic = topicFrame.toString();
const _sequenceId = seqIdFrame.readInt32LE(0); // proxy artifact — may be discarded
const envelope = JSON.parse(jsonFrame.toString());
const body = envelope.body;
// dispatch on topic...
}

Publishing a message

Send two frames — topic and JSON. Build the envelope, then publish:

interface Sender {
name: string;
version: string;
}

interface FoopMessage<TBody> {
sender: Sender;
envelopeVersion: number;
messageType: string;
messageTypeVersion: number;
body: TBody;
}

const SENDER: Sender = { name: 'shell', version: '1.0.0.0' };

function buildEnvelope<TBody>(messageType: string, body: TBody): FoopMessage<TBody> {
return { sender: SENDER, envelopeVersion: 0, messageType, messageTypeVersion: 0, body };
}

async function publish<TBody>(messageType: string, body: TBody): Promise<void> {
const json = JSON.stringify(buildEnvelope(messageType, body));
await pub.send([messageType, json]);
}

// Example:
await publish('NavigatedToMessage', { url: 'https://example.com/store/22/start', requestId: null });

Envelope Format

All messages on the bus share the same JSON envelope, regardless of direction or layer.

Schema

{
"sender": {
"name": "string",
"version": "string (dotted quad, e.g. 1.0.0.0)",
},
"envelopeVersion": 0,
"messageType": "string (message type identifier, e.g. \"NavigateToMessage\")",
"messageTypeVersion": 0,
"body": {}
}

Field descriptions

FieldTypeRequiredDescription
sender.namestringYesLogical name of the sending module (e.g. "frontendbridge", "shell", "FO.Kiosk.Shell")
sender.versionstringYesVersion of the sending module as a dotted quad
envelopeVersionintegerYesEnvelope schema version. Currently always 0. Reserved for future versioning — no behaviour is defined for non-zero values
messageTypestringYesThe message type identifier (e.g. "NavigateToMessage"). Must match the ZeroMQ topic frame
messageTypeVersionintegerYesBody schema version. Currently always 0. Reserved for future versioning — no behaviour is defined for non-zero values
bodyobjectYesMessage-specific payload. See each message type below

Serialization rules

  • Property names are camelCase in both directions.
  • Deserialization is case-insensitive.
  • null fields are serialized as JSON null.
  • Version strings use dotted quad format (e.g. "1.0.0.0").

TypeScript Implementation

Types

The following TypeScript interfaces cover all messages in the FrontendBridge ↔ Shell protocol.

// ---- Envelope ----

interface Sender {
name: string;
version: string;
}

interface FoopMessage<TBody> {
sender: Sender;
envelopeVersion: number;
messageType: string;
messageTypeVersion: number;
body: TBody;
}

// ---- Bridge → Shell message bodies ----

interface NavigateToBody {
url: string;
requestId: string;
}

interface DesiredUrlResponseBody {
url: string | null;
requestId: string;
}

interface InvokeJavascriptBody {
functionName: string;
argumentsJson: string;
requestId?: string;
}

// ---- Shell → Bridge message bodies ----

type NavigationErrorCategory = 'navigation_failed' | 'ssl_error' | 'access_denied' | 'unknown';

interface NavigationError {
category: NavigationErrorCategory;
/** Implementation-specific error detail, e.g. "net::ERR_NAME_NOT_RESOLVED" */
message: string;
}

interface NavigatedToBody {
/** The URL navigated to, or the URL that was attempted on failure */
url: string;
/** Echoes requestId from NavigateToMessage when navigating in response to a bridge request; null for spontaneous navigations */
requestId: string | null;
/** Present when the WebView failed to load the URL */
error?: NavigationError;
}

interface GetDesiredUrlBody {
requestId: string;
}

interface JavascriptResultBody {
functionName: string;
argumentsJson: string;
/** Echoes requestId from InvokeJavascriptMessage when responding to a bridge request; omitted for unsolicited events */
requestId?: string;
}

Complete Implementation Example

The following shows a minimal but complete setup: connecting the sockets, subscribing to all bridge-originated messages, dispatching on topic, and publishing messages back to the bridge.

import * as zmq from 'zeromq';
import { randomUUID } from 'crypto';

// ---- Setup ----

const sub = new zmq.Subscriber();
const pub = new zmq.Publisher();

sub.connect('tcp://127.0.0.1:42422'); // receive (XPUB port)
pub.connect('tcp://127.0.0.1:42421'); // send (XSUB port)

// Subscribe to all messages the bridge sends to the shell
sub.subscribe('NavigateToMessage');
sub.subscribe('DesiredUrlResponseMessage');
sub.subscribe('InvokeJavascriptMessage');

// ---- Publishing helpers ----

const SENDER: Sender = { name: 'shell', version: '1.0.0.0' };

function buildEnvelope<TBody>(messageType: string, body: TBody): FoopMessage<TBody> {
return { sender: SENDER, envelopeVersion: 0, messageType, messageTypeVersion: 0, body };
}

async function publish<TBody>(messageType: string, body: TBody): Promise<void> {
const json = JSON.stringify(buildEnvelope(messageType, body));
await pub.send([messageType, json]);
}

// Notify the bridge that the WebView has navigated to a new URL.
// Call this on every navigation, including in-page navigations.
// Pass requestId when responding to a NavigateToMessage; pass null for spontaneous navigations.
// Pass error when the WebView failed to load the URL.
async function sendNavigatedTo(
url: string,
requestId: string | null = null,
error?: NavigationError,
): Promise<void> {
const body: NavigatedToBody = { url, requestId, ...(error && { error }) };
await publish('NavigatedToMessage', body);
}

// Ask the bridge for the current desired URL.
// Useful on startup or after a reconnect when local URL state is lost.
// The bridge will respond with DesiredUrlResponseMessage.
async function requestDesiredUrl(): Promise<string> {
const requestId = randomUUID();
const body: GetDesiredUrlBody = { requestId };
await publish('GetDesiredUrlMessage', body);
return requestId; // match against DesiredUrlResponseMessage.requestId
}

// Send a JavaScript result to the bridge.
async function sendJavascriptResult(
functionName: string,
argumentsJson: string,
requestId?: string,
): Promise<void> {
const body: JavascriptResultBody = { functionName, argumentsJson, requestId };
await publish('JavascriptResultMessage', body);
}

// ---- Receive loop ----

async function startReceiving(): Promise<void> {
for await (const [topicFrame, seqIdFrame, jsonFrame] of sub) {
const topic = topicFrame.toString();
const _sequenceId = seqIdFrame.readInt32LE(0); // proxy artifact — may be discarded
const envelope = JSON.parse(jsonFrame.toString()) as FoopMessage<unknown>;
const body = envelope.body;

switch (topic) {
case 'NavigateToMessage': {
const { url, requestId } = body as NavigateToBody;
console.log(`Navigate to: ${url} (requestId: ${requestId})`);
// Navigate your WebView to `url`.
// On success: sendNavigatedTo(url, requestId)
// On failure: sendNavigatedTo(url, requestId, { category: 'navigation_failed', message: '...' })
break;
}

case 'DesiredUrlResponseMessage': {
const { url, requestId } = body as DesiredUrlResponseBody;
if (url === null) {
// Bridge is still starting up — retry after a delay (1000ms is a reasonable default)
setTimeout(() => requestDesiredUrl(), 1000);
} else {
console.log(`Desired URL: ${url} (requestId: ${requestId})`);
// Navigate your WebView to `url`
}
break;
}

case 'InvokeJavascriptMessage': {
const { functionName, argumentsJson, requestId } = body as InvokeJavascriptBody;
console.log(`Invoke JS: ${functionName}(${argumentsJson ?? ''})`);
// Call the function in the WebView and collect the return value.
// Always reply when requestId is present. On JS error, include error details in argumentsJson.
const resultJson = JSON.stringify({ success: true }); // replace with actual JS invocation result
await sendJavascriptResult(functionName, resultJson, requestId);
break;
}

default:
console.warn(`Unhandled topic: ${topic}`);
}
}
}

// ---- Entry point ----

async function main(): Promise<void> {
startReceiving(); // runs indefinitely

// On startup, ask the bridge for the current URL
await requestDesiredUrl();
}

main().catch(console.error);

FrontendBridge ↔ Shell Messages

These messages are exchanged between FrontendBridge and the Shell. They are the primary protocol of this module.

Bridge → Shell

Instructs the shell to navigate its WebView to a given URL. Sent by the bridge on startup (from settings) and whenever the configured URL needs to change. The bridge is solely responsible for deciding when to send this message — the shell has no involvement in that decision.

FieldTypeDescription
urlstringThe URL the shell should navigate to
requestIdstring (UUID)Identifies this navigation request; echoed back in NavigatedToMessage
{
"sender": { "name": "frontendbridge", "version": "1.0.0.0" },
"envelopeVersion": 0,
"messageType": "NavigateToMessage",
"messageTypeVersion": 0,
"body": {
"url": "https://example.com/store/22/start",
"requestId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}

DesiredUrlResponseMessage

Sent in response to GetDesiredUrlMessage. Returns the URL that is currently configured in bridge settings.

FieldTypeDescription
urlstring or nullThe configured FrontendUrl. May be null during early startup — shell should retry after a short delay. No maximum retry count is defined; a reasonable implementation uses incremental backoff starting at ~1000ms
requestIdstring (UUID)Echoes the requestId from the originating GetDesiredUrlMessage
{
"sender": { "name": "frontendbridge", "version": "1.0.0.0" },
"envelopeVersion": 0,
"messageType": "DesiredUrlResponseMessage",
"messageTypeVersion": 0,
"body": {
"url": "https://example.com/store/22/start",
"requestId": "x1y2z3a4-b5c6-d7e8-f9a0-b1c2d3e4f5a6"
}
}

InvokeJavascriptMessage

Instructs the shell to invoke a named JavaScript function inside the loaded web content and report back the result.

FieldTypeDescription
functionNamestringIdentifies the JS function or handler to invoke
argumentsJsonstringThe stringified JSON payload to pass to the handler
requestIdstring (UUID) or omittedWhen present, the shell must reply with a JavascriptResultMessage echoing this value. When absent, the JS is still invoked but any resulting event is treated as unsolicited
{
"sender": { "name": "frontendbridge", "version": "1.0.0.0" },
"envelopeVersion": 0,
"messageType": "InvokeJavascriptMessage",
"messageTypeVersion": 0,
"body": {
"functionName": "setLanguage",
"argumentsJson": "{\"locale\":\"sv-SE\"}",
"requestId": "j1k2l3m4-n5o6-p7q8-r9s0-t1u2v3w4x5y6"
}
}

Shell → Bridge

Notifies the bridge that the shell's WebView has navigated to a URL. Should be sent on every URL change, including in-page navigations, and on navigation failure.

Shell implementation note: Bridge-triggered navigations can produce duplicate NavigatedToMessage events if not guarded. The bridge tolerates duplicates; any shell implementation should suppress them. See ELECTRON.md for an Electron-specific example.

FieldTypeDescription
urlstringThe URL navigated to, or the URL that was attempted on failure
requestIdstring (UUID) or nullEchoes the requestId from the originating NavigateToMessage when navigating in response to a bridge request. null for spontaneous navigations (e.g. in-page navigations, redirects)
errorobject or omittedPresent when the WebView failed to load the URL. Contains category (one of navigation_failed, ssl_error, access_denied, unknown) and message (implementation-specific detail string)
{
"sender": { "name": "shell", "version": "1.0.0.0" },
"envelopeVersion": 0,
"messageType": "NavigatedToMessage",
"messageTypeVersion": 0,
"body": {
"url": "https://example.com/store/22/checkout",
"requestId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}

Spontaneous navigation example:

{
"body": {
"url": "https://example.com/store/22/checkout",
"requestId": null
}
}

Navigation failure example:

{
"body": {
"url": "https://example.com/store/22/start",
"requestId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"error": {
"category": "navigation_failed",
"message": "net::ERR_NAME_NOT_RESOLVED"
}
}
}

GetDesiredUrlMessage

Asks the bridge for the current desired URL. Used on startup or after a reconnect when the shell does not have URL state. The bridge responds with DesiredUrlResponseMessage.

FieldTypeDescription
requestIdstring (UUID)Generated by the shell; echoed back in the response for matching
{
"sender": { "name": "shell", "version": "1.0.0.0" },
"envelopeVersion": 0,
"messageType": "GetDesiredUrlMessage",
"messageTypeVersion": 0,
"body": {
"requestId": "x1y2z3a4-b5c6-d7e8-f9a0-b1c2d3e4f5a6"
}
}

JavascriptResultMessage

Reports the return value of a JavaScript function. Used in two cases:

  • Response to InvokeJavascriptMessage: the bridge asked a JS function to run; this is the reply. requestId echoes the request.
  • Unsolicited event: JavaScript inside the web content fires autonomously (e.g. menuUpdated). requestId is omitted.
FieldTypeDescription
functionNamestringIdentifies the function or event name. For responses, echoes functionName from the originating InvokeJavascriptMessage; for unsolicited events, uses the plugin-defined event name
argumentsJsonstringThe stringified JSON result or event payload. On JS error, include error details here
requestIdstring (UUID) or omittedEchoes requestId from InvokeJavascriptMessage when this is a response. Omitted for unsolicited events

Response to InvokeJavascriptMessage example:

{
"sender": { "name": "shell", "version": "1.0.0.0" },
"envelopeVersion": 0,
"messageType": "JavascriptResultMessage",
"messageTypeVersion": 0,
"body": {
"functionName": "setLanguage",
"argumentsJson": "{\"success\":true}",
"requestId": "j1k2l3m4-n5o6-p7q8-r9s0-t1u2v3w4x5y6"
}
}

Unsolicited event example (JS fires autonomously inside web content):

{
"sender": { "name": "shell", "version": "1.0.0.0" },
"envelopeVersion": 0,
"messageType": "JavascriptResultMessage",
"messageTypeVersion": 0,
"body": {
"functionName": "onPaymentComplete",
"argumentsJson": "{\"orderId\":\"12345\"}"
}
}

Plugin Messaging

Plugins communicate with the backend exclusively through window.__foopBridge, a messaging object injected into the page by the shell before any page code runs. The page itself has no involvement in this communication — it only loads and calls the plugin. Plugins have no knowledge of the underlying transport (ZeroMQ, WebView2 postMessage, etc.) — that is entirely the shell's concern.

Plugin selection is entirely the web content's concern — plugins are configured via the backoffice and loaded by the page at runtime. The shell's only responsibilities are to serve plugin files and inject window.__foopBridge.

Any shell implementation (Electron, UWP WebView2, etc.) must satisfy the window.__foopBridge contract below. See ELECTRON.md for a conforming Electron implementation.

window.__foopBridge contract

Interface

interface FoopBridgeMessage {
type: string;
data?: unknown;
}

interface FoopBridgeDisposable {
dispose(): void;
}

interface FoopBridge {
sendMessage(msg: FoopBridgeMessage): void;
sendMessageWithResponse(msg: FoopBridgeMessage): Promise<unknown>;
addMessageListener(handler: (msg: object) => void): FoopBridgeDisposable;
}

declare const window: Window & { __foopBridge: FoopBridge };

Required behaviors

Injection timing

window.__foopBridge must be available before any page or plugin code runs. The shell must inject it synchronously as part of its preload / world-injection step.

sendMessage(msg)

  • msg must be { type: string, data?: unknown }. data is optional and may be any JSON-serializable value, including primitives.
  • Publishes msg as a JavascriptResultMessage over ZeroMQ with functionName set from msg.type and argumentsJson set to JSON.stringify(msg.data).
  • Fire-and-forget — no return value, no acknowledgement.

sendMessageWithResponse(msg)

  • msg must be { type: string, data?: unknown }. data is optional and may be any JSON-serializable value, including primitives.
  • The shell generates a requestId (UUID v4) internally — the plugin never sees it.
  • Publishes a JavascriptResultMessage over ZeroMQ with that requestId.
  • Returns a Promise that resolves with the response payload when a matching InvokeJavascriptMessage arrives from the bridge with the same requestId.
  • Routing: when an InvokeJavascriptMessage arrives with a requestId that matches a pending sendMessageWithResponse call, it resolves that promise and is not delivered to addMessageListener handlers. Messages without a matching pending call are delivered to listeners as normal.
  • Timeout: implementations should add a timeout. The duration is implementation-defined. On timeout the promise must reject with an error that identifies it as a timeout, so callers can handle it.
  • The shell is responsible for matching responses to outstanding promises and for cleaning up after resolution.

addMessageListener(handler)

  • Registers handler to be called whenever the bridge pushes a message to the plugin (i.e. when an InvokeJavascriptMessage arrives over ZeroMQ that does not match a pending sendMessageWithResponse call).
  • The msg passed to handler is the plain payload object — no requestId, no envelope fields.
  • Returns a FoopBridgeDisposable. Calling dispose() must immediately stop delivery to that handler and release all references to it.
  • Multiple listeners may be registered simultaneously; all must be called for each incoming message.

Isolation guarantee

Plugins must never observe requestIds, ZeroMQ topic names, or message envelope fields. The shell strips all transport-level concerns before delivering to handlers or resolving promises.

Plugin format

A plugin is a JS module that exports a single async function as its default export. It receives no arguments — all backend communication goes through window.__foopBridge. The plugin may also interact with the page through other means (registering services, DOM manipulation, etc.).

// my-plugin/1.0.0/my-plugin.js
export default async function() {
// Listen for messages from the backend
window.__foopBridge.addMessageListener((msg) => {
if (msg.type === 'someBackendEvent') {
// handle it
}
});

// Send a fire-and-forget message
window.__foopBridge.sendMessage({ type: 'pluginReady' });

// Send a message and await the backend's reply
try {
const result = await window.__foopBridge.sendMessageWithResponse({
type: 'startPayment',
data: { orderId: 'ord-42', amount: 1500, currencyIsoCode: 'SEK' },
});
} catch (err) {
// Handle timeout or other errors
}
}

Page loader

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

Communication Patterns

Publish / Subscribe (Fire-and-Forget)

Most messages follow a one-way notification pattern. The sender publishes a message and does not wait for acknowledgement.

  • NavigateToMessage — bridge publishes navigation commands
  • NavigatedToMessage — shell publishes URL changes
  • JavascriptResultMessage (unsolicited) — plugin fires an event without a preceding request

Request / Response (Correlated)

RequestResponseCorrelation Field
GetDesiredUrlMessageDesiredUrlResponseMessagerequestId
InvokeJavascriptMessageJavascriptResultMessagerequestId

Interaction Flows

Normal Startup

The bridge starts first. On startup it immediately sends a NavigateToMessage with the URL from its settings.

  1. Bridge starts, reads FrontendUrl from settings.
  2. Bridge publishes NavigateToMessage with that URL and a new UUID requestId.
  3. Shell receives NavigateToMessage, loads the URL in the WebView.
  4. Shell publishes NavigatedToMessage with the URL it landed on and the requestId.
  5. Bridge receives NavigatedToMessage and records the current URL.

Frontend Restart (Reconnect)

When the shell restarts or reconnects (crash, update, etc.) it does not know which URL to display. It asks the bridge before doing anything.

  1. Shell starts or reconnects, has no URL state.
  2. Shell generates a UUID requestId and publishes GetDesiredUrlMessage.
  3. Bridge reads the current FrontendUrl from settings.
  4. Bridge publishes DesiredUrlResponseMessage echoing the requestId and the URL.
  5. Shell matches the response by requestId, loads the URL.
  6. Shell publishes NavigatedToMessage.

If url is null in the response, the bridge is still starting up. The shell should retry with incremental backoff (1000ms is a reasonable starting interval). The failure policy after repeated nulls is implementation-defined.

Deferred Navigation

The bridge is solely responsible for deciding when to send a NavigateToMessage. In some cases the bridge may defer a navigation until it is safe to proceed — for example, when a purchase is in progress. From the shell's perspective this is transparent: it simply receives a NavigateToMessage when the bridge is ready.

JavaScript Invocation

The bridge can instruct the shell to call a named JavaScript function in the loaded web content.

  1. Bridge publishes InvokeJavascriptMessage with a functionName, argumentsJson, and an optional requestId.
  2. Shell invokes the JavaScript function in the web content with the parsed argumentsJson payload.
  3. When requestId is present, shell always replies with JavascriptResultMessage. On JS error, include error details in argumentsJson.
  4. When requestId is absent, the JS is still invoked but any resulting event is treated as unsolicited.

Unsolicited JavaScript Event

JavaScript running inside the web content may fire events autonomously (e.g. payment completed). The shell captures these and forwards them to the bus.

  1. JavaScript inside the web content fires an event.
  2. Shell captures the event.
  3. Shell publishes JavascriptResultMessage with no requestId to indicate it is unsolicited.
  4. Bridge receives the message and dispatches based on functionName.

Shell ↔ Web Content Bridging

The shell connects the ZeroMQ bus to the web content running inside a WebView. It bridges in both directions: delivering backend-pushed messages to window.__foopBridge listeners, and forwarding plugin-initiated messages back to the bridge as JavascriptResultMessage.

Plugins are loaded by the page at runtime via dynamic import(). Each plugin exports a single async function as its default export; the page calls it with no arguments. The plugin uses window.__foopBridge — injected by the shell — for all backend communication.

See ELECTRON.md for a complete Electron implementation.

Plugin file serving

The shell serves plugin files from a local HTTP server. The page loads them via standard ES module import() and has no knowledge that the shell supplies the files:

// main.js — serve plugins from a local directory
const express = require('express');
const app = express();
app.use('/plugins', express.static(path.join(__dirname, 'plugins')));
app.listen(3000, '127.0.0.1');

The page loads plugins as:

http://localhost:3000/plugins/my-plugin/1.0.0/my-plugin.js