Pular para o conteúdo

Sistema de Plugins

O mecanismo de extensibilidade do runtime. Plugins são unidades isoladas que podem interceptar requisições, registrar rotas, fornecer serviços a outros plugins e expor UI no shell. O sistema suporta hot reload, dependências declarativas e dois modos de execução (persistente vs serverless).

Para o pipeline de requisições que envolve plugins, veja Runtime. Para o pool que executa plugins serverless, veja Worker Pool.

pluginDirs (RUNTIME_PLUGIN_DIRS)
PluginLoader — scan + parse manifest.yaml + topological sort + lazy import
PluginRegistry — stores plugins, runs hooks, resolves routes, shares services
Hooks: onInit · onRequest · onResponse · onShutdown
onServerStart · onWorkerSpawn · onWorkerTerminate
ComponentePapel
PluginLoaderDescoberta automática em pluginDirs, parsing do manifest.yaml, ordenação topológica, lazy import
PluginRegistryArmazena plugins, executa hooks, gerencia o compartilhamento de serviços, resolve rotas
plugins/plugin-example/
├── manifest.yaml # Metadata + config
├── plugin.ts # Middleware (persistent mode, main process)
├── index.ts # Worker entrypoint (serverless mode, worker pool)
├── server/api.ts # Shared API code
├── client/ # SPA (React/Solid/Vue/etc.)
└── dist/ # Build output
├── plugin.js
├── index.js
└── client/index.html

Uma decisão central. Escolha um — não duplique a API entre plugin.ts e index.ts.

ModoQuando usarplugin.ts tem routes?index.ts tem routes?entrypoint
PersistenteConexões de DB, WebSocket/SSE, cache em memória, cron, estado compartilhadoSimNão (apenas SPA)dist/client/index.html
ServerlessCRUD stateless, operações de arquivo, isolamento, escala horizontalNãoSimdist/index.js

Roda no processo principal. A API fica em plugin.ts; index.ts apenas serve a SPA.

// plugin.ts — runs in the main process (persistent)
export default function databasePlugin(config): PluginImpl {
let db: DatabasePool;
return {
routes: api, // API here (a Hono app)
onInit(ctx) {
db = new DatabasePool(config.url); // persistent connection
},
};
}
// index.ts — SPA only
export default { fetch: createStaticHandler(clientDir) };

Plugins persistentes atuais: gateway, keyval, logs, metrics, proxy, turso, vhosts.

ExtensãoModoComportamento
.htmlSPAApenas serveStatic; index.ts NÃO é executado
.js / .tsServiceImporta o módulo; espera export default { fetch?, routes? }
name: "@buntime/plugin-example" # Unique identifier
base: "/example" # /[a-zA-Z0-9_-]+; omit for hook-only plugins
enabled: true # default: true
# Worker / plugin entrypoints
entrypoint: dist/index.js # service mode (or .html for SPA)
pluginEntry: dist/plugin.js # middleware in the main process
# Dependencies (topological sort)
dependencies:
- "@buntime/plugin-turso" # required — fails if absent
optionalDependencies:
- "@buntime/plugin-keyval" # ignored if absent
# Auth bypass via onRequest
publicRoutes:
ALL: ["/health"]
GET: ["/api/public/**"]
POST: ["/api/webhook"]
# Shell menus (cpanel)
menus:
- { icon: lucide:box, path: /example, title: Example }
# Env vars for workers
env:
MY_VAR: "value"
# Config schema for Helm/Rancher UI
config:
apiKey:
type: password
label: API Key
env: EXAMPLE_API_KEY # maps to ConfigMap
CampoTipoDescrição
namestringIdentificador único, formato @scope/plugin-name
basestringCaminho base para as routes do plugin
enabledbooleanfalse para ignorar no carregamento
entrypointstringEntrypoint do worker (HTML para SPA, JS para service)
pluginEntrystringMiddleware do processo principal
dependenciesstring[]Plugins obrigatórios (erro se ausentes)
optionalDependenciesstring[]Plugins opcionais (ignorados se ausentes)
publicRoutesarray | objectIgnoram (bypass) os hooks onRequest
menusMenuItem[]Itens de menu para o shell
injectBasebooleanControla a injeção de <base href>
visibilityenumpublic | protected | internal
envrecordVars para os workers do plugin
configobjectSchema de config para geração do Helm

