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.
| Goal | How |
|---|---|
| Modularity | Each plugin delivers its own UI as a worker |
| Independence | Isolated build/deploy per plugin |
| Framework-agnostic | React, Solid, Qwik, Vue — any of them |
| Security isolation | Sandboxed iframes, no access to the shell DOM |
| Typed communication | MessageChannel + automatic serialization (props, events, RPC) |
Topology
Section titled “Topology”Buntime Runtime (port 8000)├── Shell (CPanel)│ ├── Layout, navigation│ └── <z-frame> elements ──┐└── Workers │ ├── Plugin UI A ◄───────┤ MessageChannel ├── Metrics ◄───────┤ (props sync, RPC, events) ├── Logs ◄───────┤ └── ... ◄───────┘Packages
Section titled “Packages”| Package | Role |
|---|---|
@zomme/frame | <z-frame> web component (shell side) and frameSDK (iframe side) |
@zomme/frame-react | React bindings (useFrameSDK, useRouteSync) |
<z-frame> — Shell Side
Section titled “<z-frame> — Shell Side”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>Attributes
Section titled “Attributes”| Attribute | Type | Description |
|---|---|---|
name | string | Identifier (required) |
src | string | App URL in the iframe (required) |
base | string | Base path for routing (default: /<name>) |
pathname | string | Initial path (default: /) |
sandbox | string | iframe permissions |
JavaScript API
Section titled “JavaScript API”const frame = document.querySelector("z-frame");
// Dynamic props — automatically synced to the iframeframe.theme = "dark";frame.user = currentUser;frame.apiUrl = "https://api.example.com";
// Emit events to the iframeframe.emit("route-change", { path: "/settings" });
// RPC: call a function registered by the iframeconst stats = await frame.getStats();
// Listen to events coming from the iframeframe.addEventListener("ready", () => {});frame.addEventListener("navigate", (e) => router.push(e.detail.path));frameSDK — Iframe Side
Section titled “frameSDK — Iframe Side”import { frameSDK } from "@zomme/frame/sdk";
await frameSDK.initialize(); // required before use
// Access props passed by the shellconsole.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 shellframeSDK.emit("navigate", { path: "/settings" });
// Listen to events from the shellframeSDK.on("route-change", ({ path }) => router.navigate(path));
// Register functions for the shell to callframeSDK.register({ refreshData: async () => loadData(), getStats: () => ({ count: 42 }),});
// Watch for changes on specific propsframeSDK.watch(["theme"], (changes) => { if ("theme" in changes) { const [next, prev] = changes.theme; applyTheme(next); }});React Bindings
Section titled “React Bindings”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>;}Plugin with UI — Structure
Section titled “Plugin with UI — Structure”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.htmlManifest
Section titled “Manifest”name: "@buntime/my-plugin"base: "/my-plugin"entrypoint: dist/client/index.html # HTML → automatic SPA modemenus: - title: My Plugin icon: lucide:cloud-upload path: /my-pluginThere is no longer a fragment field in the manifest. Plugins with an HTML
entrypoint are automatically available as micro-frontends.
Client entry point
Section titled “Client entry point”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 />);Shell Integration (CPanel)
Section titled “Shell Integration (CPanel)”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.
Communication — Protocol
Section titled “Communication — Protocol”Initialization Flow
Section titled “Initialization Flow”Shell (z-frame) Iframe frameSDK │ creates iframe (src) │ │ │──────────────────────────▶ │ │ │ │ load │ │ │ ──── frameSDK.initialize() ─▶ │ postMessage(INIT, props, │ │ │ [port2]) │ │ │──────────────────────────▶ │ │ │ │ receives port2, props │ │ │ ◀─── port.postMessage(READY) ── │ emit('ready') │ │Message Types
Section titled “Message Types”| Type | Direction | Purpose |
|---|---|---|
INIT | Shell → Frame | Initial props + MessagePort |
READY | Frame → Shell | Frame initialized |
PROPS_UPDATE | Shell → Frame | Props update |
EVENT | Shell → Frame | Custom event |
CUSTOM_EVENT | Frame → Shell | Custom event |
FUNCTION_CALL | Bidirectional | RPC call |
FUNCTION_RESPONSE | Bidirectional | RPC return value |
Functions as Props
Section titled “Functions as Props”Functions are automatically serialized — virtual proxy via RPC:
// Shell: passes function as propframe.onSave = async (data) => { await api.save(data); return { success: true };};
// Frame: calls transparentlyconst result = await frameSDK.props.onSave({ id: 123 });console.log(result.success); // trueRegistered Functions
Section titled “Registered Functions”// Frame: registersframeSDK.register("getStats", () => ({ users: 42 }));
// Shell: callsconst stats = await frame.getStats();Base Path Injection
Section titled “Base Path Injection”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:
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.
Benefits and Limitations
Section titled “Benefits and Limitations”Benefits
Section titled “Benefits”| Benefit | How it happens |
|---|---|
| Security isolation | Sandboxed iframe — no access to the shell DOM |
| Independent deploy | Plugin updated without rebuilding the runtime |
| Technology freedom | Each plugin chooses its framework |
| Typed communication | TypeScript for props/events via @zomme/frame |
| Lazy loading | Frames load on demand |
| Resilience | An error in one frame does not affect the shell |
Limitations
Section titled “Limitations”- 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.
Migrating from @buntime/piercing
Section titled “Migrating from @buntime/piercing”The old system used Shadow DOM with piercing. To migrate:
- Remove the
fragmentsection frommanifest.yaml. - Replace imports of
@buntime/piercingwith@zomme/frame(or@zomme/frame-react). - Initialize the SDK:
await frameSDK.initialize()in the client entry. - Replace Shadow DOM access with
useFrameSDK()(orframeSDKdirectly). - Simplify
getApiBase()to use<base>orframeSDK.props.base.
Related Documentation
Section titled “Related Documentation”- The Runtime —
<base href>injection,X-Base/X-Not-Foundheaders. - Plugin System — manifest with
entrypoint,menus,injectBase. - Worker Pool — wrapper that serves the iframe HTML.
- SPA App Shell guide — building a plugin SPA against the shell.