@owncast/plugin-sdk 0.2.0 → 0.3.1
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 +7 -6
- package/bin/owncast-plugin.js +130 -36
- package/index.d.ts +7 -4
- package/index.js +1 -0
- package/package.json +1 -1
- package/testing.js +46 -4
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, instead, scaffold a new project with `
|
|
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
|
|
package/bin/owncast-plugin.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// `owncast-plugin build` , bundle src/plugin.{js,ts} into <
|
|
2
|
+
// `owncast-plugin build` , bundle src/plugin.{js,ts} into <slug>.wasm
|
|
3
3
|
// `owncast-plugin test` , run scenarios in __tests__/ against the wasm
|
|
4
4
|
// `owncast-plugin serve` , run a localhost dev HTTP server
|
|
5
|
-
// `owncast-plugin package`, produce a single-file <
|
|
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,26 +20,94 @@ 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(
|
|
27
|
-
`unknown command: ${cmd}\nusage: owncast-plugin <build|test|serve|package>`,
|
|
28
|
-
);
|
|
29
|
-
process.exit(1);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
23
|
function fail(e) {
|
|
33
24
|
console.error(`${cmd} failed: ${e.message}`);
|
|
34
25
|
process.exit(1);
|
|
35
26
|
}
|
|
36
27
|
|
|
28
|
+
// toolchainEnv extends the current environment with the variables
|
|
29
|
+
// the dynamic linker needs to find `libbinaryen` next to `wasm-merge`
|
|
30
|
+
// and `wasm-opt` (which extism-js shells out to during the wasm
|
|
31
|
+
// pipeline). Linux uses LD_LIBRARY_PATH; macOS uses DYLD_LIBRARY_PATH
|
|
32
|
+
// plus DYLD_FALLBACK_LIBRARY_PATH (Apple Silicon strips
|
|
33
|
+
// DYLD_LIBRARY_PATH in some sandboxed contexts, the FALLBACK
|
|
34
|
+
// variant survives). Setting all three is safe on both OSes; the
|
|
35
|
+
// inactive ones are ignored. This is the difference between "build
|
|
36
|
+
// succeeds" and `library not loaded: @rpath/libbinaryen.dylib` on
|
|
37
|
+
// macOS.
|
|
38
|
+
function toolchainEnv(cache) {
|
|
39
|
+
const libDir = path.join(cache, "lib");
|
|
40
|
+
return {
|
|
41
|
+
...process.env,
|
|
42
|
+
PATH: `${cache}:${process.env.PATH}`,
|
|
43
|
+
LD_LIBRARY_PATH: `${libDir}:${process.env.LD_LIBRARY_PATH || ""}`,
|
|
44
|
+
DYLD_LIBRARY_PATH: `${libDir}:${process.env.DYLD_LIBRARY_PATH || ""}`,
|
|
45
|
+
DYLD_FALLBACK_LIBRARY_PATH: `${libDir}:${process.env.DYLD_FALLBACK_LIBRARY_PATH || "/usr/local/lib:/usr/lib"}`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// slugPattern matches a valid plugin slug: a lowercase letter
|
|
50
|
+
// followed by lowercase letters/digits/hyphens, up to 64 chars total.
|
|
51
|
+
// Same shape the host + SDK + registry all validate against.
|
|
52
|
+
const slugPattern = /^[a-z][a-z0-9-]{0,63}$/;
|
|
53
|
+
|
|
54
|
+
// slugify mirrors the host's Go slugify: ASCII letters and digits
|
|
55
|
+
// pass through lowercased; everything else collapses to a single
|
|
56
|
+
// hyphen; leading and trailing hyphens are trimmed.
|
|
57
|
+
// Non-ASCII names (e.g. "Café") degrade noisily (-> "caf"); plugins
|
|
58
|
+
// with accented or non-Latin display names should pin `slug` in the
|
|
59
|
+
// manifest instead of relying on auto-derivation.
|
|
60
|
+
function slugify(input) {
|
|
61
|
+
let out = "";
|
|
62
|
+
let prevHyphen = false;
|
|
63
|
+
for (const ch of input) {
|
|
64
|
+
const code = ch.codePointAt(0);
|
|
65
|
+
let lower = ch;
|
|
66
|
+
if (code >= 65 && code <= 90) lower = String.fromCodePoint(code + 32);
|
|
67
|
+
const lc = lower.codePointAt(0);
|
|
68
|
+
if ((lc >= 97 && lc <= 122) || (lc >= 48 && lc <= 57)) {
|
|
69
|
+
out += lower;
|
|
70
|
+
prevHyphen = false;
|
|
71
|
+
} else if (!prevHyphen && out.length > 0) {
|
|
72
|
+
out += "-";
|
|
73
|
+
prevHyphen = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out.replace(/-+$/, "");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// readAndResolveManifest loads plugin.manifest.json, validates the
|
|
80
|
+
// required fields, and returns a manifest object with `slug` filled
|
|
81
|
+
// in: either the author's explicit `slug`, or one auto-derived from
|
|
82
|
+
// `name`. The returned object is what gets baked into MANIFEST_BASE
|
|
83
|
+
// in the build's synthesized entry, so register() always emits both
|
|
84
|
+
// name (display) and slug (identifier).
|
|
85
|
+
function readAndResolveManifest(manifestPath) {
|
|
86
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
87
|
+
if (!manifest.name || typeof manifest.name !== "string") {
|
|
88
|
+
throw new Error("manifest.name is required");
|
|
89
|
+
}
|
|
90
|
+
if (!manifest.version || typeof manifest.version !== "string") {
|
|
91
|
+
throw new Error("manifest.version is required");
|
|
92
|
+
}
|
|
93
|
+
let slug = manifest.slug;
|
|
94
|
+
if (!slug) {
|
|
95
|
+
slug = slugify(manifest.name);
|
|
96
|
+
if (!slug) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`could not derive a slug from manifest.name ${JSON.stringify(manifest.name)}; set manifest.slug explicitly`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!slugPattern.test(slug)) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`manifest.slug ${JSON.stringify(slug)} must match ${slugPattern} (lowercase letters/digits/hyphens, starting with a letter, max 64 chars)`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
manifest.slug = slug;
|
|
108
|
+
return manifest;
|
|
109
|
+
}
|
|
110
|
+
|
|
37
111
|
function testMain(args) {
|
|
38
112
|
runBinary("owncast-plugin-test", args);
|
|
39
113
|
}
|
|
@@ -53,10 +127,7 @@ function runBinary(name, args) {
|
|
|
53
127
|
);
|
|
54
128
|
process.exit(1);
|
|
55
129
|
}
|
|
56
|
-
const env =
|
|
57
|
-
...process.env,
|
|
58
|
-
LD_LIBRARY_PATH: `${path.join(cache, "lib")}:${process.env.LD_LIBRARY_PATH || ""}`,
|
|
59
|
-
};
|
|
130
|
+
const env = toolchainEnv(cache);
|
|
60
131
|
try {
|
|
61
132
|
execFileSync(bin, args.length > 0 ? args : [process.cwd()], {
|
|
62
133
|
stdio: "inherit",
|
|
@@ -73,9 +144,8 @@ async function buildMain() {
|
|
|
73
144
|
if (!fs.existsSync(manifestPath)) {
|
|
74
145
|
throw new Error("plugin.manifest.json not found in current directory");
|
|
75
146
|
}
|
|
76
|
-
const manifest =
|
|
77
|
-
const
|
|
78
|
-
if (!name) throw new Error("manifest.name is required");
|
|
147
|
+
const manifest = readAndResolveManifest(manifestPath);
|
|
148
|
+
const slug = manifest.slug;
|
|
79
149
|
|
|
80
150
|
// Detect entry point.
|
|
81
151
|
let entry = null;
|
|
@@ -160,13 +230,9 @@ module.exports = { register, on_event, on_filter, on_http_request };
|
|
|
160
230
|
`extism-js not found at ${extismJs}, run \`npm install\` to fetch the toolchain`,
|
|
161
231
|
);
|
|
162
232
|
}
|
|
163
|
-
const env =
|
|
164
|
-
...process.env,
|
|
165
|
-
PATH: `${cache}:${process.env.PATH}`,
|
|
166
|
-
LD_LIBRARY_PATH: `${path.join(cache, "lib")}:${process.env.LD_LIBRARY_PATH || ""}`,
|
|
167
|
-
};
|
|
233
|
+
const env = toolchainEnv(cache);
|
|
168
234
|
|
|
169
|
-
const wasmOut = path.join(cwd, `${
|
|
235
|
+
const wasmOut = path.join(cwd, `${slug}.wasm`);
|
|
170
236
|
execFileSync(extismJs, [bundledJS, "-i", dts, "-o", wasmOut], {
|
|
171
237
|
stdio: "inherit",
|
|
172
238
|
env,
|
|
@@ -178,7 +244,7 @@ module.exports = { register, on_event, on_filter, on_http_request };
|
|
|
178
244
|
// show up live during dev (no rebuild needed for HTML/CSS changes).
|
|
179
245
|
const assetsSrc = path.join(cwd, "assets");
|
|
180
246
|
if (fs.existsSync(assetsSrc) && fs.statSync(assetsSrc).isDirectory()) {
|
|
181
|
-
const assetsDest = path.join(cwd, `${
|
|
247
|
+
const assetsDest = path.join(cwd, `${slug}-assets`);
|
|
182
248
|
let needsLink = true;
|
|
183
249
|
// Use lstatSync (not existsSync), existsSync follows symlinks and
|
|
184
250
|
// returns false for a dangling link, but the link's inode is still
|
|
@@ -222,11 +288,10 @@ async function packageMain() {
|
|
|
222
288
|
if (!fs.existsSync(manifestPath)) {
|
|
223
289
|
throw new Error("plugin.manifest.json not found in current directory");
|
|
224
290
|
}
|
|
225
|
-
const manifest =
|
|
226
|
-
const
|
|
227
|
-
if (!name) throw new Error("manifest.name is required");
|
|
291
|
+
const manifest = readAndResolveManifest(manifestPath);
|
|
292
|
+
const slug = manifest.slug;
|
|
228
293
|
|
|
229
|
-
const wasmPath = path.join(cwd, `${
|
|
294
|
+
const wasmPath = path.join(cwd, `${slug}.wasm`);
|
|
230
295
|
if (!fs.existsSync(wasmPath)) {
|
|
231
296
|
await buildMain();
|
|
232
297
|
}
|
|
@@ -236,6 +301,15 @@ async function packageMain() {
|
|
|
236
301
|
zip.file("plugin.manifest.json", fs.readFileSync(manifestPath));
|
|
237
302
|
zip.file("plugin.wasm", fs.readFileSync(wasmPath));
|
|
238
303
|
let fileCount = 2;
|
|
304
|
+
// Bundle a top-level icon.png if the plugin source root has one.
|
|
305
|
+
// The host reads it from /api/plugins/<slug>/icon to render in the
|
|
306
|
+
// admin list and sidebar (no manifest field, no http.serve
|
|
307
|
+
// permission required).
|
|
308
|
+
const iconPath = path.join(cwd, "icon.png");
|
|
309
|
+
if (fs.existsSync(iconPath) && fs.statSync(iconPath).isFile()) {
|
|
310
|
+
zip.file("icon.png", fs.readFileSync(iconPath));
|
|
311
|
+
fileCount++;
|
|
312
|
+
}
|
|
239
313
|
if (fs.existsSync(assetsDir) && fs.statSync(assetsDir).isDirectory()) {
|
|
240
314
|
for (const file of walkFiles(assetsDir)) {
|
|
241
315
|
const rel = path.relative(assetsDir, file).split(path.sep).join("/");
|
|
@@ -244,7 +318,7 @@ async function packageMain() {
|
|
|
244
318
|
}
|
|
245
319
|
}
|
|
246
320
|
|
|
247
|
-
const outPath = path.join(cwd, `${
|
|
321
|
+
const outPath = path.join(cwd, `${slug}.ocpkg`);
|
|
248
322
|
const buf = await zip.generateAsync({
|
|
249
323
|
type: "nodebuffer",
|
|
250
324
|
compression: "DEFLATE",
|
|
@@ -387,3 +461,23 @@ function findCacheDir() {
|
|
|
387
461
|
}
|
|
388
462
|
return candidates[0];
|
|
389
463
|
}
|
|
464
|
+
|
|
465
|
+
// Dispatch sits at the bottom so every const + function above is
|
|
466
|
+
// fully initialized before any handler runs. Calling a handler from
|
|
467
|
+
// the top of the file would put the top-level `const slugPattern`
|
|
468
|
+
// (and friends) in the TDZ for the first synchronous slice of
|
|
469
|
+
// buildMain/packageMain.
|
|
470
|
+
if (cmd === "build") {
|
|
471
|
+
buildMain().catch(fail);
|
|
472
|
+
} else if (cmd === "test") {
|
|
473
|
+
testMain(restArgs);
|
|
474
|
+
} else if (cmd === "serve") {
|
|
475
|
+
serveMain(restArgs);
|
|
476
|
+
} else if (cmd === "package") {
|
|
477
|
+
packageMain().catch(fail);
|
|
478
|
+
} else {
|
|
479
|
+
console.error(
|
|
480
|
+
`unknown command: ${cmd}\nusage: owncast-plugin <build|test|serve|package>`,
|
|
481
|
+
);
|
|
482
|
+
process.exit(1);
|
|
483
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -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";
|
|
@@ -349,11 +350,13 @@ export const owncast: {
|
|
|
349
350
|
upload(name: string, data: Uint8Array | string): UploadResult | null;
|
|
350
351
|
};
|
|
351
352
|
/** Post to the fediverse on the streamer's behalf. Requires `fediverse.post`,
|
|
352
|
-
* which is high-trust
|
|
353
|
-
*
|
|
353
|
+
* which is high-trust (posts go out under the streamer's own handle);
|
|
354
|
+
* admins should grant it sparingly. */
|
|
354
355
|
fediverse: {
|
|
355
|
-
/** Publish a public, text-only post. Returns { url }
|
|
356
|
-
* 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.). */
|
|
357
360
|
post(text: string): { url: string } | null;
|
|
358
361
|
};
|
|
359
362
|
/** Send notifications via Owncast's configured channels.
|
package/index.js
CHANGED
package/package.json
CHANGED
package/testing.js
CHANGED
|
@@ -33,6 +33,30 @@ const { execFileSync } = require("child_process");
|
|
|
33
33
|
// binary specifically (not just any toolchain file) so we correctly fall
|
|
34
34
|
// through to the dev tools/ dir when postinstall has only fetched part of the
|
|
35
35
|
// toolchain (e.g., on a not-yet-released SDK version).
|
|
36
|
+
// slugifyForTest mirrors the slugify in the build CLI + host SDKs so
|
|
37
|
+
// this entrypoint can locate the .wasm file when a manifest omits
|
|
38
|
+
// `slug`. ASCII letters and digits pass through lowercased;
|
|
39
|
+
// everything else collapses to a single hyphen; trailing hyphens are
|
|
40
|
+
// trimmed.
|
|
41
|
+
function slugifyForTest(input) {
|
|
42
|
+
let out = "";
|
|
43
|
+
let prevHyphen = false;
|
|
44
|
+
for (const ch of input) {
|
|
45
|
+
const code = ch.codePointAt(0);
|
|
46
|
+
let lower = ch;
|
|
47
|
+
if (code >= 65 && code <= 90) lower = String.fromCodePoint(code + 32);
|
|
48
|
+
const lc = lower.codePointAt(0);
|
|
49
|
+
if ((lc >= 97 && lc <= 122) || (lc >= 48 && lc <= 57)) {
|
|
50
|
+
out += lower;
|
|
51
|
+
prevHyphen = false;
|
|
52
|
+
} else if (!prevHyphen && out.length > 0) {
|
|
53
|
+
out += "-";
|
|
54
|
+
prevHyphen = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out.replace(/-+$/, "");
|
|
58
|
+
}
|
|
59
|
+
|
|
36
60
|
function findCacheDir() {
|
|
37
61
|
const candidates = [
|
|
38
62
|
path.join(__dirname, "bin", ".cache"), // installed under node_modules
|
|
@@ -77,10 +101,20 @@ function runScenarios(scenarios, opts = {}) {
|
|
|
77
101
|
console.error("manifest.name is required");
|
|
78
102
|
process.exit(2);
|
|
79
103
|
}
|
|
80
|
-
|
|
104
|
+
// wasm + symlink filenames key off slug (the identifier), not the
|
|
105
|
+
// display name. Derive the slug here the same way the build CLI
|
|
106
|
+
// does so this entrypoint works on manifests that omit `slug`.
|
|
107
|
+
const slug = manifest.slug || slugifyForTest(manifest.name);
|
|
108
|
+
if (!slug) {
|
|
109
|
+
console.error(
|
|
110
|
+
`could not derive slug from manifest.name ${JSON.stringify(manifest.name)}; set manifest.slug explicitly`,
|
|
111
|
+
);
|
|
112
|
+
process.exit(2);
|
|
113
|
+
}
|
|
114
|
+
const wasmPath = path.join(cwd, `${slug}.wasm`);
|
|
81
115
|
if (!fs.existsSync(wasmPath)) {
|
|
82
116
|
console.error(
|
|
83
|
-
`${
|
|
117
|
+
`${slug}.wasm not found at ${wasmPath}, run \`owncast-plugin package\` first`,
|
|
84
118
|
);
|
|
85
119
|
process.exit(2);
|
|
86
120
|
}
|
|
@@ -100,16 +134,24 @@ function runScenarios(scenarios, opts = {}) {
|
|
|
100
134
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "owncast-plugin-test-"));
|
|
101
135
|
try {
|
|
102
136
|
fs.symlinkSync(manifestPath, path.join(tmp, "plugin.manifest.json"));
|
|
103
|
-
fs.symlinkSync(wasmPath, path.join(tmp, `${
|
|
137
|
+
fs.symlinkSync(wasmPath, path.join(tmp, `${slug}.wasm`));
|
|
104
138
|
fs.mkdirSync(path.join(tmp, "__tests__"));
|
|
105
139
|
fs.writeFileSync(
|
|
106
140
|
path.join(tmp, "__tests__", "scenarios.test.json"),
|
|
107
141
|
JSON.stringify(scenarios, null, 2),
|
|
108
142
|
);
|
|
109
143
|
|
|
144
|
+
// Match the build CLI: extism-js (and its wasm-merge/wasm-opt
|
|
145
|
+
// children) needs LD_LIBRARY_PATH on Linux and DYLD_LIBRARY_PATH +
|
|
146
|
+
// DYLD_FALLBACK_LIBRARY_PATH on macOS to find libbinaryen via
|
|
147
|
+
// @rpath. Setting all three is safe on both OSes; the inactive
|
|
148
|
+
// ones are ignored.
|
|
149
|
+
const libDir = path.join(cache, "lib");
|
|
110
150
|
const env = {
|
|
111
151
|
...process.env,
|
|
112
|
-
LD_LIBRARY_PATH: `${
|
|
152
|
+
LD_LIBRARY_PATH: `${libDir}:${process.env.LD_LIBRARY_PATH || ""}`,
|
|
153
|
+
DYLD_LIBRARY_PATH: `${libDir}:${process.env.DYLD_LIBRARY_PATH || ""}`,
|
|
154
|
+
DYLD_FALLBACK_LIBRARY_PATH: `${libDir}:${process.env.DYLD_FALLBACK_LIBRARY_PATH || "/usr/local/lib:/usr/lib"}`,
|
|
113
155
|
};
|
|
114
156
|
try {
|
|
115
157
|
execFileSync(bin, [tmp], { stdio: "inherit", env });
|