import type { Applet, AppletEvent } from "@web-applets/sdk"; import QS from "query-string"; import { applets } from "@web-applets/sdk"; import { type ElementConfigurator, h } from "spellcaster/hyperscript.js"; import { effect, isSignal, Signal, signal } from "spellcaster/spellcaster.js"; import { xxh32 } from "xxh32"; //////////////////////////////////////////// // 🪟 Applet connector //////////////////////////////////////////// export async function applet( src: string, opts: { addSlashSuffix?: boolean; applets?: Record; container?: HTMLElement | Element; id?: string; setHeight?: boolean; } = {}, ): Promise> { src = `${src}${ src.endsWith("/") ? "" : opts.addSlashSuffix === undefined || opts.addSlashSuffix === true ? "/" : "" }`; if (opts.applets) { src = QS.stringifyUrl({ url: src, query: opts.applets }); } const existingFrame: HTMLIFrameElement | null = window.document.querySelector(`[src="${src}"]`); let frame; if (existingFrame) { frame = existingFrame; } else { frame = document.createElement("iframe"); frame.src = src; if (opts.id) frame.id = opts.id; if (opts.container) { opts.container.appendChild(frame); } else { window.document.body.appendChild(frame); } } if (frame.contentWindow === null) { throw new Error("iframe does not have a contentWindow"); } const applet = await applets.connect(frame.contentWindow).catch((err) => { console.error("Error connecting to " + src, err); throw err; }); if (opts.setHeight) { applet.onresize = () => { frame.height = `${applet.height}px`; frame.classList.add("has-loaded"); }; } else { if (frame.contentDocument?.readyState === "complete") { frame.classList.add("has-loaded"); } frame.addEventListener("load", () => { frame.classList.add("has-loaded"); }); } return applet; } //////////////////////////////////////////// // 🪟 Applet registration //////////////////////////////////////////// export function register() { const id = `${location.host}${location.pathname}`; const scope = applets.register(); let isMainInstance = true; let waitingForPong = true; // One instance to rule them all // // Ping other instances to see if there are any. // As long as there aren't any, it is considered the main instance. // // Actions are performed on the main instance, // and data is replicated from main to the other instances. const channel = new BroadcastChannel(id); channel.addEventListener("message", async (event) => { if (event.data === "PING") { channel.postMessage("PONG"); } else if (event.data?.type === "data") { scope.data = context.codec.decode(event.data.data); } else if (waitingForPong && event.data === "PONG") { waitingForPong = false; isMainInstance = false; } else if (isMainInstance && event.data?.type === "action" && event.data?.actionId) { const result = await scope.actionHandlers[event.data.actionId]?.(...event.data.arguments); channel.postMessage({ type: "actioncomplete", id: event.data.id, result, }); } }); setTimeout(() => (waitingForPong = false), 1000); channel.postMessage("PING"); scope.ondata = (event) => { if (isMainInstance) { channel.postMessage({ type: "data", data: context.codec.encode(event.data), }); } }; const context = { scope, get id() { return id; }, get data() { return scope.data; }, set data(data: DataType) { scope.data = data; }, codec: { decode: (data: any) => data as DataType, encode: (data: DataType) => data as any, }, isMainInstance() { return isMainInstance; }, setActionHandler: (actionId: string, actionHandler: H) => { const handler = (...args: any) => { if (isMainInstance) { return actionHandler(...args); } const actionMessage = { id: crypto.randomUUID(), type: "action", actionId, arguments: args, }; return new Promise((resolve) => { const actionCallback = (event: MessageEvent) => { if (event.data?.type === "actioncomplete" && event.data?.id === actionMessage.id) { channel.removeEventListener("message", actionCallback); resolve(event.data.result); } }; channel.addEventListener("message", actionCallback); channel.postMessage(actionMessage); }); }; scope.setActionHandler(actionId, handler); }, }; return context; } //////////////////////////////////////////// // 🔮 Reactive state management //////////////////////////////////////////// export function reactive( applet: Applet, dataFn: (data: D) => T, effectFn: (t: T) => void, ) { const [getter, setter] = signal(dataFn(applet.data)); effect(() => { effectFn(getter()); return undefined; }); applet.addEventListener("data", (event: AppletEvent) => { setter(dataFn(event.data)); }); } //////////////////////////////////////////// // 🛠️ //////////////////////////////////////////// export function addScope(astroScope: string, object: O): O { return { ...object, attrs: { ...((object as any).attrs || {}), [`data-astro-cid-${astroScope}`]: "", }, }; } export function appletScopePort() { let port: MessagePort | undefined; function connection(event: AppletEvent) { if (event.data?.type === "appletconnect") { window.removeEventListener("message", connection); port = (event as any).ports[0]; } } window.addEventListener("message", connection); return () => port; } export function comparable(value: unknown) { return xxh32(JSON.stringify(value)); } export function hs( tag: string, astroScope: string, props?: Record | Signal>, configure?: ElementConfigurator, ) { const propsWithScope = props && isSignal(props) ? () => addScope(astroScope, props()) : addScope(astroScope, props || {}); return h(tag, propsWithScope, configure); } export function isPrimitive(test: unknown) { return test !== Object(test); } export function waitUntilAppletData( applet: Applet, dataFn: (a: A | undefined) => boolean, ): Promise { return new Promise((resolve) => { if (dataFn(applet.data) === true) { resolve(); return; } const callback = (event: AppletEvent) => { if (dataFn(event.data) === true) { applet.removeEventListener("data", callback); resolve(); } }; applet.addEventListener("data", callback); }); } export function waitUntilAppletIsReady(applet: Applet): Promise { return waitUntilAppletData(applet, (data) => !!data?.ready); }