@owncast/plugin-sdk 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  SDK for authoring [Owncast](https://owncast.online) plugins in JavaScript or TypeScript. Plugins compile to WebAssembly and run sandboxed inside the Owncast server.
4
4
 
5
- Most authors don't install this directly instead, scaffold a new project with `npm create owncast-plugin <name>` and the generated `package.json` already lists it as a dependency.
5
+ Most authors don't install this directly, instead, scaffold a new project with `npm create owncast-plugin <name>` and the generated `package.json` already lists it as a dependency.
6
6
 
7
7
  ## Quick start
8
8
 
@@ -38,10 +38,11 @@ Declare the permissions your plugin uses (`chat.send` for the example above) in
38
38
 
39
39
  ## What's in the package
40
40
 
41
- - `index.js` runtime: `definePlugin`, the `owncast.*` host wrappers, the `filter` constructor.
42
- - `index.d.ts` TypeScript declarations for editor autocomplete on every event payload and host API.
43
- - `bin/owncast-plugin` CLI: `build`, `test`, `serve`, `package` subcommands.
44
- - `scripts/postinstall.js` — downloads the per-platform wasm toolchain (`extism-js`, `wasm-merge`, `wasm-opt`) and the Go test/serve runner on install.
41
+ - `index.js`, runtime: `definePlugin`, the `owncast.*` host wrappers, the `filter` constructor.
42
+ - `index.d.ts`, TypeScript declarations for editor autocomplete on every event payload and host API.
43
+ - `testing.js`, JS test API (`runScenarios`) for writing `__tests__/*.test.js` with the full ergonomics of JavaScript instead of static JSON.
44
+ - `bin/owncast-plugin`, CLI: `build`, `test`, `serve`, `package` subcommands.
45
+ - `scripts/postinstall.js`, downloads the per-platform wasm toolchain (`extism-js`, `wasm-merge`, `wasm-opt`) and the Go test/serve runner on install.
45
46
 
46
47
  ## License
47
48
 
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- // `owncast-plugin build` bundle src/plugin.{js,ts} into <name>.wasm
3
- // `owncast-plugin test` run scenarios in __tests__/ against the wasm
4
- // `owncast-plugin serve` run a localhost dev HTTP server
5
- // `owncast-plugin package` produce a single-file <name>.ocpkg suitable
2
+ // `owncast-plugin build` , bundle src/plugin.{js,ts} into <name>.wasm
3
+ // `owncast-plugin test` , run scenarios in __tests__/ against the wasm
4
+ // `owncast-plugin serve` , run a localhost dev HTTP server
5
+ // `owncast-plugin package`, produce a single-file <name>.ocpkg suitable
6
6
  // for distribution / installation
7
7
 
8
8
  const fs = require("fs");
@@ -23,7 +23,9 @@ if (cmd === "build") {
23
23
  } else if (cmd === "package") {
24
24
  packageMain().catch(fail);
25
25
  } else {
26
- console.error(`unknown command: ${cmd}\nusage: owncast-plugin <build|test|serve|package>`);
26
+ console.error(
27
+ `unknown command: ${cmd}\nusage: owncast-plugin <build|test|serve|package>`,
28
+ );
27
29
  process.exit(1);
28
30
  }
29
31
 
@@ -46,17 +48,20 @@ function runBinary(name, args) {
46
48
  if (!fs.existsSync(bin)) {
47
49
  console.error(
48
50
  `${name} not found at ${bin}\n` +
49
- `In production this is fetched by the SDK postinstall. For the PoC, ` +
50
- `build it via: cd owncast && go build -o tools/${name} ./cmd/${name}`
51
+ `In production this is fetched by the SDK postinstall. For the PoC, ` +
52
+ `build it via: cd owncast && go build -o tools/${name} ./cmd/${name}`,
51
53
  );
52
54
  process.exit(1);
53
55
  }
54
56
  const env = {
55
57
  ...process.env,
56
- LD_LIBRARY_PATH: `${path.join(cache, "lib")}:${process.env.LD_LIBRARY_PATH || ""}`
58
+ LD_LIBRARY_PATH: `${path.join(cache, "lib")}:${process.env.LD_LIBRARY_PATH || ""}`,
57
59
  };
58
60
  try {
59
- execFileSync(bin, args.length > 0 ? args : [process.cwd()], { stdio: "inherit", env });
61
+ execFileSync(bin, args.length > 0 ? args : [process.cwd()], {
62
+ stdio: "inherit",
63
+ env,
64
+ });
60
65
  } catch (e) {
61
66
  process.exit(typeof e.status === "number" ? e.status : 1);
62
67
  }
@@ -74,14 +79,22 @@ async function buildMain() {
74
79
 
75
80
  // Detect entry point.
76
81
  let entry = null;
77
- for (const candidate of ["src/plugin.ts", "src/plugin.js", "plugin.ts", "plugin.js"]) {
82
+ for (const candidate of [
83
+ "src/plugin.ts",
84
+ "src/plugin.js",
85
+ "plugin.ts",
86
+ "plugin.js",
87
+ ]) {
78
88
  const p = path.join(cwd, candidate);
79
89
  if (fs.existsSync(p)) {
80
90
  entry = p;
81
91
  break;
82
92
  }
83
93
  }
84
- if (!entry) throw new Error("no plugin source found (expected src/plugin.ts or plugin.js)");
94
+ if (!entry)
95
+ throw new Error(
96
+ "no plugin source found (expected src/plugin.ts or plugin.js)",
97
+ );
85
98
 
86
99
  // Synthesize an entry that injects the manifest, requires user code,
87
100
  // then re-exports the SDK runtime exports as wasm-visible exports.
@@ -132,7 +145,7 @@ module.exports = { register, on_event, on_filter, on_http_request };
132
145
  platform: "neutral",
133
146
  target: "es2020",
134
147
  outfile: bundledJS,
135
- logLevel: "warning"
148
+ logLevel: "warning",
136
149
  });
137
150
 
138
151
  // Generate index.d.ts declaring exports + host imports based on permissions.
@@ -143,16 +156,21 @@ module.exports = { register, on_event, on_filter, on_http_request };
143
156
  const cache = findCacheDir();
144
157
  const extismJs = path.join(cache, "extism-js");
145
158
  if (!fs.existsSync(extismJs)) {
146
- throw new Error(`extism-js not found at ${extismJs} — run \`npm install\` to fetch the toolchain`);
159
+ throw new Error(
160
+ `extism-js not found at ${extismJs}, run \`npm install\` to fetch the toolchain`,
161
+ );
147
162
  }
148
163
  const env = {
149
164
  ...process.env,
150
165
  PATH: `${cache}:${process.env.PATH}`,
151
- LD_LIBRARY_PATH: `${path.join(cache, "lib")}:${process.env.LD_LIBRARY_PATH || ""}`
166
+ LD_LIBRARY_PATH: `${path.join(cache, "lib")}:${process.env.LD_LIBRARY_PATH || ""}`,
152
167
  };
153
168
 
154
169
  const wasmOut = path.join(cwd, `${name}.wasm`);
155
- execFileSync(extismJs, [bundledJS, "-i", dts, "-o", wasmOut], { stdio: "inherit", env });
170
+ execFileSync(extismJs, [bundledJS, "-i", dts, "-o", wasmOut], {
171
+ stdio: "inherit",
172
+ env,
173
+ });
156
174
 
157
175
  // If the project ships static assets in ./assets/, mirror them to the
158
176
  // canonical deployment layout (<name>-assets/) so plugin.Server finds them
@@ -162,15 +180,28 @@ module.exports = { register, on_event, on_filter, on_http_request };
162
180
  if (fs.existsSync(assetsSrc) && fs.statSync(assetsSrc).isDirectory()) {
163
181
  const assetsDest = path.join(cwd, `${name}-assets`);
164
182
  let needsLink = true;
165
- if (fs.existsSync(assetsDest)) {
166
- try {
167
- const st = fs.lstatSync(assetsDest);
168
- if (st.isSymbolicLink() && fs.realpathSync(assetsDest) === fs.realpathSync(assetsSrc)) {
169
- needsLink = false;
170
- } else {
171
- fs.rmSync(assetsDest, { recursive: true, force: true });
172
- }
173
- } catch {
183
+ // Use lstatSync (not existsSync), existsSync follows symlinks and
184
+ // returns false for a dangling link, but the link's inode is still
185
+ // there and would make symlinkSync below fail with EEXIST. lstatSync
186
+ // sees the link itself regardless of whether its target resolves.
187
+ let st;
188
+ try {
189
+ st = fs.lstatSync(assetsDest);
190
+ } catch {
191
+ // path doesn't exist at all, fall through to create it.
192
+ }
193
+ if (st) {
194
+ let target;
195
+ if (st.isSymbolicLink()) {
196
+ // realpathSync throws on dangling links; treat that as "doesn't
197
+ // match, replace it" rather than letting it abort the build.
198
+ try {
199
+ target = fs.realpathSync(assetsDest);
200
+ } catch {}
201
+ }
202
+ if (target && target === fs.realpathSync(assetsSrc)) {
203
+ needsLink = false;
204
+ } else {
174
205
  fs.rmSync(assetsDest, { recursive: true, force: true });
175
206
  }
176
207
  }
@@ -182,7 +213,7 @@ module.exports = { register, on_event, on_filter, on_http_request };
182
213
  console.log(`built ${path.relative(cwd, wasmOut)}`);
183
214
  }
184
215
 
185
- // `owncast-plugin package` bundle the project into a single .ocpkg file
216
+ // `owncast-plugin package`, bundle the project into a single .ocpkg file
186
217
  // (zip archive with plugin.manifest.json, plugin.wasm, and optional assets/).
187
218
  // Builds the wasm first if it doesn't exist.
188
219
  async function packageMain() {
@@ -217,11 +248,13 @@ async function packageMain() {
217
248
  const buf = await zip.generateAsync({
218
249
  type: "nodebuffer",
219
250
  compression: "DEFLATE",
220
- compressionOptions: { level: 6 }
251
+ compressionOptions: { level: 6 },
221
252
  });
222
253
  fs.writeFileSync(outPath, buf);
223
254
  const sizeKb = Math.round(fs.statSync(outPath).size / 1024);
224
- console.log(`packaged ${path.relative(cwd, outPath)} (${sizeKb} KB, ${fileCount} files)`);
255
+ console.log(
256
+ `packaged ${path.relative(cwd, outPath)} (${sizeKb} KB, ${fileCount} files)`,
257
+ );
225
258
  }
226
259
 
227
260
  function* walkFiles(dir) {
@@ -248,7 +281,7 @@ function generateInterface(manifest) {
248
281
  "register(): I32",
249
282
  "on_event(): I32",
250
283
  "on_filter(): I32",
251
- "on_http_request(): I32"
284
+ "on_http_request(): I32",
252
285
  ];
253
286
 
254
287
  const perms = new Set(manifest.permissions || []);
@@ -277,7 +310,9 @@ function generateInterface(manifest) {
277
310
  imports.push("owncast_user_get(idPtr: PTR): PTR");
278
311
  }
279
312
  if (perms.has("users.moderate")) {
280
- imports.push("owncast_user_set_enabled(idPtr: PTR, enabled: I32, reasonPtr: PTR): void");
313
+ imports.push(
314
+ "owncast_user_set_enabled(idPtr: PTR, enabled: I32, reasonPtr: PTR): void",
315
+ );
281
316
  imports.push("owncast_ban_ip(ipPtr: PTR): void");
282
317
  }
283
318
  if (perms.has("storage.upload")) {
@@ -290,8 +325,14 @@ function generateInterface(manifest) {
290
325
  imports.push("owncast_kv_get(keyPtr: PTR): PTR");
291
326
  imports.push("owncast_kv_set(keyPtr: PTR, valPtr: PTR): void");
292
327
  }
293
- if (perms.has("events.emit")) imports.push("owncast_emit_event(eventTypePtr: PTR, payloadPtr: PTR): void");
294
- if (perms.has("http.sse")) imports.push("owncast_sse_send(channelPtr: PTR, eventPtr: PTR, dataPtr: PTR): void");
328
+ if (perms.has("events.emit"))
329
+ imports.push(
330
+ "owncast_emit_event(eventTypePtr: PTR, payloadPtr: PTR): void",
331
+ );
332
+ if (perms.has("http.sse"))
333
+ imports.push(
334
+ "owncast_sse_send(channelPtr: PTR, eventPtr: PTR, dataPtr: PTR): void",
335
+ );
295
336
  if (perms.has("server.read")) {
296
337
  imports.push("owncast_stream_current(): PTR");
297
338
  imports.push("owncast_server_info(): PTR");
@@ -306,6 +347,10 @@ function generateInterface(manifest) {
306
347
  if (perms.has("videoconfig.write")) {
307
348
  imports.push("owncast_video_config_write(configPtr: PTR): PTR");
308
349
  }
350
+ if (perms.has("ui.modify")) {
351
+ imports.push("owncast_add_actions(actionsPtr: PTR): void");
352
+ imports.push("owncast_clear_actions(): void");
353
+ }
309
354
 
310
355
  let out = `declare module 'main' {\n`;
311
356
  for (const e of exports) out += ` export function ${e};\n`;
@@ -326,9 +371,9 @@ function findCacheDir() {
326
371
  const candidates = [
327
372
  path.join(__dirname, ".cache"),
328
373
  path.join(__dirname, "..", "bin", ".cache"),
329
- path.join(__dirname, "..", "..", "..", "tools")
374
+ path.join(__dirname, "..", "..", "..", "tools"),
330
375
  ];
331
- // Pick the first candidate that has any of the expected tools different
376
+ // Pick the first candidate that has any of the expected tools, different
332
377
  // commands need different binaries (build needs extism-js, test needs
333
378
  // owncast-plugin-test) but they share a cache.
334
379
  for (const c of candidates) {
@@ -342,4 +387,3 @@ function findCacheDir() {
342
387
  }
343
388
  return candidates[0];
344
389
  }
345
-
package/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export interface ChatMessage {
6
6
  timestamp: string;
7
7
  }
8
8
 
9
- /** A chat user payload of join/part/rename events. */
9
+ /** A chat user, payload of join/part/rename events. */
10
10
  export interface ChatUser {
11
11
  id: string;
12
12
  displayName: string;
@@ -15,13 +15,13 @@ export interface ChatUser {
15
15
  scopes?: string[];
16
16
  }
17
17
 
18
- /** Payload of `chat.user.renamed` the same user changing their name. */
18
+ /** Payload of `chat.user.renamed`, the same user changing their name. */
19
19
  export interface ChatUserRename {
20
20
  user: ChatUser;
21
21
  previousName: string;
22
22
  }
23
23
 
24
- /** Payload of `chat.message.moderated` a message hidden/restored by a mod. */
24
+ /** Payload of `chat.message.moderated`, a message hidden/restored by a mod. */
25
25
  export interface ChatMessageModeration {
26
26
  messageId: string;
27
27
  visible: boolean;
@@ -30,8 +30,8 @@ export interface ChatMessageModeration {
30
30
 
31
31
  /** Stream-lifecycle payloads. */
32
32
  export interface StreamLifecycleEvent {
33
- startedAt?: string; // ISO-8601, set for stream.started
34
- stoppedAt?: string; // ISO-8601, set for stream.stopped
33
+ startedAt?: string; // ISO-8601, set for stream.started
34
+ stoppedAt?: string; // ISO-8601, set for stream.stopped
35
35
  title?: string;
36
36
  summary?: string;
37
37
  }
@@ -125,7 +125,7 @@ export const Events: {
125
125
  /** Payload shape for fediverse engagement events. */
126
126
  export interface FediverseActor {
127
127
  name: string;
128
- handle: string; // e.g. "@alice@fediverse.example"
128
+ handle: string; // e.g. "@alice@fediverse.example"
129
129
  url?: string;
130
130
  image?: string;
131
131
  }
@@ -136,16 +136,16 @@ export interface FediverseEngagement {
136
136
  target?: { url: string };
137
137
  }
138
138
 
139
- /** Inbound fediverse post a mention or reply that contains content the
139
+ /** Inbound fediverse post, a mention or reply that contains content the
140
140
  * plugin can act on. Carries both the rendered content (which has the
141
141
  * source instance's HTML) and a plain-text version (HTML stripped). */
142
142
  export interface FediverseInboundPost {
143
143
  actor: FediverseActor;
144
- content: string; // HTML from the source instance
145
- contentText: string; // HTML stripped to plain text
146
- url: string; // permalink to the post on its source
147
- postedAt: string; // ISO-8601
148
- inReplyTo?: string; // parent post URL, when this is a reply
144
+ content: string; // HTML from the source instance
145
+ contentText: string; // HTML stripped to plain text
146
+ url: string; // permalink to the post on its source
147
+ postedAt: string; // ISO-8601
148
+ inReplyTo?: string; // parent post URL, when this is a reply
149
149
  attachments?: {
150
150
  url: string;
151
151
  mediaType: string;
@@ -171,6 +171,7 @@ export const Permissions: {
171
171
  readonly HttpSSE: "http.sse";
172
172
  readonly VideoConfigRead: "videoconfig.read";
173
173
  readonly VideoConfigWrite: "videoconfig.write";
174
+ readonly UIModify: "ui.modify";
174
175
  };
175
176
 
176
177
  export interface BrowserPushPayload {
@@ -204,7 +205,7 @@ export interface User {
204
205
  displayName: string;
205
206
  previousNames?: string[];
206
207
  createdAt?: string;
207
- disabledAt?: string; // ISO-8601 if banned, omitted otherwise
208
+ disabledAt?: string; // ISO-8601 if banned, omitted otherwise
208
209
  scopes?: string[];
209
210
  isBot?: boolean;
210
211
  isAuthenticated?: boolean;
@@ -290,12 +291,12 @@ export interface PluginDef {
290
291
  onFediverseReply?(post: FediverseInboundPost): void | Promise<void>;
291
292
 
292
293
  /** HTTP request handler. Called for any path under /plugins/<name>/ that
293
- * isn't served as a static asset. Default-public gate admin features
294
+ * isn't served as a static asset. Default-public, gate admin features
294
295
  * on `req.authenticated` yourself. Requires `http.serve` permission. */
295
296
  onHttpRequest?(req: IncomingHttpRequest): OutgoingHttpResponse;
296
297
 
297
298
  /** Handlers for plugin-emitted custom events. The key is the event type
298
- * string (e.g. "announcement.broadcast"). Notifications only to filter
299
+ * string (e.g. "announcement.broadcast"). Notifications only, to filter
299
300
  * custom events, additional API will be needed. */
300
301
  on?: { [eventType: string]: (payload: any) => void | Promise<void> };
301
302
 
@@ -314,7 +315,7 @@ export const owncast: {
314
315
  send(text: string): void;
315
316
  /** Same identity, but in action style (italic, like IRC "/me"). */
316
317
  sendAction(text: string): void;
317
- /** Post a system message no user identity, rendered as a server
318
+ /** Post a system message, no user identity, rendered as a server
318
319
  * announcement. The body is rendered as HTML, so the plugin is
319
320
  * responsible for escaping any untrusted content. Same `chat.send`
320
321
  * permission as the other send variants. */
@@ -348,7 +349,7 @@ export const owncast: {
348
349
  upload(name: string, data: Uint8Array | string): UploadResult | null;
349
350
  };
350
351
  /** Post to the fediverse on the streamer's behalf. Requires `fediverse.post`,
351
- * which is high-trust admins should grant it sparingly. The host
352
+ * which is high-trust, admins should grant it sparingly. The host
352
353
  * rate-limits at ~5 posts/hour per plugin. */
353
354
  fediverse: {
354
355
  /** Publish a public, text-only post. Returns { url } on success or null
@@ -372,6 +373,22 @@ export const owncast: {
372
373
  events: {
373
374
  emit(eventType: string, payload: unknown): void;
374
375
  };
376
+ /** Control over the viewer action buttons this plugin contributes.
377
+ * The effective list shown to viewers is `manifest.actions` ++
378
+ * whatever has been added at runtime via `add`. Requires
379
+ * `ui.modify`. */
380
+ actions: {
381
+ /** Append one or more buttons to the plugin's runtime list. Each
382
+ * entry is validated with the same rules as `manifest.actions`
383
+ * (title required; exactly one of `url` or `html`; relative URLs
384
+ * rewritten into this plugin's namespace; cross-plugin URLs
385
+ * rejected). The next viewer `/api/config` request returns
386
+ * `manifest.actions` ++ the runtime list. */
387
+ add(actions: ActionButton | ActionButton[]): void;
388
+ /** Drop the runtime additions; only `manifest.actions` remain on
389
+ * the next viewer `/api/config` request. */
390
+ clear(): void;
391
+ };
375
392
  sse: {
376
393
  /** Push one Server-Sent-Event to every browser connected to this
377
394
  * plugin's `/plugins/<name>/_sse/<channel>` stream. `event` is the SSE
@@ -416,7 +433,7 @@ export interface HttpResponse {
416
433
  body: string;
417
434
  }
418
435
 
419
- /** An entry in `manifest.actions` declares an action button the Owncast
436
+ /** An entry in `manifest.actions`, declares an action button the Owncast
420
437
  * UI surfaces while this plugin is enabled. Mirrors Owncast's existing
421
438
  * ExternalAction shape; the host merges enabled-plugin buttons with the
422
439
  * admin-configured list.
@@ -429,7 +446,7 @@ export interface HttpResponse {
429
446
  * explicit `/plugins/<your-name>/...` paths are accepted unchanged.
430
447
  *
431
448
  * When the resolved URL points back into this plugin, the manifest must
432
- * declare `http.serve` the host rejects the load otherwise. */
449
+ * declare `http.serve`, the host rejects the load otherwise. */
433
450
  export interface ActionButton {
434
451
  /** Button label. Required. */
435
452
  title: string;
@@ -437,7 +454,7 @@ export interface ActionButton {
437
454
  url?: string;
438
455
  /** Render this raw HTML when the button is pressed. Mutually exclusive with `url`. */
439
456
  html?: string;
440
- /** Icon image URL same path conventions as `url`. */
457
+ /** Icon image URL, same path conventions as `url`. */
441
458
  icon?: string;
442
459
  /** Accent color, e.g. "#3b82f6". */
443
460
  color?: string;
@@ -447,7 +464,7 @@ export interface ActionButton {
447
464
  openExternally?: boolean;
448
465
  }
449
466
 
450
- /** `manifest.network` narrows outbound HTTP scope for plugins that
467
+ /** `manifest.network`, narrows outbound HTTP scope for plugins that
451
468
  * declare the `network.fetch` permission. Required when that permission
452
469
  * is granted; the host rejects loads otherwise. */
453
470
  export interface NetworkConfig {
package/index.js CHANGED
@@ -10,45 +10,46 @@ let registered = null;
10
10
  const FilterAction = Object.freeze({
11
11
  Pass: "pass",
12
12
  Modify: "modify",
13
- Drop: "drop"
13
+ Drop: "drop",
14
14
  });
15
15
 
16
16
  const Events = Object.freeze({
17
17
  // Chat events
18
- ChatMessageReceived: "chat.message.received",
19
- ChatUserJoined: "chat.user.joined",
20
- ChatUserParted: "chat.user.parted",
21
- ChatUserRenamed: "chat.user.renamed",
18
+ ChatMessageReceived: "chat.message.received",
19
+ ChatUserJoined: "chat.user.joined",
20
+ ChatUserParted: "chat.user.parted",
21
+ ChatUserRenamed: "chat.user.renamed",
22
22
  ChatMessageModerated: "chat.message.moderated",
23
23
  // Stream lifecycle
24
- StreamStarted: "stream.started",
25
- StreamStopped: "stream.stopped",
26
- StreamTitleChanged: "stream.title.changed",
27
- // Fediverse engagement (metadata only) + inbound posts (with content)
28
- FediverseFollow: "fediverse.follow",
29
- FediverseLike: "fediverse.like",
30
- FediverseRepost: "fediverse.repost",
31
- FediverseMention: "fediverse.mention",
32
- FediverseReply: "fediverse.reply"
24
+ StreamStarted: "stream.started",
25
+ StreamStopped: "stream.stopped",
26
+ StreamTitleChanged: "stream.title.changed",
27
+ // Fediverse, engagement (metadata only) + inbound posts (with content)
28
+ FediverseFollow: "fediverse.follow",
29
+ FediverseLike: "fediverse.like",
30
+ FediverseRepost: "fediverse.repost",
31
+ FediverseMention: "fediverse.mention",
32
+ FediverseReply: "fediverse.reply",
33
33
  });
34
34
 
35
35
  const Permissions = Object.freeze({
36
- ChatSend: "chat.send",
37
- ChatHistory: "chat.history",
38
- ChatModerate: "chat.moderate",
39
- StorageKV: "storage.kv",
40
- StorageUpload: "storage.upload",
41
- EventsEmit: "events.emit",
42
- NetworkFetch: "network.fetch",
43
- HttpServe: "http.serve",
44
- ServerRead: "server.read",
36
+ ChatSend: "chat.send",
37
+ ChatHistory: "chat.history",
38
+ ChatModerate: "chat.moderate",
39
+ StorageKV: "storage.kv",
40
+ StorageUpload: "storage.upload",
41
+ EventsEmit: "events.emit",
42
+ NetworkFetch: "network.fetch",
43
+ HttpServe: "http.serve",
44
+ ServerRead: "server.read",
45
45
  NotificationsSend: "notifications.send",
46
- UsersRead: "users.read",
47
- UsersModerate: "users.moderate",
48
- FediversePost: "fediverse.post",
49
- HttpSSE: "http.sse",
50
- VideoConfigRead: "videoconfig.read",
51
- VideoConfigWrite: "videoconfig.write"
46
+ UsersRead: "users.read",
47
+ UsersModerate: "users.moderate",
48
+ FediversePost: "fediverse.post",
49
+ HttpSSE: "http.sse",
50
+ VideoConfigRead: "videoconfig.read",
51
+ VideoConfigWrite: "videoconfig.write",
52
+ UIModify: "ui.modify",
52
53
  });
53
54
 
54
55
  const filter = Object.freeze({
@@ -60,14 +61,14 @@ const filter = Object.freeze({
60
61
  },
61
62
  drop(reason) {
62
63
  return { action: FilterAction.Drop, reason: reason || "" };
63
- }
64
+ },
64
65
  });
65
66
 
66
67
  // Distinguishes notification handlers from filter handlers in the HANDLERS
67
- // map below. Internal not part of the public API.
68
+ // map below. Internal, not part of the public API.
68
69
  const HandlerKind = Object.freeze({
69
70
  Notify: "notify",
70
- Filter: "filter"
71
+ Filter: "filter",
71
72
  });
72
73
 
73
74
  // Maps a built-in handler method name to the event type it subscribes to and
@@ -75,30 +76,54 @@ const HandlerKind = Object.freeze({
75
76
  // new built-in Owncast events.
76
77
  const HANDLERS = Object.freeze({
77
78
  // Chat
78
- onChatMessage: { event: Events.ChatMessageReceived, kind: HandlerKind.Notify },
79
- filterChatMessage: { event: Events.ChatMessageReceived, kind: HandlerKind.Filter },
80
- onChatUserJoined: { event: Events.ChatUserJoined, kind: HandlerKind.Notify },
81
- onChatUserParted: { event: Events.ChatUserParted, kind: HandlerKind.Notify },
82
- onChatUserRenamed: { event: Events.ChatUserRenamed, kind: HandlerKind.Notify },
83
- onMessageModerated: { event: Events.ChatMessageModerated, kind: HandlerKind.Notify },
79
+ onChatMessage: {
80
+ event: Events.ChatMessageReceived,
81
+ kind: HandlerKind.Notify,
82
+ },
83
+ filterChatMessage: {
84
+ event: Events.ChatMessageReceived,
85
+ kind: HandlerKind.Filter,
86
+ },
87
+ onChatUserJoined: { event: Events.ChatUserJoined, kind: HandlerKind.Notify },
88
+ onChatUserParted: { event: Events.ChatUserParted, kind: HandlerKind.Notify },
89
+ onChatUserRenamed: {
90
+ event: Events.ChatUserRenamed,
91
+ kind: HandlerKind.Notify,
92
+ },
93
+ onMessageModerated: {
94
+ event: Events.ChatMessageModerated,
95
+ kind: HandlerKind.Notify,
96
+ },
84
97
  // Stream lifecycle
85
- onStreamStarted: { event: Events.StreamStarted, kind: HandlerKind.Notify },
86
- onStreamStopped: { event: Events.StreamStopped, kind: HandlerKind.Notify },
87
- onStreamTitleChanged: { event: Events.StreamTitleChanged, kind: HandlerKind.Notify },
98
+ onStreamStarted: { event: Events.StreamStarted, kind: HandlerKind.Notify },
99
+ onStreamStopped: { event: Events.StreamStopped, kind: HandlerKind.Notify },
100
+ onStreamTitleChanged: {
101
+ event: Events.StreamTitleChanged,
102
+ kind: HandlerKind.Notify,
103
+ },
88
104
  // Fediverse engagement (actor + target metadata)
89
- onFediverseFollow: { event: Events.FediverseFollow, kind: HandlerKind.Notify },
90
- onFediverseLike: { event: Events.FediverseLike, kind: HandlerKind.Notify },
91
- onFediverseRepost: { event: Events.FediverseRepost, kind: HandlerKind.Notify },
105
+ onFediverseFollow: {
106
+ event: Events.FediverseFollow,
107
+ kind: HandlerKind.Notify,
108
+ },
109
+ onFediverseLike: { event: Events.FediverseLike, kind: HandlerKind.Notify },
110
+ onFediverseRepost: {
111
+ event: Events.FediverseRepost,
112
+ kind: HandlerKind.Notify,
113
+ },
92
114
  // Fediverse inbound posts (with content)
93
- onFediverseMention: { event: Events.FediverseMention, kind: HandlerKind.Notify },
94
- onFediverseReply: { event: Events.FediverseReply, kind: HandlerKind.Notify }
115
+ onFediverseMention: {
116
+ event: Events.FediverseMention,
117
+ kind: HandlerKind.Notify,
118
+ },
119
+ onFediverseReply: { event: Events.FediverseReply, kind: HandlerKind.Notify },
95
120
  });
96
121
 
97
122
  // typeof comparisons in well-known categories. JS guarantees these strings,
98
123
  // but we go through named constants so a stray typo can't pass silently.
99
124
  const JsType = Object.freeze({
100
125
  Function: "function",
101
- Object: "object"
126
+ Object: "object",
102
127
  });
103
128
  const isFn = (x) => typeof x === JsType.Function;
104
129
  const isObj = (x) => x !== null && typeof x === JsType.Object;
@@ -115,7 +140,10 @@ function describeSubscriptions() {
115
140
  const notify = [];
116
141
  const filterSubs = [];
117
142
  if (registered) {
118
- const priority = typeof registered.filterPriority === "number" ? registered.filterPriority : 100;
143
+ const priority =
144
+ typeof registered.filterPriority === "number"
145
+ ? registered.filterPriority
146
+ : 100;
119
147
  for (const [method, info] of Object.entries(HANDLERS)) {
120
148
  if (!isFn(registered[method])) continue;
121
149
  if (info.kind === HandlerKind.Notify) {
@@ -137,7 +165,11 @@ function dispatchEvent(envelope) {
137
165
  if (!registered) return;
138
166
  const { eventType, payload } = envelope;
139
167
  for (const [method, info] of Object.entries(HANDLERS)) {
140
- if (info.kind === HandlerKind.Notify && info.event === eventType && isFn(registered[method])) {
168
+ if (
169
+ info.kind === HandlerKind.Notify &&
170
+ info.event === eventType &&
171
+ isFn(registered[method])
172
+ ) {
141
173
  registered[method](payload);
142
174
  return;
143
175
  }
@@ -151,7 +183,11 @@ function dispatchFilter(envelope) {
151
183
  if (!registered) return filter.pass();
152
184
  const { eventType, payload } = envelope;
153
185
  for (const [method, info] of Object.entries(HANDLERS)) {
154
- if (info.kind === HandlerKind.Filter && info.event === eventType && isFn(registered[method])) {
186
+ if (
187
+ info.kind === HandlerKind.Filter &&
188
+ info.event === eventType &&
189
+ isFn(registered[method])
190
+ ) {
155
191
  return registered[method](payload) || filter.pass();
156
192
  }
157
193
  }
@@ -169,101 +205,140 @@ function dispatchHttp(request) {
169
205
  return {
170
206
  status: out.status || 200,
171
207
  headers: out.headers || {},
172
- body: out.body == null ? "" : String(out.body)
208
+ body: out.body == null ? "" : String(out.body),
173
209
  };
174
210
  }
175
211
 
212
+ // permError builds an actionable Error and logs it to stderr (which the
213
+ // host runtime captures), so a plugin author running `owncast-plugin
214
+ // serve` or hitting the host's logs sees exactly which permission to
215
+ // add to their manifest. apiName is the SDK call the author wrote
216
+ // (e.g. "owncast.actions.set"); perm is the manifest permission string.
217
+ function permError(apiName, perm) {
218
+ const msg = `${apiName} requires the '${perm}' permission. Add it to your plugin.manifest.json's "permissions" array.`;
219
+ console.error(`[owncast-plugin] ${msg}`);
220
+ return new Error(msg);
221
+ }
222
+
176
223
  const owncast = {
177
224
  chat: {
178
225
  send(text) {
179
226
  const fns = Host.getFunctions();
180
- if (!fns.owncast_send_chat) throw new Error(`permission '${Permissions.ChatSend}' not granted`);
227
+ if (!fns.owncast_send_chat)
228
+ throw new Error(`permission '${Permissions.ChatSend}' not granted`);
181
229
  fns.owncast_send_chat(Memory.fromString(text).offset);
182
230
  },
183
231
  sendAction(text) {
184
232
  const fns = Host.getFunctions();
185
- if (!fns.owncast_send_chat_action) throw new Error(`permission '${Permissions.ChatSend}' not granted`);
233
+ if (!fns.owncast_send_chat_action)
234
+ throw new Error(`permission '${Permissions.ChatSend}' not granted`);
186
235
  fns.owncast_send_chat_action(Memory.fromString(text).offset);
187
236
  },
188
237
  system(body) {
189
238
  const fns = Host.getFunctions();
190
- if (!fns.owncast_send_chat_system) throw new Error(`permission '${Permissions.ChatSend}' not granted`);
239
+ if (!fns.owncast_send_chat_system)
240
+ throw new Error(`permission '${Permissions.ChatSend}' not granted`);
191
241
  fns.owncast_send_chat_system(Memory.fromString(body).offset);
192
242
  },
193
243
  history(limit) {
194
244
  const fns = Host.getFunctions();
195
- if (!fns.owncast_chat_history) throw new Error(`permission '${Permissions.ChatHistory}' not granted`);
245
+ if (!fns.owncast_chat_history)
246
+ throw new Error(`permission '${Permissions.ChatHistory}' not granted`);
196
247
  const offset = fns.owncast_chat_history(limit || 0);
197
248
  if (offset == 0) return [];
198
249
  return JSON.parse(Memory.find(offset).readString());
199
250
  },
200
251
  deleteMessage(messageId) {
201
252
  const fns = Host.getFunctions();
202
- if (!fns.owncast_delete_message) throw new Error(`permission '${Permissions.ChatModerate}' not granted`);
253
+ if (!fns.owncast_delete_message)
254
+ throw new Error(`permission '${Permissions.ChatModerate}' not granted`);
203
255
  fns.owncast_delete_message(Memory.fromString(String(messageId)).offset);
204
256
  },
205
257
  kick(clientId) {
206
258
  const fns = Host.getFunctions();
207
- if (!fns.owncast_kick_client) throw new Error(`permission '${Permissions.ChatModerate}' not granted`);
259
+ if (!fns.owncast_kick_client)
260
+ throw new Error(`permission '${Permissions.ChatModerate}' not granted`);
208
261
  fns.owncast_kick_client(BigInt(clientId));
209
262
  },
210
263
  sendTo(clientId, text) {
211
264
  const fns = Host.getFunctions();
212
- if (!fns.owncast_send_chat_to) throw new Error(`permission '${Permissions.ChatSend}' not granted`);
213
- fns.owncast_send_chat_to(BigInt(clientId), Memory.fromString(text).offset);
265
+ if (!fns.owncast_send_chat_to)
266
+ throw new Error(`permission '${Permissions.ChatSend}' not granted`);
267
+ fns.owncast_send_chat_to(
268
+ BigInt(clientId),
269
+ Memory.fromString(text).offset,
270
+ );
214
271
  },
215
272
  clients() {
216
273
  const fns = Host.getFunctions();
217
- if (!fns.owncast_chat_clients) throw new Error(`permission '${Permissions.ChatHistory}' not granted`);
274
+ if (!fns.owncast_chat_clients)
275
+ throw new Error(`permission '${Permissions.ChatHistory}' not granted`);
218
276
  const offset = fns.owncast_chat_clients();
219
277
  if (offset == 0) return [];
220
278
  return JSON.parse(Memory.find(offset).readString());
221
- }
279
+ },
222
280
  },
223
281
  users: {
224
282
  list() {
225
283
  const fns = Host.getFunctions();
226
- if (!fns.owncast_users_list) throw new Error(`permission '${Permissions.UsersRead}' not granted`);
284
+ if (!fns.owncast_users_list)
285
+ throw new Error(`permission '${Permissions.UsersRead}' not granted`);
227
286
  const offset = fns.owncast_users_list();
228
287
  if (offset == 0) return [];
229
288
  return JSON.parse(Memory.find(offset).readString());
230
289
  },
231
290
  get(id) {
232
291
  const fns = Host.getFunctions();
233
- if (!fns.owncast_user_get) throw new Error(`permission '${Permissions.UsersRead}' not granted`);
292
+ if (!fns.owncast_user_get)
293
+ throw new Error(`permission '${Permissions.UsersRead}' not granted`);
234
294
  const offset = fns.owncast_user_get(Memory.fromString(id).offset);
235
295
  if (offset == 0) return null;
236
296
  return JSON.parse(Memory.find(offset).readString());
237
297
  },
238
298
  setEnabled(id, enabled, reason) {
239
299
  const fns = Host.getFunctions();
240
- if (!fns.owncast_user_set_enabled) throw new Error(`permission '${Permissions.UsersModerate}' not granted`);
300
+ if (!fns.owncast_user_set_enabled)
301
+ throw new Error(
302
+ `permission '${Permissions.UsersModerate}' not granted`,
303
+ );
241
304
  fns.owncast_user_set_enabled(
242
305
  Memory.fromString(id).offset,
243
306
  enabled ? 1 : 0,
244
- Memory.fromString(reason || "").offset
307
+ Memory.fromString(reason || "").offset,
245
308
  );
246
309
  },
247
310
  banIP(ip) {
248
311
  const fns = Host.getFunctions();
249
- if (!fns.owncast_ban_ip) throw new Error(`permission '${Permissions.UsersModerate}' not granted`);
312
+ if (!fns.owncast_ban_ip)
313
+ throw new Error(
314
+ `permission '${Permissions.UsersModerate}' not granted`,
315
+ );
250
316
  fns.owncast_ban_ip(Memory.fromString(ip).offset);
251
- }
317
+ },
252
318
  },
253
319
  storage: {
254
320
  upload(name, data) {
255
321
  const fns = Host.getFunctions();
256
- if (!fns.owncast_storage_upload) throw new Error(`permission '${Permissions.StorageUpload}' not granted`);
257
- const dataMem = data instanceof Uint8Array
258
- ? Memory.fromBuffer(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength))
259
- : Memory.fromString(String(data));
322
+ if (!fns.owncast_storage_upload)
323
+ throw new Error(
324
+ `permission '${Permissions.StorageUpload}' not granted`,
325
+ );
326
+ const dataMem =
327
+ data instanceof Uint8Array
328
+ ? Memory.fromBuffer(
329
+ data.buffer.slice(
330
+ data.byteOffset,
331
+ data.byteOffset + data.byteLength,
332
+ ),
333
+ )
334
+ : Memory.fromString(String(data));
260
335
  const offset = fns.owncast_storage_upload(
261
336
  Memory.fromString(name).offset,
262
- dataMem.offset
337
+ dataMem.offset,
263
338
  );
264
339
  if (offset == 0) return null;
265
340
  return JSON.parse(Memory.find(offset).readString());
266
- }
341
+ },
267
342
  },
268
343
  fediverse: {
269
344
  /** Publish a public text-only post to the fediverse on the streamer's
@@ -271,82 +346,107 @@ const owncast = {
271
346
  * disabled by admin, etc.). Requires `fediverse.post`. */
272
347
  post(text) {
273
348
  const fns = Host.getFunctions();
274
- if (!fns.owncast_fediverse_post) throw new Error(`permission '${Permissions.FediversePost}' not granted`);
349
+ if (!fns.owncast_fediverse_post)
350
+ throw new Error(
351
+ `permission '${Permissions.FediversePost}' not granted`,
352
+ );
275
353
  const offset = fns.owncast_fediverse_post(Memory.fromString(text).offset);
276
354
  if (offset == 0) return null;
277
355
  return JSON.parse(Memory.find(offset).readString());
278
- }
356
+ },
279
357
  },
280
358
  notifications: {
281
359
  discord(text) {
282
360
  const fns = Host.getFunctions();
283
- if (!fns.owncast_notify_discord) throw new Error(`permission '${Permissions.NotificationsSend}' not granted`);
361
+ if (!fns.owncast_notify_discord)
362
+ throw new Error(
363
+ `permission '${Permissions.NotificationsSend}' not granted`,
364
+ );
284
365
  fns.owncast_notify_discord(Memory.fromString(text).offset);
285
366
  },
286
367
  browserPush(payload) {
287
368
  const fns = Host.getFunctions();
288
- if (!fns.owncast_notify_browser_push) throw new Error(`permission '${Permissions.NotificationsSend}' not granted`);
369
+ if (!fns.owncast_notify_browser_push)
370
+ throw new Error(
371
+ `permission '${Permissions.NotificationsSend}' not granted`,
372
+ );
289
373
  const obj = typeof payload === "string" ? { title: payload } : payload;
290
- fns.owncast_notify_browser_push(Memory.fromString(JSON.stringify(obj)).offset);
374
+ fns.owncast_notify_browser_push(
375
+ Memory.fromString(JSON.stringify(obj)).offset,
376
+ );
291
377
  },
292
378
  fediverse(payload) {
293
379
  const fns = Host.getFunctions();
294
- if (!fns.owncast_notify_fediverse) throw new Error(`permission '${Permissions.NotificationsSend}' not granted`);
295
- fns.owncast_notify_fediverse(Memory.fromString(JSON.stringify(payload)).offset);
296
- }
380
+ if (!fns.owncast_notify_fediverse)
381
+ throw new Error(
382
+ `permission '${Permissions.NotificationsSend}' not granted`,
383
+ );
384
+ fns.owncast_notify_fediverse(
385
+ Memory.fromString(JSON.stringify(payload)).offset,
386
+ );
387
+ },
297
388
  },
298
389
  stream: {
299
390
  current() {
300
391
  const fns = Host.getFunctions();
301
- if (!fns.owncast_stream_current) throw new Error(`permission '${Permissions.ServerRead}' not granted`);
392
+ if (!fns.owncast_stream_current)
393
+ throw new Error(`permission '${Permissions.ServerRead}' not granted`);
302
394
  const offset = fns.owncast_stream_current();
303
395
  if (offset == 0) return { online: false, viewers: 0 };
304
396
  return JSON.parse(Memory.find(offset).readString());
305
397
  },
306
398
  broadcaster() {
307
399
  const fns = Host.getFunctions();
308
- if (!fns.owncast_stream_broadcaster) throw new Error(`permission '${Permissions.ServerRead}' not granted`);
400
+ if (!fns.owncast_stream_broadcaster)
401
+ throw new Error(`permission '${Permissions.ServerRead}' not granted`);
309
402
  const offset = fns.owncast_stream_broadcaster();
310
403
  if (offset == 0) return {};
311
404
  return JSON.parse(Memory.find(offset).readString());
312
- }
405
+ },
313
406
  },
314
407
  server: {
315
408
  info() {
316
409
  const fns = Host.getFunctions();
317
- if (!fns.owncast_server_info) throw new Error(`permission '${Permissions.ServerRead}' not granted`);
410
+ if (!fns.owncast_server_info)
411
+ throw new Error(`permission '${Permissions.ServerRead}' not granted`);
318
412
  const offset = fns.owncast_server_info();
319
413
  if (offset == 0) return {};
320
414
  return JSON.parse(Memory.find(offset).readString());
321
415
  },
322
416
  socials() {
323
417
  const fns = Host.getFunctions();
324
- if (!fns.owncast_server_socials) throw new Error(`permission '${Permissions.ServerRead}' not granted`);
418
+ if (!fns.owncast_server_socials)
419
+ throw new Error(`permission '${Permissions.ServerRead}' not granted`);
325
420
  const offset = fns.owncast_server_socials();
326
421
  if (offset == 0) return [];
327
422
  return JSON.parse(Memory.find(offset).readString());
328
423
  },
329
424
  federation() {
330
425
  const fns = Host.getFunctions();
331
- if (!fns.owncast_server_federation) throw new Error(`permission '${Permissions.ServerRead}' not granted`);
426
+ if (!fns.owncast_server_federation)
427
+ throw new Error(`permission '${Permissions.ServerRead}' not granted`);
332
428
  const offset = fns.owncast_server_federation();
333
429
  if (offset == 0) return { enabled: false };
334
430
  return JSON.parse(Memory.find(offset).readString());
335
431
  },
336
432
  tags() {
337
433
  const fns = Host.getFunctions();
338
- if (!fns.owncast_server_tags) throw new Error(`permission '${Permissions.ServerRead}' not granted`);
434
+ if (!fns.owncast_server_tags)
435
+ throw new Error(`permission '${Permissions.ServerRead}' not granted`);
339
436
  const offset = fns.owncast_server_tags();
340
437
  if (offset == 0) return [];
341
438
  return JSON.parse(Memory.find(offset).readString());
342
- }
439
+ },
343
440
  },
344
441
  videoConfig: {
345
442
  /** Read the current video/transcoding config: { latencyLevel, codec,
346
443
  * variants }. Requires `videoconfig.read`. */
347
444
  read() {
348
445
  const fns = Host.getFunctions();
349
- if (!fns.owncast_video_config_read) throw new Error(`permission '${Permissions.VideoConfigRead}' not granted`);
446
+ if (!fns.owncast_video_config_read)
447
+ throw new Error(
448
+ `permission '${Permissions.VideoConfigRead}' not granted`,
449
+ );
350
450
  const offset = fns.owncast_video_config_read();
351
451
  if (offset == 0) return { latencyLevel: 0, codec: "", variants: [] };
352
452
  return JSON.parse(Memory.find(offset).readString());
@@ -356,39 +456,73 @@ const owncast = {
356
456
  * rejects the config. Requires `videoconfig.write`. */
357
457
  write(config) {
358
458
  const fns = Host.getFunctions();
359
- if (!fns.owncast_video_config_write) throw new Error(`permission '${Permissions.VideoConfigWrite}' not granted`);
360
- const offset = fns.owncast_video_config_write(Memory.fromString(JSON.stringify(config || {})).offset);
361
- if (offset == 0) throw new Error('videoConfig.write failed');
459
+ if (!fns.owncast_video_config_write)
460
+ throw new Error(
461
+ `permission '${Permissions.VideoConfigWrite}' not granted`,
462
+ );
463
+ const offset = fns.owncast_video_config_write(
464
+ Memory.fromString(JSON.stringify(config || {})).offset,
465
+ );
466
+ if (offset == 0) throw new Error("videoConfig.write failed");
362
467
  const result = JSON.parse(Memory.find(offset).readString());
363
- if (!result.ok) throw new Error(result.error || 'videoConfig.write failed');
364
- }
468
+ if (!result.ok)
469
+ throw new Error(result.error || "videoConfig.write failed");
470
+ },
365
471
  },
366
472
  kv: {
367
473
  get(key) {
368
474
  const fns = Host.getFunctions();
369
- if (!fns.owncast_kv_get) throw new Error(`permission '${Permissions.StorageKV}' not granted`);
475
+ if (!fns.owncast_kv_get)
476
+ throw new Error(`permission '${Permissions.StorageKV}' not granted`);
370
477
  const offset = fns.owncast_kv_get(Memory.fromString(key).offset);
371
478
  if (offset == 0) return null;
372
479
  return Memory.find(offset).readString();
373
480
  },
374
481
  set(key, value) {
375
482
  const fns = Host.getFunctions();
376
- if (!fns.owncast_kv_set) throw new Error(`permission '${Permissions.StorageKV}' not granted`);
483
+ if (!fns.owncast_kv_set)
484
+ throw new Error(`permission '${Permissions.StorageKV}' not granted`);
377
485
  fns.owncast_kv_set(
378
486
  Memory.fromString(key).offset,
379
- Memory.fromString(String(value)).offset
487
+ Memory.fromString(String(value)).offset,
380
488
  );
381
- }
489
+ },
382
490
  },
383
491
  events: {
384
492
  emit(eventType, payload) {
385
493
  const fns = Host.getFunctions();
386
- if (!fns.owncast_emit_event) throw new Error(`permission '${Permissions.EventsEmit}' not granted`);
494
+ if (!fns.owncast_emit_event)
495
+ throw new Error(`permission '${Permissions.EventsEmit}' not granted`);
387
496
  fns.owncast_emit_event(
388
497
  Memory.fromString(eventType).offset,
389
- Memory.fromString(JSON.stringify(payload)).offset
498
+ Memory.fromString(JSON.stringify(payload)).offset,
390
499
  );
391
- }
500
+ },
501
+ },
502
+ actions: {
503
+ // Append one or more action buttons to the plugin's effective list
504
+ // (manifest.actions ++ runtime additions). Accepts a single button
505
+ // object or an array. The host validates each entry (title
506
+ // required, exactly one of url/html, relative URLs rewritten into
507
+ // this plugin's namespace, cross-plugin URLs rejected) and persists
508
+ // the result, so the next /api/config request returns the longer
509
+ // list. Requires 'ui.modify'.
510
+ add(actions) {
511
+ const fns = Host.getFunctions();
512
+ if (!fns.owncast_add_actions)
513
+ throw permError("owncast.actions.add", Permissions.UIModify);
514
+ const list = Array.isArray(actions) ? actions : [actions];
515
+ fns.owncast_add_actions(Memory.fromString(JSON.stringify(list)).offset);
516
+ },
517
+ // Drop the runtime additions so only manifest.actions remain in
518
+ // the effective list on the next /api/config request. Requires
519
+ // 'ui.modify'.
520
+ clear() {
521
+ const fns = Host.getFunctions();
522
+ if (!fns.owncast_clear_actions)
523
+ throw permError("owncast.actions.clear", Permissions.UIModify);
524
+ fns.owncast_clear_actions();
525
+ },
392
526
  },
393
527
  sse: {
394
528
  // send(channel, event, data) pushes one Server-Sent-Event to every
@@ -401,14 +535,15 @@ const owncast = {
401
535
  // the 'http.sse' permission.
402
536
  send(channel, event, data) {
403
537
  const fns = Host.getFunctions();
404
- if (!fns.owncast_sse_send) throw new Error(`permission '${Permissions.HttpSSE}' not granted`);
538
+ if (!fns.owncast_sse_send)
539
+ throw new Error(`permission '${Permissions.HttpSSE}' not granted`);
405
540
  const payload = typeof data === "string" ? data : JSON.stringify(data);
406
541
  fns.owncast_sse_send(
407
542
  Memory.fromString(channel || "").offset,
408
543
  Memory.fromString(event || "").offset,
409
- Memory.fromString(payload).offset
544
+ Memory.fromString(payload).offset,
410
545
  );
411
- }
546
+ },
412
547
  },
413
548
  http: {
414
549
  // fetch(url, opts) → { status, headers, body }
@@ -420,13 +555,17 @@ const owncast = {
420
555
  const req = {
421
556
  url,
422
557
  method: opts.method || "GET",
423
- headers: opts.headers || {}
558
+ headers: opts.headers || {},
424
559
  };
425
560
  const body = opts.body != null ? String(opts.body) : null;
426
561
  const res = body != null ? Http.request(req, body) : Http.request(req);
427
- return { status: res.status, headers: res.headers || {}, body: res.body || "" };
428
- }
429
- }
562
+ return {
563
+ status: res.status,
564
+ headers: res.headers || {},
565
+ body: res.body || "",
566
+ };
567
+ },
568
+ },
430
569
  };
431
570
 
432
571
  module.exports = {
@@ -439,5 +578,5 @@ module.exports = {
439
578
  describeSubscriptions,
440
579
  dispatchEvent,
441
580
  dispatchFilter,
442
- dispatchHttp
581
+ dispatchHttp,
443
582
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owncast/plugin-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "SDK for authoring Owncast plugins in JavaScript",
5
5
  "license": "MIT",
6
6
  "author": "Owncast",
@@ -28,6 +28,7 @@
28
28
  "files": [
29
29
  "index.js",
30
30
  "index.d.ts",
31
+ "testing.js",
31
32
  "bin/owncast-plugin.js",
32
33
  "scripts/postinstall.js"
33
34
  ],
@@ -44,4 +45,4 @@
44
45
  "publishConfig": {
45
46
  "access": "public"
46
47
  }
47
- }
48
+ }
@@ -2,13 +2,13 @@
2
2
  // Downloads per-platform tooling into <sdk>/bin/.cache so the build CLI
3
3
  // finds it without polluting the user's system:
4
4
  //
5
- // - extism-js JS → wasm compiler (extism/js-pdk releases)
6
- // - wasm-merge, wasm-opt, lib binaryen post-processing (WebAssembly/binaryen releases)
7
- // - owncast-plugin-test/serve scenario runner + dev server (this repo's releases)
5
+ // - extism-js , JS → wasm compiler (extism/js-pdk releases)
6
+ // - wasm-merge, wasm-opt, lib , binaryen post-processing (WebAssembly/binaryen releases)
7
+ // - owncast-plugin-test/serve , scenario runner + dev server (this repo's releases)
8
8
  //
9
9
  // PoC scope: linux-x86_64 + darwin-arm64 + darwin-x86_64 covered.
10
10
  // owncast-plugin-test/serve downloads gracefully skip if the matching
11
- // release asset isn't published yet dev builds can substitute their own
11
+ // release asset isn't published yet, dev builds can substitute their own
12
12
  // via tools/bootstrap.sh.
13
13
 
14
14
  const fs = require("fs");
@@ -41,7 +41,7 @@ function extismJsURL() {
41
41
  "linux-x86_64": `extism-js-x86_64-linux-${EXTISM_JS_VERSION}.gz`,
42
42
  "linux-aarch64": `extism-js-aarch64-linux-${EXTISM_JS_VERSION}.gz`,
43
43
  "darwin-x86_64": `extism-js-x86_64-macos-${EXTISM_JS_VERSION}.gz`,
44
- "darwin-arm64": `extism-js-aarch64-macos-${EXTISM_JS_VERSION}.gz`
44
+ "darwin-arm64": `extism-js-aarch64-macos-${EXTISM_JS_VERSION}.gz`,
45
45
  };
46
46
  const file = map[platformKey()];
47
47
  return `https://github.com/extism/js-pdk/releases/download/${EXTISM_JS_VERSION}/${file}`;
@@ -52,7 +52,7 @@ function binaryenURL() {
52
52
  "linux-x86_64": `binaryen-${BINARYEN_VERSION}-x86_64-linux.tar.gz`,
53
53
  "linux-aarch64": `binaryen-${BINARYEN_VERSION}-aarch64-linux.tar.gz`,
54
54
  "darwin-x86_64": `binaryen-${BINARYEN_VERSION}-x86_64-macos.tar.gz`,
55
- "darwin-arm64": `binaryen-${BINARYEN_VERSION}-arm64-macos.tar.gz`
55
+ "darwin-arm64": `binaryen-${BINARYEN_VERSION}-arm64-macos.tar.gz`,
56
56
  };
57
57
  const file = map[platformKey()];
58
58
  return `https://github.com/WebAssembly/binaryen/releases/download/${BINARYEN_VERSION}/${file}`;
@@ -65,7 +65,7 @@ function hostBinaryURL(name) {
65
65
  "linux-x86_64": "linux-amd64",
66
66
  "linux-aarch64": "linux-arm64",
67
67
  "darwin-x86_64": "darwin-amd64",
68
- "darwin-arm64": "darwin-arm64"
68
+ "darwin-arm64": "darwin-arm64",
69
69
  };
70
70
  const suffix = map[platformKey()];
71
71
  return `https://github.com/${HOST_BINARIES_REPO}/releases/download/v${HOST_BINARIES_VERSION}/${name}-${suffix}`;
@@ -75,8 +75,10 @@ function download(url, dest) {
75
75
  return new Promise((resolve, reject) => {
76
76
  const req = (u) =>
77
77
  https.get(u, (res) => {
78
- if (res.statusCode === 302 || res.statusCode === 301) return req(res.headers.location);
79
- if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode} for ${u}`));
78
+ if (res.statusCode === 302 || res.statusCode === 301)
79
+ return req(res.headers.location);
80
+ if (res.statusCode !== 200)
81
+ return reject(new Error(`HTTP ${res.statusCode} for ${u}`));
80
82
  const out = fs.createWriteStream(dest);
81
83
  res.pipe(out);
82
84
  out.on("finish", () => out.close(resolve));
@@ -113,7 +115,7 @@ async function main() {
113
115
  fs.copyFileSync(path.join(extracted, "bin", "wasm-opt"), wasmOptDest);
114
116
  fs.chmodSync(wasmMergeDest, 0o755);
115
117
  fs.chmodSync(wasmOptDest, 0o755);
116
- // copy lib too wasm-opt links against libbinaryen.so on linux
118
+ // copy lib too, wasm-opt links against libbinaryen.so on linux
117
119
  const libSrc = path.join(extracted, "lib");
118
120
  if (fs.existsSync(libSrc)) {
119
121
  fs.cpSync(libSrc, path.join(cacheDir, "lib"), { recursive: true });
@@ -122,7 +124,7 @@ async function main() {
122
124
  fs.unlinkSync(tar);
123
125
  }
124
126
 
125
- // owncast-plugin-test + owncast-plugin-serve built from this repo's
127
+ // owncast-plugin-test + owncast-plugin-serve, built from this repo's
126
128
  // host-runtime/ Go sources, published as gzipped release assets on
127
129
  // github.com/owncast/plugin-sdk (roughly halves the download). Skip silently
128
130
  // if the release doesn't exist yet (dev environments running against a
@@ -133,7 +135,9 @@ async function main() {
133
135
  if (fs.existsSync(dest)) continue;
134
136
  const gz = dest + ".gz";
135
137
  try {
136
- console.log(`[plugin-sdk] downloading ${binary} ${HOST_BINARIES_VERSION}...`);
138
+ console.log(
139
+ `[plugin-sdk] downloading ${binary} ${HOST_BINARIES_VERSION}...`,
140
+ );
137
141
  await download(hostBinaryURL(binary) + ".gz", gz);
138
142
  fs.writeFileSync(dest, zlib.gunzipSync(fs.readFileSync(gz)));
139
143
  fs.chmodSync(dest, 0o755);
@@ -143,7 +147,7 @@ async function main() {
143
147
  // warning so the user sees them but the install still succeeds.
144
148
  console.warn(
145
149
  `[plugin-sdk] could not fetch ${binary}: ${e.message}\n` +
146
- ` Build locally via tools/bootstrap.sh, or use the latest GitHub release.`
150
+ ` Build locally via tools/bootstrap.sh, or use the latest GitHub release.`,
147
151
  );
148
152
  // Make sure no partial files are left behind.
149
153
  for (const p of [gz, dest]) if (fs.existsSync(p)) fs.unlinkSync(p);
package/testing.js ADDED
@@ -0,0 +1,124 @@
1
+ // JavaScript test API for Owncast plugins.
2
+ //
3
+ // Lets authors write their __tests__/*.test.js with the full ergonomics of JS
4
+ //, loops, helpers, fixtures, computed payloads, shared setup, instead of
5
+ // hand-authoring static JSON. Each call to `runScenarios([...])` invokes the
6
+ // same `owncast-plugin-test` host binary the JSON scenarios use, so this is
7
+ // purely a more pleasant authoring layer over the same execution.
8
+ //
9
+ // Quick start:
10
+ //
11
+ // const { runScenarios } = require("@owncast/plugin-sdk/testing");
12
+ //
13
+ // const chat = (user, body) => ({
14
+ // event: "chat.message.received",
15
+ // payload: { id: "1", user, body, timestamp: "2024-01-01T00:00:00Z" },
16
+ // });
17
+ //
18
+ // runScenarios([
19
+ // { name: "greets users", events: [chat("alice", "hi")], expect: { chatSends: ["hello, alice!"] } },
20
+ // { name: "ignores others", events: [chat("bob", "morning")], expect: { chatSends: [] } },
21
+ // ]);
22
+ //
23
+ // Each scenario object has the same shape as a JSON scenario file:
24
+ // { name, given?, events: [...], expect?: {...} }
25
+ // See the Plugin Author Guide for every assertion field.
26
+
27
+ const fs = require("fs");
28
+ const os = require("os");
29
+ const path = require("path");
30
+ const { execFileSync } = require("child_process");
31
+
32
+ // Find the directory holding the owncast-plugin-test binary. Check for that
33
+ // binary specifically (not just any toolchain file) so we correctly fall
34
+ // through to the dev tools/ dir when postinstall has only fetched part of the
35
+ // toolchain (e.g., on a not-yet-released SDK version).
36
+ function findCacheDir() {
37
+ const candidates = [
38
+ path.join(__dirname, "bin", ".cache"), // installed under node_modules
39
+ path.join(__dirname, "..", "..", "tools"), // dev fallback (repo root tools/)
40
+ ];
41
+ for (const c of candidates) {
42
+ if (fs.existsSync(path.join(c, "owncast-plugin-test"))) return c;
43
+ }
44
+ return candidates[0];
45
+ }
46
+
47
+ /**
48
+ * Run an array of scenarios against the loaded plugin via the
49
+ * `owncast-plugin-test` host binary.
50
+ *
51
+ * The binary takes a project directory and auto-discovers
52
+ * `__tests__/*.test.json`. To avoid colliding with any JSON scenarios you
53
+ * might also have in the project, this function sets up a temporary project
54
+ * dir that links to your manifest + wasm and contains only the generated
55
+ * scenarios it's running.
56
+ *
57
+ * Exits the process with status 0 if every scenario passed, non-zero otherwise.
58
+ *
59
+ * @param {Array<object>} scenarios, scenario objects: { name, given?, events, expect? }
60
+ * @param {object} [opts]
61
+ * @param {string} [opts.cwd], plugin project directory (default: process.cwd())
62
+ */
63
+ function runScenarios(scenarios, opts = {}) {
64
+ if (!Array.isArray(scenarios) || scenarios.length === 0) {
65
+ console.error("runScenarios: no scenarios provided");
66
+ process.exit(2);
67
+ }
68
+
69
+ const cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd();
70
+ const manifestPath = path.join(cwd, "plugin.manifest.json");
71
+ if (!fs.existsSync(manifestPath)) {
72
+ console.error(`plugin.manifest.json not found in ${cwd}`);
73
+ process.exit(2);
74
+ }
75
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
76
+ if (!manifest.name) {
77
+ console.error("manifest.name is required");
78
+ process.exit(2);
79
+ }
80
+ const wasmPath = path.join(cwd, `${manifest.name}.wasm`);
81
+ if (!fs.existsSync(wasmPath)) {
82
+ console.error(
83
+ `${manifest.name}.wasm not found at ${wasmPath}, run \`owncast-plugin build\` first`,
84
+ );
85
+ process.exit(2);
86
+ }
87
+
88
+ const cache = findCacheDir();
89
+ const bin = path.join(cache, "owncast-plugin-test");
90
+ if (!fs.existsSync(bin)) {
91
+ console.error(
92
+ `owncast-plugin-test not found at ${bin}\n` +
93
+ `Reinstall @owncast/plugin-sdk to fetch the host toolchain (postinstall handles it).`,
94
+ );
95
+ process.exit(2);
96
+ }
97
+
98
+ // Build a temp project dir that links to the wasm + manifest and contains
99
+ // only the scenarios we're running. The binary will auto-discover them.
100
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "owncast-plugin-test-"));
101
+ try {
102
+ fs.symlinkSync(manifestPath, path.join(tmp, "plugin.manifest.json"));
103
+ fs.symlinkSync(wasmPath, path.join(tmp, `${manifest.name}.wasm`));
104
+ fs.mkdirSync(path.join(tmp, "__tests__"));
105
+ fs.writeFileSync(
106
+ path.join(tmp, "__tests__", "scenarios.test.json"),
107
+ JSON.stringify(scenarios, null, 2),
108
+ );
109
+
110
+ const env = {
111
+ ...process.env,
112
+ LD_LIBRARY_PATH: `${path.join(cache, "lib")}:${process.env.LD_LIBRARY_PATH || ""}`,
113
+ };
114
+ try {
115
+ execFileSync(bin, [tmp], { stdio: "inherit", env });
116
+ } catch (e) {
117
+ process.exit(typeof e.status === "number" ? e.status : 1);
118
+ }
119
+ } finally {
120
+ fs.rmSync(tmp, { recursive: true, force: true });
121
+ }
122
+ }
123
+
124
+ module.exports = { runScenarios };