Experiment to rebuild Diffuse using web applets.
at main 488 lines 12 kB view raw
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 query = { groupId: opts.groupId || "main" }; 37 38 if (query) { 39 src = QS.stringifyUrl({ url: src, query }); 40 } 41 42 const context = opts.newInstance ? self : opts.context || self.top || self.parent; 43 const existingFrame: HTMLIFrameElement | null = opts.newInstance 44 ? null 45 : 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 DiffuseApplet<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: Codec<T>; 115 unloadHandler?: () => void; 116 117 isMainInstance(): boolean | null; 118 setActionHandler<H extends Function>(actionId: string, actionHandler: H): void; 119}; 120 121export type Codec<T> = { 122 decode(data: any): T; 123 encode(data: T): any; 124}; 125 126export function lookupGroupId() { 127 const url = new URL(location.href); 128 return url.searchParams.get("groupId") || "main"; 129} 130 131export function register<DataType = any>( 132 options: { mode?: "broadcast" | "shared-worker"; worker?: Comlink.Remote<WorkerTasks> } = {}, 133): DiffuseApplet<DataType> { 134 const mode = options.mode ?? "broadcast"; 135 const scope = applets.register<DataType>(); 136 137 const groupId = lookupGroupId(); 138 const channelId = `${location.host}${location.pathname}/${groupId}`; 139 const instanceId = crypto.randomUUID(); 140 141 // Codec 142 const codec = { 143 decode: (data: any) => data as DataType, 144 encode: (data: DataType) => data as any, 145 }; 146 147 // Context 148 const context: DiffuseApplet<DataType> = { 149 groupId, 150 scope, 151 152 settled() { 153 return channelContext?.promise.then(() => {}) ?? Promise.resolve(); 154 }, 155 156 get instanceId() { 157 return instanceId; 158 }, 159 160 get data() { 161 return scope.data; 162 }, 163 164 set data(data: DataType) { 165 scope.data = data; 166 }, 167 168 codec, 169 170 isMainInstance() { 171 return channelContext?.mainSignal[0]() ?? null; 172 }, 173 174 setActionHandler: <H extends Function>(actionId: string, actionHandler: H) => { 175 switch (mode) { 176 case "broadcast": 177 return channelContext?.setActionHandler(actionId, actionHandler); 178 179 case "shared-worker": 180 return scope.setActionHandler(actionId, actionHandler); 181 } 182 }, 183 }; 184 185 if (options.worker) { 186 context.scope.onworkerport = (event) => { 187 if (!event.port) return; 188 options.worker?._listen(transfer(event.port)); 189 }; 190 } 191 192 // Channel 193 const channelContext = 194 mode === "broadcast" 195 ? broadcastChannel<DataType>({ 196 channelId, 197 context, 198 instanceId, 199 scope, 200 }) 201 : undefined; 202 203 return context; 204} 205 206function broadcastChannel<DataType>({ 207 channelId, 208 context, 209 instanceId, 210 scope, 211}: { 212 channelId: string; 213 context: DiffuseApplet<DataType>; 214 instanceId: string; 215 scope: AppletScope<DataType>; 216}) { 217 const mainSignal = signal<boolean>(true); 218 const [isMain, setIsMain] = mainSignal; 219 220 // One instance to rule them all 221 // 222 // Ping other instances to see if there are any. 223 // As long as there aren't any, it is considered the main instance. 224 // 225 // Actions are performed on the main instance, 226 // and data is replicated from main to the other instances. 227 const channel = new BroadcastChannel(channelId); 228 229 channel.addEventListener("message", async (event) => { 230 switch (event.data?.type) { 231 case "PING": { 232 channel.postMessage({ 233 type: "PONG", 234 instanceId: event.data.instanceId, 235 originInstanceId: instanceId, 236 }); 237 238 if (isMain() && event.data?.isInitialPing === true) { 239 channel.postMessage({ 240 type: "data", 241 data: context.codec.encode(scope.data), 242 }); 243 } 244 break; 245 } 246 247 case "PONG": { 248 if (event.data.instanceId === instanceId) { 249 setIsMain(false); 250 } 251 break; 252 } 253 254 case "UNLOADED": { 255 if (!context.isMainInstance()) { 256 // We need to wait until the other side is actually unloaded 🤷‍♀️ 257 setTimeout(async () => { 258 const promised = await makeMainPromise(); 259 setIsMain(promised.isMain); 260 if (promised.isMain) context.unloadHandler?.(); 261 }, 250); 262 } 263 break; 264 } 265 266 case "action": { 267 if (isMain()) { 268 const result = await scope.actionHandlers[event.data.actionId]?.(...event.data.arguments); 269 channel.postMessage({ 270 type: "actioncomplete", 271 actionInstanceId: event.data.actionInstanceId, 272 result, 273 }); 274 } 275 break; 276 } 277 278 case "data": { 279 scope.data = context.codec.decode(event.data.data); 280 break; 281 } 282 } 283 }); 284 285 // Promise that fullfills whenever it figures out its the main instance or not. 286 let pinged = false; 287 288 function makeMainPromise(timeoutDuration: number = 500) { 289 return new Promise<{ isMain: boolean }>((resolve) => { 290 const timeoutId = setTimeout(() => { 291 channel.removeEventListener("message", handler); 292 resolve({ isMain: true }); 293 }, timeoutDuration); 294 295 const handler = (event: MessageEvent) => { 296 if ( 297 (event.data?.type === "PONG" || event.data?.type === "PING") && 298 event.data?.instanceId === instanceId 299 ) { 300 clearTimeout(timeoutId); 301 channel.removeEventListener("message", handler); 302 resolve({ isMain: false }); 303 } 304 }; 305 306 channel.addEventListener("message", handler); 307 channel.postMessage({ 308 type: "PING", 309 instanceId, 310 isInitialPing: !pinged, 311 }); 312 313 pinged = true; 314 }); 315 } 316 317 const promise = makeMainPromise(); 318 319 // If the data on the main instance changes, 320 // pass it on to other instances. 321 scope.addEventListener("data", async (event: AppletEvent) => { 322 await promise; 323 324 if (isMain()) { 325 channel.postMessage({ 326 type: "data", 327 data: context.codec.encode(event.data), 328 }); 329 } 330 }); 331 332 // Action handler 333 const setActionHandler = <H extends Function>(actionId: string, actionHandler: H) => { 334 const handler = async (...args: any) => { 335 if (isMain()) { 336 return actionHandler(...args); 337 } 338 339 // Check if a main instance is still available, 340 // if not, then this is the new main. 341 const promised = await makeMainPromise(); 342 setIsMain(promised.isMain); 343 344 if (isMain()) { 345 return actionHandler(...args); 346 } 347 348 const actionMessage = { 349 actionInstanceId: crypto.randomUUID(), 350 actionId, 351 type: "action", 352 arguments: args, 353 }; 354 355 return await new Promise((resolve) => { 356 const actionCallback = (event: MessageEvent) => { 357 if ( 358 event.data?.type === "actioncomplete" && 359 event.data?.actionInstanceId === actionMessage.actionInstanceId 360 ) { 361 channel.removeEventListener("message", actionCallback); 362 resolve(event.data.result); 363 } 364 }; 365 366 channel.addEventListener("message", actionCallback); 367 channel.postMessage(actionMessage); 368 }); 369 }; 370 371 scope.setActionHandler(actionId, handler); 372 }; 373 374 // Before unload 375 self.addEventListener("beforeunload", (event) => { 376 if (context.isMainInstance()) { 377 channel.postMessage({ 378 type: "UNLOADED", 379 }); 380 } 381 }); 382 383 // Fin 384 return { 385 channel, 386 mainSignal, 387 promise, 388 setActionHandler, 389 }; 390} 391 392//////////////////////////////////////////// 393// 🔮 Reactive state management 394//////////////////////////////////////////// 395export function reactive<D, T>( 396 applet: Applet<D> | AppletScope<D>, 397 dataFn: (data: D) => T, 398 effectFn: (t: T) => void, 399) { 400 let value = dataFn(applet.data); 401 effectFn(value); 402 403 applet.addEventListener("data", (event: AppletEvent) => { 404 const newData = dataFn(event.data); 405 if (newData !== value) { 406 value = newData; 407 effectFn(value); 408 } 409 }); 410} 411 412//////////////////////////////////////////// 413// ⚡️ COMMON ACTION CALLS 414//////////////////////////////////////////// 415 416export async function inputUrl(input: Applet, uri: string, method = "GET") { 417 return await input.sendAction<ResolvedUri>( 418 "resolve", 419 { 420 method, 421 uri, 422 }, 423 { 424 timeoutDuration: 60000 * 5, 425 worker: true, 426 }, 427 ); 428} 429 430//////////////////////////////////////////// 431// 🛠️ 432//////////////////////////////////////////// 433export function addScope<O extends object>(astroScope: string, object: O): O { 434 return { 435 ...object, 436 attrs: { 437 ...((object as any).attrs || {}), 438 [`data-astro-cid-${astroScope}`]: "", 439 }, 440 }; 441} 442 443export function appletScopePort() { 444 let port: MessagePort | undefined; 445 446 function connection(event: AppletEvent) { 447 if (event.data?.type === "appletconnect") { 448 window.removeEventListener("message", connection); 449 port = (event as any).ports[0]; 450 } 451 } 452 453 window.addEventListener("message", connection); 454 455 return () => port; 456} 457 458export function hs( 459 tag: string, 460 astroScope: string, 461 props?: Record<string, unknown> | Signal<Record<string, unknown>>, 462 configure?: ElementConfigurator, 463) { 464 const propsWithScope = 465 props && isSignal(props) 466 ? () => addScope(astroScope, props()) 467 : addScope(astroScope, props || {}); 468 469 return h(tag, propsWithScope, configure); 470} 471 472export function wait<A>(applet: Applet<A>, dataFn: (a: A | undefined) => boolean): Promise<void> { 473 return new Promise((resolve) => { 474 if (dataFn(applet.data) === true) { 475 resolve(); 476 return; 477 } 478 479 const callback = (event: AppletEvent) => { 480 if (dataFn(event.data) === true) { 481 applet.removeEventListener("data", callback); 482 resolve(); 483 } 484 }; 485 486 applet.addEventListener("data", callback); 487 }); 488}