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