Standalone proof-of-concept exploring an in-process plugin system for Owncast: JavaScript plugins compiled to WebAssembly, executed inside a Go host via Extism (which uses Wazero, pure Go, no CGo).
This isn't part of Owncast yet. It's a sandbox to validate the architecture.
- SDK Architecture, system-level tour of the runtime, toolchain, contract, and how the pieces fit together
- Plugin Author Guide, start-to-finish guide for writing, testing, and shipping a plugin
- Wire Protocol, the contract between the Owncast host and any language SDK; future SDKs and the eventual server-side host runtime both implement this
Layout mirrors the planned future repo split: sdks/<lang>/ for author-facing SDKs that ship to package managers, tools/ for binaries shipped via GitHub releases, host-runtime/ for the host code that will eventually move into the Owncast server repo.
.
├── sdks/
│ └── js/ @owncast/plugin-sdk, npm package for authors
│ ├── index.js runtime: definePlugin(), host.* wrappers
│ ├── index.d.ts TypeScript types for editor autocomplete
│ ├── bin/ owncast-plugin build CLI
│ ├── scripts/ postinstall fetches extism-js + binaryen
│ └── create-owncast-plugin/ scaffolder (`npx create-owncast-plugin@latest`)
│
├── host-runtime/ Go: PoC host runtime, moves to the Owncast server
│ │ repo when integration lands
│ ├── plugin/ runtime library: manager, dispatcher, server, host-fns
│ │ └── testing/ scenario runner used by the test binary
│ ├── kv/ Store interface + bbolt and in-memory impls
│ ├── cmd/owncast-plugin-test/ standalone test runner CLI
│ ├── cmd/owncast-plugin-serve/ localhost dev HTTP server CLI
│ └── main.go demo: simulated chat stream against the runtime
│
├── examples/
│ └── js/ one JS example per architectural feature (see below)
├── plugins/ .ocpkg packages the demo host loads (build artifacts, gitignored)
├── tools/ prebuilt extism-js, wasm-merge, wasm-opt, Go binaries
└── docs/ guides + wire protocol + roadmap
- Manifest is the source of truth,
plugin.manifest.jsondeclares display name, slug (the canonical identifier), version, subscriptions (notify/filter), and permissions. The host compares it against the plugin's runtimeregister()output at load; mismatches on slug, version, or permissions are rejected. - Typed handlers per event, instead of one
onEvent(event)with a string switch, plugins define methods likeonChatMessage(msg)andfilterChatMessage(msg). The SDK derives the manifest's subscriptions from which methods are present, so the author maintains a single source of truth. on: { ... }for custom events, plugin-emitted events (e.g."announcement.broadcast") are subscribed to via a keyed object. Authors define their own constants for these strings.- Notifications vs filters:
on*handlers, fire-and-forget, plugins run in parallelfilter*handlers, sequential, priority-ordered, returnfilter.pass()/.modify(payload)/.drop(reason). Errors fail open.- Plugin → host calls via
owncast.chat.send,owncast.kv.{get,set},owncast.events.emit,owncast.http.fetch, gated by declared permissions. For HTTP, the manifest must declarenetwork.fetchANDnetwork.allowedHosts(a list of hostname globs); the host wires those straight into Extism'sAllowedHosts. The wildcard"*"is permitted but must be written explicitly so the manifest reflects the granted scope.
- Plugins can serve HTTP, under
/plugins/<slug>/*, static assets are served directly; unmatched paths fall through to the plugin'sonHttpRequest(req)handler. Requireshttp.servepermission. Default-public; gate admin-only features onreq.authenticated. Response headers are filtered through an allowlist (noSet-Cookieetc.); request and response bodies are size-capped. - Two distribution formats:
- Loose files,
<slug>.wasm+<slug>.manifest.json+ optional<slug>-assets/dropped intoplugins/. Easy to inspect and iterate during dev. - Single-file
.ocpkgpackages, bundlingplugin.manifest.json+ the compiled plugin + optionalassets/into one file. Built viaowncast-plugin package. The admin uploads it from the Plugins page (or drops it intodata/plugins/) and enables it. Recommended for distribution.
- Loose files,
- Plugin → plugin via
owncast.events.emit(type, payload), the emitted event re-enters the dispatcher and fans out to subscribers. Recursion is capped atMaxEmitDepth = 8. - Per-plugin instance, Extism plugin instances are reused across calls. Calls into a single plugin are mutex-serialized; different plugins run concurrently.
- Plugin config is namespaced per-plugin, stored under
plugins.kv.<slug>.in Owncast's datastore; plugins can't read each other's keys. (The PoChost-runtime/kv/package ships bbolt + in-memory impls for the standalone demo binary, the real Owncast host wiresdatastoreKVStoreinstead.)
# Build the Go-side binaries this repo owns (one-time, after cloning)
tools/bootstrap.sh
# Build each example into ./plugins/ (npm install fetches extism-js et al.
# via the SDK's postinstall on first run)
for ex in examples/js/*/; do tools/build-plugin.sh "$ex"; done
# Run the simulated chat stream
cd host-runtime && go run . ../pluginstools/bootstrap.sh compiles owncast-plugin-test and owncast-plugin-serve from host-runtime/cmd/. End users installing the published SDK get these as per-platform release-asset downloads via the postinstall instead, bootstrap.sh is for repo developers running against a not-yet-released checkout.
You should see the chat stream flow through the filter chain (slow-mode, buggy-filter, profanity-filter), then fan out to notification subscribers (chat-logger, echo-bot, message-counter, relay), with relay re-emitting announcement.broadcast events that announcer handles.
for ex in examples/js/*/; do tools/owncast-plugin-test "$ex"; doneOr cd examples/js/<slug> && npm test for a single plugin (which also rebuilds it first).
npx create-owncast-plugin@latest my-plugin
cd my-plugin
npm install # postinstall fetches extism-js + binaryen
npm run build # produces my-plugin.wasm
npm test # runs scenario tests in __tests__/
npm run serve # localhost dev server at http://localhost:8080/plugins/my-plugin/
npm run package # produces my-plugin.ocpkg, single-file distributableAuthor code goes in src/plugin.js. Edit plugin.manifest.json to declare permissions (subscriptions are derived from your handler methods). The TypeScript declarations in @owncast/plugin-sdk give editor autocomplete. Static assets, HTML pages, images, JS, go in assets/; they're served at /plugins/<slug>/....
Plugins are tested against the actual built .wasm using the same plugin runtime code that the production Owncast app uses, so passing tests guarantee the same code path passes in production. No Owncast restart, no live stream needed.
Tests are JSON scenarios in __tests__/*.test.json:
[
{
"name": "echoes the message back",
"events": [
{
"event": "chat.message.received",
"payload": { "user": { "id": "u-alice", "displayName": "alice" }, "body": "hi" }
}
],
"expect": { "chatSends": ["alice said: hi"] }
},
{
"name": "rate-limits same user within 2s",
"given": { "kv": { "last:u-alice": "1704067200000" } },
"events": [
{
"filter": "chat.message.received",
"payload": {
"user": { "id": "u-alice", "displayName": "alice" },
"body": "spam",
"timestamp": "2024-01-01T00:00:01Z"
},
"expect": { "action": "drop", "reason": "/slow-mode/" }
}
]
}
]Available step types:
event: "<type>", fire-and-forget notification dispatchfilter: "<type>", filter chain; inlineexpect: {action, payload?, reason?}http: { method, path, headers, body, expect: {status, headers?, body?} }, sends request through the sameplugin.Serverproduction uses
Available assertions:
- Per-step
expect.action/expect.payload/expect.reasonfor filter steps;http.expectfor HTTP steps - Final-state
expect.chatSends(exact list),expect.emits(exact list of{eventType, payload}),expect.kv(partial map),expect.httpRequests(outbound HTTP from the plugin) - Pre-state
given.kv(initial plugin-config namespace),given.httpResponses(canned HTTP responses for outboundowncast.http.fetchcalls)
The runner is the owncast-plugin-test binary. For JS plugins, npm test invokes it via the SDK CLI. Non-JS plugin authors install the binary directly.
Minimum plugin:
const { definePlugin, owncast, filter } = require("@owncast/plugin-sdk");
module.exports = definePlugin({
// Notification handler, typed payload, no string switching.
onChatMessage(msg) {
owncast.chat.send(`echo: ${msg.body}`);
},
// Filter handler, return filter.pass() / .modify() / .drop().
filterChatMessage(msg) {
return msg.body.includes("spam") ? filter.drop("spam") : filter.pass();
},
// Custom plugin-emitted events.
on: {
"announcement.broadcast"(payload) {
console.log(`announcement from ${payload.by}: ${payload.text}`);
},
},
});See examples/js/README.md for the full catalog of plugin examples with one-line summaries. Each example has its own README inside its directory.
- Owncast integration: the host runtime in
host-runtime/is PoC scaffolding. The real home is the Owncast server repo; the wire interface indocs/WIRE_PROTOCOL.mdis the contract between the two repos. - Manager persistence: the enabled-plugin set and per-plugin approved-permission snapshots are stored at
<pluginsDir>/.enabled.jsonfor the PoC's standalone demo binary. Owncast already wires a config-store-backed implementation; the file-backed default exists only for the demo. - Typed plugin config from the admin:
manifest.configschema is parsed but no host function exposes config values to plugin code. Intent is typed config values per plugin, editable from the Owncast admin UI (today plugins persist their own state viaowncast.kv.{get,set}). - Strike system for notifications + HTTP: the filter chain auto-disables a plugin after consecutive failures. The notification and HTTP handler paths have per-call timeouts but don't count strikes, a permanently-broken
onChatMessagekeeps getting called forever. - Action button HTML sanitization: action buttons with an
htmlfield ship the HTML verbatim. The Owncast frontend renders trusted external-action HTML today; once these come from plugins, server-side sanitization (or a tighter allowlist) is worth considering. - Additional language SDKs:
sdks/go/andsdks/python/are planned. They'll implement the same wire protocol and consume the shared scenario test corpus and release binaries. - Drop-a-JS-file authoring: the eventual dream is for the host to embed the JS-to-wasm compiler so authors can ship
.jsdirectly. Today the build step is mandatory.