@w3streamdev/plugin-core 1.0.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/dist/index.mjs ADDED
@@ -0,0 +1,571 @@
1
+ // src/plugin-object.ts
2
+ import { EventEmitter } from "eventemitter3";
3
+
4
+ // src/postmessage-bridge.ts
5
+ var PROTOCOL_NAMESPACE = "_w3stream";
6
+ var W3PostMessageBridge = class {
7
+ constructor(plugin, iframe, viewId, meeting, storeSync) {
8
+ this.messageHandler = null;
9
+ this.connected = false;
10
+ this.plugin = plugin;
11
+ this.iframe = iframe;
12
+ this.viewId = viewId;
13
+ this.pluginId = plugin.id;
14
+ this.meeting = meeting;
15
+ this.storeSync = storeSync;
16
+ }
17
+ connect() {
18
+ if (this.connected) return;
19
+ this.connected = true;
20
+ this.messageHandler = this.handleMessage.bind(this);
21
+ window.addEventListener("message", this.messageHandler);
22
+ }
23
+ destroy() {
24
+ if (this.messageHandler) {
25
+ window.removeEventListener("message", this.messageHandler);
26
+ this.messageHandler = null;
27
+ }
28
+ this.connected = false;
29
+ }
30
+ /** Send PLUGIN_DATA to the iframe */
31
+ send(eventName, data) {
32
+ this.iframe.contentWindow?.postMessage(
33
+ {
34
+ type: "PLUGIN_DATA",
35
+ pluginId: this.pluginId,
36
+ viewId: this.viewId,
37
+ eventName,
38
+ data,
39
+ [PROTOCOL_NAMESPACE]: true
40
+ },
41
+ "*"
42
+ );
43
+ }
44
+ /** Send PLUGIN_CLOSE to the iframe */
45
+ sendClose() {
46
+ this.iframe.contentWindow?.postMessage(
47
+ {
48
+ type: "PLUGIN_CLOSE",
49
+ pluginId: this.pluginId,
50
+ viewId: this.viewId,
51
+ [PROTOCOL_NAMESPACE]: true
52
+ },
53
+ "*"
54
+ );
55
+ }
56
+ /** Send a STORE_UPDATE to this iframe */
57
+ sendStoreUpdate(storeName, key, value, fromPeerId) {
58
+ this.iframe.contentWindow?.postMessage(
59
+ {
60
+ type: "STORE_UPDATE",
61
+ pluginId: this.pluginId,
62
+ storeName,
63
+ key,
64
+ value,
65
+ fromPeerId,
66
+ [PROTOCOL_NAMESPACE]: true
67
+ },
68
+ "*"
69
+ );
70
+ }
71
+ handleMessage(event) {
72
+ const msg = event.data;
73
+ if (!msg || msg[PROTOCOL_NAMESPACE] !== true) return;
74
+ if (msg["pluginId"] !== this.pluginId) return;
75
+ if (event.source !== this.iframe.contentWindow) return;
76
+ switch (msg["type"]) {
77
+ case "PLUGIN_READY":
78
+ this.sendInit();
79
+ break;
80
+ case "PLUGIN_EVENT": {
81
+ const eventName = msg["eventName"];
82
+ const data = msg["data"];
83
+ this.plugin.emit(eventName, data);
84
+ break;
85
+ }
86
+ case "STORE_POPULATE": {
87
+ const storeName = msg["storeName"];
88
+ if (this.storeSync) {
89
+ const snapshot = this.storeSync.getStoreSnapshot(
90
+ this.pluginId,
91
+ storeName
92
+ );
93
+ this.iframe.contentWindow?.postMessage(
94
+ {
95
+ type: "STORE_DATA",
96
+ pluginId: this.pluginId,
97
+ storeName,
98
+ data: snapshot,
99
+ [PROTOCOL_NAMESPACE]: true
100
+ },
101
+ "*"
102
+ );
103
+ }
104
+ break;
105
+ }
106
+ case "STORE_UPDATE": {
107
+ const storeName = msg["storeName"];
108
+ const key = msg["key"];
109
+ const value = msg["value"];
110
+ const selfPeerId = this.meeting.self?.id ?? "";
111
+ if (this.storeSync) {
112
+ this.storeSync.applyUpdate(
113
+ this.pluginId,
114
+ storeName,
115
+ key,
116
+ value,
117
+ selfPeerId,
118
+ this.viewId
119
+ // origin viewId to avoid echo
120
+ );
121
+ }
122
+ break;
123
+ }
124
+ }
125
+ }
126
+ sendInit() {
127
+ const m = this.meeting;
128
+ const participants = m.participants?.joined?.toArray?.() ?? m.participants?.active?.toArray?.() ?? [];
129
+ const peers = participants.map((p) => ({
130
+ id: p.id ?? "",
131
+ name: p.name ?? "",
132
+ picture: p.picture,
133
+ isHost: false,
134
+ customParticipantId: p.customParticipantId
135
+ }));
136
+ const initMsg = {
137
+ type: "PLUGIN_INIT",
138
+ pluginId: this.pluginId,
139
+ viewId: this.viewId,
140
+ authToken: m.__authToken ?? "",
141
+ meetingId: m.meta?.meetingId ?? "",
142
+ selfPeerId: m.self?.id ?? "",
143
+ selfName: m.self?.name ?? "",
144
+ enabledBy: this.plugin.enabledBy,
145
+ roomName: m.meta?.roomName ?? m.meta?.meetingTitle ?? "",
146
+ peers,
147
+ [PROTOCOL_NAMESPACE]: true
148
+ };
149
+ this.iframe.contentWindow?.postMessage(initMsg, "*");
150
+ this.plugin.emit("ready", { viewId: this.viewId });
151
+ }
152
+ };
153
+
154
+ // src/plugin-object.ts
155
+ var W3_PLUGIN_ACTIVATE = "W3_PLUGIN_ACTIVATE";
156
+ var W3_PLUGIN_DEACTIVATE = "W3_PLUGIN_DEACTIVATE";
157
+ function createW3PluginObject(serverPlugin, meeting, _config, storeSync) {
158
+ const emitter = new EventEmitter();
159
+ const m = meeting;
160
+ const bridges = /* @__PURE__ */ new Map();
161
+ const iframes = /* @__PURE__ */ new Map();
162
+ const plugin = {
163
+ // Static fields from server
164
+ id: serverPlugin.id,
165
+ name: serverPlugin.name,
166
+ description: serverPlugin.description,
167
+ picture: serverPlugin.picture,
168
+ baseURL: serverPlugin.baseURL,
169
+ organizationId: serverPlugin.organizationId,
170
+ published: serverPlugin.published,
171
+ private: serverPlugin.private,
172
+ staggered: serverPlugin.staggered,
173
+ tags: serverPlugin.tags,
174
+ type: serverPlugin.type,
175
+ createdAt: new Date(serverPlugin.createdAt),
176
+ updatedAt: new Date(serverPlugin.updatedAt),
177
+ config: void 0,
178
+ // Runtime state
179
+ active: false,
180
+ enabledBy: "",
181
+ iframes,
182
+ _bridges: bridges,
183
+ // ── activate() — broadcast to all peers via RTK data channel ──
184
+ activate: async () => {
185
+ plugin.active = true;
186
+ plugin.enabledBy = m.self?.id ?? "";
187
+ m.participants?.broadcastMessage?.(W3_PLUGIN_ACTIVATE, {
188
+ pluginId: serverPlugin.id,
189
+ enabledBy: plugin.enabledBy
190
+ });
191
+ emitter.emit("enabled");
192
+ },
193
+ // ── deactivate() — broadcast to all peers ──
194
+ deactivate: async () => {
195
+ plugin.active = false;
196
+ for (const [viewId, bridge] of bridges) {
197
+ bridge.sendClose();
198
+ bridge.destroy();
199
+ storeSync?.unregisterBridge(serverPlugin.id, viewId);
200
+ }
201
+ bridges.clear();
202
+ iframes.clear();
203
+ m.participants?.broadcastMessage?.(W3_PLUGIN_DEACTIVATE, {
204
+ pluginId: serverPlugin.id
205
+ });
206
+ emitter.emit("closed");
207
+ },
208
+ // ── activateForSelf() — local only (staggered plugins) ──
209
+ activateForSelf: async () => {
210
+ plugin.active = true;
211
+ plugin.enabledBy = m.self?.id ?? "";
212
+ emitter.emit("enabled");
213
+ },
214
+ deactivateForSelf: () => {
215
+ plugin.active = false;
216
+ for (const [viewId, bridge] of bridges) {
217
+ bridge.sendClose();
218
+ bridge.destroy();
219
+ storeSync?.unregisterBridge(serverPlugin.id, viewId);
220
+ }
221
+ bridges.clear();
222
+ iframes.clear();
223
+ emitter.emit("closed");
224
+ },
225
+ // Deprecated aliases
226
+ enable: async () => plugin.activateForSelf(),
227
+ disable: () => plugin.deactivateForSelf(),
228
+ // ── addPluginView() — creates a W3PostMessageBridge ──
229
+ addPluginView: (iframe, viewId = "plugin-main") => {
230
+ if (bridges.has(viewId)) {
231
+ bridges.get(viewId).destroy();
232
+ storeSync?.unregisterBridge(serverPlugin.id, viewId);
233
+ }
234
+ iframes.set(viewId, iframe);
235
+ const bridge = new W3PostMessageBridge(
236
+ plugin,
237
+ iframe,
238
+ viewId,
239
+ meeting,
240
+ storeSync
241
+ );
242
+ bridges.set(viewId, bridge);
243
+ if (storeSync) {
244
+ storeSync.registerBridge(serverPlugin.id, viewId, bridge);
245
+ }
246
+ bridge.connect();
247
+ },
248
+ // ── removePluginView() ──
249
+ removePluginView: (viewId = "plugin-main") => {
250
+ const bridge = bridges.get(viewId);
251
+ if (bridge) {
252
+ bridge.destroy();
253
+ bridges.delete(viewId);
254
+ storeSync?.unregisterBridge(serverPlugin.id, viewId);
255
+ }
256
+ iframes.delete(viewId);
257
+ },
258
+ // ── sendData() — sends PLUGIN_DATA to all registered iframes ──
259
+ sendData: (payload) => {
260
+ for (const bridge of bridges.values()) {
261
+ bridge.send(payload.eventName, payload.data);
262
+ }
263
+ },
264
+ sendIframeEvent: (message) => {
265
+ const msg = message;
266
+ if (msg.eventName) {
267
+ plugin.sendData({ eventName: msg.eventName, data: msg.data });
268
+ }
269
+ },
270
+ // ── EventEmitter delegation ──
271
+ on: (event, cb) => {
272
+ emitter.on(event, cb);
273
+ return plugin;
274
+ },
275
+ off: (event, cb) => {
276
+ emitter.off(event, cb);
277
+ return plugin;
278
+ },
279
+ once: (event, cb) => {
280
+ emitter.once(event, cb);
281
+ return plugin;
282
+ },
283
+ emit: (event, ...args) => emitter.emit(event, ...args),
284
+ addListener: (event, cb) => {
285
+ emitter.on(event, cb);
286
+ return plugin;
287
+ },
288
+ removeListener: (event, cb) => {
289
+ emitter.off(event, cb);
290
+ return plugin;
291
+ },
292
+ removeAllListeners: (event) => {
293
+ if (event) emitter.removeAllListeners(event);
294
+ else emitter.removeAllListeners();
295
+ return plugin;
296
+ },
297
+ listeners: (event) => emitter.listeners(event),
298
+ listenerCount: (event) => emitter.listenerCount(event),
299
+ eventNames: () => emitter.eventNames(),
300
+ setMaxListeners: (_n) => plugin,
301
+ getMaxListeners: () => 10,
302
+ rawListeners: (event) => emitter.listeners(event),
303
+ prependListener: (event, cb) => {
304
+ emitter.on(event, cb);
305
+ return plugin;
306
+ },
307
+ prependOnceListener: (event, cb) => {
308
+ emitter.once(event, cb);
309
+ return plugin;
310
+ },
311
+ // No-op telemetry
312
+ telemetry: {},
313
+ logger: console
314
+ };
315
+ return plugin;
316
+ }
317
+
318
+ // src/store-sync.ts
319
+ var W3_STORE_UPDATE = "W3_STORE_UPDATE";
320
+ var StoreSyncManager = class {
321
+ constructor(meeting) {
322
+ /** pluginId → storeName → key → value */
323
+ this.stores = /* @__PURE__ */ new Map();
324
+ /** pluginId → viewId → W3PostMessageBridge */
325
+ this.bridges = /* @__PURE__ */ new Map();
326
+ this.meeting = meeting;
327
+ }
328
+ /**
329
+ * Register a bridge so store updates can be forwarded to its iframe.
330
+ */
331
+ registerBridge(pluginId, viewId, bridge) {
332
+ if (!this.bridges.has(pluginId)) this.bridges.set(pluginId, /* @__PURE__ */ new Map());
333
+ this.bridges.get(pluginId).set(viewId, bridge);
334
+ }
335
+ /**
336
+ * Unregister a bridge when the plugin view is removed.
337
+ */
338
+ unregisterBridge(pluginId, viewId) {
339
+ this.bridges.get(pluginId)?.delete(viewId);
340
+ }
341
+ /**
342
+ * Returns the current snapshot of a named store for a plugin.
343
+ */
344
+ getStoreSnapshot(pluginId, storeName) {
345
+ const pluginStores = this.stores.get(pluginId);
346
+ if (!pluginStores) return {};
347
+ const store = pluginStores.get(storeName);
348
+ if (!store) return {};
349
+ return Object.fromEntries(store.entries());
350
+ }
351
+ /**
352
+ * Apply a store update from a local iframe:
353
+ * 1. Update the in-memory store
354
+ * 2. Forward to all other local iframes for this plugin (excluding origin)
355
+ * 3. Broadcast to all remote peers via RTK data channel
356
+ */
357
+ applyUpdate(pluginId, storeName, key, value, fromPeerId, originViewId) {
358
+ if (!this.stores.has(pluginId)) this.stores.set(pluginId, /* @__PURE__ */ new Map());
359
+ const pluginStores = this.stores.get(pluginId);
360
+ if (!pluginStores.has(storeName)) pluginStores.set(storeName, /* @__PURE__ */ new Map());
361
+ pluginStores.get(storeName).set(key, value);
362
+ const pluginBridges = this.bridges.get(pluginId);
363
+ if (pluginBridges) {
364
+ for (const [viewId, bridge] of pluginBridges) {
365
+ if (viewId === originViewId) continue;
366
+ bridge.sendStoreUpdate(storeName, key, value, fromPeerId);
367
+ }
368
+ }
369
+ this.meeting.participants?.broadcastMessage?.(W3_STORE_UPDATE, {
370
+ pluginId,
371
+ storeName,
372
+ key,
373
+ value,
374
+ fromPeerId
375
+ });
376
+ }
377
+ /**
378
+ * Apply a store update received from a remote peer:
379
+ * 1. Update the in-memory store
380
+ * 2. Forward to all local iframes for this plugin
381
+ * (No re-broadcast — the sender already broadcast to all peers)
382
+ */
383
+ applyRemoteUpdate(pluginId, storeName, key, value, fromPeerId) {
384
+ if (!this.stores.has(pluginId)) this.stores.set(pluginId, /* @__PURE__ */ new Map());
385
+ const pluginStores = this.stores.get(pluginId);
386
+ if (!pluginStores.has(storeName)) pluginStores.set(storeName, /* @__PURE__ */ new Map());
387
+ pluginStores.get(storeName).set(key, value);
388
+ const pluginBridges = this.bridges.get(pluginId);
389
+ if (pluginBridges) {
390
+ for (const bridge of pluginBridges.values()) {
391
+ bridge.sendStoreUpdate(storeName, key, value, fromPeerId);
392
+ }
393
+ }
394
+ }
395
+ /**
396
+ * Listen for incoming store updates from remote peers via RTK data channel.
397
+ * Call this once after constructing the StoreSyncManager.
398
+ */
399
+ listenForRemoteUpdates() {
400
+ const participants = this.meeting.participants;
401
+ if (!participants?.on) return;
402
+ const handler = (...args) => {
403
+ let data;
404
+ if (args.length >= 2) {
405
+ data = args[1];
406
+ } else if (args.length === 1) {
407
+ data = args[0];
408
+ }
409
+ if (!data) return;
410
+ const payload = data["payload"] ?? data;
411
+ const type = data["type"] ?? payload["type"];
412
+ if (type !== W3_STORE_UPDATE) return;
413
+ const update = payload;
414
+ this.applyRemoteUpdate(
415
+ update.pluginId,
416
+ update.storeName,
417
+ update.key,
418
+ update.value,
419
+ update.fromPeerId
420
+ );
421
+ };
422
+ participants.on("broadcastedMessage", handler);
423
+ participants.on("dataMessage", handler);
424
+ }
425
+ };
426
+
427
+ // src/plugin-manager.ts
428
+ var PluginList = class {
429
+ constructor() {
430
+ this.items = [];
431
+ this.listeners = /* @__PURE__ */ new Map();
432
+ }
433
+ toArray() {
434
+ return [...this.items];
435
+ }
436
+ addListener(event, callback) {
437
+ if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
438
+ this.listeners.get(event).add(callback);
439
+ }
440
+ removeListener(event, callback) {
441
+ this.listeners.get(event)?.delete(callback);
442
+ }
443
+ _setItems(items) {
444
+ this.items = items;
445
+ this.listeners.get("stateUpdate")?.forEach((cb) => cb());
446
+ }
447
+ _addItem(item) {
448
+ this.items.push(item);
449
+ this.listeners.get("stateUpdate")?.forEach((cb) => cb());
450
+ }
451
+ _removeItem(id) {
452
+ this.items = this.items.filter((p) => p.id !== id);
453
+ this.listeners.get("stateUpdate")?.forEach((cb) => cb());
454
+ }
455
+ };
456
+ async function fetchPluginsFromServer(config) {
457
+ const res = await fetch(`${config.serverURL}/v2/plugins/user`, {
458
+ headers: {
459
+ Authorization: `Bearer ${config.apiKey}`,
460
+ "Content-Type": "application/json",
461
+ ...config.devMode ? { "X-Dev-Mode": "true" } : {}
462
+ }
463
+ });
464
+ if (!res.ok) {
465
+ throw new Error(
466
+ `[plugin-core] Failed to fetch plugins: ${res.status} ${res.statusText}`
467
+ );
468
+ }
469
+ const json = await res.json();
470
+ return json.data;
471
+ }
472
+ async function patchMeeting(meeting, config) {
473
+ const m = meeting;
474
+ const serverPlugins = await fetchPluginsFromServer(config);
475
+ const storeSync = new StoreSyncManager(meeting);
476
+ storeSync.listenForRemoteUpdates();
477
+ const pluginObjects = serverPlugins.map(
478
+ (record) => createW3PluginObject(record, meeting, config, storeSync)
479
+ );
480
+ const all = new PluginList();
481
+ all._setItems(pluginObjects);
482
+ const active = new PluginList();
483
+ const pluginsProxy = {
484
+ all,
485
+ active
486
+ };
487
+ Object.defineProperty(m, "plugins", {
488
+ value: pluginsProxy,
489
+ writable: true,
490
+ configurable: true
491
+ });
492
+ if (m.participants?.on) {
493
+ const handleBroadcast = (...args) => {
494
+ let data;
495
+ if (args.length >= 2) {
496
+ data = args[1];
497
+ } else if (args.length === 1) {
498
+ data = args[0];
499
+ }
500
+ if (!data) return;
501
+ const payload = data["payload"] ?? data;
502
+ const type = data["type"] ?? payload["type"];
503
+ const pluginId = payload["pluginId"];
504
+ if (!pluginId) return;
505
+ const plugin = pluginObjects.find((p) => p.id === pluginId);
506
+ if (!plugin) return;
507
+ if (type === W3_PLUGIN_ACTIVATE) {
508
+ plugin.active = true;
509
+ plugin.enabledBy = payload["enabledBy"] ?? "";
510
+ plugin.emit("enabled");
511
+ if (!active.toArray().find((p) => p.id === pluginId)) {
512
+ active._addItem(plugin);
513
+ }
514
+ } else if (type === W3_PLUGIN_DEACTIVATE) {
515
+ plugin.active = false;
516
+ plugin.emit("closed");
517
+ active._removeItem(pluginId);
518
+ }
519
+ };
520
+ m.participants.on("broadcastedMessage", handleBroadcast);
521
+ m.participants.on("dataMessage", handleBroadcast);
522
+ }
523
+ for (const plugin of pluginObjects) {
524
+ const originalActivate = plugin.activate;
525
+ plugin.activate = async () => {
526
+ await originalActivate();
527
+ if (!active.toArray().find((p) => p.id === plugin.id)) {
528
+ active._addItem(plugin);
529
+ }
530
+ };
531
+ const originalDeactivate = plugin.deactivate;
532
+ plugin.deactivate = async () => {
533
+ await originalDeactivate();
534
+ active._removeItem(plugin.id);
535
+ };
536
+ const originalActivateForSelf = plugin.activateForSelf;
537
+ plugin.activateForSelf = async () => {
538
+ await originalActivateForSelf();
539
+ if (!active.toArray().find((p) => p.id === plugin.id)) {
540
+ active._addItem(plugin);
541
+ }
542
+ };
543
+ const originalDeactivateForSelf = plugin.deactivateForSelf;
544
+ plugin.deactivateForSelf = () => {
545
+ originalDeactivateForSelf();
546
+ active._removeItem(plugin.id);
547
+ };
548
+ }
549
+ }
550
+ async function initWithPlugins(rtkInitOptions, pluginConfig) {
551
+ let RealtimeKitClient;
552
+ try {
553
+ const rtk = await import("@cloudflare/realtimekit");
554
+ RealtimeKitClient = rtk.default ?? rtk;
555
+ } catch {
556
+ throw new Error(
557
+ "[plugin-core] @cloudflare/realtimekit must be installed to use initWithPlugins(). Install it or use patchMeeting() directly after initializing the meeting."
558
+ );
559
+ }
560
+ const meeting = await RealtimeKitClient.init(rtkInitOptions);
561
+ await patchMeeting(meeting, pluginConfig);
562
+ return meeting;
563
+ }
564
+ export {
565
+ PluginList,
566
+ StoreSyncManager,
567
+ W3PostMessageBridge,
568
+ createW3PluginObject,
569
+ initWithPlugins,
570
+ patchMeeting
571
+ };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@w3streamdev/plugin-core",
3
+ "version": "1.0.0",
4
+ "description": "Host-side patch layer — wires @cloudflare/realtimekit into your plugin infrastructure",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": ["dist"],
16
+ "scripts": {
17
+ "build": "tsup src/index.ts --format cjs,esm --dts --clean",
18
+ "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
19
+ "typecheck": "tsc --noEmit",
20
+ "lint": "eslint src",
21
+ "clean": "rm -rf dist"
22
+ },
23
+ "peerDependencies": {
24
+ "@cloudflare/realtimekit": ">=0"
25
+ },
26
+ "peerDependenciesMeta": {
27
+ "@cloudflare/realtimekit": { "optional": false }
28
+ },
29
+ "dependencies": {
30
+ "eventemitter3": "^5.0.1"
31
+ },
32
+ "devDependencies": {
33
+ "@cloudflare/realtimekit": "latest",
34
+ "tsup": "^8.0.0",
35
+ "typescript": "^5.4.0"
36
+ },
37
+ "license": "MIT"
38
+ }