@owncast/plugin-sdk 0.4.2 → 0.6.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/bin/owncast-plugin.js +23 -204
- package/index.d.ts +52 -0
- package/index.js +79 -1
- package/package.json +1 -1
- package/scripts/postinstall.js +99 -98
- package/testing.js +2 -2
package/bin/owncast-plugin.js
CHANGED
|
@@ -25,27 +25,6 @@ function fail(e) {
|
|
|
25
25
|
process.exit(1);
|
|
26
26
|
}
|
|
27
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
28
|
// slugPattern matches a valid plugin slug: a lowercase letter
|
|
50
29
|
// followed by lowercase letters/digits/hyphens, up to 64 chars total.
|
|
51
30
|
// Same shape the host + SDK + registry all validate against.
|
|
@@ -127,11 +106,9 @@ function runBinary(name, args) {
|
|
|
127
106
|
);
|
|
128
107
|
process.exit(1);
|
|
129
108
|
}
|
|
130
|
-
const env = toolchainEnv(cache);
|
|
131
109
|
try {
|
|
132
110
|
execFileSync(bin, args.length > 0 ? args : [process.cwd()], {
|
|
133
111
|
stdio: "inherit",
|
|
134
|
-
env,
|
|
135
112
|
});
|
|
136
113
|
} catch (e) {
|
|
137
114
|
process.exit(typeof e.status === "number" ? e.status : 1);
|
|
@@ -166,84 +143,27 @@ async function buildMain() {
|
|
|
166
143
|
"no plugin source found (expected src/plugin.ts or plugin.js)",
|
|
167
144
|
);
|
|
168
145
|
|
|
169
|
-
//
|
|
170
|
-
//
|
|
146
|
+
// Shared-engine model: bundle the author's plugin into a tiny CommonJS
|
|
147
|
+
// script with @owncast/plugin-sdk marked EXTERNAL. It ships in the .ocpkg as
|
|
148
|
+
// plugin.js; the host infers the JavaScript runtime from that filename and
|
|
149
|
+
// runs it on the embedded JS engine, which provides
|
|
150
|
+
// require("@owncast/plugin-sdk"). No per-plugin wasm, no extism-js.
|
|
171
151
|
const buildDir = path.join(cwd, ".owncast-build");
|
|
172
152
|
fs.mkdirSync(buildDir, { recursive: true });
|
|
173
|
-
const
|
|
174
|
-
const manifestJSON = JSON.stringify(manifest);
|
|
175
|
-
// Always emit register/on_event/on_filter as wasm exports. The SDK derives
|
|
176
|
-
// subscriptions at runtime from the plugin's handler methods and merges
|
|
177
|
-
// them into the manifest returned by register(). The host then only calls
|
|
178
|
-
// on_event/on_filter for plugins actually subscribed to that event, so
|
|
179
|
-
// unused exports are harmless.
|
|
180
|
-
const entrySrc = `const sdk = require("@owncast/plugin-sdk");
|
|
181
|
-
const MANIFEST_BASE = ${manifestJSON};
|
|
182
|
-
require(${JSON.stringify(entry)});
|
|
183
|
-
function register() {
|
|
184
|
-
const manifest = Object.assign({}, MANIFEST_BASE, { subscriptions: sdk.describeSubscriptions() });
|
|
185
|
-
Host.outputString(JSON.stringify(manifest));
|
|
186
|
-
return 0;
|
|
187
|
-
}
|
|
188
|
-
function on_event() {
|
|
189
|
-
const envelope = JSON.parse(Host.inputString());
|
|
190
|
-
sdk.dispatchEvent(envelope);
|
|
191
|
-
return 0;
|
|
192
|
-
}
|
|
193
|
-
function on_filter() {
|
|
194
|
-
const envelope = JSON.parse(Host.inputString());
|
|
195
|
-
const result = sdk.dispatchFilter(envelope);
|
|
196
|
-
Host.outputString(JSON.stringify(result));
|
|
197
|
-
return 0;
|
|
198
|
-
}
|
|
199
|
-
function on_http_request() {
|
|
200
|
-
const request = JSON.parse(Host.inputString());
|
|
201
|
-
const response = sdk.dispatchHttp(request);
|
|
202
|
-
Host.outputString(JSON.stringify(response));
|
|
203
|
-
return 0;
|
|
204
|
-
}
|
|
205
|
-
module.exports = { register, on_event, on_filter, on_http_request };
|
|
206
|
-
`;
|
|
207
|
-
fs.writeFileSync(synthEntry, entrySrc);
|
|
208
|
-
|
|
209
|
-
// Bundle to a single CJS file targeting the QuickJS runtime extism-js uses.
|
|
210
|
-
const bundledJS = path.join(buildDir, "bundle.js");
|
|
153
|
+
const scriptOut = path.join(cwd, `${slug}.js`);
|
|
211
154
|
await esbuild.build({
|
|
212
|
-
entryPoints: [
|
|
155
|
+
entryPoints: [entry],
|
|
213
156
|
bundle: true,
|
|
214
157
|
format: "cjs",
|
|
215
158
|
platform: "neutral",
|
|
216
159
|
target: "es2020",
|
|
217
|
-
|
|
160
|
+
external: ["@owncast/plugin-sdk"],
|
|
161
|
+
outfile: scriptOut,
|
|
218
162
|
logLevel: "warning",
|
|
219
163
|
});
|
|
220
164
|
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
fs.writeFileSync(dts, generateInterface(manifest));
|
|
224
|
-
|
|
225
|
-
// Find toolchain.
|
|
226
|
-
const cache = findCacheDir();
|
|
227
|
-
const extismJs = path.join(cache, "extism-js");
|
|
228
|
-
if (!fs.existsSync(extismJs)) {
|
|
229
|
-
throw new Error(
|
|
230
|
-
`extism-js not found at ${extismJs}, run \`npm install\` to fetch the toolchain`,
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
const env = toolchainEnv(cache);
|
|
234
|
-
|
|
235
|
-
const wasmOut = path.join(cwd, `${slug}.wasm`);
|
|
236
|
-
execFileSync(extismJs, [bundledJS, "-i", dts, "-o", wasmOut], {
|
|
237
|
-
stdio: "inherit",
|
|
238
|
-
env,
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
// public/ and assets/ live at the source root; the host's
|
|
242
|
-
// loose-files loader picks them up as siblings of the built
|
|
243
|
-
// <slug>.wasm without any rename, so the build CLI doesn't need to
|
|
244
|
-
// create or mirror anything for them.
|
|
245
|
-
|
|
246
|
-
console.log(`built ${path.relative(cwd, wasmOut)}`);
|
|
165
|
+
// public/ and assets/ live at the source root; the packager picks them up.
|
|
166
|
+
console.log(`built ${path.relative(cwd, scriptOut)}`);
|
|
247
167
|
}
|
|
248
168
|
|
|
249
169
|
// `owncast-plugin package`, bundle the project into a single .ocpkg file
|
|
@@ -259,16 +179,19 @@ async function packageMain() {
|
|
|
259
179
|
const manifest = readAndResolveManifest(manifestPath);
|
|
260
180
|
const slug = manifest.slug;
|
|
261
181
|
|
|
262
|
-
const
|
|
263
|
-
if (!fs.existsSync(
|
|
182
|
+
const scriptPath = path.join(cwd, `${slug}.js`);
|
|
183
|
+
if (!fs.existsSync(scriptPath)) {
|
|
264
184
|
await buildMain();
|
|
265
185
|
}
|
|
266
186
|
|
|
187
|
+
// The code entry's name (plugin.js) is what tells the host this is a
|
|
188
|
+
// JavaScript plugin — no "type" field in the manifest. The manifest ships
|
|
189
|
+
// verbatim.
|
|
267
190
|
const publicDir = path.join(cwd, "public");
|
|
268
191
|
const assetsDir = path.join(cwd, "assets");
|
|
269
192
|
const zip = new JSZip();
|
|
270
193
|
zip.file("plugin.manifest.json", fs.readFileSync(manifestPath));
|
|
271
|
-
zip.file("plugin.
|
|
194
|
+
zip.file("plugin.js", fs.readFileSync(scriptPath));
|
|
272
195
|
let fileCount = 2;
|
|
273
196
|
// Bundle a top-level icon.png if the plugin source root has one.
|
|
274
197
|
// The host reads it from /api/plugins/<slug>/icon to render in the
|
|
@@ -319,19 +242,19 @@ async function packageMain() {
|
|
|
319
242
|
`packaged ${path.relative(cwd, outPath)} (${sizeKb} KB, ${fileCount} files)`,
|
|
320
243
|
);
|
|
321
244
|
|
|
322
|
-
// Drop the intermediate <slug>.
|
|
245
|
+
// Drop the intermediate <slug>.js now that it's bundled inside the
|
|
323
246
|
// .ocpkg. The .ocpkg is the only artifact authors care about: leaving
|
|
324
|
-
// the loose
|
|
247
|
+
// the loose script next to it just confuses "what do I ship". Only
|
|
325
248
|
// runs on a successful package so a mid-pipeline failure leaves the
|
|
326
249
|
// last good build in place for debugging.
|
|
327
250
|
try {
|
|
328
|
-
fs.unlinkSync(
|
|
251
|
+
fs.unlinkSync(scriptPath);
|
|
329
252
|
} catch (e) {
|
|
330
253
|
// Don't fail the package step over a cleanup miss. The .ocpkg is
|
|
331
254
|
// already written; surface the warning so the author notices the
|
|
332
255
|
// straggler but treat the run as successful.
|
|
333
256
|
if (e.code !== "ENOENT") {
|
|
334
|
-
console.warn(`warning: could not clean up ${path.relative(cwd,
|
|
257
|
+
console.warn(`warning: could not clean up ${path.relative(cwd, scriptPath)}: ${e.message}`);
|
|
335
258
|
}
|
|
336
259
|
}
|
|
337
260
|
}
|
|
@@ -356,108 +279,6 @@ function* walkFiles(dir) {
|
|
|
356
279
|
}
|
|
357
280
|
}
|
|
358
281
|
|
|
359
|
-
function generateInterface(manifest) {
|
|
360
|
-
const exports = [
|
|
361
|
-
"register(): I32",
|
|
362
|
-
"on_event(): I32",
|
|
363
|
-
"on_filter(): I32",
|
|
364
|
-
"on_http_request(): I32",
|
|
365
|
-
];
|
|
366
|
-
|
|
367
|
-
const perms = new Set(manifest.permissions || []);
|
|
368
|
-
const imports = [];
|
|
369
|
-
// Timers are ambient (no permission): the host always provides them, since
|
|
370
|
-
// a plugin can't setTimeout in the sandbox.
|
|
371
|
-
imports.push("owncast_timer_set(id: I64, delayMs: I64, repeat: I32): I32");
|
|
372
|
-
imports.push("owncast_timer_clear(id: I64): void");
|
|
373
|
-
// Config is ambient too: a plugin reading its own manifest-declared config
|
|
374
|
-
// (admin override falling back to the declared default) needs no permission.
|
|
375
|
-
imports.push("owncast_config_get(keyPtr: PTR): PTR");
|
|
376
|
-
if (perms.has("chat.send")) {
|
|
377
|
-
imports.push("owncast_send_chat(textPtr: PTR): void");
|
|
378
|
-
imports.push("owncast_send_chat_action(textPtr: PTR): void");
|
|
379
|
-
imports.push("owncast_send_chat_system(bodyPtr: PTR): void");
|
|
380
|
-
imports.push("owncast_send_chat_to(clientId: I64, textPtr: PTR): void");
|
|
381
|
-
}
|
|
382
|
-
if (perms.has("chat.history")) {
|
|
383
|
-
imports.push("owncast_chat_history(limit: I32): PTR");
|
|
384
|
-
imports.push("owncast_chat_clients(): PTR");
|
|
385
|
-
}
|
|
386
|
-
if (perms.has("chat.moderate")) {
|
|
387
|
-
imports.push("owncast_delete_message(idPtr: PTR): void");
|
|
388
|
-
imports.push("owncast_kick_client(clientId: I64): void");
|
|
389
|
-
}
|
|
390
|
-
if (perms.has("notifications.send")) {
|
|
391
|
-
imports.push("owncast_notify_discord(textPtr: PTR): void");
|
|
392
|
-
imports.push("owncast_notify_browser_push(payloadPtr: PTR): void");
|
|
393
|
-
imports.push("owncast_notify_fediverse(payloadPtr: PTR): void");
|
|
394
|
-
}
|
|
395
|
-
if (perms.has("users.read")) {
|
|
396
|
-
imports.push("owncast_users_list(): PTR");
|
|
397
|
-
imports.push("owncast_user_get(idPtr: PTR): PTR");
|
|
398
|
-
}
|
|
399
|
-
if (perms.has("users.moderate")) {
|
|
400
|
-
imports.push(
|
|
401
|
-
"owncast_user_set_enabled(idPtr: PTR, enabled: I32, reasonPtr: PTR): void",
|
|
402
|
-
);
|
|
403
|
-
imports.push("owncast_ban_ip(ipPtr: PTR): void");
|
|
404
|
-
}
|
|
405
|
-
if (perms.has("storage.upload")) {
|
|
406
|
-
imports.push("owncast_storage_upload(namePtr: PTR, dataPtr: PTR): PTR");
|
|
407
|
-
}
|
|
408
|
-
if (perms.has("storage.fs")) {
|
|
409
|
-
imports.push("owncast_fs_read(pathPtr: PTR): PTR");
|
|
410
|
-
imports.push("owncast_fs_write(pathPtr: PTR, dataPtr: PTR): PTR");
|
|
411
|
-
imports.push("owncast_fs_list(dirPtr: PTR): PTR");
|
|
412
|
-
imports.push("owncast_fs_delete(pathPtr: PTR): PTR");
|
|
413
|
-
imports.push("owncast_fs_exists(pathPtr: PTR): I32");
|
|
414
|
-
}
|
|
415
|
-
if (perms.has("fediverse.post")) {
|
|
416
|
-
imports.push("owncast_fediverse_post(textPtr: PTR): PTR");
|
|
417
|
-
}
|
|
418
|
-
if (perms.has("storage.kv")) {
|
|
419
|
-
imports.push("owncast_kv_get(keyPtr: PTR): PTR");
|
|
420
|
-
imports.push("owncast_kv_set(keyPtr: PTR, valPtr: PTR): void");
|
|
421
|
-
}
|
|
422
|
-
if (perms.has("events.emit"))
|
|
423
|
-
imports.push(
|
|
424
|
-
"owncast_emit_event(eventTypePtr: PTR, payloadPtr: PTR): void",
|
|
425
|
-
);
|
|
426
|
-
if (perms.has("http.sse"))
|
|
427
|
-
imports.push(
|
|
428
|
-
"owncast_sse_send(channelPtr: PTR, eventPtr: PTR, dataPtr: PTR): void",
|
|
429
|
-
);
|
|
430
|
-
if (perms.has("server.read")) {
|
|
431
|
-
imports.push("owncast_stream_current(): PTR");
|
|
432
|
-
imports.push("owncast_server_info(): PTR");
|
|
433
|
-
imports.push("owncast_server_socials(): PTR");
|
|
434
|
-
imports.push("owncast_server_emotes(): PTR");
|
|
435
|
-
imports.push("owncast_server_federation(): PTR");
|
|
436
|
-
imports.push("owncast_stream_broadcaster(): PTR");
|
|
437
|
-
imports.push("owncast_server_tags(): PTR");
|
|
438
|
-
}
|
|
439
|
-
if (perms.has("videoconfig.read")) {
|
|
440
|
-
imports.push("owncast_video_config_read(): PTR");
|
|
441
|
-
}
|
|
442
|
-
if (perms.has("videoconfig.write")) {
|
|
443
|
-
imports.push("owncast_video_config_write(configPtr: PTR): PTR");
|
|
444
|
-
}
|
|
445
|
-
if (perms.has("ui.modify")) {
|
|
446
|
-
imports.push("owncast_add_actions(actionsPtr: PTR): void");
|
|
447
|
-
imports.push("owncast_clear_actions(): void");
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
let out = `declare module 'main' {\n`;
|
|
451
|
-
for (const e of exports) out += ` export function ${e};\n`;
|
|
452
|
-
out += `}\n`;
|
|
453
|
-
if (imports.length > 0) {
|
|
454
|
-
out += `\ndeclare module 'extism:host' {\n interface user {\n`;
|
|
455
|
-
for (const i of imports) out += ` ${i};\n`;
|
|
456
|
-
out += ` }\n}\n`;
|
|
457
|
-
}
|
|
458
|
-
return out;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
282
|
function findCacheDir() {
|
|
462
283
|
// Look in node_modules/@owncast/plugin-sdk/bin/.cache (when used as a dep)
|
|
463
284
|
// and in the repo's tools/ dir (when developing). The dev candidate
|
|
@@ -468,12 +289,10 @@ function findCacheDir() {
|
|
|
468
289
|
path.join(__dirname, "..", "bin", ".cache"),
|
|
469
290
|
path.join(__dirname, "..", "..", "..", "tools"),
|
|
470
291
|
];
|
|
471
|
-
// Pick the first candidate that has
|
|
472
|
-
//
|
|
473
|
-
// owncast-plugin-test) but they share a cache.
|
|
292
|
+
// Pick the first candidate that has the prebuilt host binaries (the only
|
|
293
|
+
// tooling the SDK ships now — `build` is pure esbuild and needs nothing here).
|
|
474
294
|
for (const c of candidates) {
|
|
475
295
|
if (
|
|
476
|
-
fs.existsSync(path.join(c, "extism-js")) ||
|
|
477
296
|
fs.existsSync(path.join(c, "owncast-plugin-test")) ||
|
|
478
297
|
fs.existsSync(path.join(c, "owncast-plugin-serve"))
|
|
479
298
|
) {
|
package/index.d.ts
CHANGED
|
@@ -284,6 +284,15 @@ export interface OutgoingHttpResponse {
|
|
|
284
284
|
body?: string;
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
/** Request context passed to `onTabContent` and `onPageContent` handlers. */
|
|
288
|
+
export interface ContentRequest {
|
|
289
|
+
/** The tab or page-content slot's slug, as declared in the manifest. */
|
|
290
|
+
slug: string;
|
|
291
|
+
/** The viewing user's chat identity, when available. Undefined for
|
|
292
|
+
* anonymous viewers or when the host cannot resolve an identity. */
|
|
293
|
+
user?: ChatUser;
|
|
294
|
+
}
|
|
295
|
+
|
|
287
296
|
/** Payload for the sse.connect / sse.disconnect events. Fired when a browser
|
|
288
297
|
* opens or closes one of the plugin's `/plugins/<name>/_sse/<channel>`
|
|
289
298
|
* streams, so the plugin can track who is connected. `connectionId` is unique
|
|
@@ -303,6 +312,22 @@ export interface TickEvent {
|
|
|
303
312
|
}
|
|
304
313
|
|
|
305
314
|
export interface PluginDef {
|
|
315
|
+
/** Declarative chat-command table. When set, the SDK wires the chat
|
|
316
|
+
* subscription and prefix parsing for you — no onChatMessage needed. Maps
|
|
317
|
+
* canonical command name → definition (run/description/usage/aliases/
|
|
318
|
+
* modOnly/cooldownMs/...); see {@link CommandDefinition}. For advanced
|
|
319
|
+
* composition (e.g. dropping command messages via a filter) use the
|
|
320
|
+
* lower-level {@link defineCommands} router instead. If you also provide
|
|
321
|
+
* onChatMessage, the router runs first and then onChatMessage runs for every
|
|
322
|
+
* message. */
|
|
323
|
+
commands?: Record<string, CommandDefinition>;
|
|
324
|
+
/** Command prefix for the `commands` table. Default "!". */
|
|
325
|
+
commandPrefix?: string;
|
|
326
|
+
/** Match command names case-sensitively. Default false. */
|
|
327
|
+
commandsCaseSensitive?: boolean;
|
|
328
|
+
/** Called when a prefixed message matched no command in `commands`. */
|
|
329
|
+
onUnknownCommand?(ctx: CommandContext): void;
|
|
330
|
+
|
|
306
331
|
/** Notification handler for chat messages. Fire-and-forget. */
|
|
307
332
|
onChatMessage?(msg: ChatMessage): void | Promise<void>;
|
|
308
333
|
|
|
@@ -353,6 +378,18 @@ export interface PluginDef {
|
|
|
353
378
|
* on `req.authenticated` yourself. Requires `http.serve` permission. */
|
|
354
379
|
onHttpRequest?(req: IncomingHttpRequest): OutgoingHttpResponse;
|
|
355
380
|
|
|
381
|
+
/** Render HTML for a dynamic tab. Called by the host when the tab was
|
|
382
|
+
* declared in the manifest without a static `content` file. Return the
|
|
383
|
+
* full HTML string to inline as the tab body. `req.user` is the viewer's
|
|
384
|
+
* chat identity when available, undefined for anonymous viewers. */
|
|
385
|
+
onTabContent?(req: ContentRequest): string;
|
|
386
|
+
|
|
387
|
+
/** Render HTML for the plugin's dynamic extraPageContent slot. Called by
|
|
388
|
+
* the host when extraPageContent was declared without a static `content`
|
|
389
|
+
* file. Return the full HTML string to inline into the viewer page.
|
|
390
|
+
* `req.user` is the viewer's chat identity when available. */
|
|
391
|
+
onPageContent?(req: ContentRequest): string;
|
|
392
|
+
|
|
356
393
|
/** Handlers for plugin-emitted custom events. The key is the event type
|
|
357
394
|
* string (e.g. "announcement.broadcast"). Notifications only, to filter
|
|
358
395
|
* custom events, additional API will be needed. */
|
|
@@ -386,6 +423,11 @@ export interface CommandContext {
|
|
|
386
423
|
|
|
387
424
|
/** One command in a {@link defineCommands} table. */
|
|
388
425
|
export interface CommandDefinition {
|
|
426
|
+
/** Short, human-readable summary of what the command does. Surfaced in
|
|
427
|
+
* command listings (e.g. a future `!help`); ignored by the router itself. */
|
|
428
|
+
description?: string;
|
|
429
|
+
/** Optional usage/example string, e.g. "!latency <0-4>". */
|
|
430
|
+
usage?: string;
|
|
389
431
|
/** Alternate names that invoke this command. */
|
|
390
432
|
aliases?: string[];
|
|
391
433
|
/** Only allow senders whose scopes include "MODERATOR". */
|
|
@@ -523,6 +565,16 @@ export const owncast: {
|
|
|
523
565
|
* value. */
|
|
524
566
|
get<T = unknown>(key: string, fallback?: T): T;
|
|
525
567
|
};
|
|
568
|
+
/** Read files the plugin bundled in its own `assets/` directory — templates,
|
|
569
|
+
* data files, and other bundled resources loaded at request time. Path is
|
|
570
|
+
* relative to `assets/` and must not contain `..`. Ambient — no permission
|
|
571
|
+
* required. */
|
|
572
|
+
assets: {
|
|
573
|
+
/** Raw bytes of the file, or `null` if not found. */
|
|
574
|
+
read(path: string): Uint8Array | null;
|
|
575
|
+
/** File contents as a UTF-8 string, or `null` if not found. */
|
|
576
|
+
readText(path: string): string | null;
|
|
577
|
+
};
|
|
526
578
|
events: {
|
|
527
579
|
emit(eventType: string, payload: unknown): void;
|
|
528
580
|
};
|
package/index.js
CHANGED
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
|
|
8
8
|
let registered = null;
|
|
9
9
|
|
|
10
|
+
// Command metadata recorded by defineCommands, reported to the host via
|
|
11
|
+
// register() so it can build a unified `!help` across all plugins. One entry
|
|
12
|
+
// per command: { name, prefix, description, usage, aliases, modOnly }.
|
|
13
|
+
const commandManifest = [];
|
|
14
|
+
|
|
10
15
|
// Host-driven timers. The sandbox has no setTimeout, so owncast.timer.* asks
|
|
11
16
|
// the host to schedule a callback and call back via the internal "timer.fire"
|
|
12
17
|
// event. The author's callback stays here in the long-lived instance, keyed by
|
|
@@ -149,6 +154,28 @@ const isFn = (x) => typeof x === JsType.Function;
|
|
|
149
154
|
const isObj = (x) => x !== null && typeof x === JsType.Object;
|
|
150
155
|
|
|
151
156
|
function definePlugin(def) {
|
|
157
|
+
// `commands` is declarative sugar: give definePlugin a command table (and an
|
|
158
|
+
// optional commandPrefix) and the SDK wires the chat subscription for you —
|
|
159
|
+
// no onChatMessage needed. It expands into an onChatMessage handler here, so
|
|
160
|
+
// subscription derivation and dispatch treat it like any chat handler. If you
|
|
161
|
+
// also pass onChatMessage, the command router runs first, then your handler.
|
|
162
|
+
// For advanced composition (e.g. dropping command messages from chat via a
|
|
163
|
+
// filter), use the lower-level defineCommands() router directly instead.
|
|
164
|
+
if (def && isObj(def.commands)) {
|
|
165
|
+
const router = defineCommands({
|
|
166
|
+
prefix: def.commandPrefix,
|
|
167
|
+
caseSensitive: def.commandsCaseSensitive,
|
|
168
|
+
commands: def.commands,
|
|
169
|
+
onUnknown: def.onUnknownCommand,
|
|
170
|
+
});
|
|
171
|
+
const userHandler = def.onChatMessage;
|
|
172
|
+
def.onChatMessage = isFn(userHandler)
|
|
173
|
+
? (msg) => {
|
|
174
|
+
router(msg);
|
|
175
|
+
userHandler(msg);
|
|
176
|
+
}
|
|
177
|
+
: router;
|
|
178
|
+
}
|
|
152
179
|
registered = def;
|
|
153
180
|
return def;
|
|
154
181
|
}
|
|
@@ -183,13 +210,24 @@ function defineCommands(config) {
|
|
|
183
210
|
const caseSensitive = !!config.caseSensitive;
|
|
184
211
|
const norm = (s) => (caseSensitive ? s : s.toLowerCase());
|
|
185
212
|
|
|
186
|
-
// Resolve every name and alias to its canonical command definition
|
|
213
|
+
// Resolve every name and alias to its canonical command definition, and
|
|
214
|
+
// record metadata so the host can build a unified `!help` (see
|
|
215
|
+
// describeCommands). Metadata is reported via register(); it never affects
|
|
216
|
+
// routing.
|
|
187
217
|
const table = new Map();
|
|
188
218
|
const defs = config.commands || {};
|
|
189
219
|
for (const name of Object.keys(defs)) {
|
|
190
220
|
const def = defs[name];
|
|
191
221
|
table.set(norm(name), { name, def });
|
|
192
222
|
for (const alias of def.aliases || []) table.set(norm(alias), { name, def });
|
|
223
|
+
commandManifest.push({
|
|
224
|
+
name,
|
|
225
|
+
prefix,
|
|
226
|
+
description: def.description || "",
|
|
227
|
+
usage: def.usage || "",
|
|
228
|
+
aliases: def.aliases || [],
|
|
229
|
+
modOnly: !!def.modOnly,
|
|
230
|
+
});
|
|
193
231
|
}
|
|
194
232
|
|
|
195
233
|
// Per-(command,user) cooldown clock, in memory for the plugin's lifetime.
|
|
@@ -286,6 +324,13 @@ function describeSubscriptions() {
|
|
|
286
324
|
return { notify, filter: filterSubs };
|
|
287
325
|
}
|
|
288
326
|
|
|
327
|
+
// Used by the build-generated entry to report the plugin's chat commands in
|
|
328
|
+
// register(), so the host can answer a unified `!help`. Empty when the plugin
|
|
329
|
+
// declares no commands.
|
|
330
|
+
function describeCommands() {
|
|
331
|
+
return commandManifest;
|
|
332
|
+
}
|
|
333
|
+
|
|
289
334
|
function dispatchEvent(envelope) {
|
|
290
335
|
const { eventType, payload } = envelope;
|
|
291
336
|
// Internal: a host-scheduled timer elapsed. Run the author's callback,
|
|
@@ -770,6 +815,26 @@ const owncast = {
|
|
|
770
815
|
return JSON.parse(Memory.find(offset).readString());
|
|
771
816
|
},
|
|
772
817
|
},
|
|
818
|
+
// Read files the plugin shipped in its own assets/ directory. Useful for
|
|
819
|
+
// templates, data files, and other bundled resources that need to be read
|
|
820
|
+
// at request time. Path is relative to assets/ and must not contain "..".
|
|
821
|
+
// Ambient — no permission required.
|
|
822
|
+
assets: {
|
|
823
|
+
// Returns a Uint8Array of the file's raw bytes, or null if not found.
|
|
824
|
+
read(path) {
|
|
825
|
+
const fns = Host.getFunctions();
|
|
826
|
+
const offset = fns.owncast_asset_read(Memory.fromString(path).offset);
|
|
827
|
+
if (offset == 0) return null;
|
|
828
|
+
return new Uint8Array(Memory.find(offset).readBytes());
|
|
829
|
+
},
|
|
830
|
+
// Returns the file contents as a UTF-8 string, or null if not found.
|
|
831
|
+
readText(path) {
|
|
832
|
+
const fns = Host.getFunctions();
|
|
833
|
+
const offset = fns.owncast_asset_read(Memory.fromString(path).offset);
|
|
834
|
+
if (offset == 0) return null;
|
|
835
|
+
return Memory.find(offset).readString();
|
|
836
|
+
},
|
|
837
|
+
},
|
|
773
838
|
events: {
|
|
774
839
|
emit(eventType, payload) {
|
|
775
840
|
const fns = Host.getFunctions();
|
|
@@ -870,6 +935,16 @@ const owncast = {
|
|
|
870
935
|
},
|
|
871
936
|
};
|
|
872
937
|
|
|
938
|
+
function dispatchTabContent(req) {
|
|
939
|
+
if (!registered || !isFn(registered.onTabContent)) return "";
|
|
940
|
+
return registered.onTabContent(req) || "";
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function dispatchPageContent(req) {
|
|
944
|
+
if (!registered || !isFn(registered.onPageContent)) return "";
|
|
945
|
+
return registered.onPageContent(req) || "";
|
|
946
|
+
}
|
|
947
|
+
|
|
873
948
|
module.exports = {
|
|
874
949
|
definePlugin,
|
|
875
950
|
defineCommands,
|
|
@@ -879,7 +954,10 @@ module.exports = {
|
|
|
879
954
|
Events,
|
|
880
955
|
Permissions,
|
|
881
956
|
describeSubscriptions,
|
|
957
|
+
describeCommands,
|
|
882
958
|
dispatchEvent,
|
|
883
959
|
dispatchFilter,
|
|
884
960
|
dispatchHttp,
|
|
961
|
+
dispatchTabContent,
|
|
962
|
+
dispatchPageContent,
|
|
885
963
|
};
|
package/package.json
CHANGED
package/scripts/postinstall.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Downloads
|
|
3
|
-
//
|
|
2
|
+
// Downloads the prebuilt host binaries into <sdk>/bin/.cache so `owncast-plugin
|
|
3
|
+
// test` / `serve` work without polluting the user's system:
|
|
4
4
|
//
|
|
5
|
-
// - extism-js , JS → wasm compiler (extism/js-pdk releases)
|
|
6
|
-
// - wasm-merge, wasm-opt, lib , binaryen post-processing (WebAssembly/binaryen releases)
|
|
7
5
|
// - owncast-plugin-test/serve , scenario runner + dev server (this repo's releases)
|
|
8
6
|
//
|
|
7
|
+
// That's all an author needs: plugins ship source and run on the interpreter
|
|
8
|
+
// engine the host already embeds, so the wasm compiler toolchain (extism-js,
|
|
9
|
+
// binaryen) is NOT downloaded here — it's a maintainer-only dependency of the
|
|
10
|
+
// engine build (see engines/install-toolchain.mjs).
|
|
11
|
+
//
|
|
9
12
|
// PoC scope: linux-x86_64 + darwin-arm64 + darwin-x86_64 covered.
|
|
10
13
|
// owncast-plugin-test/serve downloads gracefully skip if the matching
|
|
11
14
|
// release asset isn't published yet, dev builds can substitute their own
|
|
@@ -15,27 +18,74 @@ const fs = require("fs");
|
|
|
15
18
|
const path = require("path");
|
|
16
19
|
const https = require("https");
|
|
17
20
|
const zlib = require("zlib");
|
|
18
|
-
const { execFileSync } = require("child_process");
|
|
19
21
|
|
|
20
|
-
const EXTISM_JS_VERSION = "v1.6.0";
|
|
21
|
-
const BINARYEN_VERSION = "version_119";
|
|
22
22
|
const HOST_BINARIES_REPO = "owncast/plugin-sdk";
|
|
23
23
|
|
|
24
|
-
// The host binaries (owncast-plugin-test/serve)
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
24
|
+
// The host binaries (owncast-plugin-test/serve) implement the host-function
|
|
25
|
+
// contract that the bundled JS runtime imports. That contract is additive
|
|
26
|
+
// within a major version — host functions are only ever added, never removed or
|
|
27
|
+
// renamed (a removal is a breaking change that requires a major bump) — so the
|
|
28
|
+
// NEWEST published binary is compatible with every plugin runtime. We therefore
|
|
29
|
+
// fetch the latest release tag rather than deriving one from the npm version.
|
|
30
|
+
//
|
|
31
|
+
// This keeps the binary in lockstep with `@owncast/plugin-sdk@^x` (which npm
|
|
32
|
+
// already floats to the newest compatible runtime) and fixes the old "zero the
|
|
33
|
+
// patch" guess: that fetched v<major>.<minor>.0, which 404'd on JS-only patches
|
|
34
|
+
// and — when a host change shipped in a patch (e.g. timer support in 0.4.2) —
|
|
35
|
+
// fetched a binary too old to satisfy the runtime's imports, breaking
|
|
36
|
+
// `npm test`.
|
|
37
|
+
//
|
|
38
|
+
// Override with OWNCAST_PLUGIN_HOST_BINARIES_VERSION (with or without a leading
|
|
39
|
+
// "v") to pin a specific tag, e.g. in CI or when bisecting.
|
|
40
|
+
function latestReleaseTag() {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
https
|
|
43
|
+
.get(
|
|
44
|
+
`https://api.github.com/repos/${HOST_BINARIES_REPO}/releases/latest`,
|
|
45
|
+
{
|
|
46
|
+
headers: {
|
|
47
|
+
"User-Agent": "owncast-plugin-sdk-postinstall",
|
|
48
|
+
Accept: "application/vnd.github+json",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
(res) => {
|
|
52
|
+
if (res.statusCode !== 200) {
|
|
53
|
+
res.resume();
|
|
54
|
+
return reject(new Error(`HTTP ${res.statusCode}`));
|
|
55
|
+
}
|
|
56
|
+
let body = "";
|
|
57
|
+
res.on("data", (c) => (body += c));
|
|
58
|
+
res.on("end", () => {
|
|
59
|
+
try {
|
|
60
|
+
const tag = JSON.parse(body).tag_name;
|
|
61
|
+
if (!tag) return reject(new Error("no tag_name in response"));
|
|
62
|
+
resolve(tag);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
reject(err);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
.on("error", reject);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function resolveHostBinariesVersion() {
|
|
32
74
|
const override = process.env.OWNCAST_PLUGIN_HOST_BINARIES_VERSION;
|
|
33
|
-
if (override) return override.replace(/^v
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
75
|
+
if (override) return override.replace(/^v/i, "");
|
|
76
|
+
try {
|
|
77
|
+
return (await latestReleaseTag()).replace(/^v/i, "");
|
|
78
|
+
} catch (e) {
|
|
79
|
+
// Offline or API error: best-effort fall back to this package's own
|
|
80
|
+
// version. The download below 404-skips gracefully if no such release.
|
|
81
|
+
const pkg = require("../package.json").version;
|
|
82
|
+
console.warn(
|
|
83
|
+
`[plugin-sdk] could not resolve latest host-binary release ` +
|
|
84
|
+
`(${e.message}); falling back to v${pkg}`,
|
|
85
|
+
);
|
|
86
|
+
return pkg;
|
|
87
|
+
}
|
|
37
88
|
}
|
|
38
|
-
const HOST_BINARIES_VERSION = hostBinariesVersion();
|
|
39
89
|
|
|
40
90
|
const platform = process.platform;
|
|
41
91
|
const arch = process.arch;
|
|
@@ -48,30 +98,7 @@ function platformKey() {
|
|
|
48
98
|
throw new Error(`unsupported platform: ${platform}/${arch}`);
|
|
49
99
|
}
|
|
50
100
|
|
|
51
|
-
function
|
|
52
|
-
// extism-js release naming uses different conventions per OS.
|
|
53
|
-
const map = {
|
|
54
|
-
"linux-x86_64": `extism-js-x86_64-linux-${EXTISM_JS_VERSION}.gz`,
|
|
55
|
-
"linux-aarch64": `extism-js-aarch64-linux-${EXTISM_JS_VERSION}.gz`,
|
|
56
|
-
"darwin-x86_64": `extism-js-x86_64-macos-${EXTISM_JS_VERSION}.gz`,
|
|
57
|
-
"darwin-arm64": `extism-js-aarch64-macos-${EXTISM_JS_VERSION}.gz`,
|
|
58
|
-
};
|
|
59
|
-
const file = map[platformKey()];
|
|
60
|
-
return `https://github.com/extism/js-pdk/releases/download/${EXTISM_JS_VERSION}/${file}`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function binaryenURL() {
|
|
64
|
-
const map = {
|
|
65
|
-
"linux-x86_64": `binaryen-${BINARYEN_VERSION}-x86_64-linux.tar.gz`,
|
|
66
|
-
"linux-aarch64": `binaryen-${BINARYEN_VERSION}-aarch64-linux.tar.gz`,
|
|
67
|
-
"darwin-x86_64": `binaryen-${BINARYEN_VERSION}-x86_64-macos.tar.gz`,
|
|
68
|
-
"darwin-arm64": `binaryen-${BINARYEN_VERSION}-arm64-macos.tar.gz`,
|
|
69
|
-
};
|
|
70
|
-
const file = map[platformKey()];
|
|
71
|
-
return `https://github.com/WebAssembly/binaryen/releases/download/${BINARYEN_VERSION}/${file}`;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function hostBinaryURL(name) {
|
|
101
|
+
function hostBinaryURL(name, version) {
|
|
75
102
|
// Per-platform asset naming matches Go's GOOS-GOARCH convention so the
|
|
76
103
|
// release CI can `go build` once per matrix entry without renaming.
|
|
77
104
|
const map = {
|
|
@@ -81,7 +108,7 @@ function hostBinaryURL(name) {
|
|
|
81
108
|
"darwin-arm64": "darwin-arm64",
|
|
82
109
|
};
|
|
83
110
|
const suffix = map[platformKey()];
|
|
84
|
-
return `https://github.com/${HOST_BINARIES_REPO}/releases/download/v${
|
|
111
|
+
return `https://github.com/${HOST_BINARIES_REPO}/releases/download/v${version}/${name}-${suffix}`;
|
|
85
112
|
}
|
|
86
113
|
|
|
87
114
|
function download(url, dest) {
|
|
@@ -105,65 +132,39 @@ async function main() {
|
|
|
105
132
|
const cacheDir = path.join(__dirname, "..", "bin", ".cache");
|
|
106
133
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
107
134
|
|
|
108
|
-
const extismDest = path.join(cacheDir, "extism-js");
|
|
109
|
-
if (!fs.existsSync(extismDest)) {
|
|
110
|
-
const gz = path.join(cacheDir, "extism-js.gz");
|
|
111
|
-
console.log(`[plugin-sdk] downloading extism-js ${EXTISM_JS_VERSION}...`);
|
|
112
|
-
await download(extismJsURL(), gz);
|
|
113
|
-
const buf = zlib.gunzipSync(fs.readFileSync(gz));
|
|
114
|
-
fs.writeFileSync(extismDest, buf);
|
|
115
|
-
fs.chmodSync(extismDest, 0o755);
|
|
116
|
-
fs.unlinkSync(gz);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const wasmMergeDest = path.join(cacheDir, "wasm-merge");
|
|
120
|
-
const wasmOptDest = path.join(cacheDir, "wasm-opt");
|
|
121
|
-
if (!fs.existsSync(wasmMergeDest) || !fs.existsSync(wasmOptDest)) {
|
|
122
|
-
const tar = path.join(cacheDir, "binaryen.tar.gz");
|
|
123
|
-
console.log(`[plugin-sdk] downloading binaryen ${BINARYEN_VERSION}...`);
|
|
124
|
-
await download(binaryenURL(), tar);
|
|
125
|
-
execFileSync("tar", ["xzf", tar, "-C", cacheDir]);
|
|
126
|
-
const extracted = path.join(cacheDir, `binaryen-${BINARYEN_VERSION}`);
|
|
127
|
-
fs.copyFileSync(path.join(extracted, "bin", "wasm-merge"), wasmMergeDest);
|
|
128
|
-
fs.copyFileSync(path.join(extracted, "bin", "wasm-opt"), wasmOptDest);
|
|
129
|
-
fs.chmodSync(wasmMergeDest, 0o755);
|
|
130
|
-
fs.chmodSync(wasmOptDest, 0o755);
|
|
131
|
-
// copy lib too, wasm-opt links against libbinaryen.so on linux
|
|
132
|
-
const libSrc = path.join(extracted, "lib");
|
|
133
|
-
if (fs.existsSync(libSrc)) {
|
|
134
|
-
fs.cpSync(libSrc, path.join(cacheDir, "lib"), { recursive: true });
|
|
135
|
-
}
|
|
136
|
-
fs.rmSync(extracted, { recursive: true });
|
|
137
|
-
fs.unlinkSync(tar);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
135
|
// owncast-plugin-test + owncast-plugin-serve, built from this repo's
|
|
141
136
|
// host-runtime/ Go sources, published as gzipped release assets on
|
|
142
137
|
// github.com/owncast/plugin-sdk (roughly halves the download). Skip silently
|
|
143
138
|
// if the release doesn't exist yet (dev environments running against a
|
|
144
139
|
// not-yet-released SDK version can substitute their own via
|
|
145
140
|
// tools/bootstrap.sh).
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
141
|
+
const hostBinaries = ["owncast-plugin-test", "owncast-plugin-serve"];
|
|
142
|
+
const missing = hostBinaries.filter(
|
|
143
|
+
(b) => !fs.existsSync(path.join(cacheDir, b)),
|
|
144
|
+
);
|
|
145
|
+
if (missing.length) {
|
|
146
|
+
// Resolve the version only when something needs downloading, so a repeat
|
|
147
|
+
// install with a populated cache never hits the network.
|
|
148
|
+
const version = await resolveHostBinariesVersion();
|
|
149
|
+
for (const binary of missing) {
|
|
150
|
+
const dest = path.join(cacheDir, binary);
|
|
151
|
+
const gz = dest + ".gz";
|
|
152
|
+
try {
|
|
153
|
+
console.log(`[plugin-sdk] downloading ${binary} v${version}...`);
|
|
154
|
+
await download(hostBinaryURL(binary, version) + ".gz", gz);
|
|
155
|
+
fs.writeFileSync(dest, zlib.gunzipSync(fs.readFileSync(gz)));
|
|
156
|
+
fs.chmodSync(dest, 0o755);
|
|
157
|
+
fs.unlinkSync(gz);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
// 404 is expected before the first release; other errors get a soft
|
|
160
|
+
// warning so the user sees them but the install still succeeds.
|
|
161
|
+
console.warn(
|
|
162
|
+
`[plugin-sdk] could not fetch ${binary}: ${e.message}\n` +
|
|
163
|
+
` Build locally via tools/bootstrap.sh, or use the latest GitHub release.`,
|
|
164
|
+
);
|
|
165
|
+
// Make sure no partial files are left behind.
|
|
166
|
+
for (const p of [gz, dest]) if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
167
|
+
}
|
|
167
168
|
}
|
|
168
169
|
}
|
|
169
170
|
|
package/testing.js
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
//
|
|
11
11
|
// const { runScenarios } = require("@owncast/plugin-sdk/testing");
|
|
12
12
|
//
|
|
13
|
-
// const chat = (
|
|
13
|
+
// const chat = (name, body) => ({
|
|
14
14
|
// event: "chat.message.received",
|
|
15
|
-
// payload: { id: "1", user, body, timestamp: "2024-01-01T00:00:00Z" },
|
|
15
|
+
// payload: { id: "1", user: { id: name, displayName: name }, body, timestamp: "2024-01-01T00:00:00Z" },
|
|
16
16
|
// });
|
|
17
17
|
//
|
|
18
18
|
// runScenarios([
|