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