Plugin System
The runtime’s extensibility mechanism. Plugins are isolated units that can intercept requests, register routes, provide services to other plugins, and expose UI in the shell. The system supports hot reload, declarative dependencies, and two execution modes (persistent vs serverless).
For the request pipeline that involves plugins, see Runtime. For the pool that runs serverless plugins, see Worker Pool.
Architecture
Section titled “Architecture”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| Component | Role |
|---|---|
PluginLoader | Auto-discovery in pluginDirs, manifest.yaml parsing, topological sort, lazy import |
PluginRegistry | Stores plugins, runs hooks, manages service sharing, resolves routes |
Plugin structure
Section titled “Plugin structure”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.htmlAPI modes — persistent vs serverless
Section titled “API modes — persistent vs serverless”A central decision. Choose one — do not duplicate the API between plugin.ts
and index.ts.
| Mode | When to use | plugin.ts has routes? | index.ts has routes? | entrypoint |
|---|---|---|---|---|
| Persistent | DB connections, WebSocket/SSE, in-memory cache, cron, shared state | Yes | No (SPA only) | dist/client/index.html |
| Serverless | Stateless CRUD, file ops, isolation, horizontal scaling | No | Yes | dist/index.js |
Runs in the main process. The API lives on plugin.ts; index.ts only
serves the 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 onlyexport default { fetch: createStaticHandler(clientDir) };Current persistent plugins: gateway, keyval, logs, metrics,
proxy, turso, vhosts.
Runs in the worker pool, per request. The API lives on index.ts;
plugin.ts has no routes.
// plugin.ts — no routesexport default (config): PluginImpl => ({ onInit(ctx) { /* optional */ },});
// index.ts — API in the worker poolexport default { routes: { "/api/*": api.fetch }, fetch: createStaticHandler(clientDir),};Entrypoint modes
Section titled “Entrypoint modes”| Extension | Mode | Behavior |
|---|---|---|
.html | SPA | serveStatic only; index.ts is NOT executed |
.js / .ts | Service | Imports the module; expects export default { fetch?, routes? } |
Manifest schema
Section titled “Manifest schema”name: "@buntime/plugin-example" # Unique identifierbase: "/example" # /[a-zA-Z0-9_-]+; omit for hook-only pluginsenabled: true # default: true
# Worker / plugin entrypointsentrypoint: 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 absentoptionalDependencies: - "@buntime/plugin-keyval" # ignored if absent
# Auth bypass via onRequestpublicRoutes: ALL: ["/health"] GET: ["/api/public/**"] POST: ["/api/webhook"]
# Shell menus (cpanel)menus: - { icon: lucide:box, path: /example, title: Example }
# Env vars for workersenv: MY_VAR: "value"
# Config schema for Helm/Rancher UIconfig: apiKey: type: password label: API Key env: EXAMPLE_API_KEY # maps to ConfigMapKey fields
Section titled “Key fields”| Field | Type | Description |
|---|---|---|
name | string | Unique identifier, format @scope/plugin-name |
base | string | Base path for plugin routes |
enabled | boolean | false to skip on load |
entrypoint | string | Worker entrypoint (HTML for SPA, JS for service) |
pluginEntry | string | Main-process middleware |
dependencies | string[] | Required plugins (error if absent) |
optionalDependencies | string[] | Optional plugins (ignored if absent) |
publicRoutes | array | object | Bypass onRequest hooks |
menus | MenuItem[] | Menu items for the shell |
injectBase | boolean | Controls <base href> injection |
visibility | enum | public | protected | internal |
env | record | Vars for plugin workers |
config | object | Config schema for Helm generation |
Hook-only infrastructure plugins that don’t serve routes should omit base
entirely — don’t set base: "", which can later be mistaken for a root
plugin-app base during route resolution.
Auto-discovery
Section titled “Auto-discovery”PluginLoader scans pluginDirs (PATH-style, :-separated). Supported
structures:
1. Direct: {pluginDir}/plugin.ts + manifest.yaml2. Subdirectory: {pluginDir}/{name}/plugin.ts + manifest.yaml3. Scoped: {pluginDir}/@scope/{name}/plugin.ts + manifest.yamlEntry-file priority: manifest.pluginEntry → plugin.{ts,js} →
index.{ts,js}. Default for RUNTIME_PLUGIN_DIRS: /data/.plugins:/data/plugins
(built-in first, external second).
Topological sort — Kahn’s algorithm
Section titled “Topological sort — Kahn’s algorithm”Plugins are sorted by dependency before loading, using Kahn’s algorithm (O(V + E)):
- Compute the in-degree (number of dependencies) for each plugin.
- Enqueue plugins with in-degree zero.
- For each processed plugin, decrement the in-degree of its dependents; enqueue any that reach zero.
- Any plugin left unprocessed → a dependency cycle, which is a hard error.
Example: with keyval depending on turso, the resulting order is always
turso → keyval, regardless of filesystem order.
Lifecycle hooks
Section titled “Lifecycle hooks”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;}| Hook | When | Return | Notes |
|---|---|---|---|
onInit(ctx) | On plugin load | void/Promise | 30s timeout — hard failure if exceeded |
onShutdown() | On SIGINT | void/Promise | Reverse order (LIFO) |
onServerStart(server) | After Bun.serve() | void | Access to the instance for WS upgrade |
onRequest(req, app?) | Before each handler | Request (modified) | Response (short-circuit) | undefined | Topological order |
onResponse(res, app) | After response generation | Response | Topological order |
onWorkerSpawn(worker, app) | Worker created | void | — |
onWorkerTerminate(worker, app) | Worker terminated | void | — |
onInit — the context
Section titled “onInit — the context”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}The canonical config-reading pattern reads Bun.env first (ConfigMap/Helm),
then the manifest config, then a default:
const apiKey = Bun.env.MY_API_KEY ?? pluginConfig.apiKey ?? "default";onRequest — short-circuit
Section titled “onRequest — short-circuit”onRequest(req, app) { if (rateLimitExceeded) { return new Response("Too Many Requests", { status: 429 }); // short-circuit } return undefined; // continue the pipeline}| Return | Behavior |
|---|---|
undefined | Continue with the original request |
Request | Continue with the modified request |
Response | Short-circuit — the runtime returns immediately |
Plugins run in topological order, e.g. Metrics → Proxy → Gateway → Worker.
Service sharing — provides / getPlugin
Section titled “Service sharing — provides / getPlugin”Plugins share functionality without importing each other. A provider returns
its public surface from provides(); a consumer reaches it through
ctx.getPlugin().
// Provider — plugin-turso/plugin.tsexport default (config): PluginImpl => { const service = new TursoService(config); return { provides: () => service, // exposed to other plugins onInit(ctx) { /* ... */ }, };};
// Consumer — must declare the dependency in its manifestexport default (config): PluginImpl => ({ onInit(ctx) { const turso = ctx.getPlugin<TursoService>("@buntime/plugin-turso"); if (!turso) throw new Error("Requires @buntime/plugin-turso"); },});Rules:
provides()may be sync or async; its return value is whatgetPluginyields.- Exports are available after the provider’s
onInit— declaredependenciesso the loader orders them correctly.
Hot reload
Section titled “Hot reload”# 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>/enablePOST /api/plugins/<url-encoded-name>/disablereload clears the registry, re-scans directories, sorts topologically,
re-runs onInit, and refreshes the live HTTP server’s native route table.
The runtime serves three kinds of plugin HTTP surface, each reaching the live server differently:
| Surface | Declared as | How it dispatches | Hot-reloads? |
|---|---|---|---|
| Hono routes | PluginImpl.routes | app.fetch queries the registry per request | Yes — inherently dynamic |
| Fetch handler | PluginImpl.server.fetch | app.fetch iterates the registry per request | Yes — inherently dynamic |
| Native routes | PluginImpl.server.routes | Bun matches these before app.fetch; the table is fixed at Bun.serve() time | Yes — reload calls server.reload({ routes }) |
enable/disable flip the plugin’s manifest enabled flag on disk (a
surgical line edit that preserves comments), then rescan and refresh routes.
Because the manifest is the source of truth for enabled state, the change
survives restarts. Names are URL-encoded, so scoped names work:
POST /api/plugins/%40acme%2Fplugin-x/disable.
Public routes
Section titled “Public routes”Bypass onRequest hooks (auth). Two formats:
# Array — all methodspublicRoutes: - "/health" - "/api/public/**"
# Object — per methodpublicRoutes: ALL: ["/health"] GET: ["/api/users/**"] POST: ["/api/webhook"]Wildcards: * (single segment), ** (multi-segment). Plugin public routes are
absolute; worker public routes are relative and made absolute by the runtime
(/api/health → /todos-kv/api/health).
Plugins with UI
Section titled “Plugins with UI”Plugins with an HTML entrypoint are automatically exposed as micro-frontends
in the shell via <z-frame>:
name: "@buntime/plugin-keyval"base: "/keyval"entrypoint: dist/client/index.htmlpluginEntry: dist/plugin.jsmenus: - { title: KeyVal, icon: lucide:database, path: /keyval }Details in Micro-Frontend Architecture.
Best practices
Section titled “Best practices”- Single mode — don’t duplicate the API between
plugin.tsandindex.ts. Bun.env.X ?? config.x— read with a fallback, never write.:for multi-values — PATH-style, never commas.- Declare dependencies — required in
dependencies, optional inoptionalDependencies. - Fast init — there’s a 30s timeout in
onInit. Lazy-load expensive connections. - Graceful
onShutdown— flush caches, close DB, clear timers. - Lightweight hooks —
onRequest/onResponserun per request.
Related
Section titled “Related”- Runtime — request pipeline, resolution order.
- Worker Pool — serverless plugin execution.
- Writing a plugin — a step-by-step guide.
- Plugins overview — the bundled core plugins.