Skip to content

Latest commit

 

History

History
229 lines (192 loc) · 12.2 KB

File metadata and controls

229 lines (192 loc) · 12.2 KB

SDK architecture

A system-level tour of what's in this repository and how the pieces fit together. This is informational, for writing a plugin, see the Plugin Author Guide. For the byte-level host/plugin protocol, see the Wire Protocol.

What this repo is

This repository is where the Owncast plugin system is developed. It contains:

  • the JavaScript and Python SDKs authors write plugins against, plus the build CLIs and a project scaffolder,
  • the shared interpreter engines (one per language) and the toolchain that builds them,
  • the host runtime (Go) that loads and runs plugins. The runtime itself now lives in Owncast (services/plugins) and is imported here (see Relationship to Owncast),
  • example plugins (parallel JS and Python ports) and their tests.

Execution model

Plugins are authored in JavaScript/TypeScript or Python. Rather than each plugin compiling its own interpreter into a self-contained module, the host embeds one shared engine per language, a QuickJS (JS) or CPython (Python) interpreter compiled to WebAssembly with the Extism PDK (extism-js / extism-py). The host compiles each engine once with Extism on Wazero (pure-Go wasm, no CGo/subprocess) and instantiates it per plugin, injecting the plugin's source via Extism config at load. This collapses per-plugin memory (N plugins share one compiled engine instead of N copies) and shrinks plugin packages from megabytes to a few KB.

A plugin authored directly as a self-contained wasm module (Rust/Go/etc.) is also supported and loaded as-is. The host picks the path from the package's code file (see build flow).

  • Every plugin exports the same fixed functions: register, on_event, on_filter, on_http_request, on_tab_content, on_page_content, on_page_styles, on_page_scripts, and on_auth_check. Most are optional. See the Wire Protocol for the full table and which permissions gate them.
  • The host provides host functions (owncast_*). Because all plugins of a language share one engine, the engine imports the full set and the host enforces each plugin's permissions at call time: a host function resolves the calling plugin's identity (from a per-instance config value) and rejects the call if the plugin's manifest didn't grant the permission.
  • Everything crosses the boundary as JSON.
  • Inbound Fediverse hooks are internal notify subscriptions. They are not external HTTP webhooks. Owncast verifies the HTTP signature and actor origin, then sends the raw activity to onFediverse / on_fediverse and also sends any matching specialized follow, like, repost, quote, mention, or reply event. The fediverse.inbound manifest permission gates all seven subscriptions.

Repository layout

host-runtime/            Go module: imports the runtime + builds the two Go CLIs
  cmd/owncast-plugin-serve/   localhost dev server
  cmd/owncast-plugin-test/    scenario test runner
  main.go                a demo host that simulates a stream
sdks/js/                 @owncast/plugin-sdk, the npm package
  index.js               definePlugin(), command handlers + owncast.* wrappers
  index.d.ts             TypeScript types (the author-facing contract)
  bin/owncast-plugin.js  the build/package/test/serve CLI
  scripts/postinstall.js fetches the test/serve binaries
  create-owncast-plugin/ npm initializer (scaffolder)
sdks/python/             owncast-plugin-py: the Python SDK + build CLI
engines/                 the shared engines' fixed bootstrap + build scripts
  javascript/entry.js    the JS engine bootstrap (SDK + dispatch + script loader)
  build.mjs, build_py.py  build the engine wasms, copy them into Owncast's embed dir
examples/js/, examples/python/   parallel example plugins (one dir per plugin)
tools/                   engine-build toolchain (extism-js/py, binaryen; gitignored)
docs/                    these documents
.github/workflows/       release workflow for the Go binaries

The host runtime (services/plugins)

