From 83ffb4046a64ab6b1322044c8f1f0004e5a06c7b Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Sat, 8 Aug 2026 21:54:28 -0700 Subject: [PATCH 1/2] feat(events): document host-namespaced custom events --- README.md | 17 ++++++++++++++++- docs/PLUGIN_AUTHOR_GUIDE.md | 14 ++++++++------ docs/WIRE_PROTOCOL.md | 7 +++++-- examples/js/README.md | 4 ++-- examples/js/announcer/INSTRUCTIONS.md | 8 ++++++-- examples/js/announcer/README.md | 4 +++- .../js/announcer/__tests__/announcer.test.json | 4 ++-- examples/js/announcer/plugin.manifest.json | 2 +- examples/js/announcer/src/plugin.js | 2 +- examples/js/relay/INSTRUCTIONS.md | 9 +++++++-- examples/js/relay/README.md | 5 ++++- examples/python/README.md | 4 ++-- examples/python/announcer/INSTRUCTIONS.md | 8 ++++++-- examples/python/announcer/README.md | 9 +++++++-- .../announcer/__tests__/announcer.test.json | 4 ++-- examples/python/announcer/plugin.manifest.json | 2 +- examples/python/announcer/src/plugin.py | 2 +- examples/python/relay/INSTRUCTIONS.md | 9 +++++++-- examples/python/relay/README.md | 5 ++++- .../skills/create-owncast-plugin-js/SKILL.md | 6 ++++-- .../js/create-owncast-plugin/template/AGENTS.md | 2 +- .../template/src/plugin.js | 2 +- sdks/js/index.d.ts | 9 +++++++-- sdks/js/index.js | 2 ++ sdks/python/owncast_plugin/__init__.py | 6 ++++++ .../skills/create-owncast-plugin-py/SKILL.md | 7 +++++-- sdks/python/owncast_plugin/template/AGENTS.md | 2 +- .../owncast_plugin/template/src/plugin.py | 2 +- 28 files changed, 113 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 49b8bd0..0606cf7 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,13 @@ cd host-runtime && go run . ../plugins `tools/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. +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 emitting +`announcement.broadcast`. announcer subscribes to the host-namespaced +`relay.announcement.broadcast`, so that leg stays silent until the +`host-runtime` Owncast pin carries slug-prefixed custom events (see +[Open items](#open-items--not-yet-done)). ## Run all example tests @@ -199,3 +205,12 @@ See **[examples/js/README.md](./examples/js/README.md)** for the full catalog of - **Action button HTML sanitization**: action buttons with an `html` field 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/` and `sdks/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 `.js` directly. Today the build step is mandatory. +- **Host-namespaced custom events need a pin bump**: `host-runtime/go.mod` pins + `github.com/owncast/owncast` at a build predating slug-prefixed custom events, + so `owncast-plugin test`, `owncast-plugin serve`, and the demo host still + dispatch the raw suffix. Until the pin moves to a release carrying the fix, + `examples/{js,python}/relay/__tests__/relay.test.json` expects the unprefixed + `announcement.broadcast`, and the relay to announcer round-trip does not fire + locally because announcer subscribes to `relay.announcement.broadcast`. Bump + the pin, flip both scenario expectations to the prefixed name, then re-run the + example suite. diff --git a/docs/PLUGIN_AUTHOR_GUIDE.md b/docs/PLUGIN_AUTHOR_GUIDE.md index 7be6fe1..7b146a8 100644 --- a/docs/PLUGIN_AUTHOR_GUIDE.md +++ b/docs/PLUGIN_AUTHOR_GUIDE.md @@ -1111,19 +1111,21 @@ The `tabs-demo` example ships two static tabs. `page-content-demo` demonstrates ## Plugin-to-plugin events -Plugins compose by emitting custom events: +Plugins compose by emitting custom events. The host prefixes every emitted name +with the emitting plugin's slug, so another plugin cannot impersonate it: ```js -// Emitter (needs events.emit permission) -owncast.events.emit("my-plugin.thing-happened", { id: 123 }); +// Emitter (relay, needs events.emit permission) +owncast.events.emit("announcement.broadcast", { id: 123 }); // Subscriber on: { - "my-plugin.thing-happened"(payload) { /* ... */ } + "relay.announcement.broadcast"(payload) { /* ... */ } } ``` -Use `.` namespacing. Event names are arbitrary strings. +The name you pass to `emit` is a suffix and may contain dots for hierarchy. +Subscribe using the sender's fully qualified `.` name. ## Testing @@ -1517,7 +1519,7 @@ module.exports = definePlugin({ // announcer/src/plugin.js, no permissions needed module.exports = definePlugin({ on: { - "announcement.broadcast"(payload) { + "relay.announcement.broadcast"(payload) { owncast.log.info(`Announcement from ${payload.by}: ${payload.text}`); }, }, diff --git a/docs/WIRE_PROTOCOL.md b/docs/WIRE_PROTOCOL.md index e40429a..7478fce 100644 --- a/docs/WIRE_PROTOCOL.md +++ b/docs/WIRE_PROTOCOL.md @@ -238,8 +238,11 @@ plugin should store values above `Number.MAX_SAFE_INTEGER` (2^53 - 1) as TEXT. ### `events.emit` - `owncast_emit_event(eventTypePtr: PTR, payloadPtr: PTR): void`. Inputs: - `eventTypePtr` is a UTF-8 event name and `payloadPtr` is one JSON value. - Output: none. + `eventTypePtr` is a UTF-8 event-name suffix and `payloadPtr` is one JSON + value. The host prefixes the suffix with the calling plugin's slug, which it + resolves from the wasm call rather than trusting the guest, so a plugin can + only publish under `.` and can neither impersonate another + plugin nor forge a built-in event. Output: none. ### `server.read` diff --git a/examples/js/README.md b/examples/js/README.md index 309ae8f..8c8220e 100644 --- a/examples/js/README.md +++ b/examples/js/README.md @@ -13,8 +13,8 @@ One self-contained npm project per directory. Each has its own `README.md` with | [profanity-filter](./profanity-filter/) | `filter.modify(payload)`, rewrites flagged words to asterisks. | | [slow-mode](./slow-mode/) | `filter.drop(reason)`, rate-limits per user, with plugin-config-backed state. | | [buggy-filter](./buggy-filter/) | Always throws, exercises the host's fail-open + strike system. | -| [relay](./relay/) | Emits a custom `announcement.broadcast` event (plugin → plugin). | -| [announcer](./announcer/) | Subscribes to `announcement.broadcast` via the `on: { ... }` map. | +| [relay](./relay/) | Emits `announcement.broadcast`, delivered as `relay.announcement.broadcast`. | +| [announcer](./announcer/) | Subscribes to `relay.announcement.broadcast` via the `on: { ... }` map. | | [ip-bot](./ip-bot/) | Outbound HTTP via `owncast.http.fetch`, mocked in tests. | | [overlay](./overlay/) | `http.serve`, static files from `public/` + dynamic JSON endpoint. | | [stream-tracker](./stream-tracker/) | Every typed lifecycle / chat-user handler + read APIs. | diff --git a/examples/js/announcer/INSTRUCTIONS.md b/examples/js/announcer/INSTRUCTIONS.md index 6532bdd..503b490 100644 --- a/examples/js/announcer/INSTRUCTIONS.md +++ b/examples/js/announcer/INSTRUCTIONS.md @@ -1,6 +1,8 @@ # Announcer -A receiver-side demo. It listens for a custom `announcement.broadcast` event that the **relay** example plugin emits, and logs each one to the server. The event type is a plugin-defined string, not a built-in Owncast event. +A receiver-side demo. It listens for `relay.announcement.broadcast`, emitted by +the **relay** example plugin, and logs each one to the server. The event type is +plugin-defined, not a built-in Owncast event. ## How to use it @@ -8,7 +10,9 @@ This plugin does nothing on its own. It's one half of a pair. 1. Install and enable **both** this plugin and the **relay** plugin. 2. In chat, type `/announce ` (that command is handled by relay). -3. relay emits an `announcement.broadcast` event. This plugin receives it and writes an info entry to the Owncast server log through `owncast.log.info`. +3. relay emits `announcement.broadcast`. The host delivers it as + `relay.announcement.broadcast`, this plugin receives it and writes an info + entry to the Owncast server log through `owncast.log.info`. There is no viewer-facing output. Watch the Owncast server logs to see it fire. diff --git a/examples/js/announcer/README.md b/examples/js/announcer/README.md index 9d68897..207d7a9 100644 --- a/examples/js/announcer/README.md +++ b/examples/js/announcer/README.md @@ -1,5 +1,7 @@ # announcer -Subscribes to the custom `announcement.broadcast` event emitted by `../relay` and logs it. The event type is a plugin-defined string, not a built-in Owncast event. +Subscribes to `relay.announcement.broadcast`, the custom event emitted by +`../relay`, and logs it. It is a plugin-defined event, not a built-in Owncast +event. The host adds the `relay.` prefix, so that is the name to subscribe to. **Demonstrates:** custom-event subscription via the `on: { ... }` object in `definePlugin` and info-level server logging through `owncast.log.info`. Neither receiving the event nor writing the log requires a permission. diff --git a/examples/js/announcer/__tests__/announcer.test.json b/examples/js/announcer/__tests__/announcer.test.json index 5a95952..2d2f57b 100644 --- a/examples/js/announcer/__tests__/announcer.test.json +++ b/examples/js/announcer/__tests__/announcer.test.json @@ -1,9 +1,9 @@ [ { - "name": "logs announcement.broadcast at info level", + "name": "logs relay.announcement.broadcast at info level", "events": [ { - "event": "announcement.broadcast", + "event": "relay.announcement.broadcast", "payload": { "by": "alice", "text": "stream is live", "at": "2024-01-01T00:00:00Z" } } ], diff --git a/examples/js/announcer/plugin.manifest.json b/examples/js/announcer/plugin.manifest.json index b5680c7..ca748de 100644 --- a/examples/js/announcer/plugin.manifest.json +++ b/examples/js/announcer/plugin.manifest.json @@ -3,7 +3,7 @@ "name": "Example Announcer", "slug": "announcer", "version": "0.3.2", - "description": "Handles announcement.broadcast events. This example was written in JavaScript.", + "description": "Handles relay.announcement.broadcast events. This example was written in JavaScript.", "category": "examples", "permissions": [] } diff --git a/examples/js/announcer/src/plugin.js b/examples/js/announcer/src/plugin.js index f890272..9495aae 100644 --- a/examples/js/announcer/src/plugin.js +++ b/examples/js/announcer/src/plugin.js @@ -2,7 +2,7 @@ const { definePlugin, owncast } = require("@owncast/plugin-sdk"); module.exports = definePlugin({ on: { - "announcement.broadcast"(payload) { + "relay.announcement.broadcast"(payload) { owncast.log.info(`Announcement from ${payload.by}: ${payload.text}`); } } diff --git a/examples/js/relay/INSTRUCTIONS.md b/examples/js/relay/INSTRUCTIONS.md index f684443..b454927 100644 --- a/examples/js/relay/INSTRUCTIONS.md +++ b/examples/js/relay/INSTRUCTIONS.md @@ -1,12 +1,17 @@ # Announcement Relay -Watches chat for an `/announce ` command and re-broadcasts it as a custom `announcement.broadcast` event that other plugins can subscribe to. Pairs with the **announcer** example. +Watches chat for an `/announce ` command and re-broadcasts it as a custom +`announcement.broadcast` event, delivered to subscribers as +`relay.announcement.broadcast`. Pairs with the **announcer** example. ## How to use it 1. Install and enable this plugin (and **announcer**, if you want to see the event received). 2. In chat, type `/announce Doors open at 8pm`. -3. relay emits an `announcement.broadcast` event carrying the text, the user, and a timestamp. The **announcer** plugin, or any plugin that subscribes, picks it up. Watch the server log to see the round-trip. +3. relay emits `announcement.broadcast` carrying the text, the user, and a + timestamp. The host delivers `relay.announcement.broadcast` to the + **announcer** plugin, or any plugin that subscribes. Watch the server log to + see the round-trip. This is a plugin-to-plugin communication demo. On its own it has no viewer-facing output. diff --git a/examples/js/relay/README.md b/examples/js/relay/README.md index a2b4ff8..fea0012 100644 --- a/examples/js/relay/README.md +++ b/examples/js/relay/README.md @@ -1,5 +1,8 @@ # relay -When a chat message starts with `/announce `, emits a custom `announcement.broadcast` event carrying the announcement body, user, and timestamp. Other plugins (see `../announcer`) can subscribe. +When a chat message starts with `/announce `, emits a custom +`announcement.broadcast` event carrying the announcement body, user, and +timestamp. The host delivers it as `relay.announcement.broadcast`, which other +plugins (see `../announcer`) can subscribe to. **Demonstrates:** plugin → plugin communication via `owncast.events.emit(type, payload)`, the `events.emit` permission. Pairs with `announcer/` to show one full custom-event round-trip. diff --git a/examples/python/README.md b/examples/python/README.md index 03a2542..21e46b5 100644 --- a/examples/python/README.md +++ b/examples/python/README.md @@ -13,8 +13,8 @@ One self-contained plugin per directory, authored in Python and compiled to wasm | [profanity-filter](./profanity-filter/) | `filter.modify(payload)`, rewrites flagged words to asterisks. | | [slow-mode](./slow-mode/) | `filter.drop(reason)`, rate-limits per user, with in-memory state. | | [buggy-filter](./buggy-filter/) | Always raises, exercises the host's fail-open + strike system. | -| [relay](./relay/) | Emits a custom `announcement.broadcast` event (plugin → plugin). | -| [announcer](./announcer/) | Subscribes to `announcement.broadcast` via `@plugin.on(...)`. | +| [relay](./relay/) | Emits `announcement.broadcast`, delivered as `relay.announcement.broadcast`. | +| [announcer](./announcer/) | Subscribes to `relay.announcement.broadcast` via `@plugin.on(...)`. | | [ip-bot](./ip-bot/) | Outbound HTTP via `owncast.http.fetch`, mocked in tests. | | [overlay](./overlay/) | `http.serve`, static files from `public/` + dynamic JSON endpoint. | | [stream-tracker](./stream-tracker/) | Every typed lifecycle / chat-user handler + read APIs. | diff --git a/examples/python/announcer/INSTRUCTIONS.md b/examples/python/announcer/INSTRUCTIONS.md index 6532bdd..503b490 100644 --- a/examples/python/announcer/INSTRUCTIONS.md +++ b/examples/python/announcer/INSTRUCTIONS.md @@ -1,6 +1,8 @@ # Announcer -A receiver-side demo. It listens for a custom `announcement.broadcast` event that the **relay** example plugin emits, and logs each one to the server. The event type is a plugin-defined string, not a built-in Owncast event. +A receiver-side demo. It listens for `relay.announcement.broadcast`, emitted by +the **relay** example plugin, and logs each one to the server. The event type is +plugin-defined, not a built-in Owncast event. ## How to use it @@ -8,7 +10,9 @@ This plugin does nothing on its own. It's one half of a pair. 1. Install and enable **both** this plugin and the **relay** plugin. 2. In chat, type `/announce ` (that command is handled by relay). -3. relay emits an `announcement.broadcast` event. This plugin receives it and writes an info entry to the Owncast server log through `owncast.log.info`. +3. relay emits `announcement.broadcast`. The host delivers it as + `relay.announcement.broadcast`, this plugin receives it and writes an info + entry to the Owncast server log through `owncast.log.info`. There is no viewer-facing output. Watch the Owncast server logs to see it fire. diff --git a/examples/python/announcer/README.md b/examples/python/announcer/README.md index a4d0e4d..83d411c 100644 --- a/examples/python/announcer/README.md +++ b/examples/python/announcer/README.md @@ -1,5 +1,10 @@ # announcer -Subscribes to the custom `announcement.broadcast` event emitted by `../relay` and logs it. The event type is a plugin-defined string, not a built-in Owncast event. +Subscribes to `relay.announcement.broadcast`, the custom event emitted by +`../relay`, and logs it. It is a plugin-defined event, not a built-in Owncast +event. The host adds the `relay.` prefix, so that is the name to subscribe to. -**Demonstrates:** custom-event subscription via the `@plugin.on("announcement.broadcast")` decorator and info-level server logging through `owncast.log.info`. Neither receiving the event nor writing the log requires a permission. +**Demonstrates:** custom-event subscription via the +`@plugin.on("relay.announcement.broadcast")` decorator and info-level server +logging through `owncast.log.info`. Neither receiving the event nor writing the +log requires a permission. diff --git a/examples/python/announcer/__tests__/announcer.test.json b/examples/python/announcer/__tests__/announcer.test.json index 5a95952..2d2f57b 100644 --- a/examples/python/announcer/__tests__/announcer.test.json +++ b/examples/python/announcer/__tests__/announcer.test.json @@ -1,9 +1,9 @@ [ { - "name": "logs announcement.broadcast at info level", + "name": "logs relay.announcement.broadcast at info level", "events": [ { - "event": "announcement.broadcast", + "event": "relay.announcement.broadcast", "payload": { "by": "alice", "text": "stream is live", "at": "2024-01-01T00:00:00Z" } } ], diff --git a/examples/python/announcer/plugin.manifest.json b/examples/python/announcer/plugin.manifest.json index be4f61d..48c57ef 100644 --- a/examples/python/announcer/plugin.manifest.json +++ b/examples/python/announcer/plugin.manifest.json @@ -3,7 +3,7 @@ "name": "Example Announcer", "slug": "announcer", "version": "0.3.2", - "description": "Handles announcement.broadcast events. This example was written in Python.", + "description": "Handles relay.announcement.broadcast events. This example was written in Python.", "category": "examples", "permissions": [] } diff --git a/examples/python/announcer/src/plugin.py b/examples/python/announcer/src/plugin.py index f522681..3dba1fa 100644 --- a/examples/python/announcer/src/plugin.py +++ b/examples/python/announcer/src/plugin.py @@ -1,7 +1,7 @@ from owncast_plugin import owncast, plugin -@plugin.on("announcement.broadcast") +@plugin.on("relay.announcement.broadcast") def handle(payload): by = payload.get("by") if isinstance(payload, dict) else None text = payload.get("text") if isinstance(payload, dict) else None diff --git a/examples/python/relay/INSTRUCTIONS.md b/examples/python/relay/INSTRUCTIONS.md index f684443..b454927 100644 --- a/examples/python/relay/INSTRUCTIONS.md +++ b/examples/python/relay/INSTRUCTIONS.md @@ -1,12 +1,17 @@ # Announcement Relay -Watches chat for an `/announce ` command and re-broadcasts it as a custom `announcement.broadcast` event that other plugins can subscribe to. Pairs with the **announcer** example. +Watches chat for an `/announce ` command and re-broadcasts it as a custom +`announcement.broadcast` event, delivered to subscribers as +`relay.announcement.broadcast`. Pairs with the **announcer** example. ## How to use it 1. Install and enable this plugin (and **announcer**, if you want to see the event received). 2. In chat, type `/announce Doors open at 8pm`. -3. relay emits an `announcement.broadcast` event carrying the text, the user, and a timestamp. The **announcer** plugin, or any plugin that subscribes, picks it up. Watch the server log to see the round-trip. +3. relay emits `announcement.broadcast` carrying the text, the user, and a + timestamp. The host delivers `relay.announcement.broadcast` to the + **announcer** plugin, or any plugin that subscribes. Watch the server log to + see the round-trip. This is a plugin-to-plugin communication demo. On its own it has no viewer-facing output. diff --git a/examples/python/relay/README.md b/examples/python/relay/README.md index 77c1240..54ece96 100644 --- a/examples/python/relay/README.md +++ b/examples/python/relay/README.md @@ -1,5 +1,8 @@ # relay -When a chat message starts with `/announce `, emits a custom `announcement.broadcast` event carrying the announcement body, user, and timestamp. Other plugins (see `../announcer`) can subscribe. +When a chat message starts with `/announce `, emits a custom +`announcement.broadcast` event carrying the announcement body, user, and +timestamp. The host delivers it as `relay.announcement.broadcast`, which other +plugins (see `../announcer`) can subscribe to. **Demonstrates:** plugin → plugin communication via `owncast.events.emit(type, payload)`, the `events.emit` permission. Pairs with `announcer/` to show one full custom-event round trip. diff --git a/sdks/js/create-owncast-plugin/template/.agents/skills/create-owncast-plugin-js/SKILL.md b/sdks/js/create-owncast-plugin/template/.agents/skills/create-owncast-plugin-js/SKILL.md index 06be124..b198c94 100644 --- a/sdks/js/create-owncast-plugin/template/.agents/skills/create-owncast-plugin-js/SKILL.md +++ b/sdks/js/create-owncast-plugin/template/.agents/skills/create-owncast-plugin-js/SKILL.md @@ -204,7 +204,7 @@ combine for richer plugins. | Post publicly to the fediverse (high-trust) | (any) | `owncast.fediverse.post(text)` | `fediverse.post` | | React to any verified inbound fediverse activity | `onFediverse(activity)` for raw JSON, plus `onFediverseFollow/Like/Repost/Quote/Mention/Reply` for specialized payloads | none | `fediverse.inbound` | | Read/change video/transcoding config | (any) | `owncast.videoConfig.read/write` | `videoconfig.read` / `videoconfig.write` | -| Compose with other plugins via custom events | emit: `owncast.events.emit`, receive: `on:{}`| `owncast.events.emit(type, payload)` | `events.emit` (emitter only) | +| Compose with other plugins via custom events | emit: `owncast.events.emit`, receive: `on:{}`| `owncast.events.emit(suffix, payload)`, delivered as `.` | `events.emit` (emitter only) | | Gate the site behind a member login (paywall) | `onHttpRequest` (login flow) + `onAuthCheck` (re-validation) | `owncast.users.register` + `owncast.auth.grantSession/endSession` | `auth.gate` + `users.register` (+ `http.serve`); model on `examples/js/github-auth` | **Golden rule:** the `permissions` array must contain exactly the permissions @@ -240,7 +240,9 @@ which handlers exist. Full list of handlers: `onChatMessage`, `onStreamTitleChanged`, `onFediverse`, `onFediverseFollow/Like/Repost/Quote/Mention/Reply`, `onHttpRequest`, `onTick`, `onSseConnect/Disconnect`, `onTabContent`, `onPageContent`, `onPageStyles`, `onPageScripts`, and -`on: { "namespace.event"() {} }` for custom events. +`on: { "emitter-slug.event"() {} }` for custom events. The host prefixes every +emitted name with the emitter's slug, so subscribe using the sender's slug and +pass only the suffix to `owncast.events.emit`. Important shape/behavior notes: diff --git a/sdks/js/create-owncast-plugin/template/AGENTS.md b/sdks/js/create-owncast-plugin/template/AGENTS.md index 5ee6f97..5ed3b0d 100644 --- a/sdks/js/create-owncast-plugin/template/AGENTS.md +++ b/sdks/js/create-owncast-plugin/template/AGENTS.md @@ -81,7 +81,7 @@ the plugin. Admins judge trust by the declared list, so don't over-declare. | Post publicly to the fediverse (high-trust) | (any) | `owncast.fediverse.post(text)` | `fediverse.post` | | React to any verified inbound fediverse activity | `onFediverse(activity)` for raw JSON, plus `onFediverseFollow/Like/Repost/Quote/Mention/Reply` for specialized payloads | none | `fediverse.inbound` | | Read/change video config | (any) | `owncast.videoConfig.read/write` | `videoconfig.read` / `videoconfig.write` | -| Compose with other plugins | emit `owncast.events.emit`, receive `on:{}` | `owncast.events.emit(type, payload)` | `events.emit` (emitter only) | +| Compose with other plugins | emit `owncast.events.emit`, receive `on:{}` | `owncast.events.emit(suffix, payload)`, delivered as `.` | `events.emit` (emitter only) | | Gate the site behind a member login (paywall) | `onHttpRequest` (login flow) + `onAuthCheck` (re-validation) | `owncast.users.register` + `owncast.auth.grantSession/endSession` | `auth.gate` + `users.register` (+ `http.serve`) | ## Gotchas that bite diff --git a/sdks/js/create-owncast-plugin/template/src/plugin.js b/sdks/js/create-owncast-plugin/template/src/plugin.js index 3e79e69..5844045 100644 --- a/sdks/js/create-owncast-plugin/template/src/plugin.js +++ b/sdks/js/create-owncast-plugin/template/src/plugin.js @@ -21,6 +21,6 @@ module.exports = definePlugin({ // filterChatMessage(msg) { return filter.pass(); /* or filter.modify(...) / filter.drop(reason) */ } // onChatUserJoined(user) { ... } // onStreamStarted(info) { ... } - // on: { "your.custom.event"(payload) { ... } } + // on: { "other-plugin.their-event"(payload) { ... } } // onHttpRequest(req) { return { status: 200, body: "..." }; } }); diff --git a/sdks/js/index.d.ts b/sdks/js/index.d.ts index bd27f79..5413b32 100644 --- a/sdks/js/index.d.ts +++ b/sdks/js/index.d.ts @@ -535,8 +535,9 @@ export interface PluginDef { * Requires `ui.modify`. */ onPageScripts?(): string | null | void; - /** Handlers for plugin-emitted custom events. The key is the event type - * string (e.g. "announcement.broadcast"). Notifications only, to filter + /** Handlers for plugin-emitted custom events. The key is the sender's fully + * qualified event type, `.` (e.g. + * "relay.announcement.broadcast"). Notifications only, to filter * custom events, additional API will be needed. */ on?: { [eventType: string]: (payload: any) => void | Promise }; @@ -761,6 +762,10 @@ export const owncast: { readText(path: string): string | null; }; events: { + /** Emit a custom event. The host prefixes `eventType` with this plugin's + * slug before dispatching, so subscribers receive + * `.` and no other plugin can emit under your + * namespace. Requires `events.emit`. */ emit(eventType: string, payload: unknown): void; }; /** Control over the viewer action buttons this plugin contributes. diff --git a/sdks/js/index.js b/sdks/js/index.js index d97ffb3..11c4d8f 100644 --- a/sdks/js/index.js +++ b/sdks/js/index.js @@ -834,6 +834,8 @@ const owncast = { }, }, events: { + // The host prefixes eventType with this plugin's slug, so subscribers see + // "." and a plugin can't emit under another's namespace. emit(eventType, payload) { const fns = hostFns("owncast_emit_event", Permissions.EventsEmit); fns.owncast_emit_event( diff --git a/sdks/python/owncast_plugin/__init__.py b/sdks/python/owncast_plugin/__init__.py index b745ba2..98b29be 100644 --- a/sdks/python/owncast_plugin/__init__.py +++ b/sdks/python/owncast_plugin/__init__.py @@ -567,6 +567,12 @@ def query_row(self, sql, params=None): class _Events: def emit(self, event_type, payload): + """Emit a custom event. + + The host prefixes ``event_type`` with this plugin's slug, so + subscribers receive ``.`` and no other plugin + can emit under your namespace. Requires the ``events.emit`` permission. + """ _host("owncast_emit_event")(str(event_type), json.dumps(payload)) diff --git a/sdks/python/owncast_plugin/template/.agents/skills/create-owncast-plugin-py/SKILL.md b/sdks/python/owncast_plugin/template/.agents/skills/create-owncast-plugin-py/SKILL.md index 844eebf..27a0909 100644 --- a/sdks/python/owncast_plugin/template/.agents/skills/create-owncast-plugin-py/SKILL.md +++ b/sdks/python/owncast_plugin/template/.agents/skills/create-owncast-plugin-py/SKILL.md @@ -210,7 +210,7 @@ Several rows combine for richer plugins. | Post publicly to the fediverse (high-trust) | (any) | `owncast.fediverse.post(text)` | `fediverse.post` | | React to any verified inbound fediverse activity | `@plugin.on_fediverse` gets a non-subscriptable `_Obj` attribute view. Use `payload.raw` for the underlying dictionary and keys like `@context`. Specialized handlers: `@plugin.on_fediverse_follow/like/repost/quote/mention/reply` | none | `fediverse.inbound` | | Read/change video/transcoding config | (any) | `owncast.video_config.read/write` | `videoconfig.read` / `videoconfig.write` | -| Compose with other plugins via custom events | emit: `owncast.events.emit`, receive: `@plugin.on(...)` | `owncast.events.emit(type, payload)` | `events.emit` (emitter only) | +| Compose with other plugins via custom events | emit: `owncast.events.emit`, receive: `@plugin.on(...)` | `owncast.events.emit(suffix, payload)`, delivered as `.` | `events.emit` (emitter only) | | Gate the site behind a member login (paywall) | `@plugin.on_http_request` (login flow) + `@plugin.on_auth_check` (re-validation) | `owncast.users.register` + `owncast.auth.grant_session/end_session` | `auth.gate` + `users.register` (+ `http.serve`); model on `examples/python/github-auth` | **Golden rule:** the `permissions` array must contain exactly the permissions @@ -246,7 +246,10 @@ from which handlers exist. Decorators: `@plugin.on_chat_message`, `_disconnect`, `@plugin.on_tab_content("slug")`, `@plugin.on_page_content("slug")`, `@plugin.on_page_styles`, `@plugin.on_page_scripts`, HTTP routes (`@plugin.get/post/put/delete/patch(path)`, `@plugin.route`, -`@plugin.on_http_request`), and `@plugin.on("namespace.event")` for custom events. +`@plugin.on_http_request`), and `@plugin.on("emitter-slug.event")` for custom +events. The host prefixes every emitted name with the emitter's slug, so +subscribe using the sender's slug and pass only the suffix to +`owncast.events.emit`. Important shape/behavior notes: diff --git a/sdks/python/owncast_plugin/template/AGENTS.md b/sdks/python/owncast_plugin/template/AGENTS.md index 5de5c57..55201a5 100644 --- a/sdks/python/owncast_plugin/template/AGENTS.md +++ b/sdks/python/owncast_plugin/template/AGENTS.md @@ -82,7 +82,7 @@ the plugin. Admins judge trust by the declared list, so don't over-declare. | Post publicly to the fediverse (high-trust) | (any) | `owncast.fediverse.post(text)` | `fediverse.post` | | React to any verified inbound fediverse activity | `@plugin.on_fediverse` gets a non-subscriptable `_Obj` attribute view. Use `payload.raw` for the underlying dictionary and keys like `@context`. Specialized handlers: `@plugin.on_fediverse_follow/like/repost/quote/mention/reply` | none | `fediverse.inbound` | | Read/change video config | (any) | `owncast.video_config.read/write` | `videoconfig.read` / `videoconfig.write` | -| Compose with other plugins | emit `owncast.events.emit`, receive `@plugin.on(...)` | `owncast.events.emit(type, payload)` | `events.emit` (emitter only) | +| Compose with other plugins | emit `owncast.events.emit`, receive `@plugin.on(...)` | `owncast.events.emit(suffix, payload)`, delivered as `.` | `events.emit` (emitter only) | | Gate the site behind a member login (paywall) | `@plugin.on_http_request` (login flow) + `@plugin.on_auth_check` (re-validation) | `owncast.users.register` + `owncast.auth.grant_session/end_session` | `auth.gate` + `users.register` (+ `http.serve`) | ## Gotchas that bite diff --git a/sdks/python/owncast_plugin/template/src/plugin.py b/sdks/python/owncast_plugin/template/src/plugin.py index a38ba07..134bfcc 100644 --- a/sdks/python/owncast_plugin/template/src/plugin.py +++ b/sdks/python/owncast_plugin/template/src/plugin.py @@ -27,7 +27,7 @@ def greet(msg): # @plugin.on_stream_started # def live(info): ... # -# @plugin.on("your.custom.event") +# @plugin.on("other-plugin.their-event") # def handle(payload): ... # # @plugin.get("/api/hello") From 95782f2e458f84dbbf528e2830ed426aa1e62a20 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Sat, 8 Aug 2026 22:18:45 -0700 Subject: [PATCH 2/2] docs(events): document the reserved core-event rejection on emit --- docs/PLUGIN_AUTHOR_GUIDE.md | 3 +++ docs/WIRE_PROTOCOL.md | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/PLUGIN_AUTHOR_GUIDE.md b/docs/PLUGIN_AUTHOR_GUIDE.md index 7b146a8..b698484 100644 --- a/docs/PLUGIN_AUTHOR_GUIDE.md +++ b/docs/PLUGIN_AUTHOR_GUIDE.md @@ -1126,6 +1126,9 @@ on: { The name you pass to `emit` is a suffix and may contain dots for hierarchy. Subscribe using the sender's fully qualified `.` name. +If your slug plus the suffix would compose a built-in event name (a plugin +slugged `chat` emitting `message.received`), the host drops the emit rather +than deliver a forged core event. ## Testing diff --git a/docs/WIRE_PROTOCOL.md b/docs/WIRE_PROTOCOL.md index 7478fce..4839a94 100644 --- a/docs/WIRE_PROTOCOL.md +++ b/docs/WIRE_PROTOCOL.md @@ -241,8 +241,10 @@ plugin should store values above `Number.MAX_SAFE_INTEGER` (2^53 - 1) as TEXT. `eventTypePtr` is a UTF-8 event-name suffix and `payloadPtr` is one JSON value. The host prefixes the suffix with the calling plugin's slug, which it resolves from the wasm call rather than trusting the guest, so a plugin can - only publish under `.` and can neither impersonate another - plugin nor forge a built-in event. Output: none. + only publish under `.` and cannot impersonate another plugin. + If the composed name would equal a built-in event (a plugin slugged `chat` + emitting `message.received`), the host drops the call instead of dispatching + a forged core event. Output: none. ### `server.read`