@zaflun/lumio-sdk 0.6.3 → 0.10.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/README.md CHANGED
@@ -44,14 +44,14 @@ Lumio.render(<MyWidget />, { target: "editor" });
44
44
 
45
45
  | Hook | Purpose |
46
46
  |------|---------|
47
- | `useExtensionStorage()` | Persistent key-value storage synced across surfaces |
47
+ | `useExtensionStorage(key, initialValue)` | Persistent key-value storage, one key per call, synced across surfaces |
48
48
  | `useVisionState()` | Per-key state with real-time sync |
49
49
  | `useExtensionContext()` | Extension metadata (ID, target, overlay) |
50
50
  | `useLumioEvent()` | Subscribe to platform events (49 event types) |
51
51
  | `useLumioConfig()` | Read/write extension config |
52
52
  | `useLumioTheme()` | Active overlay/widget theme (colors, fonts) |
53
53
  | `useLumioIdentity()` | Current user/account context |
54
- | `useLumioAction()` | Trigger platform actions (21 action types) |
54
+ | `useLumioAction()` | Run a Lumio action (`events:emit`, `overlay:update_layer`, …) through the host |
55
55
  | `useQuery()` | Call server query functions |
56
56
  | `useMutation()` | Call server mutation functions |
57
57
 
@@ -94,16 +94,17 @@ const scoreFont = localFont({
94
94
  });
