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 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 return undefined;
288 });
289
290 applet.addEventListener("data", (event: AppletEvent) => {
291 setter(dataFn(event.data));
292 });
293}
294
295////////////////////////////////////////////
296// ⚡️ COMMON ACTION CALLS
297////////////////////////////////////////////
298
299export async function inputUrl(input: Applet, uri: string, method = "GET") {
300 return await input.sendAction<ResolvedUri>(
301 "resolve",
302 {
303 method,
304 uri,
305 },
306 {
307 timeoutDuration: 60000 * 5,
308 },
309 );
310}
311
312////////////////////////////////////////////
313// 🛠️
314////////////////////////////////////////////
315export function addScope<O extends object>(astroScope: string, object: O): O {
316 return {
317 ...object,
318 attrs: {
319 ...((object as any).attrs || {}),
320 [`data-astro-cid-${astroScope}`]: "",
321 },
322 };
323}
324
325export function appletScopePort() {
326 let port: MessagePort | undefined;
327
328 function connection(event: AppletEvent) {
329 if (event.data?.type === "appletconnect") {
330 window.removeEventListener("message", connection);
331 port = (event as any).ports[0];
332 }
333 }
334
335 window.addEventListener("message", connection);
336
337 return () => port;
338}
339
340export function cleanUndefinedValuesForTracks(tracks: Track[]): Track[] {
341 return tracks.map((track) => {
342 const t = { ...track };
343
344 if (t.tags) {
345 if ("album" in t.tags && t.tags.album === undefined) delete t.tags.album;
346 if ("artist" in t.tags && t.tags.artist === undefined) delete t.tags.artist;
347 if ("genre" in t.tags && t.tags.genre === undefined) delete t.tags.genre;
348 if ("year" in t.tags && t.tags.year === undefined) delete t.tags.year;
349
350 if ("of" in t.tags.disc && t.tags.disc.of === undefined) delete t.tags.disc.of;
351 if ("of" in t.tags.track && t.tags.track.of === undefined) delete t.tags.track.of;
352 }
353
354 return t;
355 });
356}
357
358export function comparable(value: unknown) {
359 return xxh32(JSON.stringify(value));
360}
361
362export function hs(
363 tag: string,
364 astroScope: string,
365 props?: Record<string, unknown> | Signal<Record<string, unknown>>,
366 configure?: ElementConfigurator,
367) {
368 const propsWithScope =
369 props && isSignal(props)
370 ? () => addScope(astroScope, props())
371 : addScope(astroScope, props || {});
372
373 return h(tag, propsWithScope, configure);
374}
375
376export function isPrimitive(test: unknown) {
377 return test !== Object(test);
378}
379
380export function jsonDecode<T>(a: any): T {
381 return JSON.parse(new TextDecoder().decode(a));
382}
383
384export function jsonEncode<T>(a: T): Uint8Array {
385 return new TextEncoder().encode(JSON.stringify(a));
386}
387
388export async function trackArtworkCacheId(track: Track): Promise<string> {
389 return await crypto.subtle
390 .digest("SHA-256", new TextEncoder().encode(track.uri))
391 .then((a) => Uint8.toString(new Uint8Array(a), "base64url"));
392}
393
394export function wait<A>(applet: Applet<A>, dataFn: (a: A | undefined) => boolean): Promise<void> {
395 return new Promise((resolve) => {
396 if (dataFn(applet.data) === true) {
397 resolve();
398 return;
399 }
400
401 const callback = (event: AppletEvent) => {
402 if (dataFn(event.data) === true) {
403 applet.removeEventListener("data", callback);
404 resolve();
405 }
406 };
407
408 applet.addEventListener("data", callback);
409 });
410}