The core library. It lives in the Owncast repo (services/plugins) as the single source of truth, and host-runtime/ here imports it so the dev CLIs run the exact production code. Key files:

  • manager.go, discovers plugins in a directory, tracks them as discovered vs enabled, and handles enable/disable/reload. The enabled set persists through a pluggable EnabledStore (a JSON file by default. Owncast swaps in a datastore-backed store).
  • dispatcher.go, fans ordinary events out to subscribed plugins, delivers targeted internal events, and runs on_filter chains.
  • server.go + sse.go, serve /plugins/<name>/* (static assets + the plugin's on_http_request) and a host-owned Server-Sent-Events endpoint the plugin pushes to.
  • hostfns.go, the heart of the contract: the host-function definitions, the permission constants, and the types plugins receive. Every host function reads a function-pointer field from a HostEnv struct and resolves the calling plugin's identity + permission at call time (see registry.go).
  • engines/ + engine_cache.go, the embedded per-language engine wasms (go:embed) and the cache that compiles each once and instantiates it per plugin.
  • registry.go, the per-plugin identity registry shared host functions look up to scope a call (slug, granted permissions, kv namespace, assets). It is the call-time replacement for the old per-plugin closures.
  • commands.go, matches accepted chat messages against every plugin's command declarations, applies moderator gates and cooldowns, and dispatches the internal chat.command event to every match.
  • help.go, the host-owned unified !help: aggregates each plugin's reported command metadata and renders the listing.
  • kv/, the key/value store interface plugins get (memory + bolt implementations here. Owncast backs it with its datastore).
  • testing/, a mock host (MockHost) and the scenario runner used by owncast-plugin-test.

HostEnv is the integration seam

hostfns.go is intentionally host-agnostic. A host function like owncast_video_config_read just calls env.VideoConfig(), a field on HostEnv. BuildHostFunctions assembles the host functions a plugin gets based on its declared permissions. Whoever embeds the runtime fills in HostEnv with real data. Four hosts do this today:

Host HostEnv is backed by Used for
host-runtime/main.go a hardcoded simulated stream demo/playground
cmd/owncast-plugin-serve in-memory dev stubs + a dev chat log local plugin development
plugin/testing (MockHost) scenario-supplied fixtures owncast-plugin-test
Owncast pluginhost real Owncast services production

All four expose the same host functions and types. Only the data behind HostEnv differs. That's what lets a plugin built once run identically in tests, the dev server, and production.

The plugin API contract

The plugin-facing API exists in three representations that must agree:

  1. Go, the host functions, permissions, and types in Owncast's services/plugins/hostfns.go. The runtime lives in the Owncast repo (see Relationship to Owncast), and this SDK imports it.
  2. TypeScript, the owncast.* wrappers in sdks/js/index.js and the types in sdks/js/index.d.ts, which authors code against.
  3. services/plugins/plugin-contract.json, a generated snapshot of (1): permission identifiers, host-function names, and the field shapes of every wire type. It does nothing at runtime. It's a fingerprint.

services/plugins/contract_test.go guards against drift: it re-derives the snapshot from hostfns.go and compares it to plugin-contract.json (field shapes included). Regenerate after an intentional change with UPDATE_CONTRACT=1 go test ./services/plugins/ -run TestPluginContractMatchesSDK.

The snapshot is the artifact this SDK and other consumers vendor, so an embedded runtime can't silently fall behind. See the Wire Protocol for the byte-level contract these three representations encode.

Toolchain and build flow

There are two separate builds: the engines (built rarely, by maintainers) and an author's plugin (built often, by anyone, with no wasm toolchain needed).

Building a plugin (what authors run)

Because the interpreter is host-side, an author's build just produces their source, not a wasm module:

  • JS (sdks/js/bin/owncast-plugin.js): owncast-plugin build runs esbuild to bundle src/plugin.{ts,js} into a single CommonJS file with @owncast/plugin-sdk marked external (the SDK lives in the engine), emitting <slug>.js.
  • Python (sdks/python): the build strips the SDK import line and emits <slug>.py (the SDK is a global in the engine).

owncast-plugin package then zips the manifest + that code file + public/ (web-served) + assets/ (host-read for manifest-inlined content), plus optional icon.png / INSTRUCTIONS.md, into a single .ocpkg. The code entry is named by language (plugin.js, plugin.py, or plugin.wasm for a self-contained module), and the host infers the runtime from that filename, so the manifest needs no type field. Authors no longer touch extism-js/ extism-py or binaryen at all.

Building the engines (what maintainers run)

engines/build.mjs and engines/build_py.py compile the fixed bootstrap entry (SDK runtime + dispatch shim + a loader that reads the plugin's source from config) into engine.wasm per language, via extism-js/extism-py + binaryen, and copy the result into Owncast's embed dir (services/plugins/engines/{javascript,python}/engine.wasm, committed so Owncast's Go build stays toolchain-free). make fetches the engine toolchain itself (engines/install-toolchain.mjsengines/.toolchain/). The author-facing SDK install does not ship any wasm tooling. Rebuild + recommit when the SDK runtime or bootstrap changes. Full runbook in engines/README.md.

The npm postinstall (sdks/js/scripts/postinstall.js) fetches just the owncast-plugin-test / owncast-plugin-serve Go binaries (built from host-runtime/) for test/serve. That's all an author's install downloads. tools/bootstrap.sh builds them locally. .github/workflows/release.yml cross-compiles those two (pure Go, CGO_ENABLED=0) for linux/darwin × amd64/arm64 on every v* tag.

Command-line tools

  • owncast-plugin build / package, bundle a plugin's source into a .ocpkg.
  • owncast-plugin test, run __tests__/*.test.json scenarios against a built plugin (delegates to the owncast-plugin-test Go binary, which uses the real runtime with MockHost).
  • owncast-plugin serve, run one plugin behind a localhost dev server (owncast-plugin-serve), with stubbed host data and dev endpoints to drive chat/events into the plugin.

Testing

  • Scenario tests (*.test.json) describe given state, events/http steps, and expect assertions. The runner loads the plugin on the real embedded engine with MockHost, so passing here means the same code path passes in production.
  • Go tests cover the runtime packages (manager, dispatcher, server, sse, testing).
  • Contract/drift tests keep the Go/TS/snapshot representations aligned.

Relationship to Owncast

The runtime lives in the Owncast repo as services/plugins/, where Owncast wires HostEnv to its real services. This SDK's host-runtime/ module imports it, so the dev CLIs run the exact production runtime. The API surface in hostfns.go is pinned by services/plugins/plugin-contract.json and its contract test, so it can't drift from the Wire Protocol. The host-side integration details (wiring, the sync workflow) are documented in the Owncast repo at docs/plugins.md.