@owncast/plugin-sdk 0.2.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 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,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(
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
+ // 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
+
37
90
  function testMain(args) {
38
91
  runBinary("owncast-plugin-test", args);
39
92
  }
@@ -73,9 +126,8 @@ async function buildMain() {
73
126
  if (!fs.existsSync(manifestPath)) {
74
127
  throw new Error("plugin.manifest.json not found in current directory");
75
128
  }
76
- const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
77
- const name = manifest.name;
78
- if (!name) throw new Error("manifest.name is required");
129
+ const manifest = readAndResolveManifest(manifestPath);
130
+ const slug = manifest.slug;
79
131
 
80
132
  // Detect entry point.
81
133
  let entry = null;
@@ -166,7 +218,7 @@ module.exports = { register, on_event, on_filter, on_http_request };
166
218
  LD_LIBRARY_PATH: `${path.join(cache, "lib")}:${process.env.LD_LIBRARY_PATH || ""}`,
167
219
  };
168
220
 
169
- const wasmOut = path.join(cwd, `${name}.wasm`);
221
+ const wasmOut = path.join(cwd, `${slug}.wasm`);
170
222
  execFileSync(extismJs, [bundledJS, "-i", dts, "-o", wasmOut], {
171
223
  stdio: "inherit",
172
224
  env,
@@ -178,7 +230,7 @@ module.exports = { register, on_event, on_filter, on_http_request };
178
230
  // show up live during dev (no rebuild needed for HTML/CSS changes).
179
231
  const assetsSrc = path.join(cwd, "assets");
180
232
  if (fs.existsSync(assetsSrc) && fs.statSync(assetsSrc).isDirectory()) {
181
- const assetsDest = path.join(cwd, `${name}-assets`);
233
+ const assetsDest = path.join(cwd, `${slug}-assets`);
182
234
  let needsLink = true;
183
235
  // Use lstatSync (not existsSync), existsSync follows symlinks and
184
236
  // returns false for a dangling link, but the link's inode is still
@@ -222,11 +274,10 @@ async function packageMain() {
222
274
  if (!fs.existsSync(manifestPath)) {
223
275
  throw new Error("plugin.manifest.json not found in current directory");
224
276
  }
225
- const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
226
- const name = manifest.name;
227
- if (!name) throw new Error("manifest.name is required");
277
+ const manifest = readAndResolveManifest(manifestPath);
278
+ const slug = manifest.slug;
228
279
 
229
- const wasmPath = path.join(cwd, `${name}.wasm`);
280
+ const wasmPath = path.join(cwd, `${slug}.wasm`);
230
281
  if (!fs.existsSync(wasmPath)) {
231
282
  await buildMain();
232
283
  }
@@ -236,6 +287,15 @@ async function packageMain() {
236
287
  zip.file("plugin.manifest.json", fs.readFileSync(manifestPath));
237
288
  zip.file("plugin.wasm", fs.readFileSync(wasmPath));
238
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
+ }
239
299
  if (fs.existsSync(assetsDir) && fs.statSync(assetsDir).isDirectory()) {
240
300
  for (const file of walkFiles(assetsDir)) {
241
301
  const rel = path.relative(assetsDir, file).split(path.sep).join("/");
@@ -244,7 +304,7 @@ async function packageMain() {
244
304
  }
245
305
  }
246
306
 
247
- const outPath = path.join(cwd, `${name}.ocpkg`);
307
+ const outPath = path.join(cwd, `${slug}.ocpkg`);
248
308
  const buf = await zip.generateAsync({
249
309
  type: "nodebuffer",
250
310
  compression: "DEFLATE",
@@ -387,3 +447,23 @@ function findCacheDir() {
387
447
  }
388
448
  return candidates[0];
389
449
  }
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
@@ -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.0",
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,7 +134,7 @@ 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"),