Skip to content

Micro-Frontend Architecture

The CPanel (shell) hosts plugin UIs as isolated micro-frontends. Each plugin with an HTML entrypoint becomes an independent worker embeddable via <z-frame> (@zomme/frame). Communication happens over a bidirectional MessageChannel on top of postMessage.

For how plugins declare their UI, see Plugin System. For the worker pool that serves each iframe, see Worker Pool.

GoalHow
ModularityEach plugin delivers its own UI as a worker
IndependenceIsolated build/deploy per plugin
Framework-agnosticReact, Solid, Qwik, Vue — any of them
Security isolationSandboxed iframes, no access to the shell DOM
Typed communicationMessageChannel + automatic serialization (props, events, RPC)
Buntime Runtime (port 8000)
├── Shell (CPanel)
│ ├── Layout, navigation
│ └── <z-frame> elements ──┐
└── Workers │
├── Plugin UI A ◄───────┤ MessageChannel
├── Metrics ◄───────┤ (props sync, RPC, events)
├── Logs ◄───────┤
└── ... ◄───────┘
PackageRole
@zomme/frame<z-frame> web component (shell side) and frameSDK (iframe side)
@zomme/frame-reactReact bindings (useFrameSDK, useRouteSync)

Web component that loads an iframe and manages the MessageChannel.

<z-frame
name="metrics"
base="/metrics"
src="http://localhost:8000/metrics"
pathname="/files"
theme="dark"
></z-frame>
AttributeTypeDescription
namestringIdentifier (required)
srcstringApp URL in the iframe (required)
basestringBase path for routing (default: /<name>)
pathnamestringInitial path (default: /)
sandboxstringiframe permissions
const frame = document.querySelector("z-frame");
// Dynamic props — automatically synced to the iframe
frame.theme = "dark";
frame.user = currentUser;
frame.apiUrl = "https://api.example.com";
// Emit events to the iframe
frame.emit("route-change", { path: "/settings" });
// RPC: call a function registered by the iframe
const stats = await frame.getStats();
// Listen to events coming from the iframe
frame.addEventListener("ready", () => {});
frame.addEventListener("navigate", (e) => router.push(e.detail.path));
import { frameSDK } from "@zomme/frame/sdk";
await frameSDK.initialize(); // required before use
// Access props passed by the shell
console.log(frameSDK.props.base); // "/metrics"
console.log(frameSDK.props.theme); // "dark"
// Call functions passed by the shell (props that are functions)
await frameSDK.props.onSuccess({ status: "ok" });
// Emit events to the shell
frameSDK.emit("navigate", { path: "/settings" });
// Listen to events from the shell
frameSDK.on("route-change", ({ path }) => router.navigate(path));
// Register functions for the shell to call
frameSDK.register({
refreshData: async () => loadData(),
getStats: () => ({ count: 42 }),
});
// Watch for changes on specific props
frameSDK.watch(["theme"], (changes) => {
if ("theme" in changes) {
const [next, prev] = changes.theme;
applyTheme(next);
}
});
import { useFrameSDK, useRouteSync } from "@zomme/frame-react";
function App() {
const { props, isReady } = useFrameSDK();
// Sync route with shell
useRouteSync({
onRouteChange: (path) => router.navigate(path),
getCurrentPath: () => router.currentPath,
});
if (!isReady) return <Loading />;
return <h1>Theme: {props.theme}</h1>;
}
plugins/my-plugin/
├── manifest.yaml # entrypoint: dist/client/index.html
├── plugin.ts # Middleware in the main process
├── server/api.ts # API (for serverless, goes into index.ts)
├── client/
│ ├── index.tsx # React entry
│ ├── index.html # Shell HTML
│ ├── utils/use-frame-sdk.ts # Local hook
│ └── components/
└── dist/
├── plugin.js
└── client/index.html
name: "@buntime/my-plugin"
base: "/my-plugin"
entrypoint: dist/client/index.html # HTML → automatic SPA mode
menus:
- title: My Plugin
icon: lucide:cloud-upload
path: /my-plugin

There is no longer a fragment field in the manifest. Plugins with an HTML entrypoint are automatically available as micro-frontends.

import { createRoot } from "react-dom/client";
import { frameSDK } from "@zomme/frame/sdk";
await frameSDK.initialize();
frameSDK.register({ refresh: () => window.location.reload() });
createRoot(document.getElementById("root")!).render(<MyPluginPage />);

The CPanel wraps <z-frame> in a React component. Listening for the navigate event from the iframe and propagating it via window.history.pushState keeps the shell URL in sync with the plugin’s internal navigation. Props (base, pathname, theme) are passed as attributes/properties on <z-frame> and automatically synced to the iframe via PROPS_UPDATE.

Shell (z-frame) Iframe frameSDK
│ creates iframe (src) │ │
│──────────────────────────▶ │ │
│ │ load │
│ │ ──── frameSDK.initialize() ─▶
│ postMessage(INIT, props, │ │
│ [port2]) │ │
│──────────────────────────▶ │ │
│ │ receives port2, props │
│ │ ◀─── port.postMessage(READY) ──
│ emit('ready') │ │
TypeDirectionPurpose
INITShell → FrameInitial props + MessagePort
READYFrame → ShellFrame initialized
PROPS_UPDATEShell → FrameProps update
EVENTShell → FrameCustom event
CUSTOM_EVENTFrame → ShellCustom event
FUNCTION_CALLBidirectionalRPC call
FUNCTION_RESPONSEBidirectionalRPC return value

Functions are automatically serialized — virtual proxy via RPC:

// Shell: passes function as prop
frame.onSave = async (data) => {
await api.save(data);
return { success: true };
};
// Frame: calls transparently
const result = await frameSDK.props.onSave({ id: 123 });
console.log(result.success); // true
// Frame: registers
frameSDK.register("getStats", () => ({ users: 42 }));
// Shell: calls
const stats = await frame.getStats();

The runtime injects <base href="/plugin-name/"> into the HTML served to the iframe. This lets the plugin’s SPA router work as if it were at the root, while correctly resolving relative paths:

client/index.tsx
function getApiBase(): string {
// Before (piercing/Shadow DOM): complex getRootNode logic
// Now (frame): simple
const base = document.querySelector("base");
return base?.getAttribute("href")?.replace(/\/$/, "") || "/plugin";
// Or via SDK:
return frameSDK.props.base;
}

The injection is done in wrapper.ts when it detects an HTML response plus the X-Base header. The content is HTML-escaped to prevent XSS. See the Runtime for details on the mechanism.

BenefitHow it happens
Security isolationSandboxed iframe — no access to the shell DOM
Independent deployPlugin updated without rebuilding the runtime
Technology freedomEach plugin chooses its framework
Typed communicationTypeScript for props/events via @zomme/frame
Lazy loadingFrames load on demand
ResilienceAn error in one frame does not affect the shell
  • Each frame pays the overhead of a process + bundle.
  • Shared global state requires the shell as a mediator.
  • DevTools are more complex — open the frame in “Open frame in new tab” for isolated debugging.

The old system used Shadow DOM with piercing. To migrate:

  1. Remove the fragment section from manifest.yaml.
  2. Replace imports of @buntime/piercing with @zomme/frame (or @zomme/frame-react).
  3. Initialize the SDK: await frameSDK.initialize() in the client entry.
  4. Replace Shadow DOM access with useFrameSDK() (or frameSDK directly).
  5. Simplify getApiBase() to use <base> or frameSDK.props.base.