@owncast/plugin-sdk 0.4.2 → 0.5.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.
@@ -202,7 +202,17 @@ function on_http_request() {
202
202
  Host.outputString(JSON.stringify(response));
203
203
  return 0;
204
204
  }
205
- module.exports = { register, on_event, on_filter, on_http_request };
205
+ function on_tab_content() {
206
+ const req = JSON.parse(Host.inputString());
207
+ Host.outputString(sdk.dispatchTabContent(req));
208
+ return 0;
209
+ }
210
+ function on_page_content() {
211
+ const req = JSON.parse(Host.inputString());
212
+ Host.outputString(sdk.dispatchPageContent(req));
213
+ return 0;
214
+ }
215
+ module.exports = { register, on_event, on_filter, on_http_request, on_tab_content, on_page_content };
206
216
  `;
207
217
  fs.writeFileSync(synthEntry, entrySrc);
208
218
 
@@ -362,6 +372,8 @@ function generateInterface(manifest) {
362
372
  "on_event(): I32",
363
373
  "on_filter(): I32",
364
374
  "on_http_request(): I32",
375
+ "on_tab_content(): I32",
376
+ "on_page_content(): I32",
365
377
  ];
366
378
 
367
379
  const perms = new Set(manifest.permissions || []);
@@ -373,6 +385,8 @@ function generateInterface(manifest) {
373
385
  // Config is ambient too: a plugin reading its own manifest-declared config
374
386
  // (admin override falling back to the declared default) needs no permission.
375
387
  imports.push("owncast_config_get(keyPtr: PTR): PTR");
388
+ // Asset reading is ambient: a plugin reads only files it shipped itself.
389
+ imports.push("owncast_asset_read(pathPtr: PTR): PTR");
376
390
  if (perms.has("chat.send")) {
377
391
  imports.push("owncast_send_chat(textPtr: PTR): void");
378
392
  imports.push("owncast_send_chat_action(textPtr: PTR): void");
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
@@ -353,6 +362,18 @@ export interface PluginDef {
353
362
  * on `req.authenticated` yourself. Requires `http.serve` permission. */
354
363
  onHttpRequest?(req: IncomingHttpRequest): OutgoingHttpResponse;
355
364
 
365
+ /** Render HTML for a dynamic tab. Called by the host when the tab was
366
+ * declared in the manifest without a static `content` file. Return the
367
+ * full HTML string to inline as the tab body. `req.user` is the viewer's
368
+ * chat identity when available, undefined for anonymous viewers. */
369
+ onTabContent?(req: ContentRequest): string;
370
+
371
+ /** Render HTML for the plugin's dynamic extraPageContent slot. Called by
372
+ * the host when extraPageContent was declared without a static `content`
373
+ * file. Return the full HTML string to inline into the viewer page.
374
+ * `req.user` is the viewer's chat identity when available. */
375
+ onPageContent?(req: ContentRequest): string;
376
+
356
377
  /** Handlers for plugin-emitted custom events. The key is the event type
357
378
  * string (e.g. "announcement.broadcast"). Notifications only, to filter
358
379
  * custom events, additional API will be needed. */
@@ -523,6 +544,16 @@ export const owncast: {
523
544
  * value. */
524
545
  get<T = unknown>(key: string, fallback?: T): T;
525
546
  };
547
+ /** Read files the plugin bundled in its own `assets/` directory — templates,
548
+ * data files, and other bundled resources loaded at request time. Path is
549
+ * relative to `assets/` and must not contain `..`. Ambient — no permission
550
+ * required. */
551
+ assets: {
552
+ /** Raw bytes of the file, or `null` if not found. */
553
+ read(path: string): Uint8Array | null;
554
+ /** File contents as a UTF-8 string, or `null` if not found. */
555
+ readText(path: string): string | null;
556
+ };
526
557
  events: {
527
558
  emit(eventType: string, payload: unknown): void;
528
559
  };
package/index.js CHANGED
@@ -770,6 +770,26 @@ const owncast = {
770
770
  return JSON.parse(Memory.find(offset).readString());
771
771
  },
772
772
  },
773
+ // Read files the plugin shipped in its own assets/ directory. Useful for
774
+ // templates, data files, and other bundled resources that need to be read
775
+ // at request time. Path is relative to assets/ and must not contain "..".
776
+ // Ambient — no permission required.
777
+ assets: {
778
+ // Returns a Uint8Array of the file's raw bytes, or null if not found.
779
+ read(path) {
780
+ const fns = Host.getFunctions();
781
+ const offset = fns.owncast_asset_read(Memory.fromString(path).offset);
782
+ if (offset == 0) return null;
783
+ return new Uint8Array(Memory.find(offset).readBytes());
784
+ },
785
+ // Returns the file contents as a UTF-8 string, or null if not found.
786
+ readText(path) {
787
+ const fns = Host.getFunctions();
788
+ const offset = fns.owncast_asset_read(Memory.fromString(path).offset);
789
+ if (offset == 0) return null;
790
+ return Memory.find(offset).readString();
791
+ },
792
+ },
773
793
  events: {
774
794
  emit(eventType, payload) {
775
795
  const fns = Host.getFunctions();
@@ -870,6 +890,16 @@ const owncast = {
870
890
  },
871
891
  };
872
892
 
893
+ function dispatchTabContent(req) {
894
+ if (!registered || !isFn(registered.onTabContent)) return "";
895
+ return registered.onTabContent(req) || "";
896
+ }
897
+
898
+ function dispatchPageContent(req) {
899
+ if (!registered || !isFn(registered.onPageContent)) return "";
900
+ return registered.onPageContent(req) || "";
901
+ }
902
+
873
903
  module.exports = {
874
904
  definePlugin,
875
905
  defineCommands,
@@ -882,4 +912,6 @@ module.exports = {
882
912
  dispatchEvent,
883
913
  dispatchFilter,
884
914
  dispatchHttp,
915
+ dispatchTabContent,
916
+ dispatchPageContent,
885
917
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owncast/plugin-sdk",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "SDK for authoring Owncast plugins in JavaScript",
5
5
  "license": "MIT",
6
6
  "author": "Owncast",
@@ -21,21 +21,71 @@ const EXTISM_JS_VERSION = "v1.6.0";
21
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) are cut once per MINOR release
25
- // (a vX.Y.0 git tag); patch releases are JS-only fixes that ride on the same
26
- // runtime. Deriving the download tag straight from the npm version therefore
27
- // 404s on every patch (e.g. 0.4.1 has no v0.4.1 binaries), which silently
28
- // broke `npm test`. Zero the patch component so a patch release fetches its
29
- // minor's binaries. Override with OWNCAST_PLUGIN_HOST_BINARIES_VERSION (with or
30
- // without a leading "v") if you ever need a specific tag.
31
- function hostBinariesVersion() {
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
- const pkg = require("../package.json").version; // e.g. "0.4.1"
35
- const [major, minor] = pkg.split(".");
36
- return `${major}.${minor}.0`;
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;
@@ -71,7 +121,7 @@ function binaryenURL() {
71
121
  return `https://github.com/WebAssembly/binaryen/releases/download/${BINARYEN_VERSION}/${file}`;
