Skip to content

Commit 4e1d66c

Browse files
committed
feat(plugins): support dynamic templated content in the UI for plugins
1 parent 5d625a5 commit 4e1d66c

26 files changed

Lines changed: 862 additions & 225 deletions

docs/PLUGIN_AUTHOR_GUIDE.md

Lines changed: 79 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -699,11 +699,11 @@ A common pattern is an admin page that lets the streamer add a custom button (la
699699
700700
Three manifest fields let a plugin contribute content directly to the viewer page: CSS, JavaScript, and a block of HTML. The host inlines plugin contributions into the same response slots Owncast already uses for the admin's custom CSS, custom JS, and extra page content, so a viewer loads one stylesheet, one script, and one extra-content block regardless of how many plugins contributed.
701701
702-
| Manifest field | Inlined into | Required permission | Extension |
703-
| ------------------ | ------------------------------------- | ------------------- | --------- |
704-
| `styles` | `/api/config` → `customStyles` | `ui.modify` | `.css` |
705-
| `scripts` | `/customjavascript` | `ui.modify` | `.js` |
706-
| `extraPageContent` | `/api/config` → `extraPageContent` | `ui.modify` | `.html` |
702+
| Manifest field | Inlined into | Required permission | Notes |
703+
| ------------------ | ------------------------------------- | ------------------- | ------------------ |
704+
| `styles` | `/api/config` → `customStyles` | `ui.modify` | must end `.css` |
705+
| `scripts` | `/customjavascript` | `ui.modify` | must end `.js` |
706+
| `extraPageContent` | `/api/config` → `extraPageContent` | `ui.modify` | static or dynamic |
707707
708708
### Stylesheets
709709
@@ -742,44 +742,72 @@ Two things to keep in mind about execution:
742742

743743
### Extra page content
744744

745+
`extraPageContent` is an object with a required `slug` and an optional `content` file:
746+
745747
```json
746748
{
747749
"permissions": ["ui.modify"],
748-
"extraPageContent": "content.html"
750+
"extraPageContent": { "slug": "banner", "content": "content.html" }
749751
}
750752
```
751753

752-
One HTML file inlined at the top of the viewer's extra-content block, above whatever the admin has configured. Wrapped with `<!-- plugin: <slug> — <file> -->` for attribution. Path rules match `styles` and `scripts` but the entry has to end in `.html`. `http.serve` is not required because the HTML is folded into the `/api/config` response, not served as a URL.
754+
**Static** (`content` present): the host reads the file from `assets/` and inlines it at the top of the viewer's extra-content block, above whatever the admin has configured. Path rules match `styles` and `scripts` but the entry has to end in `.html`. `http.serve` is not required.
755+
756+
**Dynamic** (`content` absent): the host calls `onPageContent({ slug, user? })` at `/api/config` time and inlines the returned HTML string. `user` is the requesting viewer's chat identity when available.
753757

754-
The admin's extra page content goes through the markdown processor; plugin HTML does not (the host renders the admin's markdown first, then prepends your raw bytes). Tags and attributes pass through as written, so escape any untrusted strings you embed.
758+
```js
759+
module.exports = definePlugin({
760+
onPageContent({ slug, user }) {
761+
if (slug === "banner") {
762+
const name = user?.displayName ?? "visitor";
763+
return `<aside>Welcome, ${name}!</aside>`;
764+
}
765+
return "";
766+
},
767+
});
768+
```
755769

756-
A combined plugin shipping all three fields is the common pattern when you need behavior and presentation together (the `viewer-gate` example in the SDK pairs a CSS file with a JavaScript file; `page-content-demo` ships a single HTML block).
770+
The admin's extra page content goes through the markdown processor; plugin HTML does not. Tags and attributes pass through as written — escape any untrusted strings you embed.
757771
758772
## Viewer-page tabs
759773
760-
Plugins can add tabs to the viewer page's tab row (alongside the built-in **About** and **Followers** tabs) with `manifest.tabs[]`. Each entry has a `title` and a `content` path to an HTML file under `assets/`:
774+
Plugins can add tabs to the viewer page's tab row (alongside the built-in **About** and **Followers** tabs) with `manifest.tabs[]`. Each entry requires a `title` and a `slug`; `content` is optional:
761775

