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