Pular para o conteúdo

Arquitetura de Micro-Frontend

O CPanel (shell) hospeda as UIs dos plugins como micro-frontends isolados. Cada plugin com um entrypoint HTML torna-se um worker independente embutível via <z-frame> (@zomme/frame). A comunicação acontece sobre um MessageChannel bidirecional em cima de postMessage.

Para saber como os plugins declaram sua UI, veja Sistema de Plugins. Para o worker pool que serve cada iframe, veja Worker Pool.

ObjetivoComo
ModularidadeCada plugin entrega sua própria UI como um worker
IndependênciaBuild/deploy isolado por plugin
Agnóstico de frameworkReact, Solid, Qwik, Vue — qualquer um deles
Isolamento de segurançaIframes em sandbox, sem acesso ao DOM do shell
Comunicação tipadaMessageChannel + serialização automática (props, eventos, RPC)
Buntime Runtime (port 8000)
├── Shell (CPanel)
│ ├── Layout, navigation
│ └── <z-frame> elements ──┐
└── Workers │
├── Plugin UI A ◄───────┤ MessageChannel
├── Metrics ◄───────┤ (props sync, RPC, events)
├── Logs ◄───────┤
└── ... ◄───────┘
PacotePapel
@zomme/frameWeb component <z-frame> (lado do shell) e frameSDK (lado do iframe)
@zomme/frame-reactBindings React (useFrameSDK, useRouteSync)

Web component que carrega um iframe e gerencia o MessageChannel.

<z-frame
name="metrics"
base="/metrics"
src="http://localhost:8000/metrics"
pathname="/files"
theme="dark"
></z-frame>
AtributoTipoDescrição
namestringIdentificador (obrigatório)
srcstringURL da aplicação no iframe (obrigatório)
basestringCaminho base para roteamento (padrão: /<name>)
pathnamestringCaminho inicial (padrão: /)
sandboxstringPermissões do iframe
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

Não existe mais um campo fragment no manifesto. Plugins com um entrypoint HTML ficam automaticamente disponíveis como 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 />);

O CPanel envolve <z-frame> em um componente React. Escutar o evento navigate vindo do iframe e propagá-lo via window.history.pushState mantém a URL do shell em sincronia com a navegação interna do plugin. As props (base, pathname, theme) são passadas como atributos/propriedades em <z-frame> e automaticamente sincronizadas com o 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') │ │
TipoDireçãoPropósito
INITShell → FrameProps iniciais + MessagePort
READYFrame → ShellFrame inicializado
PROPS_UPDATEShell → FrameAtualização de props
EVENTShell → FrameEvento personalizado
CUSTOM_EVENTFrame → ShellEvento personalizado
FUNCTION_CALLBidirecionalChamada RPC
FUNCTION_RESPONSEBidirecionalValor de retorno RPC

As funções são serializadas automaticamente — proxy virtual 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();

O runtime injeta <base href="/plugin-name/"> no HTML servido ao iframe. Isso permite que o roteador SPA do plugin funcione como se estivesse na raiz, ao mesmo tempo em que resolve corretamente caminhos relativos:

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;
}

A injeção é feita em wrapper.ts quando ele detecta uma resposta HTML mais o cabeçalho X-Base. O conteúdo passa por escape de HTML para prevenir XSS. Veja o Runtime para detalhes sobre o mecanismo.

BenefícioComo acontece
Isolamento de segurançaIframe em sandbox — sem acesso ao DOM do shell
Deploy independentePlugin atualizado sem reconstruir o runtime
Liberdade tecnológicaCada plugin escolhe seu framework
Comunicação tipadaTypeScript para props/eventos via @zomme/frame
Lazy loadingFrames carregam sob demanda
ResiliênciaUm erro em um frame não afeta o shell
  • Cada frame paga o overhead de um processo + bundle.
  • O estado global compartilhado requer o shell como mediador.
  • O DevTools é mais complexo — abra o frame em “Open frame in new tab” para depuração isolada.

O sistema antigo usava Shadow DOM com piercing. Para migrar:

  1. Remova a seção fragment do manifest.yaml.
  2. Substitua os imports de @buntime/piercing por @zomme/frame (ou @zomme/frame-react).
  3. Inicialize o SDK: await frameSDK.initialize() no entry do client.
  4. Substitua o acesso ao Shadow DOM por useFrameSDK() (ou frameSDK diretamente).
  5. Simplifique getApiBase() para usar <base> ou frameSDK.props.base.