@owncast/plugin-sdk 0.4.1 → 0.5.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.
@@ -202,7 +202,17 @@ function on_http_request() {
202
202
  Host.outputString(JSON.stringify(response));
203
203
  return 0;
204
204
  }
205
- module.exports = { register, on_event, on_filter, on_http_request };
205
+ function on_tab_content() {
206
+ const req = JSON.parse(Host.inputString());
207
+ Host.outputString(sdk.dispatchTabContent(req));
208
+ return 0;
209
+ }
210
+ function on_page_content() {
211
+ const req = JSON.parse(Host.inputString());
212
+ Host.outputString(sdk.dispatchPageContent(req));
213
+ return 0;
214
+ }
215
+ module.exports = { register, on_event, on_filter, on_http_request, on_tab_content, on_page_content };
206
216
  `;
207
217
  fs.writeFileSync(synthEntry, entrySrc);
208
218
 
@@ -362,10 +372,21 @@ function generateInterface(manifest) {
362
372
  "on_event(): I32",
363
373
  "on_filter(): I32",
364
374
  "on_http_request(): I32",
375
+ "on_tab_content(): I32",
376
+ "on_page_content(): I32",
365
377
  ];
366
378
 
367
379
  const perms = new Set(manifest.permissions || []);
368
380
  const imports = [];
381
+ // Timers are ambient (no permission): the host always provides them, since
382
+ // a plugin can't setTimeout in the sandbox.
383
+ imports.push("owncast_timer_set(id: I64, delayMs: I64, repeat: I32): I32");
384
+ imports.push("owncast_timer_clear(id: I64): void");
385
+ // Config is ambient too: a plugin reading its own manifest-declared config
386
+ // (admin override falling back to the declared default) needs no permission.
387
+ imports.push("owncast_config_get(keyPtr: PTR): PTR");
388
+ // Asset reading is ambient: a plugin reads only files it shipped itself.
389
+ imports.push("owncast_asset_read(pathPtr: PTR): PTR");
369
390
  if (perms.has("chat.send")) {
370
391
  imports.push("owncast_send_chat(textPtr: PTR): void");
371
392
  imports.push("owncast_send_chat_action(textPtr: PTR): void");
@@ -424,6 +445,7 @@ function generateInterface(manifest) {
424
445
  imports.push("owncast_stream_current(): PTR");
425
446
  imports.push("owncast_server_info(): PTR");
426
447
  imports.push("owncast_server_socials(): PTR");
448
+ imports.push("owncast_server_emotes(): PTR");
427
449
  imports.push("owncast_server_federation(): PTR");
428
450
  imports.push("owncast_stream_broadcaster(): PTR");
429
451
  imports.push("owncast_server_tags(): PTR");
package/index.d.ts CHANGED
@@ -1,7 +1,18 @@
1
- /** Built-in chat message payload. */
1
+ /**
2
+ * Built-in chat message payload (`chat.message.received` and the chat filter).
3
+ *
4
+ * `user` carries the full sender identity — use `user.id` for stable per-user
5
+ * state and `user.scopes` (e.g. `"MODERATOR"`) for reliable, non-spoofable
6
+ * moderation gating rather than matching on the display name. `clientId`
7
+ * identifies the originating connection; pass it to `owncast.chat.sendTo` (or
8
+ * `owncast.chat.replyTo(msg, …)`) to whisper a reply back to the sender.
9
+ *
10
+ * `user` is undefined for the rare message with no associated account.
11
+ */
2
12
  export interface ChatMessage {
3
13
  id: string;
4
- user: string;
14
+ user?: ChatUser;
15
+ clientId?: number;
5
16
  body: string;
6
17
  timestamp: string;
7
18
  }
@@ -117,6 +128,7 @@ export const Events: {
117
128
  readonly StreamTitleChanged: "stream.title.changed";
118
129
  readonly SseConnect: "sse.connect";
119
130
  readonly SseDisconnect: "sse.disconnect";
131
+ readonly Tick: "tick";
120
132
  readonly FediverseFollow: "fediverse.follow";
121
133
  readonly FediverseLike: "fediverse.like";
122
134
  readonly FediverseRepost: "fediverse.repost";
@@ -197,6 +209,12 @@ export interface SocialHandle {
197
209
  icon?: string;
198
210
  }
199
211
 
212
+ /** A custom chat emote from owncast.server.emotes(). */
213
+ export interface Emote {
214
+ name: string; // the `:code:` chat clients substitute
215
+ url: string; // image the emote renders to
216
+ }
217
+
200
218
  export interface FederationInfo {
201
219
  enabled: boolean;
202
220
  username?: string;
@@ -266,6 +284,15 @@ export interface OutgoingHttpResponse {
266
284
  body?: string;
267
285
  }
268
286
 
287
+ /** Request context passed to `onTabContent` and `onPageContent` handlers. */
288
+ export interface ContentRequest {
289
+ /** The tab or page-content slot's slug, as declared in the manifest. */
290
+ slug: string;
291
+ /** The viewing user's chat identity, when available. Undefined for
292
+ * anonymous viewers or when the host cannot resolve an identity. */
293
+ user?: ChatUser;
294
+ }
295
+
269
296
  /** Payload for the sse.connect / sse.disconnect events. Fired when a browser
270
297
  * opens or closes one of the plugin's `/plugins/<name>/_sse/<channel>`
271
298
  * streams, so the plugin can track who is connected. `connectionId` is unique
@@ -278,6 +305,12 @@ export interface SSEConnectionEvent {
278
305
  user?: ChatUser;
279
306
  }
280
307
 
308
+ /** Payload for the once-a-second tick event (onTick). `now` is the host
309
+ * wall-clock time in unix milliseconds when the tick fired. */
310
+ export interface TickEvent {
311
+ now: number;
312
+ }
313
+
281
314
  export interface PluginDef {
282
315
  /** Notification handler for chat messages. Fire-and-forget. */
283
316
  onChatMessage?(msg: ChatMessage): void | Promise<void>;
@@ -309,6 +342,10 @@ export interface PluginDef {
309
342
  * the matching onSseConnect). Requires the `http.sse` permission. */
310
343
  onSseDisconnect?(event: SSEConnectionEvent): void | Promise<void>;
311
344
 
345
+ /** Fires once a second for periodic work. `now` is the host wall-clock time
346
+ * in unix milliseconds. Defining this opts the plugin into the tick. */
347
+ onTick?(event: TickEvent): void | Promise<void>;
348
+
312
349
  /** Someone on the fediverse followed the streamer's account. */
313
350
  onFediverseFollow?(event: FediverseEngagement): void | Promise<void>;
314
351
  /** Someone on the fediverse liked a streamer post / federated stream announcement. */
@@ -325,6 +362,18 @@ export interface PluginDef {
325
362
  * on `req.authenticated` yourself. Requires `http.serve` permission. */
326
363
  onHttpRequest?(req: IncomingHttpRequest): OutgoingHttpResponse;
327
364
 
365
+ /** Render HTML for a dynamic tab. Called by the host when the tab was
366
+ * declared in the manifest without a static `content` file. Return the
367
+ * full HTML string to inline as the tab body. `req.user` is the viewer's
368
+ * chat identity when available, undefined for anonymous viewers. */
369
+ onTabContent?(req: ContentRequest): string;
370
+
371
+ /** Render HTML for the plugin's dynamic extraPageContent slot. Called by
372
+ * the host when extraPageContent was declared without a static `content`
373
+ * file. Return the full HTML string to inline into the viewer page.
374
+ * `req.user` is the viewer's chat identity when available. */
375
+ onPageContent?(req: ContentRequest): string;
376
+
328
377
  /** Handlers for plugin-emitted custom events. The key is the event type
329
378
  * string (e.g. "announcement.broadcast"). Notifications only, to filter
330
379
  * custom events, additional API will be needed. */
@@ -337,6 +386,62 @@ export interface PluginDef {
337
386
 
338
387
  export function definePlugin(def: PluginDef): PluginDef;
339
388
 
389
+ /** What a command handler receives. */
390
+ export interface CommandContext {
391
+ /** The originating chat message. */
392
+ msg: ChatMessage;
393
+ /** The sender (same as `msg.user`). */
394
+ user?: ChatUser;
395
+ /** The canonical command name that matched (not the alias used). */
396
+ command: string;
397
+ /** Whitespace-split arguments after the command word. */
398
+ args: string[];
399
+ /** The raw argument string (everything after the command word, trimmed). */
400
+ argString: string;
401
+ /** Post a public reply as the plugin's chat bot. */
402
+ reply(text: string): void;
403
+ /** Whisper a reply to the sender; falls back to a public post if their
404
+ * connection is unknown. */
405
+ replyPrivately(text: string): void;
406
+ }
407
+
408
+ /** One command in a {@link defineCommands} table. */
409
+ export interface CommandDefinition {
410
+ /** Alternate names that invoke this command. */
411
+ aliases?: string[];
412
+ /** Only allow senders whose scopes include "MODERATOR". */
413
+ modOnly?: boolean;
414
+ /** Minimum milliseconds between invocations per user (clocked off
415
+ * `msg.timestamp`). */
416
+ cooldownMs?: number;
417
+ /** Invoked when the command runs. */
418
+ run(ctx: CommandContext): void;
419
+ /** Invoked instead of `run` when a non-moderator calls a `modOnly` command. */
420
+ onDenied?(ctx: CommandContext): void;
421
+ /** Invoked instead of `run` when the per-user cooldown hasn't elapsed. */
422
+ onCooldown?(ctx: CommandContext): void;
423
+ }
424
+
425
+ export interface CommandsConfig {
426
+ /** Command prefix. Default `"!"`. */
427
+ prefix?: string;
428
+ /** Match command names case-sensitively. Default false. */
429
+ caseSensitive?: boolean;
430
+ commands: Record<string, CommandDefinition>;
431
+ /** Fallback when a prefixed message matches no command. */
432
+ onUnknown?(ctx: CommandContext): void;
433
+ /** Default denied/cooldown handlers, used when a command omits its own. */
434
+ onDenied?(ctx: CommandContext): void;
435
+ onCooldown?(ctx: CommandContext): void;
436
+ }
437
+
438
+ /** Build a chat-command router (prefix parsing, aliases, per-user cooldowns,
439
+ * moderator gating). Feed the returned function a `ChatMessage`; it returns
440
+ * true when the message was a command (even if gated), false otherwise. */
441
+ export function defineCommands(
442
+ config: CommandsConfig,
443
+ ): (msg: ChatMessage) => boolean;
444
+
340
445
  /** Typed wrappers around the Owncast host. Each method throws if the
341
446
  * corresponding permission was not declared in plugin.manifest.json. */
342
447
  export const owncast: {
@@ -350,8 +455,14 @@ export const owncast: {
350
455
  * responsible for escaping any untrusted content. Same `chat.send`
351
456
  * permission as the other send variants. */
352
457
  system(body: string): void;
353
- /** Private message to one chat client. */
458
+ /** Private message to one chat client. Requires `chat.send`. */
354
459
  sendTo(clientId: number | bigint, text: string): void;
460
+ /** Whisper a reply back to whoever sent a chat message. Pass the
461
+ * `ChatMessage` from `onChatMessage`/`filterChatMessage` (or a bare
462
+ * clientId). Returns `false` if the sender's connection is unknown (no
463
+ * clientId), so callers can fall back to a public `send`. Requires
464
+ * `chat.send`. */
465
+ replyTo(msg: ChatMessage | number | bigint, text: string): boolean;
355
466
  /** Recent chat history (most recent last). Requires `chat.history`.
356
467
  * Default limit is 50; pass a smaller number to get fewer. */
357
468
  history(limit?: number): ChatMessage[];
@@ -418,6 +529,30 @@ export const owncast: {
418
529
  kv: {
419
530
  get(key: string): string | null;
420
531
  set(key: string, value: string | number): void;
532
+ /** Read a JSON value, parsed. Returns `fallback` (default `undefined`)
533
+ * when the key is unset or holds invalid JSON. Requires `storage.kv`. */
534
+ getJSON<T = unknown>(key: string, fallback?: T): T;
535
+ /** Store a value as JSON. Requires `storage.kv`. */
536
+ setJSON(key: string, value: unknown): void;
537
+ };
538
+ /** Read this plugin's admin-configurable settings, declared under
539
+ * `config` in the manifest. Ambient — no permission required. */
540
+ config: {
541
+ /** The effective value of a manifest-declared config key (admin override,
542
+ * else the declared default), parsed to its declared type. Returns
543
+ * `fallback` (default `undefined`) for an unknown key or one with no
544
+ * value. */
545
+ get<T = unknown>(key: string, fallback?: T): T;
546
+ };
547
+ /** Read files the plugin bundled in its own `assets/` directory — templates,
548
+ * data files, and other bundled resources loaded at request time. Path is
549
+ * relative to `assets/` and must not contain `..`. Ambient — no permission
550
+ * required. */
551
+ assets: {
552
+ /** Raw bytes of the file, or `null` if not found. */
553
+ read(path: string): Uint8Array | null;
554
+ /** File contents as a UTF-8 string, or `null` if not found. */
555
+ readText(path: string): string | null;
421
556
  };
422
557
  events: {
423
558
  emit(eventType: string, payload: unknown): void;
@@ -447,6 +582,20 @@ export const owncast: {
447
582
  * `http.sse` permission. */
448
583
  send(channel: string, event: string, data: unknown): void;
449
584
  };
585
+ /** Host-driven timers. The sandbox has no setTimeout; these ask the host to
586
+ * call your callback back later (in this instance). No permission required.
587
+ * Timers do not survive a plugin reload or host restart. */
588
+ timer: {
589
+ /** Run `fn` once after ~`ms` milliseconds. Returns an id for `clear()`.
590
+ * Very small delays are clamped up by the host; throws past the
591
+ * per-plugin pending-timer cap. */
592
+ setTimeout(fn: () => void, ms: number): number;
593
+ /** Run `fn` every ~`ms` milliseconds until `clear()`. The next run is
594
+ * scheduled only after the previous one returns. Returns an id. */
595
+ setInterval(fn: () => void, ms: number): number;
596
+ /** Cancel a pending timeout or interval by its id. */
597
+ clear(id: number): void;
598
+ };
450
599
  http: {
451
600
  fetch(url: string, opts?: HttpRequestOpts): HttpResponse;
452
601
  };
@@ -459,6 +608,8 @@ export const owncast: {
459
608
  server: {
460
609
  info(): ServerInfo;
461
610
  socials(): SocialHandle[];
611
+ /** Custom chat emotes (`:code:` → image URL) configured on this server. */
612
+ emotes(): Emote[];
462
613
  federation(): FederationInfo;
463
614
  tags(): string[];
464
615
  };
package/index.js CHANGED
@@ -7,6 +7,14 @@
7
7
 
8
8
  let registered = null;
9
9
 
10
+ // Host-driven timers. The sandbox has no setTimeout, so owncast.timer.* asks
11
+ // the host to schedule a callback and call back via the internal "timer.fire"
12
+ // event. The author's callback stays here in the long-lived instance, keyed by
13
+ // a guest-allocated id the host echoes back. State persists across calls
14
+ // because the plugin instance is reused; timers are dropped on reload.
15
+ let nextTimerId = 1;
16
+ const timerCallbacks = new Map(); // id -> { fn, repeat }
17
+
10
18
  const FilterAction = Object.freeze({
11
19
  Pass: "pass",
12
20
  Modify: "modify",
@@ -27,6 +35,8 @@ const Events = Object.freeze({
27
35
  // SSE connection lifecycle (who connected to / left a plugin's stream)
28
36
  SseConnect: "sse.connect",
29
37
  SseDisconnect: "sse.disconnect",
38
+ // Once-a-second tick for periodic work (opt in by defining onTick)
39
+ Tick: "tick",
30
40
  // Fediverse, engagement (metadata only) + inbound posts (with content)
31
41
  FediverseFollow: "fediverse.follow",
32
42
  FediverseLike: "fediverse.like",
@@ -109,6 +119,8 @@ const HANDLERS = Object.freeze({
109
119
  // SSE connection lifecycle
110
120
  onSseConnect: { event: Events.SseConnect, kind: HandlerKind.Notify },
111
121
  onSseDisconnect: { event: Events.SseDisconnect, kind: HandlerKind.Notify },
122
+ // Once-a-second tick
123
+ onTick: { event: Events.Tick, kind: HandlerKind.Notify },
112
124
  // Fediverse engagement (actor + target metadata)
113
125
  onFediverseFollow: {
114
126
  event: Events.FediverseFollow,
@@ -141,6 +153,111 @@ function definePlugin(def) {
141
153
  return def;
142
154
  }
143
155
 
156
+ // defineCommands builds a chat-command router so plugins stop reimplementing
157
+ // prefix parsing, aliases, per-user cooldowns, and moderator gating. It returns
158
+ // a function you feed a ChatMessage (from onChatMessage or filterChatMessage);
159
+ // it parses the command and invokes the matching handler's run(ctx). The return
160
+ // value is true when the message was a command (even if gated), false when it
161
+ // wasn't — so a filter can drop command messages from chat:
162
+ //
163
+ // const commands = defineCommands({
164
+ // prefix: "!",
165
+ // commands: {
166
+ // uptime: { run: (ctx) => ctx.reply("up!") },
167
+ // ban: { modOnly: true, cooldownMs: 5000, run: (ctx) => ctx.reply(`bye ${ctx.args[0]}`) },
168
+ // },
169
+ // });
170
+ // module.exports = definePlugin({
171
+ // onChatMessage: commands,
172
+ // // or, to hide command messages from chat:
173
+ // // filterChatMessage: (msg) => (commands(msg) ? filter.drop("command") : filter.pass()),
174
+ // });
175
+ //
176
+ // run(ctx) receives { msg, user, command, args, argString, reply, replyPrivately }.
177
+ // reply posts publicly; replyPrivately whispers to the sender (falling back to a
178
+ // public post if their connection is unknown). Optional hooks: per-command or
179
+ // top-level onCooldown(ctx) / onDenied(ctx), and a top-level onUnknown(ctx).
180
+ function defineCommands(config) {
181
+ config = config || {};
182
+ const prefix = config.prefix || "!";
183
+ const caseSensitive = !!config.caseSensitive;
184
+ const norm = (s) => (caseSensitive ? s : s.toLowerCase());
185
+
186
+ // Resolve every name and alias to its canonical command definition.
187
+ const table = new Map();
188
+ const defs = config.commands || {};
189
+ for (const name of Object.keys(defs)) {
190
+ const def = defs[name];
191
+ table.set(norm(name), { name, def });
192
+ for (const alias of def.aliases || []) table.set(norm(alias), { name, def });
193
+ }
194
+
195
+ // Per-(command,user) cooldown clock, in memory for the plugin's lifetime.
196
+ const lastRun = new Map();
197
+
198
+ return function handle(msg) {
199
+ const body = (msg && msg.body) || "";
200
+ if (!body.startsWith(prefix)) return false;
201
+ const rest = body.slice(prefix.length);
202
+ const parts = rest.split(/\s+/).filter(Boolean);
203
+ if (parts.length === 0) return false;
204
+ const called = parts[0];
205
+ const args = parts.slice(1);
206
+ const argString = rest.slice(called.length).trim();
207
+
208
+ const ctx = {
209
+ msg,
210
+ user: msg && msg.user,
211
+ command: norm(called),
212
+ args,
213
+ argString,
214
+ reply: (text) => owncast.chat.send(text),
215
+ replyPrivately: (text) => {
216
+ if (!owncast.chat.replyTo(msg, text)) owncast.chat.send(text);
217
+ },
218
+ };
219
+
220
+ const entry = table.get(norm(called));
221
+ if (!entry) {
222
+ if (isFn(config.onUnknown)) config.onUnknown(ctx);
223
+ return false;
224
+ }
225
+ const { name, def } = entry;
226
+ ctx.command = name;
227
+
228
+ // Moderator gating: the sender's scopes must include MODERATOR.
229
+ if (def.modOnly) {
230
+ const scopes = (msg.user && msg.user.scopes) || [];
231
+ if (!scopes.includes("MODERATOR")) {
232
+ if (isFn(def.onDenied)) def.onDenied(ctx);
233
+ else if (isFn(config.onDenied)) config.onDenied(ctx);
234
+ return true; // matched a command, but the caller wasn't allowed
235
+ }
236
+ }
237
+
238
+ // Per-user cooldown, clocked off msg.timestamp so it's deterministic in
239
+ // tests and independent of any sandbox clock quirks.
240
+ const cooldownMs = def.cooldownMs || 0;
241
+ if (cooldownMs > 0) {
242
+ const userId =
243
+ (msg.user && msg.user.id) ||
244
+ (msg.clientId != null ? `c${msg.clientId}` : "anon");
245
+ const key = `${name}${userId}`;
246
+ const now = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
247
+ const prev = lastRun.get(key);
248
+ if (now && prev && now - prev < cooldownMs) {
249
+ if (isFn(def.onCooldown)) def.onCooldown(ctx);
250
+ else if (isFn(config.onCooldown)) config.onCooldown(ctx);
251
+ return true;
252
+ }
253
+ if (now) lastRun.set(key, now);
254
+ }
255
+
256
+ if (isFn(def.run)) def.run(ctx);
257
+ return true;
258
+ };
259
+ }
260
+
144
261
  // Used by the build-generated entry to compute subscriptions for register().
145
262
  // Filters can optionally declare a priority via definePlugin({filterPriority}),
146
263
  // applied to every filter subscription this plugin owns. Lower = earlier.
@@ -170,8 +287,20 @@ function describeSubscriptions() {
170
287
  }
171
288
 
172
289
  function dispatchEvent(envelope) {
173
- if (!registered) return;
174
290
  const { eventType, payload } = envelope;
291
+ // Internal: a host-scheduled timer elapsed. Run the author's callback,
292
+ // dropping one-shot entries first so a throw still cleans up. Not routed to
293
+ // user handlers or the `on` map.
294
+ if (eventType === "timer.fire") {
295
+ const id = payload && payload.id;
296
+ const entry = timerCallbacks.get(id);
297
+ if (entry) {
298
+ if (!entry.repeat) timerCallbacks.delete(id);
299
+ entry.fn();
300
+ }
301
+ return;
302
+ }
303
+ if (!registered) return;
175
304
  for (const [method, info] of Object.entries(HANDLERS)) {
176
305
  if (
177
306
  info.kind === HandlerKind.Notify &&
@@ -228,6 +357,27 @@ function permError(apiName, perm) {
228
357
  return new Error(msg);
229
358
  }
230
359
 
360
+ // scheduleTimer registers a callback and asks the host to schedule it. The id
361
+ // is guest-allocated and echoed back on "timer.fire". Throws if the host
362
+ // rejects the schedule (per-plugin pending-timer cap).
363
+ function scheduleTimer(fn, ms, repeat) {
364
+ if (typeof fn !== "function") {
365
+ throw new Error("owncast.timer: callback must be a function");
366
+ }
367
+ const id = nextTimerId++;
368
+ const delay = Math.max(0, Math.floor(Number(ms) || 0));
369
+ const fns = Host.getFunctions();
370
+ if (!fns.owncast_timer_set) {
371
+ throw new Error("owncast.timer is unavailable in this host");
372
+ }
373
+ const ok = fns.owncast_timer_set(BigInt(id), BigInt(delay), repeat ? 1 : 0);
374
+ if (ok !== 1) {
375
+ throw new Error("owncast.timer: too many pending timers");
376
+ }
377
+ timerCallbacks.set(id, { fn, repeat });
378
+ return id;
379
+ }
380
+
231
381
  const owncast = {
232
382
  chat: {
233
383
  send(text) {
@@ -277,6 +427,20 @@ const owncast = {
277
427
  Memory.fromString(text).offset,
278
428
  );
279
429
  },
430
+ // replyTo whispers text back to whoever sent a chat message. Pass the
431
+ // ChatMessage from onChatMessage/filterChatMessage (or a bare clientId).
432
+ // Returns true if the sender's connection was known and the reply sent,
433
+ // false otherwise (e.g. the message carried no clientId) — letting callers
434
+ // fall back to a public post.
435
+ replyTo(msgOrClientId, text) {
436
+ const clientId =
437
+ msgOrClientId && typeof msgOrClientId === "object"
438
+ ? msgOrClientId.clientId
439
+ : msgOrClientId;
440
+ if (clientId === undefined || clientId === null) return false;
441
+ this.sendTo(clientId, text);
442
+ return true;
443
+ },
280
444
  clients() {
281
445
  const fns = Host.getFunctions();
282
446
  if (!fns.owncast_chat_clients)
@@ -503,6 +667,14 @@ const owncast = {
503
667
  if (offset == 0) return [];
504
668
  return JSON.parse(Memory.find(offset).readString());
505
669
  },
670
+ emotes() {
671
+ const fns = Host.getFunctions();
672
+ if (!fns.owncast_server_emotes)
673
+ throw new Error(`permission '${Permissions.ServerRead}' not granted`);
674
+ const offset = fns.owncast_server_emotes();
675
+ if (offset == 0) return [];
676
+ return JSON.parse(Memory.find(offset).readString());
677
+ },
506
678
  federation() {
507
679
  const fns = Host.getFunctions();
508
680
  if (!fns.owncast_server_federation)
@@ -569,6 +741,54 @@ const owncast = {
569
741
  Memory.fromString(String(value)).offset,
570
742
  );
571
743
  },
744
+ // getJSON/setJSON are convenience wrappers over the string-only store, so
745
+ // plugins don't reimplement JSON.parse/stringify for every stored object.
746
+ // getJSON returns `fallback` (default undefined) when the key is unset or
747
+ // holds invalid JSON.
748
+ getJSON(key, fallback) {
749
+ const raw = this.get(key);
750
+ if (raw == null) return fallback;
751
+ try {
752
+ return JSON.parse(raw);
753
+ } catch (_e) {
754
+ return fallback;
755
+ }
756
+ },
757
+ setJSON(key, value) {
758
+ this.set(key, JSON.stringify(value));
759
+ },
760
+ },
761
+ config: {
762
+ // get returns the effective value of a manifest-declared config key (the
763
+ // admin-set override, else the declared default), already parsed to its
764
+ // declared type. Returns `fallback` (default undefined) for an unknown key
765
+ // or one with no value. Ambient — no permission required.
766
+ get(key, fallback) {
767
+ const fns = Host.getFunctions();
768
+ const offset = fns.owncast_config_get(Memory.fromString(key).offset);
769
+ if (offset == 0) return fallback;
770
+ return JSON.parse(Memory.find(offset).readString());
771
+ },
772
+ },
773
+ // Read files the plugin shipped in its own assets/ directory. Useful for
774
+ // templates, data files, and other bundled resources that need to be read
775
+ // at request time. Path is relative to assets/ and must not contain "..".
776
+ // Ambient — no permission required.
777
+ assets: {
778
+ // Returns a Uint8Array of the file's raw bytes, or null if not found.
779
+ read(path) {
780
+ const fns = Host.getFunctions();
781
+ const offset = fns.owncast_asset_read(Memory.fromString(path).offset);
782
+ if (offset == 0) return null;
783
+ return new Uint8Array(Memory.find(offset).readBytes());
784
+ },
785
+ // Returns the file contents as a UTF-8 string, or null if not found.
786
+ readText(path) {
787
+ const fns = Host.getFunctions();
788
+ const offset = fns.owncast_asset_read(Memory.fromString(path).offset);
789
+ if (offset == 0) return null;
790
+ return Memory.find(offset).readString();
791
+ },
572
792
  },
573
793
  events: {
574
794
  emit(eventType, payload) {
@@ -627,6 +847,26 @@ const owncast = {
627
847
  );
628
848
  },
629
849
  },
850
+ timer: {
851
+ // setTimeout(fn, ms) runs fn once after ~ms milliseconds. setInterval
852
+ // repeats until clear(id). The host drives the schedule (the sandbox has
853
+ // no setTimeout); your callback runs in this instance when it fires.
854
+ // Returns an id for clear(). Very small delays are clamped up by the host,
855
+ // and there's a per-plugin cap on pending timers (throws past it).
856
+ // Note: timers are in-memory and do not survive a plugin reload or a host
857
+ // restart. No permission required.
858
+ setTimeout(fn, ms) {
859
+ return scheduleTimer(fn, ms, false);
860
+ },
861
+ setInterval(fn, ms) {
862
+ return scheduleTimer(fn, ms, true);
863
+ },
864
+ clear(id) {
865
+ timerCallbacks.delete(id);
866
+ const fns = Host.getFunctions();
867
+ if (fns.owncast_timer_clear) fns.owncast_timer_clear(BigInt(id));
868
+ },
869
+ },
630
870
  http: {
631
871
  // fetch(url, opts) → { status, headers, body }
632
872
  // Wraps Extism's built-in Http.request. Throws if the manifest didn't
@@ -650,8 +890,19 @@ const owncast = {
650
890
  },
651
891
  };
652
892
 
893
+ function dispatchTabContent(req) {
894
+ if (!registered || !isFn(registered.onTabContent)) return "";
895
+ return registered.onTabContent(req) || "";
896
+ }
897
+
898
+ function dispatchPageContent(req) {
899
+ if (!registered || !isFn(registered.onPageContent)) return "";
900
+ return registered.onPageContent(req) || "";
901
+ }
902
+
653
903
  module.exports = {
654
904
  definePlugin,
905
+ defineCommands,
655
906
  owncast,
656
907
  filter,
657
908
  FilterAction,
@@ -661,4 +912,6 @@ module.exports = {
661
912
  dispatchEvent,
662
913
  dispatchFilter,
663
914
  dispatchHttp,
915
+ dispatchTabContent,
916
+ dispatchPageContent,
664
917
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owncast/plugin-sdk",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "SDK for authoring Owncast plugins in JavaScript",
5
5
  "license": "MIT",
6
6
  "author": "Owncast",
@@ -19,11 +19,74 @@ const { execFileSync } = require("child_process");
19
19
 
20
20
  const EXTISM_JS_VERSION = "v1.6.0";
21
21
  const BINARYEN_VERSION = "version_119";
22
- // Tracks the SDK version that the host binaries were cut for. Usually
23
- // matches the SDK's own version in package.json.
24
- const HOST_BINARIES_VERSION = require("../package.json").version;
25
22
  const HOST_BINARIES_REPO = "owncast/plugin-sdk";
26
23
 
24
+ // The host binaries (owncast-plugin-test/serve) implement the host-function
25
+ // contract that the bundled JS runtime imports. That contract is additive
26
+ // within a major version — host functions are only ever added, never removed or
27
+ // renamed (a removal is a breaking change that requires a major bump) — so the
28
+ // NEWEST published binary is compatible with every plugin runtime. We therefore
29
+ // fetch the latest release tag rather than deriving one from the npm version.
30
+ //
31
+ // This keeps the binary in lockstep with `@owncast/plugin-sdk@^x` (which npm
32
+ // already floats to the newest compatible runtime) and fixes the old "zero the
33
+ // patch" guess: that fetched v<major>.<minor>.0, which 404'd on JS-only patches
34
+ // and — when a host change shipped in a patch (e.g. timer support in 0.4.2) —
35
+ // fetched a binary too old to satisfy the runtime's imports, breaking
36
+ // `npm test`.
37
+ //
38
+ // Override with OWNCAST_PLUGIN_HOST_BINARIES_VERSION (with or without a leading
39
+ // "v") to pin a specific tag, e.g. in CI or when bisecting.
40
+ function latestReleaseTag() {
41
+ return new Promise((resolve, reject) => {
42
+ https
43
+ .get(
44
+ `https://api.github.com/repos/${HOST_BINARIES_REPO}/releases/latest`,
45
+ {
46
+ headers: {
47
+ "User-Agent": "owncast-plugin-sdk-postinstall",
48
+ Accept: "application/vnd.github+json",
49
+ },
50
+ },
51
+ (res) => {
52
+ if (res.statusCode !== 200) {
53
+ res.resume();
54
+ return reject(new Error(`HTTP ${res.statusCode}`));
55
+ }
56
+ let body = "";
57
+ res.on("data", (c) => (body += c));
58
+ res.on("end", () => {
59
+ try {
60
+ const tag = JSON.parse(body).tag_name;
61
+ if (!tag) return reject(new Error("no tag_name in response"));
62
+ resolve(tag);
63
+ } catch (err) {
64
+ reject(err);
65
+ }
66
+ });
67
+ },
68
+ )
69
+ .on("error", reject);
70
+ });
71
+ }
72
+
73
+ async function resolveHostBinariesVersion() {
74
+ const override = process.env.OWNCAST_PLUGIN_HOST_BINARIES_VERSION;
75
+ if (override) return override.replace(/^v/i, "");
76
+ try {
77
+ return (await latestReleaseTag()).replace(/^v/i, "");
78
+ } catch (e) {
79
+ // Offline or API error: best-effort fall back to this package's own
80
+ // version. The download below 404-skips gracefully if no such release.
81
+ const pkg = require("../package.json").version;
82
+ console.warn(
83
+ `[plugin-sdk] could not resolve latest host-binary release ` +
84
+ `(${e.message}); falling back to v${pkg}`,
85
+ );
86
+ return pkg;
87
+ }
88
+ }
89
+
27
90
  const platform = process.platform;
