Experiment to rebuild Diffuse using web applets.
0

Configure Feed

Select the types of activity you want to include in your feed.

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}