@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.
- package/bin/owncast-plugin.js +23 -1
- package/index.d.ts +154 -3
- package/index.js +254 -1
- package/package.json +1 -1
- package/scripts/postinstall.js +95 -26
- package/testing.js +56 -9
package/bin/owncast-plugin.js
CHANGED
|
@@ -202,7 +202,17 @@ function on_http_request() {
|
|
|
202
202
|
Host.outputString(JSON.stringify(response));
|
|
203
203
|
return 0;
|
|
204
204
|
}
|
|
205
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
package/scripts/postinstall.js
CHANGED
|
@@ -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${
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|