This document explains how plugin loading works when packaging Errata into a Bun standalone executable, and how to use runtime (external) plugins safely.
- Keep built-in plugins bundled into the app/binary.
- Allow loading additional plugins from disk at runtime (server-side).
- Keep plugin behavior predictable in both dev and compiled modes.
Errata now supports two plugin sources:
-
Static bundled plugins (always available)
- Loaded via
import.meta.glob('../../plugins/*/entry.server.ts', { eager: true }). - These are compiled into the app (and into the standalone binary).
- Loaded via
-
External runtime plugins (optional)
- Loaded from disk at startup when
PLUGIN_DIRis set. - Server features load at runtime.
- Optional UI can be served by Errata via
plugin.json+ iframe panel.
- Loaded from disk at startup when
Code references:
src/server/init.tssrc/server/plugins/loader.ts
Set PLUGIN_DIR to a directory that contains one folder per plugin.
Each plugin folder may provide one of these server entry files (first match wins):
entry.server.tsentry.server.jsplugin.tsplugin.js
The module must export a valid WritingPlugin as default (or plugin).
Example layout:
external-plugins/
my-plugin/
entry.server.ts
plugin.json
ui/
index.html
panel.css
panel.js
dice-tools/
plugin.js
External plugins can define a panel UI without being in the frontend build.
Create plugin.json in the plugin root:
{
"name": "my-plugin",
"panel": {
"title": "My Plugin",
"entry": "ui/index.html",
"icon": { "type": "lucide", "name": "Sparkles" }
}
}Rules:
name(if provided) must matchmanifest.namefromentry.server.*.panel.titleoverrides the sidebar panel title.panel.entrypoints to the HTML entry file relative to plugin root.panel.iconcan set sidebar icon:- lucide:
{ "type": "lucide", "name": "Keyboard" } - svg:
{ "type": "svg", "src": "/api/plugins/my-plugin/ui/icon.svg" }
- lucide:
Errata serves panel assets at:
GET /api/plugins/:pluginName/ui/(entry HTML)GET /api/plugins/:pluginName/ui/*(relative assets)
The app renders these panels in an iframe for enabled plugins.
Code references:
src/server/plugins/loader.tssrc/server/plugins/runtime-ui.tssrc/server/api.tssrc/components/sidebar/DetailPanel.tsx
Plugin names are unique by plugin.manifest.name.
- By default, if an external plugin has the same name as a bundled plugin, it is skipped.
- To allow external replacement, set:
PLUGIN_EXTERNAL_OVERRIDE=1
When enabled, external plugin with duplicate name unregisters the current one and replaces it.
Startup logs now include plugin loading results:
- external directory load success/failure
- count loaded/skipped from external source
- final list of registered plugin names
This helps verify what actually got mounted in production.
External runtime plugin UI currently supports iframe mode via plugin.json.
- Good for: standalone HTML/CSS/JS plugin UIs loaded at runtime.
- Not supported (for external plugins): runtime React component mounting into host bundle.
Bundled plugins can use entry.client.ts for React panels and client-side hooks discovered at build time.
All plugins receive panel open/close events:
- Bundled plugins: via
onPanelOpen/onPanelCloseexports inentry.client.ts. - External plugins: via
postMessageon the iframe window (errata:panel-open,errata:panel-close,errata:data-changed).
Available hooks:
activate/deactivate— called when the plugin is enabled/disabled for a story (bundled only).onPanelOpen/onPanelClose— called when any UI panel opens/closes (fragment editor, debug, providers, export, wizard).
See docs/third-party-plugins.md for the PanelEvent interface and postMessage protocol.
Bundled plugins are inside the binary. External plugins are loaded from PLUGIN_DIR at runtime from disk.
Run example (Windows):
set PLUGIN_DIR=C:\errata\plugins
set PLUGIN_EXTERNAL_OVERRIDE=1
errata.exeRun example (Linux/macOS):
PLUGIN_DIR=/opt/errata/plugins PLUGIN_EXTERNAL_OVERRIDE=1 ./errataPlugin panel URL when enabled: http://localhost:7739/api/plugins/my-plugin/ui/
Use the project scripts instead of calling bun build --compile directly:
bun run build:binary # vite build + bun build --compile → dist/errata(.exe) + dist/public/
bun run package:binary # zip binary + public/ → dist/errata-bundle.zip
bun run release:binary # both stepsThe binary wrapper (scripts/binary-entry.mjs) remaps virtual Bun paths so runtime asset reads resolve to dist/public/.
The GitHub Actions workflow at .github/workflows/release-binary.yml builds binaries automatically on every release publish.
It builds on three platforms in parallel:
| Runner | Artifact |
|---|---|
windows-latest |
errata-windows-x64.zip |
ubuntu-latest |
errata-linux-x64.zip |
macos-latest |
errata-macos-arm64.zip |
Each zip contains the compiled binary + public/ static assets. Archives are uploaded to the GitHub release as assets.
To trigger: create a release on GitHub (or gh release create v1.x.x).
- Keep API keys and other secrets in environment variables or configure via the UI.
- Keep story data external via
DATA_DIR. - Use external plugins only from trusted sources (runtime code execution risk).
- External iframe panels are sandboxed (
allow-scripts allow-same-origin allow-forms).
On startup, Errata now creates missing base directories automatically:
DATA_DIRDATA_DIR/storiesPLUGIN_DIR(if set)
This removes the need to pre-create empty folders on fresh installs.
External plugins are arbitrary code loaded at runtime.
Recommendations:
- Use a dedicated plugin directory with restricted write access.
- Review plugin code before deployment.
- Run Errata with least-privilege OS/service credentials.
- Treat
PLUGIN_EXTERNAL_OVERRIDE=1as a controlled deployment setting.