@owncast/plugin-sdk 0.3.1 → 0.4.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.
@@ -238,50 +238,18 @@ module.exports = { register, on_event, on_filter, on_http_request };
238
238
  env,
239
239
  });
240
240
 
241
- // If the project ships static assets in ./assets/, mirror them to the
242
- // canonical deployment layout (<name>-assets/) so plugin.Server finds them
243
- // without per-deployment renames. We use a symlink so edits to assets/
244
- // show up live during dev (no rebuild needed for HTML/CSS changes).
245
- const assetsSrc = path.join(cwd, "assets");
246
- if (fs.existsSync(assetsSrc) && fs.statSync(assetsSrc).isDirectory()) {
247
- const assetsDest = path.join(cwd, `${slug}-assets`);
248
- let needsLink = true;
249
- // Use lstatSync (not existsSync), existsSync follows symlinks and
250
- // returns false for a dangling link, but the link's inode is still
251
- // there and would make symlinkSync below fail with EEXIST. lstatSync
252
- // sees the link itself regardless of whether its target resolves.
253
- let st;
254
- try {
255
- st = fs.lstatSync(assetsDest);
256
- } catch {
257
- // path doesn't exist at all, fall through to create it.
258
- }
259
- if (st) {
260
- let target;
261
- if (st.isSymbolicLink()) {
262
- // realpathSync throws on dangling links; treat that as "doesn't
263
- // match, replace it" rather than letting it abort the build.
264
- try {
265
- target = fs.realpathSync(assetsDest);
266
- } catch {}
267
- }
268
- if (target && target === fs.realpathSync(assetsSrc)) {
269
- needsLink = false;
270
- } else {
271
- fs.rmSync(assetsDest, { recursive: true, force: true });
272
- }
273
- }
274
- if (needsLink) {
275
- fs.symlinkSync(path.resolve(assetsSrc), assetsDest, "dir");
276
- }
277
- }
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.
278
245
 
279
246
  console.log(`built ${path.relative(cwd, wasmOut)}`);
280
247
  }
281
248
 
282
249
  // `owncast-plugin package`, bundle the project into a single .ocpkg file
