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"; 2import * as Comlink from "comlink"; 3 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 QS from "query-string"; 8 9import type { ResolvedUri } from "@applets/core/types"; 10import { transfer, type WorkerTasks } from "@scripts/common"; 11 12//////////////////////////////////////////// 13// 🪟 Applet connecting 14//////////////////////////////////////////// 15export async function applet<D>( 16 src: string, 17 opts: { 18 addSlashSuffix?: boolean; 19 container?: HTMLElement | Element; 20 context?: Window; 21 frameId?: string; 22 groupId?: string; 23 newInstance?: boolean; 24 setHeight?: boolean; 25 } = {}, 26): Promise<Applet<D>> { 27 src = `${src}${ 28 src.endsWith("/") 29 ? "" 30 : opts.addSlashSuffix === undefined || opts.addSlashSuffix === true 31 ? "/" 32 : "" 33 }`; 34 35 let query: undefined | Record<string, string>; 36 37 if (opts.groupId) { 38 query = { groupId: opts.groupId }; 39 } 40 41 if (query) { 42 src = QS.stringifyUrl({ url: src, query }); 43 } 44 45 const context = opts.newInstance ? self : opts.context || self.top || self.parent; 46 const existingFrame: HTMLIFrameElement | null = opts.newInstance 47 ? null 48 : context.document.querySelector(`[src="${src}"]`); 49 50 let frame; 51 52 if (existingFrame) { 53 frame = existingFrame; 54 } else { 55 frame = document.createElement("iframe"); 56 frame.loading = "eager"; 57 frame.src = src; 58 if (opts.frameId) frame.id = opts.frameId; 59 60 if (opts.container) { 61 opts.container.appendChild(frame); 62 } else { 63 context.document.body.appendChild(frame); 64 } 65 } 66 67 if (frame.contentWindow === null) { 68 throw new Error("iframe does not have a contentWindow"); 69 } 70 71 const applet = await applets.connect<D>(frame.contentWindow, { context }).catch((err) => { 72 console.error("Error connecting to " + src, err); 73 throw err; 74 }); 75 76 if (opts.setHeight) { 77 applet.onresize = () => { 78 frame.height = `${applet.height}px`; 79 frame.classList.add("has-loaded"); 80 }; 81 } else { 82 if (frame.contentDocument?.readyState === "complete") { 83 frame.classList.add("has-loaded"); 84 } 85 86 frame.addEventListener("load", () => { 87 frame.classList.add("has-loaded"); 88 }); 89 } 90 91 return applet; 92} 93 94export function tunnel( 95 worker: Comlink.Remote<WorkerTasks>, 96 connections: Record<string, Applet | Promise<Applet>>, 97) { 98 Object.entries(connections).forEach(([scheme, promise]) => { 99 Promise.resolve(promise).then((conn) => { 100 return worker._manage(scheme, transfer(conn.ports.worker)); 101 }); 102 }); 103} 104 105//////////////////////////////////////////// 106// 🪟 Applet registration 107//////////////////////////////////////////// 108export type DiffuseApplet<T> = { 109 groupId: string | undefined; 110 scope: AppletScope<T>; 111 112 settled(): Promise<void>; 113 114 get instanceId(): string; 115 set data(data: T); 116 117 codec: Codec<T>; 118 119 isMainInstance(): boolean | null; 120 setActionHandler<H extends Function>(actionId: string, actionHandler: H): void; 121}; 122 123export type Codec<T> = { 124 decode(data: any): T; 125 encode(data: T): any; 126}; 127 128export function lookupGroupId() { 129 const url = new URL(location.href); 130 return url.searchParams.get("groupId") || "main"; 131} 132 133export function register<DataType = any>( 134 options: { mode?: "broadcast" | "shared-worker"; worker?: Comlink.Remote<WorkerTasks> } = {}, 135): DiffuseApplet<DataType> { 136 const mode = options.mode ?? "broadcast"; 137 const scope = applets.register<DataType>(); 138 139 const groupId = lookupGroupId(); 140 const channelId = `${location.host}${location.pathname}/${groupId}`; 141 const instanceId = crypto.randomUUID(); 142 143 // Codec 144 const codec = { 145 decode: (data: any) => data as DataType, 146 encode: (data: DataType) => data as any, 147 }; 148 149 // Channel 150 const channelContext = 151 mode === "broadcast" 152 ? broadcastChannel({ 153 channelId, 154 codec, 155 instanceId, 156 scope, 157 }) 158 : undefined; 159 160 // Context 161 const context: DiffuseApplet<DataType> = { 162 groupId, 163 scope, 164 165 settled() { 166 return channelContext?.promise.then(() => {}) ?? Promise.resolve(); 167 }, 168 169 get instanceId() { 170 return instanceId; 171 }, 172 173 get data() { 174 return scope.data; 175 }, 176 177 set data(data: DataType) { 178 scope.data = data; 179 }, 180 181 codec, 182 183 isMainInstance() { 184 return channelContext?.mainSignal[0]() ?? null; 185 }, 186 187 setActionHandler: <H extends Function>(actionId: string, actionHandler: H) => { 188 switch (mode) { 189 case "broadcast": 190 return channelContext?.setActionHandler(actionId, actionHandler); 191 192 case "shared-worker": 193 return scope.setActionHandler(actionId, actionHandler); 194 } 195 }, 196 }; 197 198 if (options.worker) { 199 context.scope.onworkerport = (event) => { 200 if (!event.port) return; 201 options.worker?._listen(transfer(event.port)); 202 }; 203 } 204 205 return context; 206} 207 208function broadcastChannel<DataType>({ 209 channelId, 210 codec, 211 instanceId, 212 scope, 213}: { 214 channelId: string; 215 codec: Codec<DataType>; 216 instanceId: string; 217 scope: AppletScope<DataType>; 218}) { 219 const mainSignal = signal<boolean>(true); 220 const [isMain, setIsMain] = mainSignal; 221 222 // One instance to rule them all 223 // 224 // Ping other instances to see if there are any. 225 // As long as there aren't any, it is considered the main instance. 226 // 227 // Actions are performed on the main instance, 228 // and data is replicated from main to the other instances. 229 const channel = new BroadcastChannel(channelId); 230 231 channel.addEventListener("message", async (event) => { 232 switch (event.data?.type) { 233 case "PING": { 234 channel.postMessage({ 235 type: "PONG", 236 instanceId: event.data.instanceId, 237 }); 238 239 if (isMain()) { 240 channel.postMessage({ 241 type: "data", 242 data: codec.encode(scope.data), 243 }); 244 } 245 break; 246 } 247 248 case "PONG": { 249 if (event.data.instanceId === instanceId) { 250 setIsMain(false); 251 } 252 break; 253 } 254 255 case "action": { 256 if (isMain()) { 257 const result = await scope.actionHandlers[event.data.actionId]?.(...event.data.arguments); 258 channel.postMessage({ 259 type: "actioncomplete", 260 actionInstanceId: event.data.actionInstanceId, 261 result, 262 }); 263 } 264 break; 265 } 266 267 case "data": { 268 scope.data = codec.decode(event.data.data); 269 break; 270 } 271 } 272 }); 273 274 // Promise that fullfills whenever it figures out its the main instance or not. 275 function makeMainPromise() { 276 return new Promise<{ isMain: boolean }>((resolve) => { 277 const timeoutId = setTimeout(() => { 278 channel.removeEventListener("message", handler); 279 resolve({ isMain: true }); 280 }, 5000); 281 282 const handler = (event: MessageEvent) => { 283 if (event.data?.type === "PONG" || event.data?.type === "PING") { 284 clearTimeout(timeoutId); 285 channel.removeEventListener("message", handler); 286 resolve({ isMain: false }); 287 } 288 }; 289 290 channel.addEventListener("message", handler); 291 channel.postMessage({ 292 type: "PING", 293 instanceId, 294 }); 295 }); 296 } 297 298 const promise = makeMainPromise(); 299 300 // If the data on the main instance changes, 301 // pass it on to other instances. 302 scope.addEventListener("data", async (event: AppletEvent) => { 303 await promise; 304 305 if (isMain()) { 306 channel.postMessage({ 307 type: "data", 308 data: codec.encode(event.data), 309 }); 310 } 311 }); 312 313 // Action handler 314 const setActionHandler = <H extends Function>(actionId: string, actionHandler: H) => { 315 const handler = async (...args: any) => { 316 if (isMain()) { 317 return actionHandler(...args); 318 } 319 320 // Check if a main instance is still available, 321 // if not, then this is the new main. 322 const promised = await makeMainPromise(); 323 setIsMain(promised.isMain); 324 325 if (isMain()) { 326 return actionHandler(...args); 327 } 328 329 const actionMessage = { 330 actionInstanceId: crypto.randomUUID(), 331 actionId, 332 type: "action", 333 arguments: args, 334 }; 335 336 return await new Promise((resolve) => { 337 const actionCallback = (event: MessageEvent) => { 338 if ( 339 event.data?.type === "actioncomplete" && 340 event.data?.actionInstanceId === actionMessage.actionInstanceId 341 ) { 342 channel.removeEventListener("message", actionCallback); 343 resolve(event.data.result); 344 } 345 }; 346 347 channel.addEventListener("message", actionCallback); 348 channel.postMessage(actionMessage); 349 }); 350 }; 351 352 scope.setActionHandler(actionId, handler); 353 }; 354 355 // Fin 356 return { 357 channel, 358 mainSignal, 359 promise, 360 setActionHandler, 361 }; 362} 363 364//////////////////////////////////////////// 365// 🔮 Reactive state management 366//////////////////////////////////////////// 367export function reactive<D, T>( 368 applet: Applet<D> | AppletScope<D>, 369 dataFn: (data: D) => T, 370 effectFn: (t: T) => void, 371) { 372 let value = dataFn(applet.data); 373 effectFn(value); 374 375 applet.addEventListener("data", (event: AppletEvent) => { 376 const newData = dataFn(event.data); 377 if (newData !== value) { 378 value = newData; 379 effectFn(value); 380 } 381 }); 382} 383 384//////////////////////////////////////////// 385// ⚡️ COMMON ACTION CALLS 386//////////////////////////////////////////// 387 388export async function inputUrl(input: Applet, uri: string, method = "GET") { 389 return await input.sendAction<ResolvedUri>( 390 "resolve", 391 { 392 method, 393 uri, 394 }, 395 { 396 timeoutDuration: 60000 * 5, 397 worker: true, 398 }, 399 ); 400} 401 402//////////////////////////////////////////// 403// 🛠️ 404//////////////////////////////////////////// 405export function addScope<O extends object>(astroScope: string, object: O): O { 406 return { 407 ...object, 408 attrs: { 409 ...((object as any).attrs || {}), 410 [`data-astro-cid-${astroScope}`]: "", 411 }, 412 }; 413} 414 415export function appletScopePort() { 416 let port: MessagePort | undefined; 417 418 function connection(event: AppletEvent) { 419 if (event.data?.type === "appletconnect") { 420 window.removeEventListener("message", connection); 421 port = (event as any).ports[0]; 422 } 423 } 424 425 window.addEventListener("message", connection); 426 427 return () => port; 428} 429 430export function hs( 431 tag: string, 432 astroScope: string, 433 props?: Record<string, unknown> | Signal<Record<string, unknown>>, 434 configure?: ElementConfigurator, 435) { 436 const propsWithScope = 437 props && isSignal(props) 438 ? () => addScope(astroScope, props()) 439 : addScope(astroScope, props || {}); 440 441 return h(tag, propsWithScope, configure); 442} 443 444export function wait<A>(applet: Applet<A>, dataFn: (a: A | undefined) => boolean): Promise<void> { 445 return new Promise((resolve) => { 446 if (dataFn(applet.data) === true) { 447 resolve(); 448 return; 449 } 450 451 const callback = (event: AppletEvent) => { 452 if (dataFn(event.data) === true) { 453 applet.removeEventListener("data", callback); 454 resolve(); 455 } 456 }; 457 458 applet.addEventListener("data", callback); 459 }); 460}