@owncast/plugin-sdk 0.4.1 → 0.4.2

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.
@@ -366,6 +366,13 @@ function generateInterface(manifest) {
366
366
 
367
367
  const perms = new Set(manifest.permissions || []);
368
368
  const imports = [];
369
+ // Timers are ambient (no permission): the host always provides them, since
370
+ // a plugin can't setTimeout in the sandbox.
371
+ imports.push("owncast_timer_set(id: I64, delayMs: I64, repeat: I32): I32");
372
+ imports.push("owncast_timer_clear(id: I64): void");
373
+ // Config is ambient too: a plugin reading its own manifest-declared config
374
+ // (admin override falling back to the declared default) needs no permission.
375
+ imports.push("owncast_config_get(keyPtr: PTR): PTR");
369
376
  if (perms.has("chat.send")) {
370
377
  imports.push("owncast_send_chat(textPtr: PTR): void");
371
378
  imports.push("owncast_send_chat_action(textPtr: PTR): void");
@@ -424,6 +431,7 @@ function generateInterface(manifest) {
424
431
  imports.push("owncast_stream_current(): PTR");
425
432
  imports.push("owncast_server_info(): PTR");
426
433
  imports.push("owncast_server_socials(): PTR");
434
+ imports.push("owncast_server_emotes(): PTR");
427
435
  imports.push("owncast_server_federation(): PTR");
428
436
  imports.push("owncast_stream_broadcaster(): PTR");
429
437
  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;
@@ -278,6 +296,12 @@ export interface SSEConnectionEvent {
278
296
  user?: ChatUser;
279
297
  }
280
298
 
299
+ /** Payload for the once-a-second tick event (onTick). `now` is the host
300
+ * wall-clock time in unix milliseconds when the tick fired. */
301
+ export interface TickEvent {
302
+ now: number;
303
+ }
304
+
281
305
  export interface PluginDef {
282
306
  /** Notification handler for chat messages. Fire-and-forget. */
283
307
  onChatMessage?(msg: ChatMessage): void | Promise<void>;
@@ -309,6 +333,10 @@ export interface PluginDef {
309
333
  * the matching onSseConnect). Requires the `http.sse` permission. */
310
334
  onSseDisconnect?(event: SSEConnectionEvent): void | Promise<void>;
311
335
 
336
+ /** Fires once a second for periodic work. `now` is the host wall-clock time
337
+ * in unix milliseconds. Defining this opts the plugin into the tick. */
338
+ onTick?(event: TickEvent): void | Promise<void>;
339
+
312
340
  /** Someone on the fediverse followed the streamer's account. */
313
341
  onFediverseFollow?(event: FediverseEngagement): void | Promise<void>;
314
342
  /** Someone on the fediverse liked a streamer post / federated stream announcement. */
@@ -337,6 +365,62 @@ export interface PluginDef {
337
365
 
338
366
  export function definePlugin(def: PluginDef): PluginDef;
339
367
 
368
+ /** What a command handler receives. */
369
+ export interface CommandContext {
370
+ /** The originating chat message. */
371
+ msg: ChatMessage;
372
+ /** The sender (same as `msg.user`). */
373
+ user?: ChatUser;
374
+ /** The canonical command name that matched (not the alias used). */
375
+ command: string;
376
+ /** Whitespace-split arguments after the command word. */
377
+ args: string[];
378
+ /** The raw argument string (everything after the command word, trimmed). */
379
+ argString: string;
380
+ /** Post a public reply as the plugin's chat bot. */
381
+ reply(text: string): void;
382
+ /** Whisper a reply to the sender; falls back to a public post if their
383
+ * connection is unknown. */
384
+ replyPrivately(text: string): void;
385
+ }
386
+
387
+ /** One command in a {@link defineCommands} table. */
388
+ export interface CommandDefinition {
389
+ /** Alternate names that invoke this command. */
390
+ aliases?: string[];
391
+ /** Only allow senders whose scopes include "MODERATOR". */
392
+ modOnly?: boolean;
393
+ /** Minimum milliseconds between invocations per user (clocked off
394
+ * `msg.timestamp`). */
395
+ cooldownMs?: number;
396
+ /** Invoked when the command runs. */
397
+ run(ctx: CommandContext): void;
398
+ /** Invoked instead of `run` when a non-moderator calls a `modOnly` command. */
399
+ onDenied?(ctx: CommandContext): void;
400
+ /** Invoked instead of `run` when the per-user cooldown hasn't elapsed. */
401
+ onCooldown?(ctx: CommandContext): void;
402
+ }
403
+
404
+ export interface CommandsConfig {
405
+ /** Command prefix. Default `"!"`. */
406
+ prefix?: string;
407
+ /** Match command names case-sensitively. Default false. */
408
+ caseSensitive?: boolean;
409
+ commands: Record<string, CommandDefinition>;
410
+ /** Fallback when a prefixed message matches no command. */
411
+ onUnknown?(ctx: CommandContext): void;
412
+ /** Default denied/cooldown handlers, used when a command omits its own. */
413
+ onDenied?(ctx: CommandContext): void;
414
+ onCooldown?(ctx: CommandContext): void;
415
+ }
416
+
417
+ /** Build a chat-command router (prefix parsing, aliases, per-user cooldowns,
418
+ * moderator gating). Feed the returned function a `ChatMessage`; it returns
419
+ * true when the message was a command (even if gated), false otherwise. */
420
+ export function defineCommands(
421
+ config: CommandsConfig,
422
+ ): (msg: ChatMessage) => boolean;
423
+
340
424
  /** Typed wrappers around the Owncast host. Each method throws if the
341
425
  * corresponding permission was not declared in plugin.manifest.json. */
342
426
  export const owncast: {
@@ -350,8 +434,14 @@ export const owncast: {
350
434
  * responsible for escaping any untrusted content. Same `chat.send`
351
435
  * permission as the other send variants. */
352
436
  system(body: string): void;
353
- /** Private message to one chat client. */
437
+ /** Private message to one chat client. Requires `chat.send`. */
354
438
  sendTo(clientId: number | bigint, text: string): void;
439
+ /** Whisper a reply back to whoever sent a chat message. Pass the
440
+ * `ChatMessage` from `onChatMessage`/`filterChatMessage` (or a bare
441
+ * clientId). Returns `false` if the sender's connection is unknown (no
442
+ * clientId), so callers can fall back to a public `send`. Requires
443
+ * `chat.send`. */
444
+ replyTo(msg: ChatMessage | number | bigint, text: string): boolean;
355
445
  /** Recent chat history (most recent last). Requires `chat.history`.
356
446
  * Default limit is 50; pass a smaller number to get fewer. */
357
447
  history(limit?: number): ChatMessage[];
@@ -418,6 +508,20 @@ export const owncast: {
418
508
  kv: {
419
509
  get(key: string): string | null;
420
510
  set(key: string, value: string | number): void;
511
+ /** Read a JSON value, parsed. Returns `fallback` (default `undefined`)
512
+ * when the key is unset or holds invalid JSON. Requires `storage.kv`. */
513
+ getJSON<T = unknown>(key: string, fallback?: T): T;
514
+ /** Store a value as JSON. Requires `storage.kv`. */
515
+ setJSON(key: string, value: unknown): void;
516
+ };
517
+ /** Read this plugin's admin-configurable settings, declared under
518
+ * `config` in the manifest. Ambient — no permission required. */
519
+ config: {
520
+ /** The effective value of a manifest-declared config key (admin override,
521
+ * else the declared default), parsed to its declared type. Returns
522
+ * `fallback` (default `undefined`) for an unknown key or one with no
523
+ * value. */
524
+ get<T = unknown>(key: string, fallback?: T): T;
421
525
  };
422
526
  events: {
423
527
  emit(eventType: string, payload: unknown): void;
@@ -447,6 +551,20 @@ export const owncast: {
447
551
  * `http.sse` permission. */
448
552
  send(channel: string, event: string, data: unknown): void;
449
553
  };
554
+ /** Host-driven timers. The sandbox has no setTimeout; these ask the host to
555
+ * call your callback back later (in this instance). No permission required.
556
+ * Timers do not survive a plugin reload or host restart. */
557
+ timer: {
558
+ /** Run `fn` once after ~`ms` milliseconds. Returns an id for `clear()`.
559
+ * Very small delays are clamped up by the host; throws past the
560
+ * per-plugin pending-timer cap. */
561
+ setTimeout(fn: () => void, ms: number): number;
562
+ /** Run `fn` every ~`ms` milliseconds until `clear()`. The next run is
563
+ * scheduled only after the previous one returns. Returns an id. */
564
+ setInterval(fn: () => void, ms: number): number;
565
+ /** Cancel a pending timeout or interval by its id. */
566
+ clear(id: number): void;
567
+ };
450
568
  http: {
451
569
  fetch(url: string, opts?: HttpRequestOpts): HttpResponse;
452
570
  };
@@ -459,6 +577,8 @@ export const owncast: {
459
577
  server: {
460
578
  info(): ServerInfo;
461
579
  socials(): SocialHandle[];
580
+ /** Custom chat emotes (`:code:` → image URL) configured on this server. */
581
+ emotes(): Emote[];
462
582
  federation(): FederationInfo;
463
583
  tags(): string[];
464
584
  };
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,34 @@ 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
+ },
572
772
  },
573
773
  events: {
574
774
  emit(eventType, payload) {
@@ -627,6 +827,26 @@ const owncast = {
627
827
  );
628
828
  },
629
829
  },
830
+ timer: {
831
+ // setTimeout(fn, ms) runs fn once after ~ms milliseconds. setInterval
832
+ // repeats until clear(id). The host drives the schedule (the sandbox has
833
+ // no setTimeout); your callback runs in this instance when it fires.
834
+ // Returns an id for clear(). Very small delays are clamped up by the host,
835
+ // and there's a per-plugin cap on pending timers (throws past it).
836
+ // Note: timers are in-memory and do not survive a plugin reload or a host
837
+ // restart. No permission required.
838
+ setTimeout(fn, ms) {
839
+ return scheduleTimer(fn, ms, false);
840
+ },
841
+ setInterval(fn, ms) {
842
+ return scheduleTimer(fn, ms, true);
843
+ },
844
+ clear(id) {
845
+ timerCallbacks.delete(id);
846
+ const fns = Host.getFunctions();
847
+ if (fns.owncast_timer_clear) fns.owncast_timer_clear(BigInt(id));
848
+ },
849
+ },
630
850
  http: {
631
851
  // fetch(url, opts) → { status, headers, body }
632
852
  // Wraps Extism's built-in Http.request. Throws if the manifest didn't
@@ -652,6 +872,7 @@ const owncast = {
652
872
 
653
873
  module.exports = {
654
874
  definePlugin,
875
+ defineCommands,
655
876
  owncast,
656
877
  filter,
657
878
  FilterAction,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owncast/plugin-sdk",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "SDK for authoring Owncast plugins in JavaScript",
5
5
  "license": "MIT",
6
6
  "author": "Owncast",
@@ -19,11 +19,24 @@ 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) are cut once per MINOR release
25
+ // (a vX.Y.0 git tag); patch releases are JS-only fixes that ride on the same
26
+ // runtime. Deriving the download tag straight from the npm version therefore
27
+ // 404s on every patch (e.g. 0.4.1 has no v0.4.1 binaries), which silently
28
+ // broke `npm test`. Zero the patch component so a patch release fetches its
29
+ // minor's binaries. Override with OWNCAST_PLUGIN_HOST_BINARIES_VERSION (with or
30
+ // without a leading "v") if you ever need a specific tag.
31
+ function hostBinariesVersion() {
32
+ const override = process.env.OWNCAST_PLUGIN_HOST_BINARIES_VERSION;
33
+ if (override) return override.replace(/^v/, "");
34
+ const pkg = require("../package.json").version; // e.g. "0.4.1"
35
+ const [major, minor] = pkg.split(".");
36
+ return `${major}.${minor}.0`;
37
+ }
38
+ const HOST_BINARIES_VERSION = hostBinariesVersion();
39
+
27
40
  const platform = process.platform;
28
41
  const arch = process.arch;
29
42
 
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 };