@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.
- package/bin/owncast-plugin.js +54 -41
- package/index.d.ts +46 -0
- package/index.js +81 -0
- package/package.json +1 -1
package/bin/owncast-plugin.js
CHANGED
|
@@ -238,50 +238,18 @@ module.exports = { register, on_event, on_filter, on_http_request };
|
|
|
238
238
|
env,
|
|
239
239
|
});
|
|
240
240
|
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
// without
|
|
244
|
-
//
|
|
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
|
|
284
|
-
// Builds the wasm first if it
|
|
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
|
-
//
|
|
337
|
-
//
|
|
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,
|