762776
```json
763777
{
764778
"permissions": ["ui.modify"],
765779
"tabs": [
766-
{ "title": "Music", "content": "music.html" },
767-
{ "title": "Schedule", "content": "schedule.html" }
780+
{ "title": "Music", "slug": "music", "content": "music.html" },
781+
{ "title": "Stream Info", "slug": "stream-info" }
768782
]
769783
}
770784
```
771785

772-
Requires `ui.modify`. `http.serve` is **not** required: each tab's HTML is read from `assets/` and inlined into the `pluginTabs[]` array on `/api/config`. The viewer page renders one AntD tab per entry with the title as the label and the inlined HTML as the body.
786+
- `slug`required. Stable identifier, unique within the plugin's tabs. Passed to `onTabContent` so the handler knows which tab to render. Lowercase letters, digits, hyphens.
787+
- `title` — required. The label shown on the tab. Keep it short (~16 characters max for mobile).
788+
- `content` — optional. Relative path to a static HTML file in `assets/`. When omitted, the host calls `onTabContent`.
789+
790+
**Static** (`content` present): the host reads the file from `assets/` and inlines it as the tab body. Path rules match `extraPageContent.content`.
773791
774-
Per-entry rules (`content`):
792+
**Dynamic** (`content` absent): the host calls `onTabContent({ slug, user? })` and inlines the returned HTML string.
775793
776-
- Bare paths auto-prefix to the plugin's namespace (`"music.html"``/plugins/<slug>/music.html`).
777-
- Fully qualified `/plugins/<slug>/...` paths pass through.
778-
- Paths in another plugin's namespace, `http(s)://` URLs, or non-`.html` extensions are rejected at load.
794+
```js
795+
module.exports = definePlugin({
796+
onTabContent({ slug, user }) {
797+
if (slug === "stream-info") {
798+
const stream = owncast.stream.current();
799+
return stream.online
800+
? `<p>Live: ${stream.title} — ${stream.viewers} viewers</p>`
801+
: `<p>Offline</p>`;
802+
}
803+
return "";
804+
},
805+
});
806+
```
779807
780-
Tab keys are derived from the plugin's slug so a tab only unmounts when the plugin is disabled/removed, not on every render. Plugin tabs appear after the built-ins in the declaration order. Keep titles short — anything past ~16 characters won't render cleanly on mobile.
808+
Requires `ui.modify`. `http.serve` is not required — the host inlines the result, nothing is served at a URL. Add whatever data permissions (`server.read`, `chat.history`, etc.) your handler actually calls.
781809
782-
The `tabs-demo` example in the SDK ships two minimal tabs (Music, Schedule) and is the recommended starting point.
810+
The `tabs-demo` example ships two static tabs; `page-content-demo` demonstrates dynamic rendering with Mustache templates and `server.read` data.
783811
784812
## Plugin-to-plugin events
785813
@@ -838,7 +866,38 @@ Point your `test` script at that one entry (`node __tests__/index.test.js`) inst
838866

839867
- `event: "<type>"`, fire-and-forget notification dispatch
840868
- `filter: "<type>"`, filter chain; inline `expect: {action, payload?, reason?}` checks the FilterResult
841-
- `http: { method, path, headers?, body?, expect: {status, headers?, body?} }`, sends an HTTP request through your plugin server
869+
- `http: { method, path, headers?, body?, user?, authenticated?, expect: {status, headers?, body?, bodyContains?} }`, sends an HTTP request through your plugin server
870+
- `tabContent: { slug, user?, expect: {body?, bodyContains?} }`, calls `onTabContent` directly and asserts on the returned HTML
871+
- `pageContent: { slug, user?, expect: {body?, bodyContains?} }`, calls `onPageContent` directly and asserts on the returned HTML
872+
873+
```json
874+
[
875+
{
876+
"name": "stream-info tab renders live title",
877+
"given": { "stream": { "online": true, "title": "Friday Night", "viewers": 12 } },
878+
"events": [
879+
{
880+
"tabContent": {
881+
"slug": "stream-info",
882+
"expect": { "bodyContains": "Friday Night" }
883+
}
884+
}
885+
]
886+
},
887+
{
888+
"name": "banner greets authenticated viewer by name",
889+
"events": [
890+
{
891+
"pageContent": {
892+
"slug": "banner",
893+
"user": { "id": "u1", "displayName": "Alice" },
894+
"expect": { "bodyContains": "Alice" }
895+
}
896+
}
897+
]
898+
}
899+
]
900+
```
842901

