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