Experiment to rebuild Diffuse using web applets.
1import type { Applet, AppletEvent, AppletScope } from "@web-applets/sdk";
2
3import QS from "query-string";
4import { applets } from "@web-applets/sdk";
5import { type ElementConfigurator, h } from "spellcaster/hyperscript.js";
6import { effect, isSignal, Signal, signal } from "spellcaster/spellcaster.js";
7import { xxh32 } from "xxh32";
8import { Track } from "@applets/core/types";
9
10////////////////////////////////////////////
11// 🪟 Applet connector
12////////////////////////////////////////////
13export async function applet<D>(
14 src: string,
15 opts: {
16 addSlashSuffix?: boolean;
17 applets?: Record<string, string>;
18 container?: HTMLElement | Element;
19 id?: string;
20 setHeight?: boolean;
21 } = {},
22): Promise<Applet<D>> {
23 src = `${src}${
24 src.endsWith("/")
25 ? ""
26 : opts.addSlashSuffix === undefined || opts.addSlashSuffix === true
27 ? "/"
28 : ""
29 }`;
30
31 if (opts.applets) {
32 src = QS.stringifyUrl({ url: src, query: opts.applets });
33 }
34
35 const existingFrame: HTMLIFrameElement | null = window.document.querySelector(`[src="${src}"]`);
36
37 let frame;
38
39 if (existingFrame) {
40 frame = existingFrame;
41 } else {
42 frame = document.createElement("iframe");
43 frame.src = src;
44 if (opts.id) frame.id = opts.id;
45
46 if (opts.container) {
47 opts.container.appendChild(frame);
48 } else {
49 window.document.body.appendChild(frame);
50 }
51 }
52
53 if (frame.contentWindow === null) {
54 throw new Error("iframe does not have a contentWindow");
55 }
56
57 const applet = await applets.connect<D>(frame.contentWindow).catch((err) => {
58 console.error("Error connecting to " + src, err);
59 throw err;
60 });
61
62 if (opts.setHeight) {
63 applet.onresize = () => {
64 frame.height = `${applet.height}px`;
65 frame.classList.add("has-loaded");
66 };
67 } else {
68 if (frame.contentDocument?.readyState === "complete") {
69 frame.classList.add("has-loaded");
70 }
71
72 frame.addEventListener("load", () => {
73 frame.classList.add("has-loaded");
74 });
75 }
76
77 return applet;
78}
79
80////////////////////////////////////////////
81// 🪟 Applet registration
82////////////////////////////////////////////
83export type BroadcastedApplet<T> = {
84 scope: AppletScope<T>;
85
86 settled(): Promise<void>;
87
88 get id(): string;
89 set data(data: T);
90
91 codec: {
92 decode(data: any): T;
93 encode(data: T): any;
94 };
95
96 isMainInstance(): boolean;
97 setActionHandler<H extends Function>(actionId: string, actionHandler: H): void;
98};
99
100export function register<DataType = any>() {
101 const channelId = `${location.host}${location.pathname}`;
102 const scope = applets.register<DataType>();
103 const id = crypto.randomUUID();
104
105 let isMainInstance = true;
106
107 // One instance to rule them all
108 //
109 // Ping other instances to see if there are any.
110 // As long as there aren't any, it is considered the main instance.
111 //
112 // Actions are performed on the main instance,
113 // and data is replicated from main to the other instances.
114 const channel = new BroadcastChannel(channelId);
115
116 channel.addEventListener("message", async (event) => {
117 switch (event.data?.type) {
118 case "PING": {
119 channel.postMessage({
120 type: "PONG",
121 id: event.data.id,
122 });
123
124 if (isMainInstance) {
125 channel.postMessage({
126 type: "data",
127 data: context.codec.encode(scope.data),
128 });
129 }
130 break;
131 }
132
133 case "PONG": {
134 if (event.data.id === id) {
135 isMainInstance = false;
136 }
137 break;
138 }
139
140 case "action": {
141 if (isMainInstance) {
142 const result = await scope.actionHandlers[event.data.actionId]?.(...event.data.arguments);
143 channel.postMessage({
144 type: "actioncomplete",
145 id: event.data.id,
146 result,
147 });
148 }
149 break;
150 }
151
152 case "data": {
153 scope.data = context.codec.decode(event.data.data);
154 break;
155 }
156 }
157 });
158
159 // Promise that fullfills whenever it figures out its the main instance or not.
160 const promise = new Promise<void>((resolve) => {
161 const id = setTimeout(() => {
162 channel.removeEventListener("message", handler);
163 resolve(undefined);
164 }, 1000);
165
166 const handler = (event: MessageEvent) => {
167 if (event.data === "pong" || event.data === "ping") {
168 clearTimeout(id);
169 channel.removeEventListener("message", handler);
170 resolve(undefined);
171 }
172 };
173
174 channel.addEventListener("message", handler);
175 });
176
177 // Send out ping
178 channel.postMessage({
179 type: "PING",
180 id,
181 });
182
183 // If the data on the main instance changes,
184 // pass it on to other instances.
185 scope.addEventListener("data", async (event: AppletEvent) => {
186 await promise;
187
188 if (isMainInstance) {
189 channel.postMessage({
190 type: "data",
191 data: context.codec.encode(event.data),
192 });
193 }
194 });
195
196 // Context
197 const context: BroadcastedApplet<DataType> = {
198 scope,
199
200 settled() {
201 return promise;
202 },
203
204 get id() {
205 return id;
206 },
207
208 get data() {
209 return scope.data;
210 },
211
212 set data(data: DataType) {
213 scope.data = data;
214 },
215
216 codec: {
217 decode: (data: any) => data as DataType,
218 encode: (data: DataType) => data as any,
219 },
220
221 isMainInstance() {
222 return isMainInstance;
223 },
224
225 setActionHandler: <H extends Function>(actionId: string, actionHandler: H) => {
226 const handler = (...args: any) => {
227 if (isMainInstance) {
228 return actionHandler(...args);
229 }
230
231 const actionMessage = {
232 id: crypto.randomUUID(),
233 type: "action",
234 actionId,
235 arguments: args,
236 };
237
238 return new Promise((resolve) => {
239 const actionCallback = (event: MessageEvent) => {
240 if (event.data?.type === "actioncomplete" && event.data?.id === actionMessage.id) {
241 channel.removeEventListener("message", actionCallback);
242 resolve(event.data.result);
243 }
244 };
245
246 channel.addEventListener("message", actionCallback);
247 channel.postMessage(actionMessage);
248 });
249 };
250
251 scope.setActionHandler(actionId, handler);
252 },
253 };
254
255 return context;
256}
257
258////////////////////////////////////////////
259// 🔮 Reactive state management
260////////////////////////////////////////////
261export function reactive<D, T>(
262 applet: Applet<D>,
263 dataFn: (data: D) => T,
264 effectFn: (t: T, setter: (t: T) => void) => void,
265) {
266 const [getter, setter] = signal(dataFn(applet.data));
267
268 effect(() => {
269 effectFn(getter(), setter);
270 return undefined;
271 });
272
273 applet.addEventListener("data", (event: AppletEvent) => {
274 setter(dataFn(event.data));
275 });
276}
277
278////////////////////////////////////////////
279// 🛠️
280////////////////////////////////////////////
281export function addScope<O extends object>(astroScope: string, object: O): O {
282 return {
283 ...object,
284 attrs: {
285 ...((object as any).attrs || {}),
286 [`data-astro-cid-${astroScope}`]: "",
287 },
288 };
289}
290
291export function appletScopePort() {
292 let port: MessagePort | undefined;
293
294 function connection(event: AppletEvent) {
295 if (event.data?.type === "appletconnect") {
296 window.removeEventListener("message", connection);
297 port = (event as any).ports[0];
298 }
299 }
300
301 window.addEventListener("message", connection);
302
303 return () => port;
304}
305
306export function cleanUndefinedValuesForTracks(tracks: Track[]): Track[] {
307 return tracks.map((track) => {
308 const t = { ...track };
309
310 if (t.tags) {
311 if ("album" in t.tags && t.tags.album === undefined) delete t.tags.album;
312 if ("artist" in t.tags && t.tags.artist === undefined) delete t.tags.artist;
313 if ("genre" in t.tags && t.tags.genre === undefined) delete t.tags.genre;
314 if ("year" in t.tags && t.tags.year === undefined) delete t.tags.year;
315
316 if ("of" in t.tags.disc && t.tags.disc.of === undefined) delete t.tags.disc.of;
317 if ("of" in t.tags.track && t.tags.track.of === undefined) delete t.tags.track.of;
318 }
319
320 return t;
321 });
322}
323
324export function comparable(value: unknown) {
325 return xxh32(JSON.stringify(value));
326}
327
328export function hs(
329 tag: string,
330 astroScope: string,
331 props?: Record<string, unknown> | Signal<Record<string, unknown>>,
332 configure?: ElementConfigurator,
333) {
334 const propsWithScope =
335 props && isSignal(props)
336 ? () => addScope(astroScope, props())
337 : addScope(astroScope, props || {});
338
339 return h(tag, propsWithScope, configure);
340}
341
342export function isPrimitive(test: unknown) {
343 return test !== Object(test);
344}
345
346export function jsonDecode<T>(a: any): T {
347 return JSON.parse(new TextDecoder().decode(a));
348}
349
350export function jsonEncode<T>(a: T): Uint8Array {
351 return new TextEncoder().encode(JSON.stringify(a));
352}
353
354export function waitUntilAppletData<A>(
355 applet: Applet<A>,
356 dataFn: (a: A | undefined) => boolean,
357): Promise<void> {
358 return new Promise((resolve) => {
359 if (dataFn(applet.data) === true) {
360 resolve();
361 return;
362 }
363
364 const callback = (event: AppletEvent) => {
365 if (dataFn(event.data) === true) {
366 applet.removeEventListener("data", callback);
367 resolve();
368 }
369 };
370
371 applet.addEventListener("data", callback);
372 });
373}
374
375export function waitUntilAppletIsReady(applet: Applet): Promise<void> {
376 return waitUntilAppletData(applet, (data) => !!data?.ready);
377}