28
91
  const arch = process.arch;
29
92
 
@@ -58,7 +121,7 @@ function binaryenURL() {
58
121
  return `https://github.com/WebAssembly/binaryen/releases/download/${BINARYEN_VERSION}/${file}`;
59
122
  }
60
123
 
61
- function hostBinaryURL(name) {
124
+ function hostBinaryURL(name, version) {
62
125
  // Per-platform asset naming matches Go's GOOS-GOARCH convention so the
63
126
  // release CI can `go build` once per matrix entry without renaming.
64
127
  const map = {
@@ -68,7 +131,7 @@ function hostBinaryURL(name) {
68
131
  "darwin-arm64": "darwin-arm64",
69
132
  };
70
133
  const suffix = map[platformKey()];
71
- return `https://github.com/${HOST_BINARIES_REPO}/releases/download/v${HOST_BINARIES_VERSION}/${name}-${suffix}`;
134
+ return `https://github.com/${HOST_BINARIES_REPO}/releases/download/v${version}/${name}-${suffix}`;
72
135
  }
73
136
 
74
137
  function download(url, dest) {
@@ -130,27 +193,33 @@ async function main() {
130
193
  // if the release doesn't exist yet (dev environments running against a
131
194
  // not-yet-released SDK version can substitute their own via
132
195
  // tools/bootstrap.sh).
133
- for (const binary of ["owncast-plugin-test", "owncast-plugin-serve"]) {
134
- const dest = path.join(cacheDir, binary);
135
- if (fs.existsSync(dest)) continue;
136
- const gz = dest + ".gz";
137
- try {
138
- console.log(
139
- `[plugin-sdk] downloading ${binary} ${HOST_BINARIES_VERSION}...`,
140
- );
141
- await download(hostBinaryURL(binary) + ".gz", gz);
142
- fs.writeFileSync(dest, zlib.gunzipSync(fs.readFileSync(gz)));
143
- fs.chmodSync(dest, 0o755);
144
- fs.unlinkSync(gz);
145
- } catch (e) {
146
- // 404 is expected before the first release; other errors get a soft
147
- // warning so the user sees them but the install still succeeds.
148
- console.warn(
149
- `[plugin-sdk] could not fetch ${binary}: ${e.message}\n` +
150
- ` Build locally via tools/bootstrap.sh, or use the latest GitHub release.`,
151
- );
152
- // Make sure no partial files are left behind.
153
- for (const p of [gz, dest]) if (fs.existsSync(p)) fs.unlinkSync(p);
196
+ const hostBinaries = ["owncast-plugin-test", "owncast-plugin-serve"];
197
+ const missing = hostBinaries.filter(
198
+ (b) => !fs.existsSync(path.join(cacheDir, b)),
199
+ );
200
+ if (missing.length) {
201
+ // Resolve the version only when something needs downloading, so a repeat
202
+ // install with a populated cache never hits the network.
203
+ const version = await resolveHostBinariesVersion();
204
+ for (const binary of missing) {
205
+ const dest = path.join(cacheDir, binary);
206
+ const gz = dest + ".gz";
207
+ try {
208
+ console.log(`[plugin-sdk] downloading ${binary} v${version}...`);
209
+ await download(hostBinaryURL(binary, version) + ".gz", gz);
210
+ fs.writeFileSync(dest, zlib.gunzipSync(fs.readFileSync(gz)));
211
+ fs.chmodSync(dest, 0o755);
212
+ fs.unlinkSync(gz);
213
+ } catch (e) {
214
+ // 404 is expected before the first release; other errors get a soft
215
+ // warning so the user sees them but the install still succeeds.
216
+ console.warn(
217
+ `[plugin-sdk] could not fetch ${binary}: ${e.message}\n` +
218
+ ` Build locally via tools/bootstrap.sh, or use the latest GitHub release.`,
219
+ );
220
+ // Make sure no partial files are left behind.
221
+ for (const p of [gz, dest]) if (fs.existsSync(p)) fs.unlinkSync(p);
222
+ }
154
223
  }
155
224
  }
156
225
 
package/testing.js CHANGED
@@ -78,28 +78,40 @@ function findCacheDir() {
78
78
  * dir that links to your manifest + wasm and contains only the generated
79
79
  * scenarios it's running.
80
80
  *
81
- * Exits the process with status 0 if every scenario passed, non-zero otherwise.
81
+ * Sets `process.exitCode` to non-zero if any scenario failed (never resets a
82
+ * previously-failed code), and returns true on success / false on failure
83
+ * WITHOUT exiting the process. That lets one node process run several test
84
+ * files in a row (see runScenarioFiles); the process ends with the right code
85
+ * once the event loop drains.
82
86
  *
83
87
  * @param {Array<object>} scenarios, scenario objects: { name, given?, events, expect? }
84
88
  * @param {object} [opts]
85
89
  * @param {string} [opts.cwd], plugin project directory (default: process.cwd())
90
+ * @returns {boolean} true if every scenario passed
86
91
  */
92
+ function fail(code) {
93
+ // Record a non-zero exit code without clobbering an earlier failure or
94
+ // aborting sibling test files. Returns false for `return fail(...)`.
95
+ if (!process.exitCode) process.exitCode = code;
96
+ return false;
97
+ }
98
+
87
99
  function runScenarios(scenarios, opts = {}) {
88
100
  if (!Array.isArray(scenarios) || scenarios.length === 0) {
89
101
  console.error("runScenarios: no scenarios provided");
90
- process.exit(2);
102
+ return fail(2);
91
103
  }
92
104
 
93
105
  const cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd();
94
106
  const manifestPath = path.join(cwd, "plugin.manifest.json");
95
107
  if (!fs.existsSync(manifestPath)) {
96
108
  console.error(`plugin.manifest.json not found in ${cwd}`);
97
- process.exit(2);
109
+ return fail(2);
98
110
  }
99
111
  const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
100
112
  if (!manifest.name) {
101
113
  console.error("manifest.name is required");
102
- process.exit(2);
114
+ return fail(2);
103
115
  }
104
116
  // wasm + symlink filenames key off slug (the identifier), not the
105
117
  // display name. Derive the slug here the same way the build CLI
@@ -109,14 +121,14 @@ function runScenarios(scenarios, opts = {}) {
109
121
  console.error(
110
122
  `could not derive slug from manifest.name ${JSON.stringify(manifest.name)}; set manifest.slug explicitly`,
111
123
  );
112
- process.exit(2);
124
+ return fail(2);
113
125
  }
114
126
  const wasmPath = path.join(cwd, `${slug}.wasm`);
115
127
  if (!fs.existsSync(wasmPath)) {
116
128
  console.error(
117
129
  `${slug}.wasm not found at ${wasmPath}, run \`owncast-plugin package\` first`,
118
130
  );
119
- process.exit(2);
131
+ return fail(2);
120
132
  }
121
133
 
122
134
  const cache = findCacheDir();
@@ -126,7 +138,7 @@ function runScenarios(scenarios, opts = {}) {
126
138
  `owncast-plugin-test not found at ${bin}\n` +
127
139
  `Reinstall @owncast/plugin-sdk to fetch the host toolchain (postinstall handles it).`,
128
140
  );
129
- process.exit(2);
141
+ return fail(2);
130
142
  }
131
143
 
132
144
  // Build a temp project dir that links to the wasm + manifest and contains
@@ -156,11 +168,46 @@ function runScenarios(scenarios, opts = {}) {
156
168
  try {
157
169
  execFileSync(bin, [tmp], { stdio: "inherit", env });
158
170
  } catch (e) {
159
- process.exit(typeof e.status === "number" ? e.status : 1);
171
+ return fail(typeof e.status === "number" ? e.status : 1);
160
172
  }
161
173
  } finally {
162
174
  fs.rmSync(tmp, { recursive: true, force: true });
163
175
  }
176
+ return true;
177
+ }
178
+
179
+ /**
180
+ * Discover and run every `__tests__/*.test.js` file in one node process,
181
+ * aggregating their exit status. Lets you organize scenarios across multiple
182
+ * files (by module/feature) without a shell loop spawning a process per file.
183
+ * Each discovered file is expected to call runScenarios() at load time.
184
+ *
185
+ * @param {object} [opts]
186
+ * @param {string} [opts.cwd], plugin project directory (default: process.cwd())
187
+ * @returns {boolean} true if every file's scenarios passed
188
+ */
189
+ function runScenarioFiles(opts = {}) {
190
+ const cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd();
191
+ const dir = path.join(cwd, "__tests__");
192
+ if (!fs.existsSync(dir)) {
193
+ console.error(`no __tests__ directory in ${cwd}`);
194
+ return fail(2);
195
+ }
196
+ const files = fs
197
+ .readdirSync(dir)
198
+ .filter((f) => f.endsWith(".test.js"))
199
+ .sort()
200
+ .map((f) => path.join(dir, f));
201
+ if (files.length === 0) {
202
+ console.error(`no *.test.js files in ${dir}`);
203
+ return fail(2);
204
+ }
205
+ for (const f of files) {
206
+ // Each file runs its own runScenarios() at require time, which records a
207
+ // non-zero process.exitCode on failure but no longer aborts the process.
208
+ require(f);
209
+ }
210
+ return !process.exitCode;
164
211
  }
165
212
 
166
- module.exports = { runScenarios };
213
+ module.exports = { runScenarios, runScenarioFiles };