@owncast/plugin-sdk 0.1.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 ADDED
@@ -0,0 +1,443 @@
1
+ // @owncast/plugin-sdk runtime, bundled into every plugin.
2
+ //
3
+ // Authors define typed handlers (onChatMessage, filterChatMessage, ...) plus
4
+ // an `on: { [customEvent]: handler }` object for plugin-emitted events. The
5
+ // SDK derives the manifest's subscriptions from which handlers are present
6
+ // and returns them via register(); authors don't maintain a duplicate list.
7
+
8
+ let registered = null;
9
+
10
+ const FilterAction = Object.freeze({
11
+ Pass: "pass",
12
+ Modify: "modify",
13
+ Drop: "drop"
14
+ });
15
+
16
+ const Events = Object.freeze({
17
+ // Chat events
18
+ ChatMessageReceived: "chat.message.received",
19
+ ChatUserJoined: "chat.user.joined",
20
+ ChatUserParted: "chat.user.parted",
21
+ ChatUserRenamed: "chat.user.renamed",
22
+ ChatMessageModerated: "chat.message.moderated",
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"
33
+ });
34
+
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",
45
+ 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"
52
+ });
53
+
54
+ const filter = Object.freeze({
55
+ pass() {
56
+ return { action: FilterAction.Pass };
57
+ },
58
+ modify(payload) {
59
+ return { action: FilterAction.Modify, payload };
60
+ },
61
+ drop(reason) {
62
+ return { action: FilterAction.Drop, reason: reason || "" };
63
+ }
64
+ });
65
+
66
+ // Distinguishes notification handlers from filter handlers in the HANDLERS
67
+ // map below. Internal — not part of the public API.
68
+ const HandlerKind = Object.freeze({
69
+ Notify: "notify",
70
+ Filter: "filter"
71
+ });
72
+
73
+ // Maps a built-in handler method name to the event type it subscribes to and
74
+ // whether it's a notification or a filter handler. Add entries here to expose
75
+ // new built-in Owncast events.
76
+ const HANDLERS = Object.freeze({
77
+ // 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 },
84
+ // 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 },
88
+ // 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 },
92
+ // Fediverse inbound posts (with content)
93
+ onFediverseMention: { event: Events.FediverseMention, kind: HandlerKind.Notify },
94
+ onFediverseReply: { event: Events.FediverseReply, kind: HandlerKind.Notify }
95
+ });
96
+
97
+ // typeof comparisons in well-known categories. JS guarantees these strings,
98
+ // but we go through named constants so a stray typo can't pass silently.
99
+ const JsType = Object.freeze({
100
+ Function: "function",
101
+ Object: "object"
102
+ });
103
+ const isFn = (x) => typeof x === JsType.Function;
104
+ const isObj = (x) => x !== null && typeof x === JsType.Object;
105
+
106
+ function definePlugin(def) {
107
+ registered = def;
108
+ return def;
109
+ }
110
+
111
+ // Used by the build-generated entry to compute subscriptions for register().
112
+ // Filters can optionally declare a priority via definePlugin({filterPriority}),
113
+ // applied to every filter subscription this plugin owns. Lower = earlier.
114
+ function describeSubscriptions() {
115
+ const notify = [];
116
+ const filterSubs = [];
117
+ if (registered) {
118
+ const priority = typeof registered.filterPriority === "number" ? registered.filterPriority : 100;
119
+ for (const [method, info] of Object.entries(HANDLERS)) {
120
+ if (!isFn(registered[method])) continue;
121
+ if (info.kind === HandlerKind.Notify) {
122
+ notify.push({ event: info.event });
123
+ } else {
124
+ filterSubs.push({ event: info.event, priority });
125
+ }
126
+ }
127
+ if (isObj(registered.on)) {
128
+ for (const eventType of Object.keys(registered.on)) {
129
+ notify.push({ event: eventType });
130
+ }
131
+ }
132
+ }
133
+ return { notify, filter: filterSubs };
134
+ }
135
+
136
+ function dispatchEvent(envelope) {
137
+ if (!registered) return;
138
+ const { eventType, payload } = envelope;
139
+ for (const [method, info] of Object.entries(HANDLERS)) {
140
+ if (info.kind === HandlerKind.Notify && info.event === eventType && isFn(registered[method])) {
141
+ registered[method](payload);
142
+ return;
143
+ }
144
+ }
145
+ if (registered.on && isFn(registered.on[eventType])) {
146
+ registered.on[eventType](payload);
147
+ }
148
+ }
149
+
150
+ function dispatchFilter(envelope) {
151
+ if (!registered) return filter.pass();
152
+ const { eventType, payload } = envelope;
153
+ for (const [method, info] of Object.entries(HANDLERS)) {
154
+ if (info.kind === HandlerKind.Filter && info.event === eventType && isFn(registered[method])) {
155
+ return registered[method](payload) || filter.pass();
156
+ }
157
+ }
158
+ return filter.pass();
159
+ }
160
+
161
+ // dispatchHttp routes incoming HTTP requests to the user's onHttpRequest
162
+ // handler. Returns a default 404 if the plugin doesn't define one.
163
+ function dispatchHttp(request) {
164
+ if (!registered || !isFn(registered.onHttpRequest)) {
165
+ return { status: 404, headers: {}, body: "" };
166
+ }
167
+ const out = registered.onHttpRequest(request);
168
+ if (!out) return { status: 200, headers: {}, body: "" };
169
+ return {
170
+ status: out.status || 200,
171
+ headers: out.headers || {},
172
+ body: out.body == null ? "" : String(out.body)
173
+ };
174
+ }
175
+
176
+ const owncast = {
177
+ chat: {
178
+ send(text) {
179
+ const fns = Host.getFunctions();
180
+ if (!fns.owncast_send_chat) throw new Error(`permission '${Permissions.ChatSend}' not granted`);
181
+ fns.owncast_send_chat(Memory.fromString(text).offset);
182
+ },
183
+ sendAction(text) {
184
+ const fns = Host.getFunctions();
185
+ if (!fns.owncast_send_chat_action) throw new Error(`permission '${Permissions.ChatSend}' not granted`);
186
+ fns.owncast_send_chat_action(Memory.fromString(text).offset);
187
+ },
188
+ system(body) {
189
+ const fns = Host.getFunctions();
190
+ if (!fns.owncast_send_chat_system) throw new Error(`permission '${Permissions.ChatSend}' not granted`);
191
+ fns.owncast_send_chat_system(Memory.fromString(body).offset);
192
+ },
193
+ history(limit) {
194
+ const fns = Host.getFunctions();
195
+ if (!fns.owncast_chat_history) throw new Error(`permission '${Permissions.ChatHistory}' not granted`);
196
+ const offset = fns.owncast_chat_history(limit || 0);
197
+ if (offset == 0) return [];
198
+ return JSON.parse(Memory.find(offset).readString());
199
+ },
200
+ deleteMessage(messageId) {
201
+ const fns = Host.getFunctions();
202
+ if (!fns.owncast_delete_message) throw new Error(`permission '${Permissions.ChatModerate}' not granted`);
203
+ fns.owncast_delete_message(Memory.fromString(String(messageId)).offset);
204
+ },
205
+ kick(clientId) {
206
+ const fns = Host.getFunctions();
207
+ if (!fns.owncast_kick_client) throw new Error(`permission '${Permissions.ChatModerate}' not granted`);
208
+ fns.owncast_kick_client(BigInt(clientId));
209
+ },
210
+ sendTo(clientId, text) {
211
+ 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);
214
+ },
215
+ clients() {
216
+ const fns = Host.getFunctions();
217
+ if (!fns.owncast_chat_clients) throw new Error(`permission '${Permissions.ChatHistory}' not granted`);
218
+ const offset = fns.owncast_chat_clients();
219
+ if (offset == 0) return [];
220
+ return JSON.parse(Memory.find(offset).readString());
221
+ }
222
+ },
223
+ users: {
224
+ list() {
225
+ const fns = Host.getFunctions();
226
+ if (!fns.owncast_users_list) throw new Error(`permission '${Permissions.UsersRead}' not granted`);
227
+ const offset = fns.owncast_users_list();
228
+ if (offset == 0) return [];
229
+ return JSON.parse(Memory.find(offset).readString());
230
+ },
231
+ get(id) {
232
+ const fns = Host.getFunctions();
233
+ if (!fns.owncast_user_get) throw new Error(`permission '${Permissions.UsersRead}' not granted`);
234
+ const offset = fns.owncast_user_get(Memory.fromString(id).offset);
235
+ if (offset == 0) return null;
236
+ return JSON.parse(Memory.find(offset).readString());
237
+ },
238
+ setEnabled(id, enabled, reason) {
239
+ const fns = Host.getFunctions();
240
+ if (!fns.owncast_user_set_enabled) throw new Error(`permission '${Permissions.UsersModerate}' not granted`);
241
+ fns.owncast_user_set_enabled(
242
+ Memory.fromString(id).offset,
243
+ enabled ? 1 : 0,
244
+ Memory.fromString(reason || "").offset
245
+ );
246
+ },
247
+ banIP(ip) {
248
+ const fns = Host.getFunctions();
249
+ if (!fns.owncast_ban_ip) throw new Error(`permission '${Permissions.UsersModerate}' not granted`);
250
+ fns.owncast_ban_ip(Memory.fromString(ip).offset);
251
+ }
252
+ },
253
+ storage: {
254
+ upload(name, data) {
255
+ 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));
260
+ const offset = fns.owncast_storage_upload(
261
+ Memory.fromString(name).offset,
262
+ dataMem.offset
263
+ );
264
+ if (offset == 0) return null;
265
+ return JSON.parse(Memory.find(offset).readString());
266
+ }
267
+ },
268
+ fediverse: {
269
+ /** Publish a public text-only post to the fediverse on the streamer's
270
+ * behalf. Returns { url } on success, null on failure (rate-limited,
271
+ * disabled by admin, etc.). Requires `fediverse.post`. */
272
+ post(text) {
273
+ const fns = Host.getFunctions();
274
+ if (!fns.owncast_fediverse_post) throw new Error(`permission '${Permissions.FediversePost}' not granted`);
275
+ const offset = fns.owncast_fediverse_post(Memory.fromString(text).offset);
276
+ if (offset == 0) return null;
277
+ return JSON.parse(Memory.find(offset).readString());
278
+ }
279
+ },
280
+ notifications: {
281
+ discord(text) {
282
+ const fns = Host.getFunctions();
283
+ if (!fns.owncast_notify_discord) throw new Error(`permission '${Permissions.NotificationsSend}' not granted`);
284
+ fns.owncast_notify_discord(Memory.fromString(text).offset);
285
+ },
286
+ browserPush(payload) {
287
+ const fns = Host.getFunctions();
288
+ if (!fns.owncast_notify_browser_push) throw new Error(`permission '${Permissions.NotificationsSend}' not granted`);
289
+ const obj = typeof payload === "string" ? { title: payload } : payload;
290
+ fns.owncast_notify_browser_push(Memory.fromString(JSON.stringify(obj)).offset);
291
+ },
292
+ fediverse(payload) {
293
+ 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
+ }
297
+ },
298
+ stream: {
299
+ current() {
300
+ const fns = Host.getFunctions();
301
+ if (!fns.owncast_stream_current) throw new Error(`permission '${Permissions.ServerRead}' not granted`);
302
+ const offset = fns.owncast_stream_current();
303
+ if (offset == 0) return { online: false, viewers: 0 };
304
+ return JSON.parse(Memory.find(offset).readString());
305
+ },
306
+ broadcaster() {
307
+ const fns = Host.getFunctions();
308
+ if (!fns.owncast_stream_broadcaster) throw new Error(`permission '${Permissions.ServerRead}' not granted`);
309
+ const offset = fns.owncast_stream_broadcaster();
310
+ if (offset == 0) return {};
311
+ return JSON.parse(Memory.find(offset).readString());
312
+ }
313
+ },
314
+ server: {
315
+ info() {
316
+ const fns = Host.getFunctions();
317
+ if (!fns.owncast_server_info) throw new Error(`permission '${Permissions.ServerRead}' not granted`);
318
+ const offset = fns.owncast_server_info();
319
+ if (offset == 0) return {};
320
+ return JSON.parse(Memory.find(offset).readString());
321
+ },
322
+ socials() {
323
+ const fns = Host.getFunctions();
324
+ if (!fns.owncast_server_socials) throw new Error(`permission '${Permissions.ServerRead}' not granted`);
325
+ const offset = fns.owncast_server_socials();
326
+ if (offset == 0) return [];
327
+ return JSON.parse(Memory.find(offset).readString());
328
+ },
329
+ federation() {
330
+ const fns = Host.getFunctions();
331
+ if (!fns.owncast_server_federation) throw new Error(`permission '${Permissions.ServerRead}' not granted`);
332
+ const offset = fns.owncast_server_federation();
333
+ if (offset == 0) return { enabled: false };
334
+ return JSON.parse(Memory.find(offset).readString());
335
+ },
336
+ tags() {
337
+ const fns = Host.getFunctions();
338
+ if (!fns.owncast_server_tags) throw new Error(`permission '${Permissions.ServerRead}' not granted`);
339
+ const offset = fns.owncast_server_tags();
340
+ if (offset == 0) return [];
341
+ return JSON.parse(Memory.find(offset).readString());
342
+ }
343
+ },
344
+ videoConfig: {
345
+ /** Read the current video/transcoding config: { latencyLevel, codec,
346
+ * variants }. Requires `videoconfig.read`. */
347
+ read() {
348
+ const fns = Host.getFunctions();
349
+ if (!fns.owncast_video_config_read) throw new Error(`permission '${Permissions.VideoConfigRead}' not granted`);
350
+ const offset = fns.owncast_video_config_read();
351
+ if (offset == 0) return { latencyLevel: 0, codec: "", variants: [] };
352
+ return JSON.parse(Memory.find(offset).readString());
353
+ },
354
+ /** Apply a partial video config change. Pass any of { latencyLevel, codec,
355
+ * variants }; omitted fields are left unchanged. Throws if the host
356
+ * rejects the config. Requires `videoconfig.write`. */
357
+ write(config) {
358
+ 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');
362
+ const result = JSON.parse(Memory.find(offset).readString());
363
+ if (!result.ok) throw new Error(result.error || 'videoConfig.write failed');
364
+ }
365
+ },
366
+ kv: {
367
+ get(key) {
368
+ const fns = Host.getFunctions();
369
+ if (!fns.owncast_kv_get) throw new Error(`permission '${Permissions.StorageKV}' not granted`);
370
+ const offset = fns.owncast_kv_get(Memory.fromString(key).offset);
371
+ if (offset == 0) return null;
372
+ return Memory.find(offset).readString();
373
+ },
374
+ set(key, value) {
375
+ const fns = Host.getFunctions();
376
+ if (!fns.owncast_kv_set) throw new Error(`permission '${Permissions.StorageKV}' not granted`);
377
+ fns.owncast_kv_set(
378
+ Memory.fromString(key).offset,
379
+ Memory.fromString(String(value)).offset
380
+ );
381
+ }
382
+ },
383
+ events: {
384
+ emit(eventType, payload) {
385
+ const fns = Host.getFunctions();
386
+ if (!fns.owncast_emit_event) throw new Error(`permission '${Permissions.EventsEmit}' not granted`);
387
+ fns.owncast_emit_event(
388
+ Memory.fromString(eventType).offset,
389
+ Memory.fromString(JSON.stringify(payload)).offset
390
+ );
391
+ }
392
+ },
393
+ sse: {
394
+ // send(channel, event, data) pushes one Server-Sent-Event to every
395
+ // browser connected to this plugin's /plugins/<name>/_sse/<channel>
396
+ // stream. `event` is the SSE event name (browser side:
397
+ // source.addEventListener(event, ...)); pass "" for the default
398
+ // "message" event. `data` is sent as-is if it's a string, otherwise
399
+ // JSON-stringified. Fire-and-forget: returns immediately, and frames to
400
+ // a slow client are dropped rather than blocking the plugin. Requires
401
+ // the 'http.sse' permission.
402
+ send(channel, event, data) {
403
+ const fns = Host.getFunctions();
404
+ if (!fns.owncast_sse_send) throw new Error(`permission '${Permissions.HttpSSE}' not granted`);
405
+ const payload = typeof data === "string" ? data : JSON.stringify(data);
406
+ fns.owncast_sse_send(
407
+ Memory.fromString(channel || "").offset,
408
+ Memory.fromString(event || "").offset,
409
+ Memory.fromString(payload).offset
410
+ );
411
+ }
412
+ },
413
+ http: {
414
+ // fetch(url, opts) → { status, headers, body }
415
+ // Wraps Extism's built-in Http.request. Throws if the manifest didn't
416
+ // declare 'network.fetch' (the host won't have set AllowedHosts, so the
417
+ // underlying call fails).
418
+ fetch(url, opts) {
419
+ opts = opts || {};
420
+ const req = {
421
+ url,
422
+ method: opts.method || "GET",
423
+ headers: opts.headers || {}
424
+ };
425
+ const body = opts.body != null ? String(opts.body) : null;
426
+ const res = body != null ? Http.request(req, body) : Http.request(req);
427
+ return { status: res.status, headers: res.headers || {}, body: res.body || "" };
428
+ }
429
+ }
430
+ };
431
+
432
+ module.exports = {
433
+ definePlugin,
434
+ owncast,
435
+ filter,
436
+ FilterAction,
437
+ Events,
438
+ Permissions,
439
+ describeSubscriptions,
440
+ dispatchEvent,
441
+ dispatchFilter,
442
+ dispatchHttp
443
+ };
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@owncast/plugin-sdk",
3
+ "version": "0.1.0",
4
+ "description": "SDK for authoring Owncast plugins in JavaScript",
5
+ "license": "MIT",
6
+ "author": "Owncast",
7
+ "homepage": "https://owncast.online/docs/plugins",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/owncast/plugin-sdk.git",
11
+ "directory": "sdks/js"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/owncast/plugin-sdk/issues"
15
+ },
16
+ "keywords": [
17
+ "owncast",
18
+ "plugin",
19
+ "sdk",
20
+ "webassembly",
21
+ "wasm"
22
+ ],
23
+ "main": "index.js",
24
+ "types": "index.d.ts",
25
+ "bin": {
26
+ "owncast-plugin": "bin/owncast-plugin.js"
27
+ },
28
+ "files": [
29
+ "index.js",
30
+ "index.d.ts",
31
+ "bin/owncast-plugin.js",
32
+ "scripts/postinstall.js"
33
+ ],
34
+ "scripts": {
35
+ "postinstall": "node scripts/postinstall.js"
36
+ },
37
+ "dependencies": {
38
+ "esbuild": "^0.24.0",
39
+ "jszip": "^3.10.1"
40
+ },
41
+ "engines": {
42
+ "node": ">=18"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ }
47
+ }
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ // Downloads per-platform tooling into <sdk>/bin/.cache so the build CLI
3
+ // finds it without polluting the user's system:
4
+ //
5
+ // - extism-js — JS → wasm compiler (extism/js-pdk releases)
6
+ // - wasm-merge, wasm-opt, lib — binaryen post-processing (WebAssembly/binaryen releases)
7
+ // - owncast-plugin-test/serve — scenario runner + dev server (this repo's releases)
8
+ //
9
+ // PoC scope: linux-x86_64 + darwin-arm64 + darwin-x86_64 covered.
10
+ // owncast-plugin-test/serve downloads gracefully skip if the matching
11
+ // release asset isn't published yet — dev builds can substitute their own
12
+ // via tools/bootstrap.sh.
13
+
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+ const https = require("https");
17
+ const zlib = require("zlib");
18
+ const { execFileSync } = require("child_process");
19
+
20
+ const EXTISM_JS_VERSION = "v1.6.0";
21
+ const BINARYEN_VERSION = "version_119";
22
+ // Tracks the SDK version that the host binaries were cut for. Usually
23
+ // matches the SDK's own version in package.json.
24
+ const HOST_BINARIES_VERSION = require("../package.json").version;
25
+ const HOST_BINARIES_REPO = "owncast/plugin-sdk";
26
+
27
+ const platform = process.platform;
28
+ const arch = process.arch;
29
+
30
+ function platformKey() {
31
+ if (platform === "linux" && arch === "x64") return "linux-x86_64";
32
+ if (platform === "linux" && arch === "arm64") return "linux-aarch64";
33
+ if (platform === "darwin" && arch === "x64") return "darwin-x86_64";
34
+ if (platform === "darwin" && arch === "arm64") return "darwin-arm64";
35
+ throw new Error(`unsupported platform: ${platform}/${arch}`);
36
+ }
37
+
38
+ function extismJsURL() {
39
+ // extism-js release naming uses different conventions per OS.
40
+ const map = {
41
+ "linux-x86_64": `extism-js-x86_64-linux-${EXTISM_JS_VERSION}.gz`,
42
+ "linux-aarch64": `extism-js-aarch64-linux-${EXTISM_JS_VERSION}.gz`,
43
+ "darwin-x86_64": `extism-js-x86_64-macos-${EXTISM_JS_VERSION}.gz`,
44
+ "darwin-arm64": `extism-js-aarch64-macos-${EXTISM_JS_VERSION}.gz`
45
+ };
46
+ const file = map[platformKey()];
47
+ return `https://github.com/extism/js-pdk/releases/download/${EXTISM_JS_VERSION}/${file}`;
48
+ }
49
+
50
+ function binaryenURL() {
51
+ const map = {
52
+ "linux-x86_64": `binaryen-${BINARYEN_VERSION}-x86_64-linux.tar.gz`,
53
+ "linux-aarch64": `binaryen-${BINARYEN_VERSION}-aarch64-linux.tar.gz`,
54
+ "darwin-x86_64": `binaryen-${BINARYEN_VERSION}-x86_64-macos.tar.gz`,
55
+ "darwin-arm64": `binaryen-${BINARYEN_VERSION}-arm64-macos.tar.gz`
56
+ };
57
+ const file = map[platformKey()];
58
+ return `https://github.com/WebAssembly/binaryen/releases/download/${BINARYEN_VERSION}/${file}`;
59
+ }
60
+
61
+ function hostBinaryURL(name) {
62
+ // Per-platform asset naming matches Go's GOOS-GOARCH convention so the
63
+ // release CI can `go build` once per matrix entry without renaming.
64
+ const map = {
65
+ "linux-x86_64": "linux-amd64",
66
+ "linux-aarch64": "linux-arm64",
67
+ "darwin-x86_64": "darwin-amd64",
68
+ "darwin-arm64": "darwin-arm64"
69
+ };
70
+ const suffix = map[platformKey()];
71
+ return `https://github.com/${HOST_BINARIES_REPO}/releases/download/v${HOST_BINARIES_VERSION}/${name}-${suffix}`;
72
+ }
73
+
74
+ function download(url, dest) {
75
+ return new Promise((resolve, reject) => {
76
+ const req = (u) =>
77
+ https.get(u, (res) => {
78
+ if (res.statusCode === 302 || res.statusCode === 301) return req(res.headers.location);
79
+ if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode} for ${u}`));
80
+ const out = fs.createWriteStream(dest);
81
+ res.pipe(out);
82
+ out.on("finish", () => out.close(resolve));
83
+ out.on("error", reject);
84
+ });
85
+ req(url);
86
+ });
87
+ }
88
+
89
+ async function main() {
90
+ const cacheDir = path.join(__dirname, "..", "bin", ".cache");
91
+ fs.mkdirSync(cacheDir, { recursive: true });
92
+
93
+ const extismDest = path.join(cacheDir, "extism-js");
94
+ if (!fs.existsSync(extismDest)) {
95
+ const gz = path.join(cacheDir, "extism-js.gz");
96
+ console.log(`[plugin-sdk] downloading extism-js ${EXTISM_JS_VERSION}...`);
97
+ await download(extismJsURL(), gz);
98
+ const buf = zlib.gunzipSync(fs.readFileSync(gz));
99
+ fs.writeFileSync(extismDest, buf);
100
+ fs.chmodSync(extismDest, 0o755);
101
+ fs.unlinkSync(gz);
102
+ }
103
+
104
+ const wasmMergeDest = path.join(cacheDir, "wasm-merge");
105
+ const wasmOptDest = path.join(cacheDir, "wasm-opt");
106
+ if (!fs.existsSync(wasmMergeDest) || !fs.existsSync(wasmOptDest)) {
107
+ const tar = path.join(cacheDir, "binaryen.tar.gz");
108
+ console.log(`[plugin-sdk] downloading binaryen ${BINARYEN_VERSION}...`);
109
+ await download(binaryenURL(), tar);
110
+ execFileSync("tar", ["xzf", tar, "-C", cacheDir]);
111
+ const extracted = path.join(cacheDir, `binaryen-${BINARYEN_VERSION}`);
112
+ fs.copyFileSync(path.join(extracted, "bin", "wasm-merge"), wasmMergeDest);
113
+ fs.copyFileSync(path.join(extracted, "bin", "wasm-opt"), wasmOptDest);
114
+ fs.chmodSync(wasmMergeDest, 0o755);
115
+ fs.chmodSync(wasmOptDest, 0o755);
116
+ // copy lib too — wasm-opt links against libbinaryen.so on linux
117
+ const libSrc = path.join(extracted, "lib");
118
+ if (fs.existsSync(libSrc)) {
119
+ fs.cpSync(libSrc, path.join(cacheDir, "lib"), { recursive: true });
120
+ }
121
+ fs.rmSync(extracted, { recursive: true });
122
+ fs.unlinkSync(tar);
123
+ }
124
+
125
+ // owncast-plugin-test + owncast-plugin-serve — built from this repo's
126
+ // host-runtime/ Go sources, published as gzipped release assets on
127
+ // github.com/owncast/plugin-sdk (roughly halves the download). Skip silently
128
+ // if the release doesn't exist yet (dev environments running against a
129
+ // not-yet-released SDK version can substitute their own via
130
+ // tools/bootstrap.sh).
131
+ for (const binary of ["owncast-plugin-test", "owncast-plugin-serve"]) {
132
+ const dest = path.join(cacheDir, binary);
133
+ if (fs.existsSync(dest)) continue;
134
+ const gz = dest + ".gz";
135
+ try {
136
+ console.log(`[plugin-sdk] downloading ${binary} ${HOST_BINARIES_VERSION}...`);
137
+ await download(hostBinaryURL(binary) + ".gz", gz);
138
+ fs.writeFileSync(dest, zlib.gunzipSync(fs.readFileSync(gz)));
139
+ fs.chmodSync(dest, 0o755);
140
+ fs.unlinkSync(gz);
141
+ } catch (e) {
142
+ // 404 is expected before the first release; other errors get a soft
143
+ // warning so the user sees them but the install still succeeds.
144
+ console.warn(
145
+ `[plugin-sdk] could not fetch ${binary}: ${e.message}\n` +
146
+ ` Build locally via tools/bootstrap.sh, or use the latest GitHub release.`
147
+ );
148
+ // Make sure no partial files are left behind.
149
+ for (const p of [gz, dest]) if (fs.existsSync(p)) fs.unlinkSync(p);
150
+ }
151
+ }
152
+
153
+ console.log("[plugin-sdk] toolchain ready");
154
+ }
155
+
156
+ main().catch((e) => {
157
+ console.error("[plugin-sdk] postinstall failed:", e.message);
158
+ process.exit(1);
159
+ });