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