843902
### Assertions
844903

docs/WIRE_PROTOCOL.md

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,18 @@ A plugin is a WebAssembly module exposing four well-known exports and (condition
88

99
## Exports (plugin → host)
1010

11-
Every plugin must export these four functions:
11+
Every plugin must export these functions:
1212

1313
| Function | Input | Output | Purpose |
1414
| ----------------- | -------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------- |
1515
| `register` | none | JSON `Manifest` | Returns the plugin's subscriptions for the host to compare against the static `plugin.manifest.json`. |
1616
| `on_event` | JSON `Envelope` | none | Notification dispatch. Fire-and-forget. |
1717
| `on_filter` | JSON `Envelope` | JSON `FilterResult` | Filter chain entry point. |
1818
| `on_http_request` | JSON `IncomingHttpRequest` | JSON `OutgoingHttpResponse` | HTTP request handler for `/plugins/<name>/*`. |
19+
| `on_tab_content` | JSON `ContentRequest` | raw HTML string | Render HTML for a dynamic tab (one without a static `content` file in the manifest). |
20+
| `on_page_content` | JSON `ContentRequest` | raw HTML string | Render HTML for a dynamic `extraPageContent` slot (one without a static `content` file). |
1921

20-
Each entry point has a per-call timeout enforced by the host. See the host's `dispatcher.go` and `server.go` for current values.
22+
`ContentRequest` shape: `{ "slug": "<tab-or-slot-slug>", "user"?: ChatUser }`. The host calls the appropriate export when building the `/api/config` response; the returned string is inlined directly as the body. An empty string is valid (renders nothing). Each entry point has a per-call timeout enforced by the host. See the host's `dispatcher.go` and `server.go` for current values.
2123

2224
## Imports (host → plugin)
2325

@@ -133,6 +135,7 @@ These imports are granted to every plugin without a declared permission. A plugi
133135
- `owncast_timer_set(id: I64, delayMs: I64, repeat: I32): I32`, schedule a host-driven timer; the host fires the `timer.fire` event (payload `{id}`) when it elapses. Returns 1 on success, 0 if the plugin is at its pending-timer cap. `delayMs` is clamped to `[100, 86_400_000]`. The SDK maps `id`→callback for `owncast.timer.setTimeout/setInterval`.
134136
- `owncast_timer_clear(id: I64): void`, cancel a pending timer by id.
135137
- `owncast_config_get(keyPtr: PTR): PTR`, returns the JSON value of a `manifest.config` key (admin override, else declared default), or 0-offset for an unknown/unset key.
138+
- `owncast_asset_read(pathPtr: PTR): PTR`, returns the raw bytes of a file from the plugin's own `assets/` directory, or 0-offset when the file is missing or the path escapes the directory. The path is relative to `assets/` and must not start with `/` or contain `..` segments; the host rejects any path that would escape the plugin's own asset tree. Plugins use this to load bundled resources (templates, data files) at request time without needing `storage.fs`.
136139

137140
The host also dispatches a `tick` event (payload `{now}`, host wall-clock ms) about once a second to any plugin defining `onTick`, independent of timers.
138141

@@ -229,35 +232,53 @@ Same per-entry rules as `manifest.styles[]`, applied to `.js` files (only `ui.mo
229232

230233
### `manifest.extraPageContent`
231234

232-
A single relative path to an HTML file the plugin contributes to the viewer's extra-content block. The host reads the file's bytes and prepends them to the admin's rendered `extraPageContent` on `/api/config`, so plugin HTML lands above the admin's prose.
235+
An object the plugin contributes to the viewer's extra-content block. The host prepends the resolved HTML to the admin's rendered `extraPageContent` on `/api/config`, so plugin HTML lands above the admin's prose.
233236

234-
Per-entry validation:
237+
```json
238+
{
239+
"slug": "string (required, identifies the slot — passed to on_page_content)",
240+
"content": "string (optional, relative path to assets/<file>.html)"
241+
}
242+
```
243+
244+
Validation:
235245

236246
- `ui.modify` permission required.
237247
- `http.serve` is **not** required: the HTML is inlined into the API response, not served at a URL.
238-
- Same path-shape rules as `manifest.styles[]`, applied to a single `.html` entry.
248+
- `slug` must be a valid slug (lowercase letters/digits/hyphens, starting with a letter, max 64 chars).
249+
- When `content` is present, the same path-shape rules as `manifest.styles[]` apply (must end in `.html`).
250+
251+
**Static** (`content` present): the host reads the file from `assets/` and inlines its bytes.
252+
253+
**Dynamic** (`content` absent): the host calls `on_page_content` with `{ slug, user? }` and inlines the returned HTML string. `user` carries the requesting viewer's chat identity when available.
239254

240255
Each contribution is wrapped with an `<!-- plugin: <slug> — <file> -->\n` comment for in-page attribution. The admin's content goes through the markdown processor before plugin HTML is prepended; plugin HTML is left raw so tags and attributes pass through as written.
241256

242257
### `manifest.tabs[]`
243258

244-
An array of viewer-page tabs the plugin contributes alongside the built-in tabs (Followers, About). Each entry's `content` is a relative path to an HTML file the host reads from the plugin's assets/ directory at request time.
259+
An array of viewer-page tabs the plugin contributes alongside the built-in tabs (Followers, About).
245260

246261
```json
247262
{
248-
"title": "string (required)",
249-
"content": "string (required, relative path to assets/<file>.html)"
263+
"title": "string (required, tab label)",
264+
"slug": "string (required, stable identifier — passed to on_tab_content)",
265+
"content": "string (optional, relative path to assets/<file>.html)"
250266
}
251267
```
252268

253-
Per-entry validation:
269+
Validation:
254270

255271
- `ui.modify` permission required.
256272
- `http.serve` is **not** required: each tab's HTML is inlined into the response, not served at a URL.
257-
- Title must be non-empty.
258-
- `content` path follows the same rules as `manifest.extraPageContent` (auto-prefix to the plugin's namespace, cross-plugin paths and `http(s)://` URLs rejected, must end in `.html`).
273+
- `title` must be non-empty. Unique within the plugin's tabs.
274+
- `slug` must be a valid slug. Unique within the plugin's tabs. Passed to `on_tab_content` so the plugin knows which tab to render.
275+
- When `content` is present, the same path rules as `manifest.extraPageContent.content` apply (must end in `.html`).
276+
277+
**Static** (`content` present): the host reads the file from `assets/` and inlines its bytes.
278+
279+
**Dynamic** (`content` absent): the host calls `on_tab_content` with `{ slug, user? }` and inlines the returned HTML string.
259280

260-
The host emits the tab list on `GET /api/config` under `pluginTabs[]` as `[{slug, title, html}]` entries. The viewer page maps each entry to a tab whose body renders the inlined HTML. Slug doubles as the React key so a tab only unmounts when the source plugin is disabled/removed.
281+
The host emits the tab list on `GET /api/config` under `pluginTabs[]` as `[{slug, title, html}]` entries. The viewer page maps each entry to a tab whose body renders the inlined HTML. `slug` doubles as the React key so a tab only unmounts when the source plugin is disabled/removed.
261282

262283
## Payload types
263284

@@ -269,7 +290,7 @@ Each language SDK is responsible for:
269290

270291
- Declaring the imports listed above (gated by manifest permissions) so the plugin author's call into `owncast.chat.send(...)` resolves to the right wasm import.
271292
- Encoding/decoding payloads as JSON or text per the table above.
272-
- Implementing the four exports' dispatch loop: parse the envelope, route to the right handler, serialize the response.
293+
- Implementing the exports' dispatch loop: parse the envelope, route to the right handler, serialize the response. This covers all six exports: `register`, `on_event`, `on_filter`, `on_http_request`, `on_tab_content`, and `on_page_content`.
273294

274295
The Owncast server repo's plugin runtime is responsible for:
275296

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,56 @@
11
# Page Content Demo
22

3-
Smallest possible example of the `manifest.extraPageContent` capability. The plugin ships a single HTML file and the host inlines its bytes at the top of the viewer page's extra-content block.
3+
Demonstrates dynamic viewer page content rendered server-side with Mustache. The plugin contributes a personalised banner above the tab row and a live "Stream Info" tab — both rendered at request time with no static HTML files or client-side fetch calls.
44

55
## What you'll see when enabled
66

7-
An amber-bordered banner appears at the top of the viewer page's extra-content block reading:
7+
**Banner (extra page content):** An amber-bordered panel at the top of the viewer page's extra-content block greeting the viewer by their chat display name. Anonymous viewers see "visitor".
88

9-
> page-content-demo: HTML reached the viewer page's extra-content block.
10-
11-
The contribution lands above whatever the admin has written into the extra page content field, so any page content the admin has configured continues to render unchanged underneath.
9+
**Stream Info tab:** A new tab in the viewer tab row showing live stream state (online/offline, title, viewer count, started time), server metadata (name, version, URL), tags, social handles, and federation status — all rendered from `owncast.stream.current()` and related APIs.
1210

1311
## How it works
1412

15-
The manifest declares a single HTML asset:
13+
The manifest declares the two slots without `content` paths, which tells the host to call the plugin's handlers instead of reading static files:
1614

1715
```json
1816
{
19-
"permissions": ["ui.modify"],
20-
"extraPageContent": "content.html"
17+
"permissions": ["ui.modify", "server.read"],
18+
"extraPageContent": { "slug": "banner" },
19+
"tabs": [
20+
{ "title": "Stream Info", "slug": "stream-info" }
21+
]
2122
}
2223
```
2324

24-
The host rewrites the bare path to `/plugins/page-content-demo/content.html` (the canonical form), reads the file's bytes from the plugin, and prepends them to the admin's rendered `extraPageContent` on `/api/config`. Each contribution is wrapped with an `<!-- plugin: <slug> ... -->` comment so a reader can attribute the markup back to the plugin that shipped it.
25+
When Owncast builds its `/api/config` response it calls:
26+
27+
- `onPageContent({ slug: "banner", user? })` to get the banner HTML
28+
- `onTabContent({ slug: "stream-info", user? })` to get the tab HTML
29+
30+
Both handlers load their Mustache template from `assets/` via `owncast.assets.readText(name)` and render it with the relevant data.
31+
32+
## Templates
33+
34+
- `assets/greeting.mustache` — the banner; uses `{{displayName}}` with Mustache's auto-escaping.
35+
- `assets/info.mustache` — the stream info tab; uses `{{#stream.online}}` / `{{^stream.online}}` conditionals and `{{#tags}}{{.}}{{/tags}}` iteration.
2536

2637
## Permissions
2738

28-
- **ui.modify** — the plugin paints inside Owncast's chrome.
39+
- **ui.modify** — required for `extraPageContent` and `tabs`.
40+
- **server.read** — required by `onTabContent` to call `owncast.stream.current()`, `owncast.server.info()`, etc.
2941

30-
`http.serve` is not required because the HTML is inlined into the `/api/config` response, not served as a URL.
42+
`http.serve` is not needed. The host calls the handlers directly and inlines the returned HTML; there are no plugin HTTP endpoints.
3143

32-
## When to use this as a template
44+
## Testing
3345

34-
Start here if you want to ship inline HTML for the viewer page: a sponsor banner, an announcement strip, a static call-to-action. Plugin HTML doesn't go through the markdown processor, so HTML tags and attributes pass through unmodified.
46+
Scenarios in `__tests__/` use the `pageContent` and `tabContent` step types to exercise the handlers directly:
47+
48+
```json
49+
{
50+
"pageContent": {
51+
"slug": "banner",
52+
"user": { "id": "u-alice", "displayName": "Alice" },
53+
"expect": { "bodyContains": "Alice" }
54+
}
55+
}
56+
```

0 commit comments

Comments
 (0)