@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 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 `npm create owncast-plugin <name>` and the generated `package.json` already lists it as a dependency.
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
- npm create owncast-plugin my-plugin
10
+ npx create-owncast-plugin@latest my-plugin
11
11
  cd my-plugin
12
- npm install # postinstall fetches the per-platform wasm toolchain
13
- npm run build # bundles src/plugin.js my-plugin.wasm + my-plugin.ocpkg
14
- npm test # runs scenarios from __tests__/
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 drop `my-plugin.ocpkg` into your Owncast server's `plugins/` directory and enable it from the admin.
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
 
@@ -1,9 +1,15 @@
1
1
  #!/usr/bin/env node
2
- // `owncast-plugin build` , bundle src/plugin.{js,ts} into <name>.wasm
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 <name>.ocpkg suitable
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 = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
77
- const name = manifest.name;
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, `${name}.wasm`);
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, `${name}-assets`);
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 = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
226
- const name = manifest.name;
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, `${name}.wasm`);
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, `${name}.ocpkg`);
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, admins should grant it sparingly. The host
353
- * rate-limits at ~5 posts/hour per plugin. */
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 } on success or null
356
- * on rate-limit / disabled / other failure. */
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
@@ -36,6 +36,7 @@ const Permissions = Object.freeze({
36
36
  ChatSend: "chat.send",
37
37
  ChatHistory: "chat.history",
38
38
  ChatModerate: "chat.moderate",
39
+ ChatFilter: "chat.filter",
39
40
  StorageKV: "storage.kv",
40
41
  StorageUpload: "storage.upload",
41
42
  EventsEmit: "events.emit",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owncast/plugin-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "SDK for authoring Owncast plugins in JavaScript",
5
5
  "license": "MIT",
6
6
  "author": "Owncast",
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
- const wasmPath = path.join(cwd, `${manifest.name}.wasm`);
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
- `${manifest.name}.wasm not found at ${wasmPath}, run \`owncast-plugin build\` first`,
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, `${manifest.name}.wasm`));
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: `${path.join(cache, "lib")}:${process.env.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 });