Skip to content

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.

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
ComponentRole
PluginLoaderAuto-discovery in pluginDirs, manifest.yaml parsing, topological sort, lazy import
PluginRegistryStores plugins, runs hooks, manages service sharing, resolves routes
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

A central decision. Choose one — do not duplicate the API between plugin.ts and index.ts.

ModeWhen to useplugin.ts has routes?index.ts has routes?entrypoint
PersistentDB connections, WebSocket/SSE, in-memory cache, cron, shared stateYesNo (SPA only)dist/client/index.html
ServerlessStateless CRUD, file ops, isolation, horizontal scalingNoYesdist/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 only
export default { fetch: createStaticHandler(clientDir) };

Current persistent plugins: gateway, keyval, logs, metrics, proxy, turso, vhosts.

ExtensionModeBehavior
.htmlSPAserveStatic only; index.ts is NOT executed
.js / .tsServiceImports the module; expects 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
FieldTypeDescription
namestringUnique identifier, format @scope/plugin-name
basestringBase path for plugin routes
enabledbooleanfalse to skip on load
entrypointstringWorker entrypoint (HTML for SPA, JS for service)
pluginEntrystringMain-process middleware
dependenciesstring[]Required plugins (error if absent)
optionalDependenciesstring[]Optional plugins (ignored if absent)
publicRoutesarray | objectBypass onRequest hooks
menusMenuItem[]Menu items for the shell
injectBasebooleanControls <base href> injection
visibilityenumpublic | protected | internal
envrecordVars for plugin workers
configobjectConfig 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.

PluginLoader scans pluginDirs (PATH-style, :-separated). Supported structures:

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

Entry-file priority: manifest.pluginEntryplugin.{ts,js}index.{ts,js}. Default for RUNTIME_PLUGIN_DIRS: /data/.plugins:/data/plugins (built-in first, external second).

Plugins are sorted by dependency before loading, using Kahn’s algorithm (O(V + E)):

  1. Compute the in-degree (number of dependencies) for each plugin.
  2. Enqueue plugins with in-degree zero.
  3. For each processed plugin, decrement the in-degree of its dependents; enqueue any that reach zero.
  4. 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.

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;
}
HookWhenReturnNotes
onInit(ctx)On plugin loadvoid/Promise30s timeout — hard failure if exceeded
onShutdown()On SIGINTvoid/PromiseReverse order (LIFO)
onServerStart(server)After Bun.serve()voidAccess to the instance for WS upgrade
onRequest(req, app?)Before each handlerRequest (modified) | Response (short-circuit) | undefinedTopological order
onResponse(res, app)After response generationResponseTopological order
onWorkerSpawn(worker, app)Worker createdvoid
onWorkerTerminate(worker, app)Worker terminatedvoid
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(req, app) {
if (rateLimitExceeded) {
return new Response("Too Many Requests", { status: 429 }); // short-circuit
}
return undefined; // continue the pipeline
}
ReturnBehavior
undefinedContinue with the original request
RequestContinue with the modified request
ResponseShort-circuit — the runtime returns immediately

Plugins run in topological order, e.g. Metrics → Proxy → Gateway → Worker.

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.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");
},
});

Rules:

  • provides() may be sync or async; its return value is what getPlugin yields.
  • Exports are available after the provider’s onInit — declare dependencies so the loader orders them correctly.
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 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:

SurfaceDeclared asHow it dispatchesHot-reloads?
Hono routesPluginImpl.routesapp.fetch queries the registry per requestYes — inherently dynamic
Fetch handlerPluginImpl.server.fetchapp.fetch iterates the registry per requestYes — inherently dynamic
Native routesPluginImpl.server.routesBun matches these before app.fetch; the table is fixed at Bun.serve() timeYes — 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.

Bypass onRequest hooks (auth). Two formats:

# Array — all methods
publicRoutes:
- "/health"
- "/api/public/**"
# Object — per method
publicRoutes:
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 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.html
pluginEntry: dist/plugin.js
menus:
- { title: KeyVal, icon: lucide:database, path: /keyval }

Details in Micro-Frontend Architecture.

  • Single mode — don’t duplicate the API between plugin.ts and index.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 in optionalDependencies.
  • Fast init — there’s a 30s timeout in onInit. Lazy-load expensive connections.
  • Graceful onShutdown — flush caches, close DB, clear timers.
  • Lightweight hooksonRequest/onResponse run per request.