95
95
  ```
96
96
 
97
- ## Three Surfaces
97
+ ## Surfaces
98
98
 
99
99
  | Surface | File | Where it renders |
100
100
  |---------|------|-----------------|
101
101
  | `editor` | `src/editor.tsx` | Dashboard sidebar panel |
102
102
  | `layer` | `src/layer.tsx` | OBS overlay (transparent) |
103
- | `interactive` | `src/interactive.tsx` | Audience page |
104
103
 
105
104
  All surfaces share storage — changes in the editor propagate to the overlay in real time.
106
105
 
106
+ Viewers interact through chat commands and channel point rewards (`twitch:reward`, `kick:reward`), handled by bot-module handlers that write `ctx.db` and call `ctx.realtime.push(topic, payload)`. The layer and editor pick that up with `useRealtime(topic, cb)`.
107
+
107
108
  ## Security
108
109
 
109
110
  Extensions run in a sandboxed Web Worker with zero DOM access. A custom React reconciler serializes the component tree to JSON, which the host renders. Extension code cannot access cookies, JWT tokens, localStorage, or any host resources.
@@ -0,0 +1,196 @@
1
+ // src/bridge/WorkerBridge.ts
2
+ var WorkerBridge = class {
3
+ notificationHandlers = /* @__PURE__ */ new Map();
4
+ requestHandlers = /* @__PURE__ */ new Map();
5
+ pendingRequests = /* @__PURE__ */ new Map();
6
+ requestCounter = 0;
7
+ requestTimeout;
8
+ messageListener = null;
9
+ destroyed = false;
10
+ constructor(options) {
11
+ this.requestTimeout = options?.requestTimeout ?? 3e4;
12
+ this.messageListener = this.handleMessage.bind(this);
13
+ globalThis.addEventListener("message", this.messageListener);
14
+ }
15
+ notify(method, params) {
16
+ const msg = {
17
+ jsonrpc: "2.0",
18
+ method,
19
+ ...params !== void 0 ? { params } : {}
20
+ };
21
+ globalThis.postMessage(msg);
22
+ }
23
+ request(method, params) {
24
+ const id = `req_${++this.requestCounter}`;
25
+ const msg = {
26
+ jsonrpc: "2.0",
27
+ id,
28
+ method,
29
+ ...params !== void 0 ? { params } : {}
30
+ };
31
+ return new Promise((resolve, reject) => {
32
+ const timer = setTimeout(() => {
33
+ this.pendingRequests.delete(id);
34
+ reject(new Error("Request timeout"));
35
+ }, this.requestTimeout);
36
+ this.pendingRequests.set(id, { resolve, reject, timer });
37
+ globalThis.postMessage(msg);
38
+ });
39
+ }
40
+ onNotification(method, handler) {
41
+ if (!this.notificationHandlers.has(method)) {
42
+ this.notificationHandlers.set(method, /* @__PURE__ */ new Set());
43
+ }
44
+ this.notificationHandlers.get(method).add(handler);
45
+ return () => {
46
+ this.notificationHandlers.get(method)?.delete(handler);
47
+ };
48
+ }
49
+ onRequest(method, handler) {
50
+ this.requestHandlers.set(method, handler);
51
+ }
52
+ destroy() {
53
+ this.destroyed = true;
54
+ if (this.messageListener) {
55
+ globalThis.removeEventListener("message", this.messageListener);
56
+ this.messageListener = null;
57
+ }
58
+ for (const { timer, reject } of this.pendingRequests.values()) {
59
+ if (timer) clearTimeout(timer);
60
+ reject(new Error("Bridge destroyed"));
61
+ }
62
+ this.pendingRequests.clear();
63
+ this.notificationHandlers.clear();
64
+ this.requestHandlers.clear();
65
+ }
66
+ handleMessage(event) {
67
+ if (this.destroyed) return;
68
+ const data = event.data;
69
+ if (!data || typeof data !== "object" || data.jsonrpc !== "2.0") return;
70
+ if ("id" in data && !("method" in data)) {
71
+ const pending = this.pendingRequests.get(data.id);
72
+ if (pending) {
73
+ this.pendingRequests.delete(data.id);
74
+ if (pending.timer) clearTimeout(pending.timer);
75
+ if (data.error) {
76
+ pending.reject(new Error(data.error.message));
77
+ } else {
78
+ pending.resolve(data.result);
79
+ }
80
+ }
81
+ return;
82
+ }
83
+ if ("id" in data && "method" in data) {
84
+ const handler = this.requestHandlers.get(data.method);
85
+ if (handler) {
86
+ handler(data.params).then((result) => {
87
+ const response = { jsonrpc: "2.0", id: data.id, result };
88
+ globalThis.postMessage(response);
89
+ }).catch((err) => {
90
+ const response = {
91
+ jsonrpc: "2.0",
92
+ id: data.id,
93
+ error: { code: -32603, message: err.message }
94
+ };
95
+ globalThis.postMessage(response);
96
+ });
97
+ }
98
+ return;
99
+ }
100
+ if ("method" in data && !("id" in data)) {
101
+ const handlers = this.notificationHandlers.get(data.method);
102
+ if (handlers) {
103
+ for (const handler of handlers) {
104
+ handler(data.params);
105
+ }
106
+ }
107
+ }
108
+ }
109
+ };
110
+
111
+ // src/renderer/callback-registry.ts
112
+ var CallbackRegistry = class {
113
+ handlers = /* @__PURE__ */ new Map();
114
+ counter = 0;
115
+ register(fn) {
116
+ const id = `cb_${++this.counter}`;
117
+ this.handlers.set(id, fn);
118
+ return id;
119
+ }
120
+ invoke(id, args) {
121
+ const handler = this.handlers.get(id);
122
+ if (!handler) {
123
+ throw new Error(`CallbackRegistry: no handler for ID "${id}"`);
124
+ }
125
+ return handler(...args);
126
+ }
127
+ update(id, fn) {
128
+ if (!this.handlers.has(id)) {
129
+ throw new Error(`CallbackRegistry: cannot update non-existent handler "${id}"`);
130
+ }
131
+ this.handlers.set(id, fn);
132
+ }
133
+ remove(id) {
134
+ this.handlers.delete(id);
135
+ }
136
+ has(id) {
137
+ return this.handlers.has(id);
138
+ }
139
+ clear() {
140
+ this.handlers.clear();
141
+ }
142
+ get size() {
143
+ return this.handlers.size;
144
+ }
145
+ };
146
+
147
+ // src/renderer/bridge-context.ts
148
+ var bridgeInstance = null;
149
+ var registryInstance = null;
150
+ var bridgeFactory = null;
151
+ var initParams = null;
152
+ function getBridge() {
153
+ if (!bridgeInstance) {
154
+ bridgeInstance = bridgeFactory ? bridgeFactory() : new WorkerBridge();
155
+ }
156
+ return bridgeInstance;
157
+ }
158
+ function getRegistry() {
159
+ if (!registryInstance) {
160
+ registryInstance = new CallbackRegistry();
161
+ }
162
+ return registryInstance;
163
+ }
164
+ function setInitParams(params) {
165
+ const rest = { ...params };
166
+ delete rest.bundleCode;
167
+ delete rest.bundleUrl;
168
+ initParams = rest;
169
+ }
170
+ function getInitParams() {
171
+ return initParams;
172
+ }
173
+ function setBridgeFactory(factory) {
174
+ bridgeFactory = factory;
175
+ }
176
+ function destroyBridgeContext() {
177
+ if (bridgeInstance) {
178
+ bridgeInstance.destroy();
179
+ bridgeInstance = null;
180
+ }
181
+ if (registryInstance) {
182
+ registryInstance.clear();
183
+ registryInstance = null;
184
+ }
185
+ initParams = null;
186
+ }
187
+
188
+ export {
189
+ WorkerBridge,
190
+ getBridge,
191
+ getRegistry,
192
+ setInitParams,
193
+ getInitParams,
194
+ setBridgeFactory,
195
+ destroyBridgeContext
196
+ };
package/dist/host.d.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { LifecycleInitParams } from '@zaflun/lumio-protocol';
2
+ import { ReactElement } from 'react';
3
+
4
+ type AnyFunction = (...args: unknown[]) => unknown;
5
+ declare class CallbackRegistry {
6
+ private handlers;
7
+ private counter;
8
+ register(fn: AnyFunction): string;
9
+ invoke(id: string, args: unknown[]): unknown;
10
+ update(id: string, fn: AnyFunction): void;
11
+ remove(id: string): void;
12
+ has(id: string): boolean;
13
+ clear(): void;
14
+ get size(): number;
15
+ }
16
+
17
+ /**
18
+ * `lifecycle.init` as the host sent it, minus the bundle. Hosts may add fields
19
+ * beyond the protocol type (`settings`, `permissions`, …).
20
+ */
21
+ type InitParams = Partial<LifecycleInitParams> & Record<string, unknown>;
22
+ /**
23
+ * The JSON-RPC transport every hook talks to. In production it is the
24
+ * `WorkerBridge` (postMessage inside the extension Worker); the devkit's mock
25
+ * host plugs in an in-process transport through {@link setBridgeFactory}.
26
+ */
27
+ interface LumioBridge {
28
+ notify(method: string, params?: unknown): void;
29
+ request(method: string, params?: unknown): Promise<unknown>;
30
+ onNotification(method: string, handler: (params: unknown) => void): () => void;
31
+ onRequest(method: string, handler: (params: unknown) => Promise<unknown>): void;
32
+ destroy(): void;
33
+ }
34
+ declare function getBridge(): LumioBridge;
35
+ declare function getRegistry(): CallbackRegistry;
36
+ /**
37
+ * Records the `lifecycle.init` params. The worker entry calls this before it
38
+ * evaluates the bundle: hooks subscribe in an effect, after the first render,
39
+ * so they would miss the one-shot notification and read it from here instead.
40
+ */
41
+ declare function setInitParams(params: Partial<LifecycleInitParams>): void;
42
+ /**
43
+ * Replaces the transport `getBridge()` creates; `null` restores the default
44
+ * `WorkerBridge`. Takes effect for the next bridge, so call it before
45
+ * anything renders or after {@link destroyBridgeContext}.
46
+ */
47
+ declare function setBridgeFactory(factory: (() => LumioBridge) | null): void;
48
+ declare function destroyBridgeContext(): void;
49
+
50
+ interface MountRootOptions {
51
+ /**
52
+ * Commit updates from outside React in a microtask (see
53
+ * `HostConfigOptions.syncUpdates`). Used by the devkit's mock host.
54
+ */
55
+ syncUpdates?: boolean;
56
+ }
57
+ /** A rendered extension root on the current bridge. */
58
+ interface MountedRoot {
59
+ /** Runs `fn` and commits the updates it schedules before returning. */
60
+ flushSync(fn: () => void): void;
61
+ /** Unmounts the tree and stops listening for `event.callback`. */
62
+ unmount(): void;
63
+ }
64
+ /**
65
+ * Renders `element` with the Lumio reconciler: every commit sends the
66
+ * serialized tree as `ui.render`, the first one is followed by `ui.ready`, and
67
+ * `event.callback` invokes the registered handler. `Lumio.render()` in the
68
+ * Worker runtime calls it once; the devkit mounts a fresh root per test.
69
+ */
70
+ declare function mountRoot(element: ReactElement, options?: MountRootOptions): MountedRoot;
71
+
72
+ export { type InitParams, type LumioBridge, type MountRootOptions, type MountedRoot, destroyBridgeContext, getBridge, getRegistry, mountRoot, setBridgeFactory, setInitParams };
package/dist/host.js ADDED
@@ -0,0 +1,340 @@
1
+ import {
2
+ destroyBridgeContext,
3
+ getBridge,
4
+ getRegistry,
5
+ setBridgeFactory,
6
+ setInitParams
7
+ } from "./chunk-GVAYWB5X.js";
8
+
9
+ // src/renderer/render.ts
10
+ import { createElement } from "react";
11
+ import Reconciler from "react-reconciler";
12
+ import { HOST_METHODS } from "@zaflun/lumio-protocol";
13
+
14
+ // src/renderer/reconciler.ts
15
+ var instanceCounter = 0;
16
+ var STRIP_PROPS = /* @__PURE__ */ new Set(["children", "key", "ref", "__self", "__source"]);
17
+ function sanitizeProps(rawProps, registry, existingHandlerIds) {
18
+ const sanitized = {};
19
+ for (const [key, value] of Object.entries(rawProps)) {
20
+ if (STRIP_PROPS.has(key)) continue;
21
+ if (typeof value === "symbol") continue;
22
+ if (typeof value === "function") {
23
+ const handlerKey = `__handler_${key}`;
24
+ const existingId = existingHandlerIds?.[key];
25
+ if (existingId && registry.has(existingId)) {
26
+ registry.update(existingId, value);
27
+ sanitized[handlerKey] = existingId;
28
+ } else {
29
+ const id = registry.register(value);
30
+ sanitized[handlerKey] = id;
31
+ }
32
+ continue;
33
+ }
34
+ sanitized[key] = value;
35
+ }
36
+ return sanitized;
37
+ }
38
+ function createLumioInstance(type, rawProps, registry) {
39
+ const sanitized = sanitizeProps(rawProps, registry);
40
+ const handlerIds = {};
41
+ for (const [key, value] of Object.entries(sanitized)) {
42
+ if (key.startsWith("__handler_")) {
43
+ const propName = key.slice("__handler_".length);
44
+ handlerIds[propName] = value;
45
+ }
46
+ }
47
+ return {
48
+ _id: `lumio_${++instanceCounter}`,
49
+ type,
50
+ props: sanitized,
51
+ children: [],
52
+ parent: null,
53
+ handlerIds
54
+ };
55
+ }
56
+ function serializeTree(instances) {
57
+ return instances.map((instance) => ({
58
+ _id: instance._id,
59
+ type: instance.type,
60
+ props: { ...instance.props },
61
+ children: serializeTree(instance.children)
62
+ }));
63
+ }
64
+ function treeFingerprint(nodes) {
65
+ return JSON.stringify(nodes, (key, value) => {
66
+ if (typeof key === "string" && key.startsWith("__handler_")) return void 0;
67
+ return value;
68
+ });
69
+ }
70
+
71
+ // src/renderer/host-config.ts
72
+ var NO_EVENT_PRIORITY = 0;
73
+ var DISCRETE_EVENT_PRIORITY = 2;
74
+ var LEGACY_UPDATE_PRIORITY = 16;
75
+ function createHostConfig(registry, options = {}) {
76
+ const syncUpdates = options.syncUpdates === true;
77
+ let currentUpdatePriority = NO_EVENT_PRIORITY;
78
+ return {
79
+ supportsMutation: true,
80
+ supportsPersistence: false,
81
+ supportsHydration: false,
82
+ isPrimaryRenderer: true,
83
+ warnsIfNotActing: false,
84
+ scheduleTimeout: setTimeout,
85
+ cancelTimeout: clearTimeout,
86
+ noTimeout: -1,
87
+ supportsMicrotasks: true,
88
+ scheduleMicrotask: queueMicrotask,
89
+ getRootHostContext() {
90
+ return {};
91
+ },
92
+ getChildHostContext(_parentCtx) {
93
+ return {};
94
+ },
95
+ getPublicInstance(instance) {
96
+ return instance;
97
+ },
98
+ createInstance(type, props) {
99
+ return createLumioInstance(type, props, registry);
100
+ },
101
+ createTextInstance(text) {
102
+ return createLumioInstance("Text", { content: text }, registry);
103
+ },
104
+ appendInitialChild(parent, child) {
105
+ child.parent = parent;
106
+ parent.children.push(child);
107
+ },
108
+ appendChild(parent, child) {
109
+ child.parent = parent;
110
+ parent.children.push(child);
111
+ },
112
+ appendChildToContainer(container, child) {
113
+ child.parent = null;
114
+ container.children.push(child);
115
+ },
116
+ removeChild(parent, child) {
117
+ const idx = parent.children.indexOf(child);
118
+ if (idx !== -1) {
119
+ parent.children.splice(idx, 1);
120
+ }
121
+ child.parent = null;
122
+ removeHandlersRecursive(child, registry);
123
+ },
124
+ removeChildFromContainer(container, child) {
125
+ const idx = container.children.indexOf(child);
126
+ if (idx !== -1) {
127
+ container.children.splice(idx, 1);
128
+ }
129
+ child.parent = null;
130
+ removeHandlersRecursive(child, registry);
131
+ },
132
+ insertBefore(parent, child, before) {
133
+ const idx = parent.children.indexOf(before);
134
+ if (idx !== -1) {
135
+ parent.children.splice(idx, 0, child);
136
+ } else {
137
+ parent.children.push(child);
138
+ }
139
+ child.parent = parent;
140
+ },
141
+ insertInContainerBefore(container, child, before) {
142
+ const idx = container.children.indexOf(before);
143
+ if (idx !== -1) {
144
+ container.children.splice(idx, 0, child);
145
+ } else {
146
+ container.children.push(child);
147
+ }
148
+ child.parent = null;
149
+ },
150
+ commitUpdate(instance, _type, _prevProps, nextProps) {
151
+ const sanitized = sanitizeProps(
152
+ nextProps,
153
+ registry,
154
+ instance.handlerIds
155
+ );
156
+ for (const [key, value] of Object.entries(sanitized)) {
157
+ if (key.startsWith("__handler_")) {
158
+ const propName = key.slice("__handler_".length);
159
+ instance.handlerIds[propName] = value;
160
+ }
161
+ }
162
+ instance.props = sanitized;
163
+ },
164
+ commitTextUpdate(instance, _oldText, newText) {
165
+ instance.props.content = newText;
166
+ },
167
+ finalizeInitialChildren() {
168
+ return false;
169
+ },
170
+ shouldSetTextContent() {
171
+ return false;
172
+ },
173
+ prepareForCommit() {
174
+ return null;
175
+ },
176
+ resetAfterCommit(container) {
177
+ container.onCommit();
178
+ },
179
+ clearContainer(container) {
180
+ container.children.length = 0;
181
+ },
182
+ preparePortalMount() {
183
+ },
184
+ detachDeletedInstance() {
185
+ },
186
+ getInstanceFromNode() {
187
+ return null;
188
+ },
189
+ beforeActiveInstanceBlur() {
190
+ },
191
+ afterActiveInstanceBlur() {
192
+ },
193
+ prepareScopeUpdate() {
194
+ },
195
+ getInstanceFromScope() {
196
+ return null;
197
+ },
198
+ // Priority methods required by react-reconciler 0.32
199
+ setCurrentUpdatePriority(priority) {
200
+ if (syncUpdates) currentUpdatePriority = priority;
201
+ },
202
+ getCurrentUpdatePriority() {
203
+ return syncUpdates ? currentUpdatePriority : LEGACY_UPDATE_PRIORITY;
204
+ },
205
+ resolveUpdatePriority() {
206
+ if (!syncUpdates) return LEGACY_UPDATE_PRIORITY;
207
+ return currentUpdatePriority !== NO_EVENT_PRIORITY ? currentUpdatePriority : DISCRETE_EVENT_PRIORITY;
208
+ },
209
+ // Transition support (not used — Lumio SDK does not support transitions)
210
+ NotPendingTransition: null,
211
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
212
+ HostTransitionContext: { $$typeof: /* @__PURE__ */ Symbol.for("react.context") },
213
+ // Form instance
214
+ resetFormInstance() {
215
+ },
216
+ // Post paint callback
217
+ requestPostPaintCallback() {
218
+ },
219
+ // Eager transition
220
+ shouldAttemptEagerTransition() {
221
+ return false;
222
+ },
223
+ // Suspend commit methods
224
+ maySuspendCommit() {
225
+ return false;
226
+ },
227
+ preloadInstance() {
228
+ return true;
229
+ },
230
+ startSuspendingCommit() {
231
+ },
232
+ suspendInstance() {
233
+ },
234
+ waitForCommitToBeReady() {
235
+ return null;
236
+ },
237
+ // Scheduler event tracking
238
+ trackSchedulerEvent() {
239
+ },
240
+ // Event resolution
241
+ resolveEventType() {
242
+ return null;
243
+ },
244
+ resolveEventTimeStamp() {
245
+ return Date.now();
246
+ }
247
+ };
248
+ }
249
+ function removeHandlersRecursive(instance, registry) {
250
+ for (const handlerId of Object.values(instance.handlerIds)) {
251
+ registry.remove(handlerId);
252
+ }
253
+ for (const child of instance.children) {
254
+ removeHandlersRecursive(child, registry);
255
+ }
256
+ }
257
+
258
+ // src/renderer/render.ts
259
+ function mountRoot(element, options = {}) {
260
+ const registry = getRegistry();
261
+ const bridge = getBridge();
262
+ const hostConfig = createHostConfig(registry, { syncUpdates: options.syncUpdates });
263
+ const reconciler = Reconciler(hostConfig);
264
+ const { flushSyncFromReconciler } = reconciler;
265
+ let lastFingerprint = "";
266
+ const container = {
267
+ children: [],
268
+ onCommit() {
269
+ const tree = serializeTree(container.children);
270
+ const fingerprint = treeFingerprint(tree);
271
+ if (fingerprint === lastFingerprint) return;
272
+ lastFingerprint = fingerprint;
273
+ bridge.notify("ui.render", { tree });
274
+ }
275
+ };
276
+ const root = reconciler.createContainer(
277
+ container,
278
+ 0,
279
+ // LegacyRoot tag
280
+ null,
281
+ // hydration callbacks
282
+ false,
283
+ // isStrictMode
284
+ null,
285
+ // concurrentUpdatesByDefaultOverride
286
+ "",
287
+ // identifierPrefix
288
+ (_error) => {
289
+ console.error("[Lumio SDK] Uncaught error:", _error);
290
+ },
291
+ (_error) => {
292
+ console.error("[Lumio SDK] Caught error:", _error);
293
+ },
294
+ (_error) => {
295
+ console.error("[Lumio SDK] Recoverable error:", _error);
296
+ },
297
+ () => {
298
+ },
299
+ // onDefaultTransitionIndicator
300
+ null
301
+ // transitionCallbacks
302
+ );
303
+ const stopCallbacks = bridge.onNotification(HOST_METHODS.EVENT_CALLBACK, (params) => {
304
+ const { handlerId, args } = params;
305
+ try {
306
+ registry.invoke(handlerId, args);
307
+ } catch {
308
+ }
309
+ });
310
+ const flushSync = (fn) => {
311
+ if (options.syncUpdates) {
312
+ flushSyncFromReconciler(fn);
313
+ reconciler.flushPassiveEffects();
314
+ } else {
315
+ fn();
316
+ }
317
+ };
318
+ flushSync(() => {
319
+ reconciler.updateContainer(element, root, null, () => {
320
+ bridge.notify("ui.ready", {});
321
+ });
322
+ });
323
+ return {
324
+ flushSync,
325
+ unmount() {
326
+ stopCallbacks();
327
+ flushSync(() => {
328
+ reconciler.updateContainer(null, root, null, null);
329
+ });
330
+ }
331
+ };
332
+ }
333
+ export {
334
+ destroyBridgeContext,
335
+ getBridge,
336
+ getRegistry,
337
+ mountRoot,
338
+ setBridgeFactory,
339
+ setInitParams
340
+ };