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