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 91//////////////////////////////////////////// 92// 🪟 Applet registration 93//////////////////////////////////////////// 94export type BroadcastedApplet<T> = { 95 groupId: string | undefined; 96 scope: AppletScope<T>; 97 98 settled(): Promise<void>; 99 100 get instanceId(): string; 101 set data(data: T); 102 103 codec: { 104 decode(data: any): T; 105 encode(data: T): any; 106 }; 107 108 isMainInstance(): boolean; 109 setActionHandler<H extends Function>(actionId: string, actionHandler: H): void; 110}; 111 112export function register<DataType = any>( 113 options: { worker?: Comlink.Remote<WorkerTasks> } = {}, 114): BroadcastedApplet<DataType> { 115 const url = new URL(location.href); 116 const scope = applets.register<DataType>(); 117 118 const groupId = url.searchParams.get("groupId") || "main"; 119 const channelId = `${location.host}${location.pathname}/${groupId}`; 120 const instanceId = crypto.randomUUID(); 121 122 let isMainInstance = true; 123 124 // One instance to rule them all 125 // 126 // Ping other instances to see if there are any. 127 // As long as there aren't any, it is considered the main instance. 128 // 129 // Actions are performed on the main instance, 130 // and data is replicated from main to the other instances. 131 const channel = new BroadcastChannel(channelId); 132 133 channel.addEventListener("message", async (event) => { 134 switch (event.data?.type) { 135 case "PING": { 136 channel.postMessage({ 137 type: "PONG", 138 instanceId: event.data.instanceId, 139 }); 140 141 if (isMainInstance) { 142 channel.postMessage({ 143 type: "data", 144 data: context.codec.encode(scope.data), 145 }); 146 } 147 break; 148 } 149 150 case "PONG": { 151 if (event.data.instanceId === instanceId) { 152 isMainInstance = false; 153 } 154 break; 155 } 156 157 case "action": { 158 if (isMainInstance) { 159 const result = await scope.actionHandlers[event.data.actionId]?.(...event.data.arguments); 160 channel.postMessage({ 161 type: "actioncomplete", 162 actionInstanceId: event.data.actionInstanceId, 163 result, 164 }); 165 } 166 break; 167 } 168 169 case "data": { 170 scope.data = context.codec.decode(event.data.data); 171 break; 172 } 173 } 174 }); 175 176 // Promise that fullfills whenever it figures out its the main instance or not. 177 function makeMainPromise() { 178 return new Promise<{ isMain: boolean }>((resolve) => { 179 const timeoutId = setTimeout(() => { 180 channel.removeEventListener("message", handler); 181 resolve({ isMain: true }); 182 }, 1000); 183 184 const handler = (event: MessageEvent) => { 185 if (event.data === "pong" || event.data === "ping") { 186 clearTimeout(timeoutId); 187 channel.removeEventListener("message", handler); 188 resolve({ isMain: false }); 189 } 190 }; 191 192 channel.addEventListener("message", handler); 193 }); 194 } 195 196 const promise = makeMainPromise(); 197 198 // Send out ping 199 channel.postMessage({ 200 type: "PING", 201 instanceId, 202 }); 203 204 // If the data on the main instance changes, 205 // pass it on to other instances. 206 scope.addEventListener("data", async (event: AppletEvent) => { 207 await promise; 208 209 if (isMainInstance) { 210 channel.postMessage({ 211 type: "data", 212 data: context.codec.encode(event.data), 213 }); 214 } 215 }); 216 217 // Context 218 const context: BroadcastedApplet<DataType> = { 219 groupId, 220 scope, 221 222 settled() { 223 return promise.then(() => {}); 224 }, 225 226 get instanceId() { 227 return instanceId; 228 }, 229 230 get data() { 231 return scope.data; 232 }, 233 234 set data(data: DataType) { 235 scope.data = data; 236 }, 237 238 codec: { 239 decode: (data: any) => data as DataType, 240 encode: (data: DataType) => data as any, 241 }, 242 243 isMainInstance() { 244 return isMainInstance; 245 }, 246 247 setActionHandler: <H extends Function>(actionId: string, actionHandler: H) => { 248 const handler = async (...args: any) => { 249 if (isMainInstance) { 250 return actionHandler(...args); 251 } 252 253 // Check if a main instance is still available, 254 // if not, then this is the new main. 255 const { isMain } = await makeMainPromise(); 256 isMainInstance = isMain; 257 258 if (isMainInstance) { 259 return actionHandler(...args); 260 } 261 262 const actionMessage = { 263 actionInstanceId: crypto.randomUUID(), 264 actionId, 265 type: "action", 266 arguments: args, 267 }; 268 269 return await new Promise((resolve) => { 270 const actionCallback = (event: MessageEvent) => { 271 if ( 272 event.data?.type === "actioncomplete" && 273 event.data?.actionInstanceId === actionMessage.actionInstanceId 274 ) { 275 channel.removeEventListener("message", actionCallback); 276 resolve(event.data.result); 277 } 278 }; 279 280 channel.addEventListener("message", actionCallback); 281 channel.postMessage(actionMessage); 282 }); 283 }; 284 285 scope.setActionHandler(actionId, handler); 286 }, 287 }; 288 289 if (options.worker !== undefined) 290 context.scope.onworkerport = (event) => { 291 if (!event.port) return; 292 options.worker?.listenForActions(transfer(event.port)); 293 }; 294 295 return context; 296} 297 298//////////////////////////////////////////// 299// 🔮 Reactive state management 300//////////////////////////////////////////// 301export function reactive<D, T>( 302 applet: Applet<D> | AppletScope<D>, 303 dataFn: (data: D) => T, 304 effectFn: (t: T) => void, 305) { 306 let value = dataFn(applet.data); 307 effectFn(value); 308 309 applet.addEventListener("data", (event: AppletEvent) => { 310 const newData = dataFn(event.data); 311 if (newData !== value) { 312 value = newData; 313 effectFn(value); 314 } 315 }); 316} 317 318export function makeConnect<X>(context: BroadcastedApplet<X>) { 319 return <D, T>(applet: Applet<D>, dataFn: (data: D) => T, effectFn: (t: T) => void) => { 320 return reactive(applet, dataFn, (t: T) => { 321 if (context.isMainInstance()) effectFn(t); 322 }); 323 }; 324} 325 326//////////////////////////////////////////// 327// ⚡️ COMMON ACTION CALLS 328//////////////////////////////////////////// 329 330export async function inputUrl(input: Applet, uri: string, method = "GET") { 331 return await input.sendAction<ResolvedUri>( 332 "resolve", 333 { 334 method, 335 uri, 336 }, 337 { 338 timeoutDuration: 60000 * 5, 339 worker: true, 340 }, 341 ); 342} 343 344//////////////////////////////////////////// 345// 🛠️ 346//////////////////////////////////////////// 347export function addScope<O extends object>(astroScope: string, object: O): O { 348 return { 349 ...object, 350 attrs: { 351 ...((object as any).attrs || {}), 352 [`data-astro-cid-${astroScope}`]: "", 353 }, 354 }; 355} 356 357export function appletScopePort() { 358 let port: MessagePort | undefined; 359 360 function connection(event: AppletEvent) { 361 if (event.data?.type === "appletconnect") { 362 window.removeEventListener("message", connection); 363 port = (event as any).ports[0]; 364 } 365 } 366 367 window.addEventListener("message", connection); 368 369 return () => port; 370} 371 372export function hs( 373 tag: string, 374 astroScope: string, 375 props?: Record<string, unknown> | Signal<Record<string, unknown>>, 376 configure?: ElementConfigurator, 377) { 378 const propsWithScope = 379 props && isSignal(props) 380 ? () => addScope(astroScope, props()) 381 : addScope(astroScope, props || {}); 382 383 return h(tag, propsWithScope, configure); 384} 385 386export function wait<A>(applet: Applet<A>, dataFn: (a: A | undefined) => boolean): Promise<void> { 387 return new Promise((resolve) => { 388 if (dataFn(applet.data) === true) { 389 resolve(); 390 return; 391 } 392 393 const callback = (event: AppletEvent) => { 394 if (dataFn(event.data) === true) { 395 applet.removeEventListener("data", callback); 396 resolve(); 397 } 398 }; 399 400 applet.addEventListener("data", callback); 401 }); 402}