You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: docs/PLUGIN_AUTHOR_GUIDE.md
+79-20Lines changed: 79 additions & 20 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -699,11 +699,11 @@ A common pattern is an admin page that lets the streamer add a custom button (la
699
699
700
700
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.
701
701
702
-
| Manifest field | Inlined into | Required permission | Extension |
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.
753
757
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
+
```
755
769
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.
757
771
758
772
## Viewer-page tabs
759
773
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:
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`.
773
791
774
-
Per-entry rules (`content`):
792
+
**Dynamic** (`content` absent): the host calls `onTabContent({ slug, user? })` and inlines the returned HTML string.
775
793
776
-
- Bare paths auto-prefix to the plugin's namespace (`"music.html"` → `/plugins/<slug>/music.html`).
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.
781
809
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.
783
811
784
812
## Plugin-to-plugin events
785
813
@@ -838,7 +866,38 @@ Point your `test` script at that one entry (`node __tests__/index.test.js`) inst
|`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). |
19
21
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.
21
23
22
24
## Imports (host → plugin)
23
25
@@ -133,6 +135,7 @@ These imports are granted to every plugin without a declared permission. A plugi
133
135
-`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`.
134
136
-`owncast_timer_clear(id: I64): void`, cancel a pending timer by id.
135
137
-`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`.
136
139
137
140
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.
138
141
@@ -229,35 +232,53 @@ Same per-entry rules as `manifest.styles[]`, applied to `.js` files (only `ui.mo
229
232
230
233
### `manifest.extraPageContent`
231
234
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.
233
236
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:
235
245
236
246
-`ui.modify` permission required.
237
247
-`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.
239
254
240
255
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.
241
256
242
257
### `manifest.tabs[]`
243
258
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).
245
260
246
261
```json
247
262
{
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)"
250
266
}
251
267
```
252
268
253
-
Per-entry validation:
269
+
Validation:
254
270
255
271
-`ui.modify` permission required.
256
272
-`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.
259
280
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.
261
282
262
283
## Payload types
263
284
@@ -269,7 +290,7 @@ Each language SDK is responsible for:
269
290
270
291
- 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.
271
292
- 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`.
273
294
274
295
The Owncast server repo's plugin runtime is responsible for:
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.
4
4
5
5
## What you'll see when enabled
6
6
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".
8
8
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.
12
10
13
11
## How it works
14
12
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:
16
14
17
15
```json
18
16
{
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
+
]
21
22
}
22
23
```
23
24
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.
25
36
26
37
## Permissions
27
38
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.
29
41
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.
31
43
32
-
## When to use this as a template
44
+
## Testing
33
45
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:
0 commit comments