@owncast/plugin-sdk 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020-2026 Gabe Kangas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # @owncast/plugin-sdk
2
+
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
+
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
+
7
+ ## Quick start
8
+
9
+ ```sh
10
+ npm create owncast-plugin my-plugin
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__/
15
+ ```
16
+
17
+ Then drop `my-plugin.ocpkg` into your Owncast server's `plugins/` directory and enable it from the admin.
18
+
19
+ ## Writing a plugin
20
+
21
+ ```js
22
+ const { definePlugin, owncast, filter } = require("@owncast/plugin-sdk");
23
+
24
+ module.exports = definePlugin({
25
+ onChatMessage(msg) {
26
+ owncast.chat.send(`echo: ${msg.body}`);
27
+ },
28
+
29
+ filterChatMessage(msg) {
30
+ return msg.body.includes("spam") ? filter.drop("spam") : filter.pass();
31
+ },
32
+ });
33
+ ```
34
+
35
+ Declare the permissions your plugin uses (`chat.send` for the example above) in `plugin.manifest.json`. The full author guide covers every event handler, host API, and the testing harness:
36
+
37
+ **[→ Owncast Plugin Author Guide](https://github.com/owncast/plugin-sdk/blob/main/docs/PLUGIN_AUTHOR_GUIDE.md)**
38
+
39
+ ## What's in the package
40
+
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
+ - `bin/owncast-plugin` — CLI: `build`, `test`, `serve`, `package` subcommands.
44
+ - `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
+ ## License
47
+
48
+ MIT
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env node
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
+ // for distribution / installation
7
+
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+ const { execFileSync } = require("child_process");
11
+ const esbuild = require("esbuild");
12
+ const JSZip = require("jszip");
13
+
14
+ const cmd = process.argv[2] || "build";
15
+ const restArgs = process.argv.slice(3);
16
+
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
+ function fail(e) {
31
+ console.error(`${cmd} failed: ${e.message}`);
32
+ process.exit(1);
33
+ }
34
+
35
+ function testMain(args) {
36
+ runBinary("owncast-plugin-test", args);
37
+ }
38
+
39
+ function serveMain(args) {
40
+ runBinary("owncast-plugin-serve", args);
41
+ }
42
+
43
+ function runBinary(name, args) {
44
+ const cache = findCacheDir();
45
+ const bin = path.join(cache, name);
46
+ if (!fs.existsSync(bin)) {
47
+ console.error(
48
+ `${name} not found at ${bin}\n` +
49
+ `In production this is fetched by the SDK postinstall. For the PoC, ` +
50
+ `build it via: cd owncast && go build -o tools/${name} ./cmd/${name}`
51
+ );
52
+ process.exit(1);
53
+ }
54
+ const env = {
55
+ ...process.env,
56
+ LD_LIBRARY_PATH: `${path.join(cache, "lib")}:${process.env.LD_LIBRARY_PATH || ""}`
57
+ };
58
+ try {
59
+ execFileSync(bin, args.length > 0 ? args : [process.cwd()], { stdio: "inherit", env });
60
+ } catch (e) {
61
+ process.exit(typeof e.status === "number" ? e.status : 1);
62
+ }
63
+ }
64
+
65
+ async function buildMain() {
66
+ const cwd = process.cwd();
67
+ const manifestPath = path.join(cwd, "plugin.manifest.json");
68
+ if (!fs.existsSync(manifestPath)) {
69
+ throw new Error("plugin.manifest.json not found in current directory");
70
+ }
71
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
72
+ const name = manifest.name;
73
+ if (!name) throw new Error("manifest.name is required");
74
+
75
+ // Detect entry point.
76
+ let entry = null;
77
+ for (const candidate of ["src/plugin.ts", "src/plugin.js", "plugin.ts", "plugin.js"]) {
78
+ const p = path.join(cwd, candidate);
79
+ if (fs.existsSync(p)) {
80
+ entry = p;
81
+ break;
82
+ }
83
+ }
84
+ if (!entry) throw new Error("no plugin source found (expected src/plugin.ts or plugin.js)");
85
+
86
+ // Synthesize an entry that injects the manifest, requires user code,
87
+ // then re-exports the SDK runtime exports as wasm-visible exports.
88
+ const buildDir = path.join(cwd, ".owncast-build");
89
+ fs.mkdirSync(buildDir, { recursive: true });
90
+ const synthEntry = path.join(buildDir, "entry.js");
91
+ const manifestJSON = JSON.stringify(manifest);
92
+ // Always emit register/on_event/on_filter as wasm exports. The SDK derives
93
+ // subscriptions at runtime from the plugin's handler methods and merges
94
+ // them into the manifest returned by register(). The host then only calls
95
+ // on_event/on_filter for plugins actually subscribed to that event, so
96
+ // unused exports are harmless.
97
+ const entrySrc = `const sdk = require("@owncast/plugin-sdk");
98
+ const MANIFEST_BASE = ${manifestJSON};
99
+ require(${JSON.stringify(entry)});
100
+ function register() {
101
+ const manifest = Object.assign({}, MANIFEST_BASE, { subscriptions: sdk.describeSubscriptions() });
102
+ Host.outputString(JSON.stringify(manifest));
103
+ return 0;
104
+ }
105
+ function on_event() {
106
+ const envelope = JSON.parse(Host.inputString());
107
+ sdk.dispatchEvent(envelope);
108
+ return 0;
109
+ }
110
+ function on_filter() {
111
+ const envelope = JSON.parse(Host.inputString());
112
+ const result = sdk.dispatchFilter(envelope);
113
+ Host.outputString(JSON.stringify(result));
114
+ return 0;
115
+ }
116
+ function on_http_request() {
117
+ const request = JSON.parse(Host.inputString());
118
+ const response = sdk.dispatchHttp(request);
119
+ Host.outputString(JSON.stringify(response));
120
+ return 0;
121
+ }
122
+ module.exports = { register, on_event, on_filter, on_http_request };
123
+ `;
124
+ fs.writeFileSync(synthEntry, entrySrc);
125
+
126
+ // Bundle to a single CJS file targeting the QuickJS runtime extism-js uses.
127
+ const bundledJS = path.join(buildDir, "bundle.js");
128
+ await esbuild.build({
129
+ entryPoints: [synthEntry],
130
+ bundle: true,
131
+ format: "cjs",
132
+ platform: "neutral",
133
+ target: "es2020",
134
+ outfile: bundledJS,
135
+ logLevel: "warning"
136
+ });
137
+
138
+ // Generate index.d.ts declaring exports + host imports based on permissions.
139
+ const dts = path.join(buildDir, "index.d.ts");
140
+ fs.writeFileSync(dts, generateInterface(manifest));
141
+
142
+ // Find toolchain.
143
+ const cache = findCacheDir();
144
+ const extismJs = path.join(cache, "extism-js");
145
+ if (!fs.existsSync(extismJs)) {
146
+ throw new Error(`extism-js not found at ${extismJs} — run \`npm install\` to fetch the toolchain`);
147
+ }
148
+ const env = {
149
+ ...process.env,
150
+ PATH: `${cache}:${process.env.PATH}`,
151
+ LD_LIBRARY_PATH: `${path.join(cache, "lib")}:${process.env.LD_LIBRARY_PATH || ""}`
152
+ };
153
+
154
+ const wasmOut = path.join(cwd, `${name}.wasm`);
155
+ execFileSync(extismJs, [bundledJS, "-i", dts, "-o", wasmOut], { stdio: "inherit", env });
156
+
157
+ // If the project ships static assets in ./assets/, mirror them to the
158
+ // canonical deployment layout (<name>-assets/) so plugin.Server finds them
159
+ // without per-deployment renames. We use a symlink so edits to assets/
160
+ // show up live during dev (no rebuild needed for HTML/CSS changes).
161
+ const assetsSrc = path.join(cwd, "assets");
162
+ if (fs.existsSync(assetsSrc) && fs.statSync(assetsSrc).isDirectory()) {
163
+ const assetsDest = path.join(cwd, `${name}-assets`);
164
+ let needsLink = true;
165
+ if (fs.existsSync(assetsDest)) {
166
+ try {
167
+ const st = fs.lstatSync(assetsDest);
168
+ if (st.isSymbolicLink() && fs.realpathSync(assetsDest) === fs.realpathSync(assetsSrc)) {
169
+ needsLink = false;
170
+ } else {
171
+ fs.rmSync(assetsDest, { recursive: true, force: true });
172
+ }
173
+ } catch {
174
+ fs.rmSync(assetsDest, { recursive: true, force: true });
175
+ }
176
+ }
177
+ if (needsLink) {
178
+ fs.symlinkSync(path.resolve(assetsSrc), assetsDest, "dir");
179
+ }
180
+ }
181
+
182
+ console.log(`built ${path.relative(cwd, wasmOut)}`);
183
+ }
184
+
185
+ // `owncast-plugin package` — bundle the project into a single .ocpkg file
186
+ // (zip archive with plugin.manifest.json, plugin.wasm, and optional assets/).
187
+ // Builds the wasm first if it doesn't exist.
188
+ async function packageMain() {
189
+ const cwd = process.cwd();
190
+ const manifestPath = path.join(cwd, "plugin.manifest.json");
191
+ if (!fs.existsSync(manifestPath)) {
192
+ throw new Error("plugin.manifest.json not found in current directory");
193
+ }
194
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
195
+ const name = manifest.name;
196
+ if (!name) throw new Error("manifest.name is required");
197
+
198
+ const wasmPath = path.join(cwd, `${name}.wasm`);
199
+ if (!fs.existsSync(wasmPath)) {
200
+ await buildMain();
201
+ }
202
+
203
+ const assetsDir = path.join(cwd, "assets");
204
+ const zip = new JSZip();
205
+ zip.file("plugin.manifest.json", fs.readFileSync(manifestPath));
206
+ zip.file("plugin.wasm", fs.readFileSync(wasmPath));
207
+ let fileCount = 2;
208
+ if (fs.existsSync(assetsDir) && fs.statSync(assetsDir).isDirectory()) {
209
+ for (const file of walkFiles(assetsDir)) {
210
+ const rel = path.relative(assetsDir, file).split(path.sep).join("/");
211
+ zip.file(`assets/${rel}`, fs.readFileSync(file));
212
+ fileCount++;
213
+ }
214
+ }
215
+
216
+ const outPath = path.join(cwd, `${name}.ocpkg`);
217
+ const buf = await zip.generateAsync({
218
+ type: "nodebuffer",
219
+ compression: "DEFLATE",
220
+ compressionOptions: { level: 6 }
221
+ });
222
+ fs.writeFileSync(outPath, buf);
223
+ const sizeKb = Math.round(fs.statSync(outPath).size / 1024);
224
+ console.log(`packaged ${path.relative(cwd, outPath)} (${sizeKb} KB, ${fileCount} files)`);
225
+ }
226
+
227
+ function* walkFiles(dir) {
228
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
229
+ // Resolve symlinks so the assets/ → <name>-assets/ link the build CLI
230
+ // makes doesn't cause us to skip files. statSync follows.
231
+ const full = path.join(dir, entry.name);
232
+ let info;
233
+ try {
234
+ info = fs.statSync(full);
235
+ } catch {
236
+ continue;
237
+ }
238
+ if (info.isDirectory()) {
239
+ yield* walkFiles(full);
240
+ } else if (info.isFile()) {
241
+ yield full;
242
+ }
243
+ }
244
+ }
245
+
246
+ function generateInterface(manifest) {
247
+ const exports = [
248
+ "register(): I32",
249
+ "on_event(): I32",
250
+ "on_filter(): I32",
251
+ "on_http_request(): I32"
252
+ ];
253
+
254
+ const perms = new Set(manifest.permissions || []);
255
+ const imports = [];
256
+ if (perms.has("chat.send")) {
257
+ imports.push("owncast_send_chat(textPtr: PTR): void");
258
+ imports.push("owncast_send_chat_action(textPtr: PTR): void");
259
+ imports.push("owncast_send_chat_system(bodyPtr: PTR): void");
260
+ imports.push("owncast_send_chat_to(clientId: I64, textPtr: PTR): void");
261
+ }
262
+ if (perms.has("chat.history")) {
263
+ imports.push("owncast_chat_history(limit: I32): PTR");
264
+ imports.push("owncast_chat_clients(): PTR");
265
+ }
266
+ if (perms.has("chat.moderate")) {
267
+ imports.push("owncast_delete_message(idPtr: PTR): void");
268
+ imports.push("owncast_kick_client(clientId: I64): void");
269
+ }
270
+ if (perms.has("notifications.send")) {
271
+ imports.push("owncast_notify_discord(textPtr: PTR): void");
272
+ imports.push("owncast_notify_browser_push(payloadPtr: PTR): void");
273
+ imports.push("owncast_notify_fediverse(payloadPtr: PTR): void");
274
+ }
275
+ if (perms.has("users.read")) {
276
+ imports.push("owncast_users_list(): PTR");
277
+ imports.push("owncast_user_get(idPtr: PTR): PTR");
278
+ }
279
+ if (perms.has("users.moderate")) {
280
+ imports.push("owncast_user_set_enabled(idPtr: PTR, enabled: I32, reasonPtr: PTR): void");
281
+ imports.push("owncast_ban_ip(ipPtr: PTR): void");
282
+ }
283
+ if (perms.has("storage.upload")) {
284
+ imports.push("owncast_storage_upload(namePtr: PTR, dataPtr: PTR): PTR");
285
+ }
286
+ if (perms.has("fediverse.post")) {
287
+ imports.push("owncast_fediverse_post(textPtr: PTR): PTR");
288
+ }
289
+ if (perms.has("storage.kv")) {
290
+ imports.push("owncast_kv_get(keyPtr: PTR): PTR");
291
+ imports.push("owncast_kv_set(keyPtr: PTR, valPtr: PTR): void");
292
+ }
293
+ if (perms.has("events.emit")) imports.push("owncast_emit_event(eventTypePtr: PTR, payloadPtr: PTR): void");
294
+ if (perms.has("http.sse")) imports.push("owncast_sse_send(channelPtr: PTR, eventPtr: PTR, dataPtr: PTR): void");
295
+ if (perms.has("server.read")) {
296
+ imports.push("owncast_stream_current(): PTR");
297
+ imports.push("owncast_server_info(): PTR");
298
+ imports.push("owncast_server_socials(): PTR");
299
+ imports.push("owncast_server_federation(): PTR");
300
+ imports.push("owncast_stream_broadcaster(): PTR");
301
+ imports.push("owncast_server_tags(): PTR");
302
+ }
303
+ if (perms.has("videoconfig.read")) {
304
+ imports.push("owncast_video_config_read(): PTR");
305
+ }
306
+ if (perms.has("videoconfig.write")) {
307
+ imports.push("owncast_video_config_write(configPtr: PTR): PTR");
308
+ }
309
+
310
+ let out = `declare module 'main' {\n`;
311
+ for (const e of exports) out += ` export function ${e};\n`;
312
+ out += `}\n`;
313
+ if (imports.length > 0) {
314
+ out += `\ndeclare module 'extism:host' {\n interface user {\n`;
315
+ for (const i of imports) out += ` ${i};\n`;
316
+ out += ` }\n}\n`;
317
+ }
318
+ return out;
319
+ }
320
+
321
+ function findCacheDir() {
322
+ // Look in node_modules/@owncast/plugin-sdk/bin/.cache (when used as a dep)
323
+ // and in the repo's tools/ dir (when developing). The dev candidate
324
+ // assumes Node resolved __dirname through any symlink to the real SDK
325
+ // path (sdks/js/bin/), then walks up to the repo root.
326
+ const candidates = [
327
+ path.join(__dirname, ".cache"),
328
+ path.join(__dirname, "..", "bin", ".cache"),
329
+ path.join(__dirname, "..", "..", "..", "tools")
330
+ ];
331
+ // Pick the first candidate that has any of the expected tools — different
332
+ // commands need different binaries (build needs extism-js, test needs
333
+ // owncast-plugin-test) but they share a cache.
334
+ for (const c of candidates) {
335
+ if (
336
+ fs.existsSync(path.join(c, "extism-js")) ||
337
+ fs.existsSync(path.join(c, "owncast-plugin-test")) ||
338
+ fs.existsSync(path.join(c, "owncast-plugin-serve"))
339
+ ) {
340
+ return c;
341
+ }
342
+ }
343
+ return candidates[0];
344
+ }
345
+