The web app ships a runtime plugin system so third parties (and our own optional features) can extend the UI without forking the app. It is modeled on the Camunda 7 / Operaton webapp plugin mechanism, adapted to this standalone Preact SPA. A plugin can add a whole page, drop a widget on the dashboard, and bring its own API calls, state, translations, and styles — all described by a single descriptor object.
Note: Plugins extend the app at the root — a top-level page, a dashboard card, or a headless API/state namespace. The core application's own sub-navigations (the Processes definition tabs, the Task detail tabs) are intentionally closed; there is no plugin point for injecting into them.
Everything is driven by one idea: a descriptor registered against a named
plugin point. At boot the loader collects every
descriptor into a single in-memory registry, then
the app renders. Because the registry is populated before the first
render() and never mutated afterwards, every host seam can read it
synchronously — no reactivity, no re-render churn.
Plugins come from two sources that share the exact same descriptor contract:
- Bundled first-party plugins that ship inside the app bundle
(
src/plugins/bundled/*/plugin.jsx, discovered viaimport.meta.glob). - Remote plugins discovered at runtime from a JSON manifest and dynamically imported — the standalone-deployment path, where an operator drops plugins next to the served app without rebuilding it.
A broken plugin can never brick the app: each one registers/loads in isolation, and a slow or missing remote manifest is guarded by a timeout.
The three extension points live in points.js. A
descriptor's point must be one of these:
| Constant | Value | What it does |
|---|---|---|
PLUGIN_POINTS.PAGE |
app.page |
A whole app section: route + primary-nav entry + hotkey + GoTo entry |
PLUGIN_POINTS.DASHBOARD_WIDGET |
dashboard.widget |
A card rendered on the dashboard |
PLUGIN_POINTS.API |
app.api |
A pure API/state namespace, no UI |
A plugin module's default export is a descriptor, or an array of them. The
canonical shape (documented in the registry.js
header):
{
id: string, // unique across all plugins
point: string, // one of PLUGIN_POINTS
priority?: number, // sort order within a point (higher = earlier), default 0
properties?: object, // point-specific config (see below)
Component?: ComponentType, // Preact component for PAGE / DASHBOARD_WIDGET
api?: object, // fn leaves (state, ...args) => Promise → engine_rest.plugins[id]
signals?: () => object, // factory → state.api.plugins[id]
translations?: object, // { "en-US": {…}, "de-DE": {…} } deep-merged into the i18n namespace
}properties is point-specific:
- PAGE —
pathandhref(the route),nameKey(i18n key for the nav label), optionalhotkey(e.g."alt+shift+8"). - Any point may set
apiBaseto route the plugin'sget/postcalls to its own backend instead of the engine REST API (seeuse_plugin_api).
Important:
idmust be unique andpointmust be a known value. Invalid or duplicate descriptors are logged and skipped — they never throw.
apiis deep-mounted atengine_rest.plugins.<id>. Each function should follow the host convention(state, ...args) => VERB(url, state, signal), so auth headers, error shapes, andRESPONSE_STATEall come for free fromhelper.jsx.signalsis a factory whose returned object is mounted atstate.api.plugins.<id>. Use it for the signals yourapiwrites into.translationsis deep-merged into thetranslationnamespace and never overwrites host keys, so namespace them underplugins.<id>.*.
Important: The registry defers each plugin's
addResourceBundleuntil i18next has loaded the app's owntranslation.jsonfor the active language. This is deliberate — adding a partial bundle for a language before its http backend load makes i18next treat that language as already present and skip the fetch, which would wipe out every base key (missingKeyfor all built-in strings). Plugin authors don't need to do anything; just be aware plugin strings appear a tick after the base UI on first load.
Inside a plugin Component, call
use_plugin_api(id) to get the render-time
context (this is the idiomatic Preact replacement for Camunda's
render(container, data) argument):
const { params, query, state, engine, signals, api, resolve_url, get, post } =
use_plugin_api("metrics");params/query— the current route's params and query string.signals— the plugin's own state branch (state.api.plugins.metrics).api— the plugin's mounted API namespace (engine_rest.plugins.metrics).get(path, signal)/post(path, body, signal)— scoped verbs. When the descriptor setsproperties.apiBase, they fetch the plugin's own backend and write the same{ status, data }shapeRequestStaterenders; otherwise they delegate to the standard engine-restGET/POST. The same plugin code thus works unchanged whether embedded or talking to a separate service.
src/plugins/bundled/metrics/plugin.jsx
is the reference bundled plugin — a PAGE that owns its API, state, translations,
and CSS, sharing no host signals, which proves namespace isolation. Its default
export is an array of one descriptor (an array so a plugin can ship several):
export default [
{
id: "metrics",
point: PLUGIN_POINTS.PAGE,
properties: {
path: "/plugin/metrics",
href: "/plugin/metrics",
nameKey: "plugins.metrics.nav",
hotkey: "alt+shift+8",
},
Component: MetricsPage,
api, // → engine_rest.plugins.metrics.*
signals: make_signals, // → state.api.plugins.metrics.*
translations, // en-US + de-DE under plugins.metrics.*
},
];Bundled plugins import host primitives directly. The blessed one-import surface
is src/plugins/index.js:
import {
PLUGIN_POINTS,
use_plugin_api,
RequestState,
GET,
POST,
} from "../../index.js";A remote plugin is plain JS served next to the app — it cannot import from the
app bundle. Instead the host exposes its own framework instances on
window.__OPERATON_PLUGIN_HOST__ (see host.js) so
the plugin shares the app's single Preact instance (bundling its own copy
would break useContext(AppState)). Such a plugin reads h, hooks,
signals, register, use_plugin_api, PLUGIN_POINTS, etc. from that object
and authors markup with html (htm bound to the host's h).
| File | Responsibility |
|---|---|
points.js |
The PLUGIN_POINTS enum. |
registry.js |
The in-memory registry. register() validates + mounts i18n/API; read seams plugins_for(), plugin_descriptor(), plugin_state_branches(). |
loader.js |
load_plugins() — registers bundled plugins, then discovers and imports remote ones under a timeout. |
plugin_api.jsx |
use_plugin_api(id) — the render-time context hook. |
host.js |
install_plugin_host() — exposes window.__OPERATON_PLUGIN_HOST__ for remote plugins. |
index.js |
The public import surface for bundled plugin authors. |
../api/plugins.js |
Dependency-free container for mounted API namespaces (also exposed as engine_rest.plugins). |
api/plugins.js is deliberately dependency-free so importing it from the
registry — which state.js pulls in — never drags in the HTTP helpers that
tests mock.
index.jsx wires it all together:
install_plugin_host();
load_plugins().finally(() => render(<App />, document.getElementById("app")));Host primitives are exposed, then every plugin (bundled + remote) loads before the first render, so the frozen registry is stable for every seam. If the remote plugin server hangs, the loader's timeout still lets the app boot.
Each seam simply reads the frozen registry:
- Routes —
index.jsxinjectsplugins_for(PLUGIN_POINTS.PAGE)as<Route>s. - Navigation —
Header.jsxmerges PAGE plugins into the primary nav (rendered once for both desktop and mobile) and binds their hotkeys. - Global search —
GoTo.jsxadds PAGE plugins to the command palette. - Dashboard —
Dashboard.jsxrendersplugins_for(PLUGIN_POINTS.DASHBOARD_WIDGET). - State / API —
state.jsmountsstate.api.plugins.<id>andengine_rest.jsxmountsengine_rest.plugins.<id>.
URL of the remote plugin manifest, a JSON array of plugin packages. Defaults
to /plugins/plugins.json when unset (see
public/plugins/plugins.json, which ships
empty). Empty values and unreplaced Docker placeholders are ignored.
# .env
VITE_PLUGINS_URL=/plugins/plugins.jsonEach manifest entry describes one remote package:
[
{
"name": "example",
"location": "/plugins/example",
"main": "plugin.js",
"css": "plugin.css"
}
]location— base URL of the package (required).main— entry module, defaults toplugin.js.css— optional stylesheet injected as a<link>.
main and css are loaded with a ?bust=<VITE_APP_VERSION> cache-busting
query. At runtime, a window.PLUGIN_PACKAGES array (future servlet injection)
takes precedence over the fetched manifest.
See Environment Variables.md for the full env-var list.
Plugin modules are unit-tested in src/plugins/*.test.js(x) and
src/api/plugins.test.js; the host seams have integration coverage in
src/components/Header.test.jsx, src/components/GoTo.test.jsx, and
src/pages/Dashboard.test.jsx. Reset the shared registry between tests with
_reset_registry() (and clear plugin_apis) so registrations don't leak across
cases — see the existing tests for the pattern.