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