@owncast/plugin-sdk 0.1.0 → 0.3.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/index.js CHANGED
@@ -10,45 +10,47 @@ let registered = null;
10
10
  const FilterAction = Object.freeze({
11
11
  Pass: "pass",
12
12
  Modify: "modify",
13
- Drop: "drop"
13
+ Drop: "drop",
14
14
  });
15
15
 
16
16
  const Events = Object.freeze({
17
17
  // Chat events
18
- ChatMessageReceived: "chat.message.received",
19
- ChatUserJoined: "chat.user.joined",
20
- ChatUserParted: "chat.user.parted",
21
- ChatUserRenamed: "chat.user.renamed",
18
+ ChatMessageReceived: "chat.message.received",
19
+ ChatUserJoined: "chat.user.joined",
20
+ ChatUserParted: "chat.user.parted",
21
+ ChatUserRenamed: "chat.user.renamed",
22
22
  ChatMessageModerated: "chat.message.moderated",
23
23
  // Stream lifecycle
24
- StreamStarted: "stream.started",
25
- StreamStopped: "stream.stopped",
26
- StreamTitleChanged: "stream.title.changed",
27
- // Fediverse engagement (metadata only) + inbound posts (with content)
28
- FediverseFollow: "fediverse.follow",
29
- FediverseLike: "fediverse.like",
30
- FediverseRepost: "fediverse.repost",
31
- FediverseMention: "fediverse.mention",
32
- FediverseReply: "fediverse.reply"
24
+ StreamStarted: "stream.started",
25
+ StreamStopped: "stream.stopped",
26
+ StreamTitleChanged: "stream.title.changed",
27
+ // Fediverse, engagement (metadata only) + inbound posts (with content)
28
+ FediverseFollow: "fediverse.follow",
29
+ FediverseLike: "fediverse.like",
30
+ FediverseRepost: "fediverse.repost",
31
+ FediverseMention: "fediverse.mention",
32
+ FediverseReply: "fediverse.reply",
33
33
  });
34
34
 
35
35
  const Permissions = Object.freeze({
36
- ChatSend: "chat.send",
37
- ChatHistory: "chat.history",
38
- ChatModerate: "chat.moderate",
39
- StorageKV: "storage.kv",
40
- StorageUpload: "storage.upload",
41
- EventsEmit: "events.emit",
42
- NetworkFetch: "network.fetch",
43
- HttpServe: "http.serve",
44
- ServerRead: "server.read",
36
+ ChatSend: "chat.send",
37
+ ChatHistory: "chat.history",
38
+ ChatModerate: "chat.moderate",
39
+ ChatFilter: "chat.filter",
40
+ StorageKV: "storage.kv",
41
+ StorageUpload: "storage.upload",
42
+ EventsEmit: "events.emit",
43
+ NetworkFetch: "network.fetch",
44
+ HttpServe: "http.serve",
45
+ ServerRead: "server.read",
45
46
  NotificationsSend: "notifications.send",
46
- UsersRead: "users.read",
47
- UsersModerate: "users.moderate",
48
- FediversePost: "fediverse.post",
49
- HttpSSE: "http.sse",
50
- VideoConfigRead: "videoconfig.read",
51
- VideoConfigWrite: "videoconfig.write"
47
+ UsersRead: "users.read",
48
+ UsersModerate: "users.moderate",
49
+ FediversePost: "fediverse.post",
50
+ HttpSSE: "http.sse",
51
+ VideoConfigRead: "videoconfig.read",
52
+ VideoConfigWrite: "videoconfig.write",
53
+ UIModify: "ui.modify",
52
54
  });
53
55
 
54
56
  const filter = Object.freeze({
@@ -60,14 +62,14 @@ const filter = Object.freeze({
60
62
  },
61
63
  drop(reason) {
62
64
  return { action: FilterAction.Drop, reason: reason || "" };
63
- }
65
+ },
64
66
  });
65
67
 
66
68
  // Distinguishes notification handlers from filter handlers in the HANDLERS