Plugins de infraestrutura apenas com hooks que não servem rotas devem omitir base por completo — não defina base: "", que mais tarde pode ser confundido com o base de um plugin-app na raiz durante a resolução de rotas.

PluginLoader varre pluginDirs (estilo PATH, separados por :). Estruturas suportadas:

1. Direct: {pluginDir}/plugin.ts + manifest.yaml
2. Subdirectory: {pluginDir}/{name}/plugin.ts + manifest.yaml
3. Scoped: {pluginDir}/@scope/{name}/plugin.ts + manifest.yaml

Prioridade do arquivo de entrada: manifest.pluginEntryplugin.{ts,js}index.{ts,js}. Padrão para RUNTIME_PLUGIN_DIRS: /data/.plugins:/data/plugins (internos primeiro, externos depois).

Os plugins são ordenados por dependência antes do carregamento, usando o algoritmo de Kahn (O(V + E)):

  1. Calcula o grau de entrada (número de dependências) de cada plugin.
  2. Enfileira os plugins com grau de entrada zero.
  3. Para cada plugin processado, decrementa o grau de entrada dos seus dependentes; enfileira os que chegarem a zero.
  4. Qualquer plugin que reste sem processar → um ciclo de dependências, que é um erro fatal.

Exemplo: com keyval dependendo de turso, a ordem resultante é sempre turso → keyval, independentemente da ordem no sistema de arquivos.

interface PluginImpl {
routes?: Hono;
middleware?: MiddlewareHandler;
server?: { routes?; fetch? };
websocket?: { open?; message?; close? };
provides?: () => unknown | Promise<unknown>;
onInit?(ctx: PluginContext): void | Promise<void>;
onShutdown?(): void | Promise<void>;
onServerStart?(server): void;
onRequest?(req, app?): Request | Response | undefined;
onResponse?(res, app): Response;
onWorkerSpawn?(worker, app): void;
onWorkerTerminate?(worker, app): void;
}
HookQuandoRetornoNotas
onInit(ctx)No carregamento do pluginvoid/Promisetimeout de 30s — falha fatal se excedido
onShutdown()No SIGINTvoid/PromiseOrdem reversa (LIFO)
onServerStart(server)Após Bun.serve()voidAcesso à instância para upgrade de WS
onRequest(req, app?)Antes de cada handlerRequest (modificado) | Response (curto-circuito) | undefinedOrdem topológica
onResponse(res, app)Após a geração da respostaResponseOrdem topológica
onWorkerSpawn(worker, app)Worker criadovoid
onWorkerTerminate(worker, app)Worker terminadovoid
interface PluginContext {
config: Record<string, unknown>; // from manifest.yaml
globalConfig: GlobalPluginConfig; // workerDirs, poolSize, pluginDirs
logger: PluginLogger; // scoped logger
auth?: PluginAuthContext; // API key store + master key
pool?: WorkerPool; // worker pool, if needed
getPlugin<T>(name: string): T | undefined; // another plugin's exports
runtime: { api: string; version: string }; // same as /.well-known/buntime
}

O padrão canônico de leitura de config lê Bun.env primeiro (ConfigMap/Helm), depois a config do manifest e, então, um valor padrão:

const apiKey = Bun.env.MY_API_KEY ?? pluginConfig.apiKey ?? "default";
onRequest(req, app) {
if (rateLimitExceeded) {
return new Response("Too Many Requests", { status: 429 }); // short-circuit
}
return undefined; // continue the pipeline
}
RetornoComportamento
undefinedContinua com a requisição original
RequestContinua com a requisição modificada
ResponseCurto-circuito — o runtime retorna imediatamente

Os plugins rodam em ordem topológica, ex. Metrics → Proxy → Gateway → Worker.

Compartilhamento de serviços — provides / getPlugin