72
122
  }
73
123
 
74
- function hostBinaryURL(name) {
124
+ function hostBinaryURL(name, version) {
75
125
  // Per-platform asset naming matches Go's GOOS-GOARCH convention so the
76
126
  // release CI can `go build` once per matrix entry without renaming.
77
127
  const map = {
@@ -81,7 +131,7 @@ function hostBinaryURL(name) {
81
131
  "darwin-arm64": "darwin-arm64",
82
132
  };
83
133
  const suffix = map[platformKey()];
84
- return `https://github.com/${HOST_BINARIES_REPO}/releases/download/v${HOST_BINARIES_VERSION}/${name}-${suffix}`;
134
+ return `https://github.com/${HOST_BINARIES_REPO}/releases/download/v${version}/${name}-${suffix}`;
85
135
  }
86
136
 
87
137
  function download(url, dest) {
@@ -143,27 +193,33 @@ async function main() {
143
193
  // if the release doesn't exist yet (dev environments running against a
144
194
  // not-yet-released SDK version can substitute their own via
145
195
  // tools/bootstrap.sh).
146
- for (const binary of ["owncast-plugin-test", "owncast-plugin-serve"]) {
147
- const dest = path.join(cacheDir, binary);
148
- if (fs.existsSync(dest)) continue;
149
- const gz = dest + ".gz";
150
- try {
151
- console.log(
152
- `[plugin-sdk] downloading ${binary} ${HOST_BINARIES_VERSION}...`,
153
- );
154
- await download(hostBinaryURL(binary) + ".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);
196
+ const hostBinaries = ["owncast-plugin-test", "owncast-plugin-serve"];
197
+ const missing = hostBinaries.filter(
198
+ (b) => !fs.existsSync(path.join(cacheDir, b)),
199
+ );
200
+ if (missing.length) {
201
+ // Resolve the version only when something needs downloading, so a repeat
202
+ // install with a populated cache never hits the network.
203
+ const version = await resolveHostBinariesVersion();
204
+ for (const binary of missing) {
205
+ const dest = path.join(cacheDir, binary);
206
+ const gz = dest + ".gz";
207
+ try {
208
+ console.log(`[plugin-sdk] downloading ${binary} v${version}...`);
209
+ await download(hostBinaryURL(binary, version) + ".gz", gz);
210
+ fs.writeFileSync(dest, zlib.gunzipSync(fs.readFileSync(gz)));
211
+ fs.chmodSync(dest, 0o755);
212
+ fs.unlinkSync(gz);
213
+ } catch (e) {
214
+ // 404 is expected before the first release; other errors get a soft
215
+ // warning so the user sees them but the install still succeeds.
216
+ console.warn(
217
+ `[plugin-sdk] could not fetch ${binary}: ${e.message}\n` +
218
+ ` Build locally via tools/bootstrap.sh, or use the latest GitHub release.`,
219
+ );
220
+ // Make sure no partial files are left behind.
221
+ for (const p of [gz, dest]) if (fs.existsSync(p)) fs.unlinkSync(p);
222
+ }
167
223
  }
168
224
  }
169
225