67
- // map below. Internal not part of the public API.
69
+ // map below. Internal, not part of the public API.
68
70
  const HandlerKind = Object.freeze({
69
71
  Notify: "notify",
70
- Filter: "filter"
72
+ Filter: "filter",
71
73
  });
72
74
 
73
75
  // Maps a built-in handler method name to the event type it subscribes to and
@@ -75,30 +77,54 @@ const HandlerKind = Object.freeze({
75
77
  // new built-in Owncast events.
76
78
  const HANDLERS = Object.freeze({
77
79
  // Chat
78
- onChatMessage: { event: Events.ChatMessageReceived, kind: HandlerKind.Notify },
79
- filterChatMessage: { event: Events.ChatMessageReceived, kind: HandlerKind.Filter },
80
- onChatUserJoined: { event: Events.ChatUserJoined, kind: HandlerKind.Notify },
81
- onChatUserParted: { event: Events.ChatUserParted, kind: HandlerKind.Notify },
82
- onChatUserRenamed: { event: Events.ChatUserRenamed, kind: HandlerKind.Notify },
83
- onMessageModerated: { event: Events.ChatMessageModerated, kind: HandlerKind.Notify },
80
+ onChatMessage: {
81
+ event: Events.ChatMessageReceived,
82
+ kind: HandlerKind.Notify,
83
+ },
84
+ filterChatMessage: {
85
+ event: Events.ChatMessageReceived,
86
+ kind: HandlerKind.Filter,
87
+ },
88
+ onChatUserJoined: { event: Events.ChatUserJoined, kind: HandlerKind.Notify },
89
+ onChatUserParted: { event: Events.ChatUserParted, kind: HandlerKind.Notify },
90
+ onChatUserRenamed: {
91
+ event: Events.ChatUserRenamed,
92
+ kind: HandlerKind.Notify,
93
+ },
94
+ onMessageModerated: {
95
+ event: Events.ChatMessageModerated,
96
+ kind: HandlerKind.Notify,
97
+ },
84
98
  // Stream lifecycle
85
- onStreamStarted: { event: Events.StreamStarted, kind: HandlerKind.Notify },
86
- onStreamStopped: { event: Events.StreamStopped, kind: HandlerKind.Notify },
87
- onStreamTitleChanged: { event: Events.StreamTitleChanged, kind: HandlerKind.Notify },
99
+ onStreamStarted: { event: Events.StreamStarted, kind: HandlerKind.Notify },
100
+ onStreamStopped: { event: Events.StreamStopped, kind: HandlerKind.Notify },
101
+ onStreamTitleChanged: {
102
+ event: Events.StreamTitleChanged,
103
+ kind: HandlerKind.Notify,
104
+ },
88
105
  // Fediverse engagement (actor + target metadata)
89
- onFediverseFollow: { event: Events.FediverseFollow, kind: HandlerKind.Notify },
90
- onFediverseLike: { event: Events.FediverseLike, kind: HandlerKind.Notify },
91
- onFediverseRepost: { event: Events.FediverseRepost, kind: HandlerKind.Notify },
106
+ onFediverseFollow: {
107
+ event: Events.FediverseFollow,
108
+ kind: HandlerKind.Notify,
109
+ },
110
+ onFediverseLike: { event: Events.FediverseLike, kind: HandlerKind.Notify },
111
+ onFediverseRepost: {
112
+ event: Events.FediverseRepost,
113
+ kind: HandlerKind.Notify,
114
+ },
92
115
  // Fediverse inbound posts (with content)
93
- onFediverseMention: { event: Events.FediverseMention, kind: HandlerKind.Notify },
94
- onFediverseReply: { event: Events.FediverseReply, kind: HandlerKind.Notify }
116
+ onFediverseMention: {
117
+ event: Events.FediverseMention,
118
+ kind: HandlerKind.Notify,
119
+ },
120
+ onFediverseReply: { event: Events.FediverseReply, kind: HandlerKind.Notify },
95
121
  });
96
122
 
97
123
  // typeof comparisons in well-known categories. JS guarantees these strings,
98
124
  // but we go through named constants so a stray typo can't pass silently.
99
125
  const JsType = Object.freeze({
100
126
  Function: "function",
101
- Object: "object"
127
+ Object: "object",
102
128
  });
103
129
  const isFn = (x) => typeof x === JsType.Function;
104
130
  const isObj = (x) => x !== null && typeof x === JsType.Object;
@@ -115,7 +141,10 @@ function describeSubscriptions() {
115
141
  const notify = [];
116
142
  const filterSubs = [];
117
143
  if (registered) {
118
- const priority = typeof registered.filterPriority === "number" ? registered.filterPriority : 100;
144
+ const priority =
145
+ typeof registered.filterPriority === "number"
146
+ ? registered.filterPriority
147
+ : 100;
119
148
  for (const [method, info] of Object.entries(HANDLERS)) {
120
149
  if (!isFn(registered[method])) continue;
121
150
  if (info.kind === HandlerKind.Notify) {
@@ -137,7 +166,11 @@ function dispatchEvent(envelope) {
137
166
  if (!registered) return;
138
167
  const { eventType, payload } = envelope;
139
168
  for (const [method, info] of Object.entries(HANDLERS)) {
140
- if (info.kind === HandlerKind.Notify && info.event === eventType && isFn(registered[method])) {
169
+ if (
170
+ info.kind === HandlerKind.Notify &&
171
+ info.event === eventType &&
172
+ isFn(registered[method])
173
+ ) {
141
174
  registered[method](payload);
142
175
  return;
143
176
  }
@@ -151,7 +184,11 @@ function dispatchFilter(envelope) {
151
184
  if (!registered) return filter.pass();
152
185
  const { eventType, payload } = envelope;
153
186
  for (const [method, info] of Object.entries(HANDLERS)) {
154
- if (info.kind === HandlerKind.Filter && info.event === eventType && isFn(registered[method])) {
187
+ if (
188
+ info.kind === HandlerKind.Filter &&
189
+ info.event === eventType &&
190
+ isFn(registered[method])
191
+ ) {
155
192
  return registered[method](payload) || filter.pass();
156
193
  }
157
194
  }
@@ -169,101 +206,140 @@ function dispatchHttp(request) {
169
206
  return {
170
207
  status: out.status || 200,
171
208
  headers: out.headers || {},
172
- body: out.body == null ? "" : String(out.body)
209
+ body: out.body == null ? "" : String(out.body),
173
210
  };
174
211
  }
175
212
 
213
+ // permError builds an actionable Error and logs it to stderr (which the
214
+ // host runtime captures), so a plugin author running `owncast-plugin
215
+ // serve` or hitting the host's logs sees exactly which permission to
216
+ // add to their manifest. apiName is the SDK call the author wrote
217
+ // (e.g. "owncast.actions.set"); perm is the manifest permission string.
218
+ function permError(apiName, perm) {
219
+ const msg = `${apiName} requires the '${perm}' permission. Add it to your plugin.manifest.json's "permissions" array.`;
220
+ console.error(`[owncast-plugin] ${msg}`);
221
+ return new Error(msg);
222
+ }
223
+
176
224
  const owncast = {
177
225
  chat: {
178
226
  send(text) {
179
227
  const fns = Host.getFunctions();
180
- if (!fns.owncast_send_chat) throw new Error(`permission '${Permissions.ChatSend}' not granted`);
228
+ if (!fns.owncast_send_chat)
229
+ throw new Error(`permission '${Permissions.ChatSend}' not granted`);
181
230
  fns.owncast_send_chat(Memory.fromString(text).offset);
182
231
  },
183
232
  sendAction(text) {
184
233
  const fns = Host.getFunctions();
185
- if (!fns.owncast_send_chat_action) throw new Error(`permission '${Permissions.ChatSend}' not granted`);
234
+ if (!fns.owncast_send_chat_action)
235
+ throw new Error(`permission '${Permissions.ChatSend}' not granted`);
186
236
  fns.owncast_send_chat_action(Memory.fromString(text).offset);
187
237
  },
188
238
  system(body) {
189
239
  const fns = Host.getFunctions();
190
- if (!fns.owncast_send_chat_system) throw new Error(`permission '${Permissions.ChatSend}' not granted`);
240
+ if (!fns.owncast_send_chat_system)
241
+ throw new Error(`permission '${Permissions.ChatSend}' not granted`);
191
242
  fns.owncast_send_chat_system(Memory.fromString(body).offset);
192
243
  },
193
244
  history(limit) {
194
245
  const fns = Host.getFunctions();
195
- if (!fns.owncast_chat_history) throw new Error(`permission '${Permissions.ChatHistory}' not granted`);
246
+ if (!fns.owncast_chat_history)
247
+ throw new Error(`permission '${Permissions.ChatHistory}' not granted`);
196
248
  const offset = fns.owncast_chat_history(limit || 0);
197
249
  if (offset == 0) return [];
198
250
  return JSON.parse(Memory.find(offset).readString());
199
251
  },
200
252
  deleteMessage(messageId) {
201
253
  const fns = Host.getFunctions();
202
- if (!fns.owncast_delete_message) throw new Error(`permission '${Permissions.ChatModerate}' not granted`);
254
+ if (!fns.owncast_delete_message)
255
+ throw new Error(`permission '${Permissions.ChatModerate}' not granted`);
203
256
  fns.owncast_delete_message(Memory.fromString(String(messageId)).offset);
204
257
  },
205
258
  kick(clientId) {
206
259
  const fns = Host.getFunctions();
207
- if (!fns.owncast_kick_client) throw new Error(`permission '${Permissions.ChatModerate}' not granted`);
260
+ if (!fns.owncast_kick_client)
261
+ throw new Error(`permission '${Permissions.ChatModerate}' not granted`);
208
262
  fns.owncast_kick_client(BigInt(clientId));
209
263
  },
210
264
  sendTo(clientId, text) {
211
265
  const fns = Host.getFunctions();
212
- if (!fns.owncast_send_chat_to) throw new Error(`permission '${Permissions.ChatSend}' not granted`);
213
- fns.owncast_send_chat_to(BigInt(clientId), Memory.fromString(text).offset);
266
+ if (!fns.owncast_send_chat_to)
267
+ throw new Error(`permission '${Permissions.ChatSend}' not granted`);
268
+ fns.owncast_send_chat_to(
269
+ BigInt(clientId),
270
+ Memory.fromString(text).offset,
271
+ );
214
272
  },
215
273
  clients() {
216
274
  const fns = Host.getFunctions();
217
- if (!fns.owncast_chat_clients) throw new Error(`permission '${Permissions.ChatHistory}' not granted`);
275
+ if (!fns.owncast_chat_clients)
276
+ throw new Error(`permission '${Permissions.ChatHistory}' not granted`);
218
277
  const offset = fns.owncast_chat_clients();
219
278
  if (offset == 0) return [];
220
279
  return JSON.parse(Memory.find(offset).readString());
221
- }
280
+ },
222
281
  },
223
282
  users: {
224
283
  list() {
225
284
  const fns = Host.getFunctions();
226
- if (!fns.owncast_users_list) throw new Error(`permission '${Permissions.UsersRead}' not granted`);
285
+ if (!fns.owncast_users_list)
286
+ throw new Error(`permission '${Permissions.UsersRead}' not granted`);
227
287
  const offset = fns.owncast_users_list();
228
288
  if (offset == 0) return [];
229
289
  return JSON.parse(Memory.find(offset).readString());
230
290
  },
231
291
  get(id) {
232
292
  const fns = Host.getFunctions();
233
- if (!fns.owncast_user_get) throw new Error(`permission '${Permissions.UsersRead}' not granted`);
293
+ if (!fns.owncast_user_get)
294
+ throw new Error(`permission '${Permissions.UsersRead}' not granted`);
234
295
  const offset = fns.owncast_user_get(Memory.fromString(id).offset);
235
296
  if (offset == 0) return null;
236
297
  return JSON.parse(Memory.find(offset).readString());
237
298
  },
238
299
  setEnabled(id, enabled, reason) {
239
300
  const fns = Host.getFunctions();
240
- if (!fns.owncast_user_set_enabled) throw new Error(`permission '${Permissions.UsersModerate}' not granted`);
301
+ if (!fns.owncast_user_set_enabled)
302
+ throw new Error(
303
+ `permission '${Permissions.UsersModerate}' not granted`,
304
+ );
241
305
  fns.owncast_user_set_enabled(
242
306
  Memory.fromString(id).offset,
243
307
  enabled ? 1 : 0,
244
- Memory.fromString(reason || "").offset
308
+ Memory.fromString(reason || "").offset,
245
309
  );
246
310
  },
247
311
  banIP(ip) {
248
312
  const fns = Host.getFunctions();
249
- if (!fns.owncast_ban_ip) throw new Error(`permission '${Permissions.UsersModerate}' not granted`);
313
+ if (!fns.owncast_ban_ip)
314
+ throw new Error(
315
+ `permission '${Permissions.UsersModerate}' not granted`,
316
+ );
250
317
  fns.owncast_ban_ip(Memory.fromString(ip).offset);
251
- }
318
+ },
252
319
  },
253
320
  storage: {
254
321
  upload(name, data) {
255
322
  const fns = Host.getFunctions();
256
- if (!fns.owncast_storage_upload) throw new Error(`permission '${Permissions.StorageUpload}' not granted`);
257
- const dataMem = data instanceof Uint8Array
258
- ? Memory.fromBuffer(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength))
259
- : Memory.fromString(String(data));
323
+ if (!fns.owncast_storage_upload)
324
+ throw new Error(
325
+ `permission '${Permissions.StorageUpload}' not granted`,
326
+ );
327
+ const dataMem =
328
+ data instanceof Uint8Array
329
+ ? Memory.fromBuffer(
330
+ data.buffer.slice(
331
+ data.byteOffset,
332
+ data.byteOffset + data.byteLength,
333
+ ),
334
+ )
335
+ : Memory.fromString(String(data));
260
336
  const offset = fns.owncast_storage_upload(
261
337
  Memory.fromString(name).offset,
262
- dataMem.offset
338
+ dataMem.offset,
263
339
  );
264
340
  if (offset == 0) return null;
265
341
  return JSON.parse(Memory.find(offset).readString());
266
- }
342
+ },
267
343
  },
268
344
  fediverse: {
269
345
  /** Publish a public text-only post to the fediverse on the streamer's
@@ -271,82 +347,107 @@ const owncast = {
271
347
  * disabled by admin, etc.). Requires `fediverse.post`. */
272
348
  post(text) {
273
349
  const fns = Host.getFunctions();
274
- if (!fns.owncast_fediverse_post) throw new Error(`permission '${Permissions.FediversePost}' not granted`);
350
+ if (!fns.owncast_fediverse_post)
351
+ throw new Error(
352
+ `permission '${Permissions.FediversePost}' not granted`,
353
+ );
275
354
  const offset = fns.owncast_fediverse_post(Memory.fromString(text).offset);
276
355
  if (offset == 0) return null;
277
356
  return JSON.parse(Memory.find(offset).readString());
278
- }
357
+ },
279
358
  },
280
359
  notifications: {
281
360
  discord(text) {
282
361
  const fns = Host.getFunctions();
283
- if (!fns.owncast_notify_discord) throw new Error(`permission '${Permissions.NotificationsSend}' not granted`);
362
+ if (!fns.owncast_notify_discord)
363
+ throw new Error(
364
+ `permission '${Permissions.NotificationsSend}' not granted`,
365
+ );
284
366
  fns.owncast_notify_discord(Memory.fromString(text).offset);
285
367
  },
286
368
  browserPush(payload) {
287
369
  const fns = Host.getFunctions();
288
- if (!fns.owncast_notify_browser_push) throw new Error(`permission '${Permissions.NotificationsSend}' not granted`);
370
+ if (!fns.owncast_notify_browser_push)
371
+ throw new Error(
372
+ `permission '${Permissions.NotificationsSend}' not granted`,
373
+ );
289
374
  const obj = typeof payload === "string" ? { title: payload } : payload;
290
- fns.owncast_notify_browser_push(Memory.fromString(JSON.stringify(obj)).offset);
375
+ fns.owncast_notify_browser_push(
376
+ Memory.fromString(JSON.stringify(obj)).offset,
377
+ );
291
378
  },
292
379
  fediverse(payload) {
293
380
  const fns = Host.getFunctions();
294
- if (!fns.owncast_notify_fediverse) throw new Error(`permission '${Permissions.NotificationsSend}' not granted`);
295
- fns.owncast_notify_fediverse(Memory.fromString(JSON.stringify(payload)).offset);
296
- }
381
+ if (!fns.owncast_notify_fediverse)
382
+ throw new Error(
383
+ `permission '${Permissions.NotificationsSend}' not granted`,
384
+ );
385
+ fns.owncast_notify_fediverse(
386
+ Memory.fromString(JSON.stringify(payload)).offset,
387
+ );
388
+ },
297
389
  },
298
390
  stream: {
299
391
  current() {
300
392
  const fns = Host.getFunctions();
301
- if (!fns.owncast_stream_current) throw new Error(`permission '${Permissions.ServerRead}' not granted`);
393
+ if (!fns.owncast_stream_current)
394
+ throw new Error(`permission '${Permissions.ServerRead}' not granted`);
302
395
  const offset = fns.owncast_stream_current();
303
396
  if (offset == 0) return { online: false, viewers: 0 };
304
397
  return JSON.parse(Memory.find(offset).readString());
305
398
  },
306
399
  broadcaster() {
307
400
  const fns = Host.getFunctions();
308
- if (!fns.owncast_stream_broadcaster) throw new Error(`permission '${Permissions.ServerRead}' not granted`);
401
+ if (!fns.owncast_stream_broadcaster)
402
+ throw new Error(`permission '${Permissions.ServerRead}' not granted`);
309
403
  const offset = fns.owncast_stream_broadcaster();
310
404
  if (offset == 0) return {};
311
405
  return JSON.parse(Memory.find(offset).readString());
312
- }
406
+ },
313
407
  },
314
408
  server: {
315
409
  info() {
316
410
  const fns = Host.getFunctions();
317
- if (!fns.owncast_server_info) throw new Error(`permission '${Permissions.ServerRead}' not granted`);
411
+ if (!fns.owncast_server_info)
412
+ throw new Error(`permission '${Permissions.ServerRead}' not granted`);
318
413
  const offset = fns.owncast_server_info();
319
414
  if (offset == 0) return {};
320
415
  return JSON.parse(Memory.find(offset).readString());
321
416
  },
322
417
  socials() {
323
418
  const fns = Host.getFunctions();
324
- if (!fns.owncast_server_socials) throw new Error(`permission '${Permissions.ServerRead}' not granted`);
419
+ if (!fns.owncast_server_socials)
420
+ throw new Error(`permission '${Permissions.ServerRead}' not granted`);
325
421
  const offset = fns.owncast_server_socials();
326
422
  if (offset == 0) return [];
327
423
  return JSON.parse(Memory.find(offset).readString());
328
424
  },
329
425
  federation() {
330
426
  const fns = Host.getFunctions();
331
- if (!fns.owncast_server_federation) throw new Error(`permission '${Permissions.ServerRead}' not granted`);
427
+ if (!fns.owncast_server_federation)
428
+ throw new Error(`permission '${Permissions.ServerRead}' not granted`);
332
429
  const offset = fns.owncast_server_federation();
333
430
  if (offset == 0) return { enabled: false };
334
431
  return JSON.parse(Memory.find(offset).readString());
335
432
  },
336
433
  tags() {
337
434
  const fns = Host.getFunctions();
338
- if (!fns.owncast_server_tags) throw new Error(`permission '${Permissions.ServerRead}' not granted`);
435
+ if (!fns.owncast_server_tags)
436
+ throw new Error(`permission '${Permissions.ServerRead}' not granted`);
339
437
  const offset = fns.owncast_server_tags();
340
438
  if (offset == 0) return [];
341
439
  return JSON.parse(Memory.find(offset).readString());
342
- }
440
+ },
343
441
  },
344
442
  videoConfig: {
345
443
  /** Read the current video/transcoding config: { latencyLevel, codec,
346
444
  * variants }. Requires `videoconfig.read`. */
347
445
  read() {
348
446
  const fns = Host.getFunctions();
349
- if (!fns.owncast_video_config_read) throw new Error(`permission '${Permissions.VideoConfigRead}' not granted`);
447
+ if (!fns.owncast_video_config_read)
448
+ throw new Error(
449
+ `permission '${Permissions.VideoConfigRead}' not granted`,
450
+ );
350
451
  const offset = fns.owncast_video_config_read();
351
452
  if (offset == 0) return { latencyLevel: 0, codec: "", variants: [] };
352
453
  return JSON.parse(Memory.find(offset).readString());
@@ -356,39 +457,73 @@ const owncast = {
356
457
  * rejects the config. Requires `videoconfig.write`. */
357
458
  write(config) {
358
459
  const fns = Host.getFunctions();
359
- if (!fns.owncast_video_config_write) throw new Error(`permission '${Permissions.VideoConfigWrite}' not granted`);
360
- const offset = fns.owncast_video_config_write(Memory.fromString(JSON.stringify(config || {})).offset);
361
- if (offset == 0) throw new Error('videoConfig.write failed');
460
+ if (!fns.owncast_video_config_write)
461
+ throw new Error(
462
+ `permission '${Permissions.VideoConfigWrite}' not granted`,
463
+ );
464
+ const offset = fns.owncast_video_config_write(
465
+ Memory.fromString(JSON.stringify(config || {})).offset,
466
+ );
467
+ if (offset == 0) throw new Error("videoConfig.write failed");
362
468
  const result = JSON.parse(Memory.find(offset).readString());
363
- if (!result.ok) throw new Error(result.error || 'videoConfig.write failed');
364
- }
469
+ if (!result.ok)
470
+ throw new Error(result.error || "videoConfig.write failed");
471
+ },
365
472
  },
366
473
  kv: {
367
474
  get(key) {
368
475
  const fns = Host.getFunctions();
369
- if (!fns.owncast_kv_get) throw new Error(`permission '${Permissions.StorageKV}' not granted`);
476
+ if (!fns.owncast_kv_get)
477
+ throw new Error(`permission '${Permissions.StorageKV}' not granted`);
370
478
  const offset = fns.owncast_kv_get(Memory.fromString(key).offset);
371
479
  if (offset == 0) return null;
372
480
  return Memory.find(offset).readString();
373
481
  },
374
482
  set(key, value) {
375
483
  const fns = Host.getFunctions();
376
- if (!fns.owncast_kv_set) throw new Error(`permission '${Permissions.StorageKV}' not granted`);
484
+ if (!fns.owncast_kv_set)
485
+ throw new Error(`permission '${Permissions.StorageKV}' not granted`);
377
486
  fns.owncast_kv_set(
378
487
  Memory.fromString(key).offset,
379
- Memory.fromString(String(value)).offset
488
+ Memory.fromString(String(value)).offset,
380
489
  );
381
- }
490
+ },
382
491
  },
383
492
  events: {
384
493
  emit(eventType, payload) {
385
494
  const fns = Host.getFunctions();
386
- if (!fns.owncast_emit_event) throw new Error(`permission '${Permissions.EventsEmit}' not granted`);
495
+ if (!fns.owncast_emit_event)
496
+ throw new Error(`permission '${Permissions.EventsEmit}' not granted`);
387
497
  fns.owncast_emit_event(
388
498
  Memory.fromString(eventType).offset,
389
- Memory.fromString(JSON.stringify(payload)).offset
499
+ Memory.fromString(JSON.stringify(payload)).offset,
390
500
  );
391
- }
501
+ },
502
+ },
503
+ actions: {
504
+ // Append one or more action buttons to the plugin's effective list
505
+ // (manifest.actions ++ runtime additions). Accepts a single button
506
+ // object or an array. The host validates each entry (title
507
+ // required, exactly one of url/html, relative URLs rewritten into
508
+ // this plugin's namespace, cross-plugin URLs rejected) and persists
509
+ // the result, so the next /api/config request returns the longer
510
+ // list. Requires 'ui.modify'.
511
+ add(actions) {
512
+ const fns = Host.getFunctions();
513
+ if (!fns.owncast_add_actions)
514
+ throw permError("owncast.actions.add", Permissions.UIModify);
515
+ const list = Array.isArray(actions) ? actions : [actions];
516
+ fns.owncast_add_actions(Memory.fromString(JSON.stringify(list)).offset);
517
+ },
518
+ // Drop the runtime additions so only manifest.actions remain in
519
+ // the effective list on the next /api/config request. Requires
520
+ // 'ui.modify'.
521
+ clear() {
522
+ const fns = Host.getFunctions();
523
+ if (!fns.owncast_clear_actions)
524
+ throw permError("owncast.actions.clear", Permissions.UIModify);
525
+ fns.owncast_clear_actions();
526
+ },
392
527
  },
393
528
  sse: {
394
529
  // send(channel, event, data) pushes one Server-Sent-Event to every
@@ -401,14 +536,15 @@ const owncast = {
401
536
  // the 'http.sse' permission.
402
537
  send(channel, event, data) {
403
538
  const fns = Host.getFunctions();
404
- if (!fns.owncast_sse_send) throw new Error(`permission '${Permissions.HttpSSE}' not granted`);
539
+ if (!fns.owncast_sse_send)
540
+ throw new Error(`permission '${Permissions.HttpSSE}' not granted`);
405
541
  const payload = typeof data === "string" ? data : JSON.stringify(data);
406
542
  fns.owncast_sse_send(
407
543
  Memory.fromString(channel || "").offset,
408
544
  Memory.fromString(event || "").offset,
409
- Memory.fromString(payload).offset
545
+ Memory.fromString(payload).offset,
410
546
  );
411
- }
547
+ },
412
548
  },
413
549
  http: {
414
550
  // fetch(url, opts) → { status, headers, body }
@@ -420,13 +556,17 @@ const owncast = {
420
556
  const req = {
421
557
  url,
422
558
  method: opts.method || "GET",
423
- headers: opts.headers || {}
559
+ headers: opts.headers || {},
424
560
  };
425
561
  const body = opts.body != null ? String(opts.body) : null;
426
562
  const res = body != null ? Http.request(req, body) : Http.request(req);
427
- return { status: res.status, headers: res.headers || {}, body: res.body || "" };
428
- }
429
- }
563
+ return {
564
+ status: res.status,
565
+ headers: res.headers || {},
566
+ body: res.body || "",
567
+ };
568
+ },
569
+ },
430
570
  };
431
571
 
432
572
  module.exports = {
@@ -439,5 +579,5 @@ module.exports = {
439
579
  describeSubscriptions,
440
580
  dispatchEvent,
441
581
  dispatchFilter,
442
- dispatchHttp
582
+ dispatchHttp,
443
583
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owncast/plugin-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "SDK for authoring Owncast plugins in JavaScript",
5
5
  "license": "MIT",
6
6
  "author": "Owncast",
@@ -28,6 +28,7 @@
28
28
  "files": [
29
29
  "index.js",
30
30
  "index.d.ts",
31
+ "testing.js",
31
32
  "bin/owncast-plugin.js",
32
33
  "scripts/postinstall.js"
33
34
  ],
@@ -44,4 +45,4 @@
44
45
  "publishConfig": {
45
46
  "access": "public"
46
47
  }
47
- }
48
+ }