Seção intitulada “Compartilhamento de serviços — provides / getPlugin”

Plugins compartilham funcionalidade sem importar uns aos outros. Um provedor retorna sua superfície pública em provides(); um consumidor a alcança através de ctx.getPlugin().

// Provider — plugin-turso/plugin.ts
export default (config): PluginImpl => {
const service = new TursoService(config);
return {
provides: () => service, // exposed to other plugins
onInit(ctx) { /* ... */ },
};
};
// Consumer — must declare the dependency in its manifest
export default (config): PluginImpl => ({
onInit(ctx) {
const turso = ctx.getPlugin<TursoService>("@buntime/plugin-turso");
if (!turso) throw new Error("Requires @buntime/plugin-turso");
},
});

Regras:

  • provides() pode ser síncrono ou assíncrono; o valor de retorno é o que getPlugin entrega.
  • Os exports ficam disponíveis após o onInit do provedor — declare dependencies para que o loader os ordene corretamente.
Terminal window
# Upload a tarball/zip (extracts to pluginDirs; does NOT load yet)
POST /api/plugins/upload
# Re-scan + reload all (loads newly uploaded plugins, no process restart)
POST /api/plugins/reload
# Enable / disable a single plugin at runtime (no restart)
POST /api/plugins/<url-encoded-name>/enable
POST /api/plugins/<url-encoded-name>/disable

reload limpa o registry, re-varre os diretórios, ordena topologicamente, re-executa onInit e atualiza a tabela de rotas nativa do servidor HTTP em execução.

O runtime serve três tipos de superfície HTTP de plugin, cada uma alcançando o servidor em execução de forma diferente:

SuperfícieDeclarada comoComo despachaFaz hot-reload?
Rotas HonoPluginImpl.routesapp.fetch consulta o registry a cada requisiçãoSim — inerentemente dinâmica
Handler de fetchPluginImpl.server.fetchapp.fetch itera o registry a cada requisiçãoSim — inerentemente dinâmica
Rotas nativasPluginImpl.server.routesO Bun faz o match destas antes de app.fetch; a tabela é fixada no momento do Bun.serve()Sim — reload chama server.reload({ routes })

enable/disable invertem a flag enabled do manifest do plugin no disco (uma edição cirúrgica de linha que preserva comentários), e então re-varrem e atualizam as rotas. Como o manifest é a fonte da verdade para o estado de habilitado, a mudança sobrevive a reinícios. Os nomes são URL-encoded, então nomes com escopo (scoped) funcionam: POST /api/plugins/%40acme%2Fplugin-x/disable.

Ignoram (bypass) os hooks onRequest (auth). Dois formatos:

# Array — all methods
publicRoutes:
- "/health"
- "/api/public/**"
# Object — per method
publicRoutes:
ALL: ["/health"]
GET: ["/api/users/**"]
POST: ["/api/webhook"]

Curingas: * (segmento único), ** (múltiplos segmentos). As rotas públicas de plugin são absolutas; as rotas públicas de worker são relativas e tornadas absolutas pelo runtime (/api/health/todos-kv/api/health).

Plugins com um entrypoint HTML são automaticamente expostos como micro-frontends no shell via <z-frame>:

name: "@buntime/plugin-keyval"
base: "/keyval"
entrypoint: dist/client/index.html
pluginEntry: dist/plugin.js
menus:
- { title: KeyVal, icon: lucide:database, path: /keyval }

Detalhes em Arquitetura de Micro-Frontend.

  • Modo único — não duplique a API entre plugin.ts e index.ts.
  • Bun.env.X ?? config.x — leia com um fallback, nunca escreva.
  • : para múltiplos valores — estilo PATH, nunca vírgulas.
  • Declare as dependências — obrigatórias em dependencies, opcionais em optionalDependencies.
  • Init rápido — há um timeout de 30s em onInit. Faça lazy-load de conexões custosas.
  • onShutdown gracioso — esvazie caches, feche o DB, limpe os timers.
  • Hooks levesonRequest/onResponse rodam a cada requisição.