283
- // (zip archive with plugin.manifest.json, plugin.wasm, and optional assets/).
284
- // Builds the wasm first if it doesn't exist.
250
+ // (zip archive with plugin.manifest.json, plugin.wasm, and optional
251
+ // public/ and assets/ directories). Builds the wasm first if it
252
+ // doesn't exist.
285
253
  async function packageMain() {
286
254
  const cwd = process.cwd();
287
255
  const manifestPath = path.join(cwd, "plugin.manifest.json");
@@ -296,6 +264,7 @@ async function packageMain() {
296
264
  await buildMain();
297
265
  }
298
266
 
267
+ const publicDir = path.join(cwd, "public");
299
268
  const assetsDir = path.join(cwd, "assets");
300
269
  const zip = new JSZip();
301
270
  zip.file("plugin.manifest.json", fs.readFileSync(manifestPath));
@@ -310,6 +279,26 @@ async function packageMain() {
310
279
  zip.file("icon.png", fs.readFileSync(iconPath));
311
280
  fileCount++;
312
281
  }
282
+ // Bundle a top-level INSTRUCTIONS.md if the plugin source root has one.
283
+ // The host serves it to the admin (which renders it as markdown in a
284
+ // details tab); like icon.png it needs no manifest field and no
285
+ // http.serve permission. The filename is fixed for simplicity.
286
+ const instructionsPath = path.join(cwd, "INSTRUCTIONS.md");
287
+ if (fs.existsSync(instructionsPath) && fs.statSync(instructionsPath).isFile()) {
288
+ zip.file("INSTRUCTIONS.md", fs.readFileSync(instructionsPath));
289
+ fileCount++;
290
+ }
291
+ // public/ → /plugins/<slug>/<path>, served by the host.
292
+ if (fs.existsSync(publicDir) && fs.statSync(publicDir).isDirectory()) {
293
+ for (const file of walkFiles(publicDir)) {
294
+ const rel = path.relative(publicDir, file).split(path.sep).join("/");
295
+ zip.file(`public/${rel}`, fs.readFileSync(file));
296
+ fileCount++;
297
+ }
298
+ }
299
+ // assets/ → host reads internally for manifest fields that inline
300
+ // file contents (styles, scripts, extraPageContent). Not served at
301
+ // a URL.
313
302
  if (fs.existsSync(assetsDir) && fs.statSync(assetsDir).isDirectory()) {
314
303
  for (const file of walkFiles(assetsDir)) {
315
304
  const rel = path.relative(assetsDir, file).split(path.sep).join("/");
@@ -329,12 +318,29 @@ async function packageMain() {
329
318
  console.log(
330
319
  `packaged ${path.relative(cwd, outPath)} (${sizeKb} KB, ${fileCount} files)`,
331
320
  );
321
+
322
+ // Drop the intermediate <slug>.wasm now that it's bundled inside the
323
+ // .ocpkg. The .ocpkg is the only artifact authors care about: leaving
324
+ // the loose .wasm next to it just confuses "what do I ship". Only
325
+ // runs on a successful package so a mid-pipeline failure leaves the
326
+ // last good build in place for debugging.
327
+ try {
328
+ fs.unlinkSync(wasmPath);
329
+ } catch (e) {
330
+ // Don't fail the package step over a cleanup miss. The .ocpkg is
331
+ // already written; surface the warning so the author notices the
332
+ // straggler but treat the run as successful.
333
+ if (e.code !== "ENOENT") {
334
+ console.warn(`warning: could not clean up ${path.relative(cwd, wasmPath)}: ${e.message}`);
335
+ }
336
+ }
332
337
  }
333
338
 
334
339
  function* walkFiles(dir) {
335
340
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
336
- // Resolve symlinks so the assets/ <name>-assets/ link the build CLI
337
- // makes doesn't cause us to skip files. statSync follows.
341
+ // statSync (not lstatSync) so a symlinked file or directory in
342
+ // the source tree resolves to its target and we read its contents
343
+ // rather than skipping it.
338
344
  const full = path.join(dir, entry.name);
339
345
  let info;
340
346
  try {
@@ -392,6 +398,13 @@ function generateInterface(manifest) {
392
398
  if (perms.has("storage.upload")) {
393
399
  imports.push("owncast_storage_upload(namePtr: PTR, dataPtr: PTR): PTR");
394
400
  }
401
+ if (perms.has("storage.fs")) {
402
+ imports.push("owncast_fs_read(pathPtr: PTR): PTR");
403
+ imports.push("owncast_fs_write(pathPtr: PTR, dataPtr: PTR): PTR");
404
+ imports.push("owncast_fs_list(dirPtr: PTR): PTR");
405
+ imports.push("owncast_fs_delete(pathPtr: PTR): PTR");
406
+ imports.push("owncast_fs_exists(pathPtr: PTR): I32");
407
+ }
395
408
  if (perms.has("fediverse.post")) {
396
409
  imports.push("owncast_fediverse_post(textPtr: PTR): PTR");
397
410
  }
package/index.d.ts CHANGED
@@ -115,6 +115,8 @@ export const Events: {
115
115
  readonly StreamStarted: "stream.started";
116
116
  readonly StreamStopped: "stream.stopped";
117
117
  readonly StreamTitleChanged: "stream.title.changed";
118
+ readonly SseConnect: "sse.connect";
119
+ readonly SseDisconnect: "sse.disconnect";
118
120
  readonly FediverseFollow: "fediverse.follow";
119
121
  readonly FediverseLike: "fediverse.like";
120
122
  readonly FediverseRepost: "fediverse.repost";
@@ -161,6 +163,7 @@ export const Permissions: {
161
163
  readonly ChatFilter: "chat.filter";
162
164
  readonly StorageKV: "storage.kv";
163
165
  readonly StorageUpload: "storage.upload";
166
+ readonly StorageFS: "storage.fs";
164
167
  readonly EventsEmit: "events.emit";
165
168
  readonly NetworkFetch: "network.fetch";
166
169
  readonly HttpServe: "http.serve";
@@ -228,6 +231,13 @@ export interface UploadResult {
228
231
  url: string;
229
232
  }
230
233
 
234
+ /** Result of a mutating owncast.fs call (write/delete). `ok` is false and
235
+ * `error` is set when the host rejected the operation. */
236
+ export interface FsResult {
237
+ ok: boolean;
238
+ error?: string;
239
+ }
240
+
231
241
  export const filter: {
232
242
  pass(): FilterResult;
233
243
  modify(payload: any): FilterResult;
@@ -256,6 +266,18 @@ export interface OutgoingHttpResponse {
256
266
  body?: string;
257
267
  }
258
268
 
269
+ /** Payload for the sse.connect / sse.disconnect events. Fired when a browser
270
+ * opens or closes one of the plugin's `/plugins/<name>/_sse/<channel>`
271
+ * streams, so the plugin can track who is connected. `connectionId` is unique
272
+ * per connection for the life of the host process, so a disconnect can be
273
+ * paired with its connect and the same user counted across several tabs.
274
+ * `user` is present only when the connection carried a chat identity. */
275
+ export interface SSEConnectionEvent {
276
+ channel: string;
277
+ connectionId: number;
278
+ user?: ChatUser;
279
+ }
280
+
259
281
  export interface PluginDef {
260
282
  /** Notification handler for chat messages. Fire-and-forget. */
261
283
  onChatMessage?(msg: ChatMessage): void | Promise<void>;
@@ -280,6 +302,13 @@ export interface PluginDef {
280
302
  /** Stream title was updated. */
281
303
  onStreamTitleChanged?(change: StreamTitleChange): void | Promise<void>;
282
304
 
305
+ /** A browser opened one of this plugin's SSE streams. Use it to track who
306
+ * is connected. Requires the `http.sse` permission. */
307
+ onSseConnect?(event: SSEConnectionEvent): void | Promise<void>;
308
+ /** A browser closed one of this plugin's SSE streams (same connectionId as
309
+ * the matching onSseConnect). Requires the `http.sse` permission. */
310
+ onSseDisconnect?(event: SSEConnectionEvent): void | Promise<void>;
311
+
283
312
  /** Someone on the fediverse followed the streamer's account. */
284
313
  onFediverseFollow?(event: FediverseEngagement): void | Promise<void>;
285
314
  /** Someone on the fediverse liked a streamer post / federated stream announcement. */
@@ -349,6 +378,23 @@ export const owncast: {
349
378
  storage: {
350
379
  upload(name: string, data: Uint8Array | string): UploadResult | null;
351
380
  };
381
+ /** Private, sandboxed filesystem under data/plugin-data/<slug>/. The bytes
382
+ * stay server-side (never served over HTTP) and the host confines every
383
+ * path to this plugin's own directory. All methods require `storage.fs`. */
384
+ fs: {
385
+ /** Read a file's raw bytes, or null if it doesn't exist. */
386
+ read(path: string): Uint8Array | null;
387
+ /** Read a file as UTF-8 text, or null if it doesn't exist. */
388
+ readText(path: string): string | null;
389
+ /** Write bytes or a string, creating parent directories as needed. */
390
+ write(path: string, data: Uint8Array | string): FsResult;
391
+ /** List entry names directly inside dir; missing dir lists as empty. */
392
+ list(dir: string): string[];
393
+ /** Remove a single file or empty directory. */
394
+ delete(path: string): FsResult;
395
+ /** Report whether a path exists inside the sandbox. */
396
+ exists(path: string): boolean;
397
+ };
352
398
  /** Post to the fediverse on the streamer's behalf. Requires `fediverse.post`,
353
399
  * which is high-trust (posts go out under the streamer's own handle);
354
400
  * admins should grant it sparingly. */
package/index.js CHANGED
@@ -24,6 +24,9 @@ const Events = Object.freeze({
24
24
  StreamStarted: "stream.started",
25
25
  StreamStopped: "stream.stopped",
26
26
  StreamTitleChanged: "stream.title.changed",
27
+ // SSE connection lifecycle (who connected to / left a plugin's stream)
28
+ SseConnect: "sse.connect",
29
+ SseDisconnect: "sse.disconnect",
27
30
  // Fediverse, engagement (metadata only) + inbound posts (with content)
28
31
  FediverseFollow: "fediverse.follow",
29
32
  FediverseLike: "fediverse.like",
@@ -39,6 +42,7 @@ const Permissions = Object.freeze({
39
42
  ChatFilter: "chat.filter",
40
43
  StorageKV: "storage.kv",
41
44
  StorageUpload: "storage.upload",
45
+ StorageFS: "storage.fs",
42
46
  EventsEmit: "events.emit",
43
47
  NetworkFetch: "network.fetch",
44
48
  HttpServe: "http.serve",
@@ -102,6 +106,9 @@ const HANDLERS = Object.freeze({
102
106
  event: Events.StreamTitleChanged,
103
107
  kind: HandlerKind.Notify,
104
108
  },
109
+ // SSE connection lifecycle
110
+ onSseConnect: { event: Events.SseConnect, kind: HandlerKind.Notify },
111
+ onSseDisconnect: { event: Events.SseDisconnect, kind: HandlerKind.Notify },
105
112
  // Fediverse engagement (actor + target metadata)
106
113
  onFediverseFollow: {
107
114
  event: Events.FediverseFollow,
@@ -341,6 +348,80 @@ const owncast = {
341
348
  return JSON.parse(Memory.find(offset).readString());
342
349
  },
343
350
  },
351
+ // Private, sandboxed filesystem under data/plugin-data/<slug>/. Unlike
352
+ // storage.upload (which publishes browser-accessible files), these bytes
353
+ // stay server-side. The host confines every path to this plugin's own
354
+ // directory. All methods require the 'storage.fs' permission.
355
+ fs: {
356
+ // Read a file's raw bytes. Returns a Uint8Array, or null if the file
357
+ // doesn't exist (or can't be read).
358
+ read(path) {
359
+ const fns = Host.getFunctions();
360
+ if (!fns.owncast_fs_read)
361
+ throw new Error(`permission '${Permissions.StorageFS}' not granted`);
362
+ const offset = fns.owncast_fs_read(Memory.fromString(path).offset);
363
+ if (offset == 0) return null;
364
+ return new Uint8Array(Memory.find(offset).readBytes());
365
+ },
366
+ // Read a file as UTF-8 text. Returns a string, or null if the file
367
+ // doesn't exist. (The Extism boundary decodes the bytes as UTF-8.)
368
+ readText(path) {
369
+ const fns = Host.getFunctions();
370
+ if (!fns.owncast_fs_read)
371
+ throw new Error(`permission '${Permissions.StorageFS}' not granted`);
372
+ const offset = fns.owncast_fs_read(Memory.fromString(path).offset);
373
+ if (offset == 0) return null;
374
+ return Memory.find(offset).readString();
375
+ },
376
+ // Write bytes (Uint8Array) or a string to a file, creating parent
377
+ // directories as needed. Returns { ok, error? }.
378
+ write(path, data) {
379
+ const fns = Host.getFunctions();
380
+ if (!fns.owncast_fs_write)
381
+ throw new Error(`permission '${Permissions.StorageFS}' not granted`);
382
+ const dataMem =
383
+ data instanceof Uint8Array
384
+ ? Memory.fromBuffer(
385
+ data.buffer.slice(
386
+ data.byteOffset,
387
+ data.byteOffset + data.byteLength,
388
+ ),
389
+ )
390
+ : Memory.fromString(String(data));
391
+ const offset = fns.owncast_fs_write(
392
+ Memory.fromString(path).offset,
393
+ dataMem.offset,
394
+ );
395
+ if (offset == 0) return { ok: false, error: "write failed" };
396
+ return JSON.parse(Memory.find(offset).readString());
397
+ },
398
+ // List the entry names (files and subdirectories) directly inside dir.
399
+ // A missing directory lists as empty. Returns string[].
400
+ list(dir) {
401
+ const fns = Host.getFunctions();
402
+ if (!fns.owncast_fs_list)
403
+ throw new Error(`permission '${Permissions.StorageFS}' not granted`);
404
+ const offset = fns.owncast_fs_list(Memory.fromString(dir || "").offset);
405
+ if (offset == 0) return [];
406
+ return JSON.parse(Memory.find(offset).readString());
407
+ },
408
+ // Remove a single file or empty directory. Returns { ok, error? }.
409
+ delete(path) {
410
+ const fns = Host.getFunctions();
411
+ if (!fns.owncast_fs_delete)
412
+ throw new Error(`permission '${Permissions.StorageFS}' not granted`);
413
+ const offset = fns.owncast_fs_delete(Memory.fromString(path).offset);
414
+ if (offset == 0) return { ok: false, error: "delete failed" };
415
+ return JSON.parse(Memory.find(offset).readString());
416
+ },
417
+ // Report whether a path exists inside the sandbox. Returns boolean.
418
+ exists(path) {
419
+ const fns = Host.getFunctions();
420
+ if (!fns.owncast_fs_exists)
421
+ throw new Error(`permission '${Permissions.StorageFS}' not granted`);
422
+ return fns.owncast_fs_exists(Memory.fromString(path).offset) === 1;
423
+ },
424
+ },
344
425
  fediverse: {
345
426
  /** Publish a public text-only post to the fediverse on the streamer's
346
427
  * behalf. Returns { url } on success, null on failure (rate-limited,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owncast/plugin-sdk",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "SDK for authoring Owncast plugins in JavaScript",
5
5
  "license": "MIT",
6
6
  "author": "Owncast",