@owncast/plugin-sdk 0.1.0 → 0.3.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 +12 -10
- package/bin/owncast-plugin.js +179 -55
- package/index.d.ts +44 -24
- package/index.js +250 -110
- package/package.json +3 -2
- package/scripts/postinstall.js +17 -13
- package/testing.js +158 -0
package/README.md
CHANGED
|
@@ -2,19 +2,20 @@
|
|
|
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 `npx create-owncast-plugin@latest <slug>` and the generated `package.json` already lists it as a dependency.
|
|
6
6
|
|
|
7
7
|
## Quick start
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
|
|
10
|
+
npx create-owncast-plugin@latest my-plugin
|
|
11
11
|
cd my-plugin
|
|
12
|
-
npm install
|
|
13
|
-
npm run build
|
|
14
|
-
npm
|
|
12
|
+
npm install # postinstall fetches the per-platform wasm toolchain
|
|
13
|
+
npm run build # compiles src/plugin.js into an intermediate build artifact
|
|
14
|
+
npm run package # zips manifest + wasm + assets + icon.png into my-plugin.ocpkg
|
|
15
|
+
npm test # runs scenarios from __tests__/
|
|
15
16
|
```
|
|
16
17
|
|
|
17
|
-
Then
|
|
18
|
+
Then install `my-plugin.ocpkg` in Owncast. From the admin's **Plugins** page click **Upload plugin** and pick the file, or copy it directly to the server's `data/plugins/` directory. Toggle **Enabled** on the plugin's row to load it.
|
|
18
19
|
|
|
19
20
|
## Writing a plugin
|
|
20
21
|
|
|
@@ -38,10 +39,11 @@ Declare the permissions your plugin uses (`chat.send` for the example above) in
|
|
|
38
39
|
|
|
39
40
|
## What's in the package
|
|
40
41
|
|
|
41
|
-
- `index.js
|
|
42
|
-
- `index.d.ts
|
|
43
|
-
- `
|
|
44
|
-
- `
|
|
42
|
+
- `index.js`, runtime: `definePlugin`, the `owncast.*` host wrappers, the `filter` constructor.
|
|
43
|
+
- `index.d.ts`, TypeScript declarations for editor autocomplete on every event payload and host API.
|
|
44
|
+
- `testing.js`, JS test API (`runScenarios`) for writing `__tests__/*.test.js` with the full ergonomics of JavaScript instead of static JSON.
|
|
45
|
+
- `bin/owncast-plugin`, CLI: `build`, `test`, `serve`, `package` subcommands.
|
|
46
|
+
- `scripts/postinstall.js`, downloads the per-platform wasm toolchain (`extism-js`, `wasm-merge`, `wasm-opt`) and the Go test/serve runner on install.
|
|
45
47
|
|
|
46
48
|
## License
|
|
47
49
|
|
package/bin/owncast-plugin.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
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 <slug>.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 <slug>.ocpkg suitable
|
|
6
6
|
// for distribution / installation
|
|
7
|
+
//
|
|
8
|
+
// "Slug" is the plugin's identifier: lowercase, hyphenated, used in
|
|
9
|
+
// filenames, URL segments, and as the registry's primary key. Plugin
|
|
10
|
+
// authors set the human-readable display name via `name` in their
|
|
11
|
+
// manifest; if they don't set `slug`, the CLI auto-derives it from
|
|
12
|
+
// `name`.
|
|
7
13
|
|
|
8
14
|
const fs = require("fs");
|
|
9
15
|
const path = require("path");
|
|
@@ -14,24 +20,73 @@ const JSZip = require("jszip");
|
|
|
14
20
|
const cmd = process.argv[2] || "build";
|
|
15
21
|
const restArgs = process.argv.slice(3);
|
|
16
22
|
|
|
17
|
-
if (cmd === "build") {
|
|
18
|
-
buildMain().catch(fail);
|
|
19
|
-
} else if (cmd === "test") {
|
|
20
|
-
testMain(restArgs);
|
|
21
|
-
} else if (cmd === "serve") {
|
|
22
|
-
serveMain(restArgs);
|
|
23
|
-
} else if (cmd === "package") {
|
|
24
|
-
packageMain().catch(fail);
|
|
25
|
-
} else {
|
|
26
|
-
console.error(`unknown command: ${cmd}\nusage: owncast-plugin <build|test|serve|package>`);
|
|
27
|
-
process.exit(1);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
23
|
function fail(e) {
|
|
31
24
|
console.error(`${cmd} failed: ${e.message}`);
|
|
32
25
|
process.exit(1);
|
|
33
26
|
}
|
|
34
27
|
|
|
28
|
+
// slugPattern matches a valid plugin slug: a lowercase letter
|
|
29
|
+
// followed by lowercase letters/digits/hyphens, up to 64 chars total.
|
|
30
|
+
// Same shape the host + SDK + registry all validate against.
|
|
31
|
+
const slugPattern = /^[a-z][a-z0-9-]{0,63}$/;
|
|
32
|
+
|
|
33
|
+
// slugify mirrors the host's Go slugify: ASCII letters and digits
|
|
34
|
+
// pass through lowercased; everything else collapses to a single
|
|
35
|
+
// hyphen; leading and trailing hyphens are trimmed.
|
|
36
|
+
// Non-ASCII names (e.g. "Café") degrade noisily (-> "caf"); plugins
|
|
37
|
+
// with accented or non-Latin display names should pin `slug` in the
|
|
38
|
+
// manifest instead of relying on auto-derivation.
|
|
39
|
+
function slugify(input) {
|
|
40
|
+
let out = "";
|
|
41
|
+
let prevHyphen = false;
|
|
42
|
+
for (const ch of input) {
|
|
43
|
+
const code = ch.codePointAt(0);
|
|
44
|
+
let lower = ch;
|
|
45
|
+
if (code >= 65 && code <= 90) lower = String.fromCodePoint(code + 32);
|
|
46
|
+
const lc = lower.codePointAt(0);
|
|
47
|
+
if ((lc >= 97 && lc <= 122) || (lc >= 48 && lc <= 57)) {
|
|
48
|
+
out += lower;
|
|
49
|
+
prevHyphen = false;
|
|
50
|
+
} else if (!prevHyphen && out.length > 0) {
|
|
51
|
+
out += "-";
|
|
52
|
+
prevHyphen = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out.replace(/-+$/, "");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// readAndResolveManifest loads plugin.manifest.json, validates the
|
|
59
|
+
// required fields, and returns a manifest object with `slug` filled
|
|
60
|
+
// in: either the author's explicit `slug`, or one auto-derived from
|
|
61
|
+
// `name`. The returned object is what gets baked into MANIFEST_BASE
|
|
62
|
+
// in the build's synthesized entry, so register() always emits both
|
|
63
|
+
// name (display) and slug (identifier).
|
|
64
|
+
function readAndResolveManifest(manifestPath) {
|
|
65
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
66
|
+
if (!manifest.name || typeof manifest.name !== "string") {
|
|
67
|
+
throw new Error("manifest.name is required");
|
|
68
|
+
}
|
|
69
|
+
if (!manifest.version || typeof manifest.version !== "string") {
|
|
70
|
+
throw new Error("manifest.version is required");
|
|
71
|
+
}
|
|
72
|
+
let slug = manifest.slug;
|
|
73
|
+
if (!slug) {
|
|
74
|
+
slug = slugify(manifest.name);
|
|
75
|
+
if (!slug) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`could not derive a slug from manifest.name ${JSON.stringify(manifest.name)}; set manifest.slug explicitly`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!slugPattern.test(slug)) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`manifest.slug ${JSON.stringify(slug)} must match ${slugPattern} (lowercase letters/digits/hyphens, starting with a letter, max 64 chars)`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
manifest.slug = slug;
|
|
87
|
+
return manifest;
|
|
88
|
+
}
|
|
89
|
+
|
|
35
90
|
function testMain(args) {
|
|
36
91
|
runBinary("owncast-plugin-test", args);
|
|
37
92
|
}
|
|
@@ -46,17 +101,20 @@ function runBinary(name, args) {
|
|
|
46
101
|
if (!fs.existsSync(bin)) {
|
|
47
102
|
console.error(
|
|
48
103
|
`${name} not found at ${bin}\n` +
|
|
49
|
-
|
|
50
|
-
|
|
104
|
+
`In production this is fetched by the SDK postinstall. For the PoC, ` +
|
|
105
|
+
`build it via: cd owncast && go build -o tools/${name} ./cmd/${name}`,
|
|
51
106
|
);
|
|
52
107
|
process.exit(1);
|
|
53
108
|
}
|
|
54
109
|
const env = {
|
|
55
110
|
...process.env,
|
|
56
|
-
LD_LIBRARY_PATH: `${path.join(cache, "lib")}:${process.env.LD_LIBRARY_PATH || ""}
|
|
111
|
+
LD_LIBRARY_PATH: `${path.join(cache, "lib")}:${process.env.LD_LIBRARY_PATH || ""}`,
|
|
57
112
|
};
|
|
58
113
|
try {
|
|
59
|
-
execFileSync(bin, args.length > 0 ? args : [process.cwd()], {
|
|
114
|
+
execFileSync(bin, args.length > 0 ? args : [process.cwd()], {
|
|
115
|
+
stdio: "inherit",
|
|
116
|
+
env,
|
|
117
|
+
});
|
|
60
118
|
} catch (e) {
|
|
61
119
|
process.exit(typeof e.status === "number" ? e.status : 1);
|
|
62
120
|
}
|
|
@@ -68,20 +126,27 @@ async function buildMain() {
|
|
|
68
126
|
if (!fs.existsSync(manifestPath)) {
|
|
69
127
|
throw new Error("plugin.manifest.json not found in current directory");
|
|
70
128
|
}
|
|
71
|
-
const manifest =
|
|
72
|
-
const
|
|
73
|
-
if (!name) throw new Error("manifest.name is required");
|
|
129
|
+
const manifest = readAndResolveManifest(manifestPath);
|
|
130
|
+
const slug = manifest.slug;
|
|
74
131
|
|
|
75
132
|
// Detect entry point.
|
|
76
133
|
let entry = null;
|
|
77
|
-
for (const candidate of [
|
|
134
|
+
for (const candidate of [
|
|
135
|
+
"src/plugin.ts",
|
|
136
|
+
"src/plugin.js",
|
|
137
|
+
"plugin.ts",
|
|
138
|
+
"plugin.js",
|
|
139
|
+
]) {
|
|
78
140
|
const p = path.join(cwd, candidate);
|
|
79
141
|
if (fs.existsSync(p)) {
|
|
80
142
|
entry = p;
|
|
81
143
|
break;
|
|
82
144
|
}
|
|
83
145
|
}
|
|
84
|
-
if (!entry)
|
|
146
|
+
if (!entry)
|
|
147
|
+
throw new Error(
|
|
148
|
+
"no plugin source found (expected src/plugin.ts or plugin.js)",
|
|
149
|
+
);
|
|
85
150
|
|
|
86
151
|
// Synthesize an entry that injects the manifest, requires user code,
|
|
87
152
|
// then re-exports the SDK runtime exports as wasm-visible exports.
|
|
@@ -132,7 +197,7 @@ module.exports = { register, on_event, on_filter, on_http_request };
|
|
|
132
197
|
platform: "neutral",
|
|
133
198
|
target: "es2020",
|
|
134
199
|
outfile: bundledJS,
|
|
135
|
-
logLevel: "warning"
|
|
200
|
+
logLevel: "warning",
|
|
136
201
|
});
|
|
137
202
|
|
|
138
203
|
// Generate index.d.ts declaring exports + host imports based on permissions.
|
|
@@ -143,16 +208,21 @@ module.exports = { register, on_event, on_filter, on_http_request };
|
|
|
143
208
|
const cache = findCacheDir();
|
|
144
209
|
const extismJs = path.join(cache, "extism-js");
|
|
145
210
|
if (!fs.existsSync(extismJs)) {
|
|
146
|
-
throw new Error(
|
|
211
|
+
throw new Error(
|
|
212
|
+
`extism-js not found at ${extismJs}, run \`npm install\` to fetch the toolchain`,
|
|
213
|
+
);
|
|
147
214
|
}
|
|
148
215
|
const env = {
|
|
149
216
|
...process.env,
|
|
150
217
|
PATH: `${cache}:${process.env.PATH}`,
|
|
151
|
-
LD_LIBRARY_PATH: `${path.join(cache, "lib")}:${process.env.LD_LIBRARY_PATH || ""}
|
|
218
|
+
LD_LIBRARY_PATH: `${path.join(cache, "lib")}:${process.env.LD_LIBRARY_PATH || ""}`,
|
|
152
219
|
};
|
|
153
220
|
|
|
154
|
-
const wasmOut = path.join(cwd, `${
|
|
155
|
-
execFileSync(extismJs, [bundledJS, "-i", dts, "-o", wasmOut], {
|
|
221
|
+
const wasmOut = path.join(cwd, `${slug}.wasm`);
|
|
222
|
+
execFileSync(extismJs, [bundledJS, "-i", dts, "-o", wasmOut], {
|
|
223
|
+
stdio: "inherit",
|
|
224
|
+
env,
|
|
225
|
+
});
|
|
156
226
|
|
|
157
227
|
// If the project ships static assets in ./assets/, mirror them to the
|
|
158
228
|
// canonical deployment layout (<name>-assets/) so plugin.Server finds them
|
|
@@ -160,17 +230,30 @@ module.exports = { register, on_event, on_filter, on_http_request };
|
|
|
160
230
|
// show up live during dev (no rebuild needed for HTML/CSS changes).
|
|
161
231
|
const assetsSrc = path.join(cwd, "assets");
|
|
162
232
|
if (fs.existsSync(assetsSrc) && fs.statSync(assetsSrc).isDirectory()) {
|
|
163
|
-
const assetsDest = path.join(cwd, `${
|
|
233
|
+
const assetsDest = path.join(cwd, `${slug}-assets`);
|
|
164
234
|
let needsLink = true;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
235
|
+
// Use lstatSync (not existsSync), existsSync follows symlinks and
|
|
236
|
+
// returns false for a dangling link, but the link's inode is still
|
|
237
|
+
// there and would make symlinkSync below fail with EEXIST. lstatSync
|
|
238
|
+
// sees the link itself regardless of whether its target resolves.
|
|
239
|
+
let st;
|
|
240
|
+
try {
|
|
241
|
+
st = fs.lstatSync(assetsDest);
|
|
242
|
+
} catch {
|
|
243
|
+
// path doesn't exist at all, fall through to create it.
|
|
244
|
+
}
|
|
245
|
+
if (st) {
|
|
246
|
+
let target;
|
|
247
|
+
if (st.isSymbolicLink()) {
|
|
248
|
+
// realpathSync throws on dangling links; treat that as "doesn't
|
|
249
|
+
// match, replace it" rather than letting it abort the build.
|
|
250
|
+
try {
|
|
251
|
+
target = fs.realpathSync(assetsDest);
|
|
252
|
+
} catch {}
|
|
253
|
+
}
|
|
254
|
+
if (target && target === fs.realpathSync(assetsSrc)) {
|
|
255
|
+
needsLink = false;
|
|
256
|
+
} else {
|
|
174
257
|
fs.rmSync(assetsDest, { recursive: true, force: true });
|
|
175
258
|
}
|
|
176
259
|
}
|
|
@@ -182,7 +265,7 @@ module.exports = { register, on_event, on_filter, on_http_request };
|
|
|
182
265
|
console.log(`built ${path.relative(cwd, wasmOut)}`);
|
|
183
266
|
}
|
|
184
267
|
|
|
185
|
-
// `owncast-plugin package
|
|
268
|
+
// `owncast-plugin package`, bundle the project into a single .ocpkg file
|
|
186
269
|
// (zip archive with plugin.manifest.json, plugin.wasm, and optional assets/).
|
|
187
270
|
// Builds the wasm first if it doesn't exist.
|
|
188
271
|
async function packageMain() {
|
|
@@ -191,11 +274,10 @@ async function packageMain() {
|
|
|
191
274
|
if (!fs.existsSync(manifestPath)) {
|
|
192
275
|
throw new Error("plugin.manifest.json not found in current directory");
|
|
193
276
|
}
|
|
194
|
-
const manifest =
|
|
195
|
-
const
|
|
196
|
-
if (!name) throw new Error("manifest.name is required");
|
|
277
|
+
const manifest = readAndResolveManifest(manifestPath);
|
|
278
|
+
const slug = manifest.slug;
|
|
197
279
|
|
|
198
|
-
const wasmPath = path.join(cwd, `${
|
|
280
|
+
const wasmPath = path.join(cwd, `${slug}.wasm`);
|
|
199
281
|
if (!fs.existsSync(wasmPath)) {
|
|
200
282
|
await buildMain();
|
|
201
283
|
}
|
|
@@ -205,6 +287,15 @@ async function packageMain() {
|
|
|
205
287
|
zip.file("plugin.manifest.json", fs.readFileSync(manifestPath));
|
|
206
288
|
zip.file("plugin.wasm", fs.readFileSync(wasmPath));
|
|
207
289
|
let fileCount = 2;
|
|
290
|
+
// Bundle a top-level icon.png if the plugin source root has one.
|
|
291
|
+
// The host reads it from /api/plugins/<slug>/icon to render in the
|
|
292
|
+
// admin list and sidebar (no manifest field, no http.serve
|
|
293
|
+
// permission required).
|
|
294
|
+
const iconPath = path.join(cwd, "icon.png");
|
|
295
|
+
if (fs.existsSync(iconPath) && fs.statSync(iconPath).isFile()) {
|
|
296
|
+
zip.file("icon.png", fs.readFileSync(iconPath));
|
|
297
|
+
fileCount++;
|
|
298
|
+
}
|
|
208
299
|
if (fs.existsSync(assetsDir) && fs.statSync(assetsDir).isDirectory()) {
|
|
209
300
|
for (const file of walkFiles(assetsDir)) {
|
|
210
301
|
const rel = path.relative(assetsDir, file).split(path.sep).join("/");
|
|
@@ -213,15 +304,17 @@ async function packageMain() {
|
|
|
213
304
|
}
|
|
214
305
|
}
|
|
215
306
|
|
|
216
|
-
const outPath = path.join(cwd, `${
|
|
307
|
+
const outPath = path.join(cwd, `${slug}.ocpkg`);
|
|
217
308
|
const buf = await zip.generateAsync({
|
|
218
309
|
type: "nodebuffer",
|
|
219
310
|
compression: "DEFLATE",
|
|
220
|
-
compressionOptions: { level: 6 }
|
|
311
|
+
compressionOptions: { level: 6 },
|
|
221
312
|
});
|
|
222
313
|
fs.writeFileSync(outPath, buf);
|
|
223
314
|
const sizeKb = Math.round(fs.statSync(outPath).size / 1024);
|
|
224
|
-
console.log(
|
|
315
|
+
console.log(
|
|
316
|
+
`packaged ${path.relative(cwd, outPath)} (${sizeKb} KB, ${fileCount} files)`,
|
|
317
|
+
);
|
|
225
318
|
}
|
|
226
319
|
|
|
227
320
|
function* walkFiles(dir) {
|
|
@@ -248,7 +341,7 @@ function generateInterface(manifest) {
|
|
|
248
341
|
"register(): I32",
|
|
249
342
|
"on_event(): I32",
|
|
250
343
|
"on_filter(): I32",
|
|
251
|
-
"on_http_request(): I32"
|
|
344
|
+
"on_http_request(): I32",
|
|
252
345
|
];
|
|
253
346
|
|
|
254
347
|
const perms = new Set(manifest.permissions || []);
|
|
@@ -277,7 +370,9 @@ function generateInterface(manifest) {
|
|
|
277
370
|
imports.push("owncast_user_get(idPtr: PTR): PTR");
|
|
278
371
|
}
|
|
279
372
|
if (perms.has("users.moderate")) {
|
|
280
|
-
imports.push(
|
|
373
|
+
imports.push(
|
|
374
|
+
"owncast_user_set_enabled(idPtr: PTR, enabled: I32, reasonPtr: PTR): void",
|
|
375
|
+
);
|
|
281
376
|
imports.push("owncast_ban_ip(ipPtr: PTR): void");
|
|
282
377
|
}
|
|
283
378
|
if (perms.has("storage.upload")) {
|
|
@@ -290,8 +385,14 @@ function generateInterface(manifest) {
|
|
|
290
385
|
imports.push("owncast_kv_get(keyPtr: PTR): PTR");
|
|
291
386
|
imports.push("owncast_kv_set(keyPtr: PTR, valPtr: PTR): void");
|
|
292
387
|
}
|
|
293
|
-
if (perms.has("events.emit"))
|
|
294
|
-
|
|
388
|
+
if (perms.has("events.emit"))
|
|
389
|
+
imports.push(
|
|
390
|
+
"owncast_emit_event(eventTypePtr: PTR, payloadPtr: PTR): void",
|
|
391
|
+
);
|
|
392
|
+
if (perms.has("http.sse"))
|
|
393
|
+
imports.push(
|
|
394
|
+
"owncast_sse_send(channelPtr: PTR, eventPtr: PTR, dataPtr: PTR): void",
|
|
395
|
+
);
|
|
295
396
|
if (perms.has("server.read")) {
|
|
296
397
|
imports.push("owncast_stream_current(): PTR");
|
|
297
398
|
imports.push("owncast_server_info(): PTR");
|
|
@@ -306,6 +407,10 @@ function generateInterface(manifest) {
|
|
|
306
407
|
if (perms.has("videoconfig.write")) {
|
|
307
408
|
imports.push("owncast_video_config_write(configPtr: PTR): PTR");
|
|
308
409
|
}
|
|
410
|
+
if (perms.has("ui.modify")) {
|
|
411
|
+
imports.push("owncast_add_actions(actionsPtr: PTR): void");
|
|
412
|
+
imports.push("owncast_clear_actions(): void");
|
|
413
|
+
}
|
|
309
414
|
|
|
310
415
|
let out = `declare module 'main' {\n`;
|
|
311
416
|
for (const e of exports) out += ` export function ${e};\n`;
|
|
@@ -326,9 +431,9 @@ function findCacheDir() {
|
|
|
326
431
|
const candidates = [
|
|
327
432
|
path.join(__dirname, ".cache"),
|
|
328
433
|
path.join(__dirname, "..", "bin", ".cache"),
|
|
329
|
-
path.join(__dirname, "..", "..", "..", "tools")
|
|
434
|
+
path.join(__dirname, "..", "..", "..", "tools"),
|
|
330
435
|
];
|
|
331
|
-
// Pick the first candidate that has any of the expected tools
|
|
436
|
+
// Pick the first candidate that has any of the expected tools, different
|
|
332
437
|
// commands need different binaries (build needs extism-js, test needs
|
|
333
438
|
// owncast-plugin-test) but they share a cache.
|
|
334
439
|
for (const c of candidates) {
|
|
@@ -343,3 +448,22 @@ function findCacheDir() {
|
|
|
343
448
|
return candidates[0];
|
|
344
449
|
}
|
|
345
450
|
|
|
451
|
+
// Dispatch sits at the bottom so every const + function above is
|
|
452
|
+
// fully initialized before any handler runs. Calling a handler from
|
|
453
|
+
// the top of the file would put the top-level `const slugPattern`
|
|
454
|
+
// (and friends) in the TDZ for the first synchronous slice of
|
|
455
|
+
// buildMain/packageMain.
|
|
456
|
+
if (cmd === "build") {
|
|
457
|
+
buildMain().catch(fail);
|
|
458
|
+
} else if (cmd === "test") {
|
|
459
|
+
testMain(restArgs);
|
|
460
|
+
} else if (cmd === "serve") {
|
|
461
|
+
serveMain(restArgs);
|
|
462
|
+
} else if (cmd === "package") {
|
|
463
|
+
packageMain().catch(fail);
|
|
464
|
+
} else {
|
|
465
|
+
console.error(
|
|
466
|
+
`unknown command: ${cmd}\nusage: owncast-plugin <build|test|serve|package>`,
|
|
467
|
+
);
|
|
468
|
+
process.exit(1);
|
|
469
|
+
}
|
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;
|
|
@@ -158,6 +158,7 @@ export const Permissions: {
|
|
|
158
158
|
readonly ChatSend: "chat.send";
|
|
159
159
|
readonly ChatHistory: "chat.history";
|
|
160
160
|
readonly ChatModerate: "chat.moderate";
|
|
161
|
+
readonly ChatFilter: "chat.filter";
|
|
161
162
|
readonly StorageKV: "storage.kv";
|
|
162
163
|
readonly StorageUpload: "storage.upload";
|
|
163
164
|
readonly EventsEmit: "events.emit";
|
|
@@ -171,6 +172,7 @@ export const Permissions: {
|
|
|
171
172
|
readonly HttpSSE: "http.sse";
|
|
172
173
|
readonly VideoConfigRead: "videoconfig.read";
|
|
173
174
|
readonly VideoConfigWrite: "videoconfig.write";
|
|
175
|
+
readonly UIModify: "ui.modify";
|
|
174
176
|
};
|
|
175
177
|
|
|
176
178
|
export interface BrowserPushPayload {
|
|
@@ -204,7 +206,7 @@ export interface User {
|
|
|
204
206
|
displayName: string;
|
|
205
207
|
previousNames?: string[];
|
|
206
208
|
createdAt?: string;
|
|
207
|
-
disabledAt?: string;
|
|
209
|
+
disabledAt?: string; // ISO-8601 if banned, omitted otherwise
|
|
208
210
|
scopes?: string[];
|
|
209
211
|
isBot?: boolean;
|
|
210
212
|
isAuthenticated?: boolean;
|
|
@@ -290,12 +292,12 @@ export interface PluginDef {
|
|
|
290
292
|
onFediverseReply?(post: FediverseInboundPost): void | Promise<void>;
|
|
291
293
|
|
|
292
294
|
/** HTTP request handler. Called for any path under /plugins/<name>/ that
|
|
293
|
-
* isn't served as a static asset. Default-public
|
|
295
|
+
* isn't served as a static asset. Default-public, gate admin features
|
|
294
296
|
* on `req.authenticated` yourself. Requires `http.serve` permission. */
|
|
295
297
|
onHttpRequest?(req: IncomingHttpRequest): OutgoingHttpResponse;
|
|
296
298
|
|
|
297
299
|
/** Handlers for plugin-emitted custom events. The key is the event type
|
|
298
|
-
* string (e.g. "announcement.broadcast"). Notifications only
|
|
300
|
+
* string (e.g. "announcement.broadcast"). Notifications only, to filter
|
|
299
301
|
* custom events, additional API will be needed. */
|
|
300
302
|
on?: { [eventType: string]: (payload: any) => void | Promise<void> };
|
|
301
303
|
|
|
@@ -314,7 +316,7 @@ export const owncast: {
|
|
|
314
316
|
send(text: string): void;
|
|
315
317
|
/** Same identity, but in action style (italic, like IRC "/me"). */
|
|
316
318
|
sendAction(text: string): void;
|
|
317
|
-
/** Post a system message
|
|
319
|
+
/** Post a system message, no user identity, rendered as a server
|
|
318
320
|
* announcement. The body is rendered as HTML, so the plugin is
|
|
319
321
|
* responsible for escaping any untrusted content. Same `chat.send`
|
|
320
322
|
* permission as the other send variants. */
|
|
@@ -348,11 +350,13 @@ export const owncast: {
|
|
|
348
350
|
upload(name: string, data: Uint8Array | string): UploadResult | null;
|
|
349
351
|
};
|
|
350
352
|
/** Post to the fediverse on the streamer's behalf. Requires `fediverse.post`,
|
|
351
|
-
* which is high-trust
|
|
352
|
-
*
|
|
353
|
+
* which is high-trust (posts go out under the streamer's own handle);
|
|
354
|
+
* admins should grant it sparingly. */
|
|
353
355
|
fediverse: {
|
|
354
|
-
/** Publish a public, text-only post. Returns { url }
|
|
355
|
-
* on
|
|
356
|
+
/** Publish a public, text-only post. Returns `{ url }` (currently empty
|
|
357
|
+
* on success: Owncast publishes the note but doesn't yet round-trip its
|
|
358
|
+
* URL), or `null` when the host rejects the call (disabled, missing
|
|
359
|
+
* permission, etc.). */
|
|
356
360
|
post(text: string): { url: string } | null;
|
|
357
361
|
};
|
|
358
362
|
/** Send notifications via Owncast's configured channels.
|
|
@@ -372,6 +376,22 @@ export const owncast: {
|
|
|
372
376
|
events: {
|
|
373
377
|
emit(eventType: string, payload: unknown): void;
|
|
374
378
|
};
|
|
379
|
+
/** Control over the viewer action buttons this plugin contributes.
|
|
380
|
+
* The effective list shown to viewers is `manifest.actions` ++
|
|
381
|
+
* whatever has been added at runtime via `add`. Requires
|
|
382
|
+
* `ui.modify`. */
|
|
383
|
+
actions: {
|
|
384
|
+
/** Append one or more buttons to the plugin's runtime list. Each
|
|
385
|
+
* entry is validated with the same rules as `manifest.actions`
|
|
386
|
+
* (title required; exactly one of `url` or `html`; relative URLs
|
|
387
|
+
* rewritten into this plugin's namespace; cross-plugin URLs
|
|
388
|
+
* rejected). The next viewer `/api/config` request returns
|
|
389
|
+
* `manifest.actions` ++ the runtime list. */
|
|
390
|
+
add(actions: ActionButton | ActionButton[]): void;
|
|
391
|
+
/** Drop the runtime additions; only `manifest.actions` remain on
|
|
392
|
+
* the next viewer `/api/config` request. */
|
|
393
|
+
clear(): void;
|
|
394
|
+
};
|
|
375
395
|
sse: {
|
|
376
396
|
/** Push one Server-Sent-Event to every browser connected to this
|
|
377
397
|
* plugin's `/plugins/<name>/_sse/<channel>` stream. `event` is the SSE
|
|
@@ -416,7 +436,7 @@ export interface HttpResponse {
|
|
|
416
436
|
body: string;
|
|
417
437
|
}
|
|
418
438
|
|
|
419
|
-
/** An entry in `manifest.actions
|
|
439
|
+
/** An entry in `manifest.actions`, declares an action button the Owncast
|
|
420
440
|
* UI surfaces while this plugin is enabled. Mirrors Owncast's existing
|
|
421
441
|
* ExternalAction shape; the host merges enabled-plugin buttons with the
|
|
422
442
|
* admin-configured list.
|
|
@@ -429,7 +449,7 @@ export interface HttpResponse {
|
|
|
429
449
|
* explicit `/plugins/<your-name>/...` paths are accepted unchanged.
|
|
430
450
|
*
|
|
431
451
|
* When the resolved URL points back into this plugin, the manifest must
|
|
432
|
-
* declare `http.serve
|
|
452
|
+
* declare `http.serve`, the host rejects the load otherwise. */
|
|
433
453
|
export interface ActionButton {
|
|
434
454
|
/** Button label. Required. */
|
|
435
455
|
title: string;
|
|
@@ -437,7 +457,7 @@ export interface ActionButton {
|
|
|
437
457
|
url?: string;
|
|
438
458
|
/** Render this raw HTML when the button is pressed. Mutually exclusive with `url`. */
|
|
439
459
|
html?: string;
|
|
440
|
-
/** Icon image URL
|
|
460
|
+
/** Icon image URL, same path conventions as `url`. */
|
|
441
461
|
icon?: string;
|
|
442
462
|
/** Accent color, e.g. "#3b82f6". */
|
|
443
463
|
color?: string;
|
|
@@ -447,7 +467,7 @@ export interface ActionButton {
|
|
|
447
467
|
openExternally?: boolean;
|
|
448
468
|
}
|
|
449
469
|
|
|
450
|
-
/** `manifest.network
|
|
470
|
+
/** `manifest.network`, narrows outbound HTTP scope for plugins that
|
|
451
471
|
* declare the `network.fetch` permission. Required when that permission
|
|
452
472
|
* is granted; the host rejects loads otherwise. */
|
|
453
473
|
export interface NetworkConfig {
|