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 } 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}