Something went wrong. Try again.
Experiment to rebuild Diffuse using web applets.
Something went wrong. Try again.
12 kB · 488 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489import type { Applet, AppletEvent, AppletScope } from "@web-applets/sdk";import * as Comlink from "comlink";
import { applets } from "@web-applets/sdk";import { type ElementConfigurator, h } from "spellcaster/hyperscript.js";import { effect, isSignal, type Signal, signal } from "spellcaster/spellcaster.js";import QS from "query-string";
import type { ResolvedUri } from "@applets/core/types";import { transfer, type WorkerTasks } from "@scripts/common";
////////////////////////////////////////////// 🪟 Applet connecting////////////////////////////////////////////export async function applet<D>( src: string, opts: { addSlashSuffix?: boolean; container?: HTMLElement | Element; context?: Window; frameId?: string; groupId?: string; newInstance?: boolean; setHeight?: boolean; } = {},): Promise<Applet<D>> { src = `${src}${ src.endsWith("/") ? "" : opts.addSlashSuffix === undefined || opts.addSlashSuffix === true ? "/" : "" }`;
let query: undefined | Record<string, string>; query = { groupId: opts.groupId || "main" };
if (query) { src = QS.stringifyUrl({ url: src, query }); }
const context = opts.newInstance ? self : opts.context || self.top || self.parent; const existingFrame: HTMLIFrameElement | null = opts.newInstance ? null : context.document.querySelector(`[src="${src}"]`);
let frame;
if (existingFrame) { frame = existingFrame; } else { frame = document.createElement("iframe"); frame.loading = "eager"; frame.src = src; if (opts.frameId) frame.id = opts.frameId;
if (opts.container) { opts.container.appendChild(frame); } else { context.document.body.appendChild(frame); } }
if (frame.contentWindow === null) { throw new Error("iframe does not have a contentWindow"); }
const applet = await applets.connect<D>(frame.contentWindow, { context }).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;}
export function tunnel( worker: Comlink.Remote<WorkerTasks>, connections: Record<string, Applet | Promise<Applet>>,) { Object.entries(connections).forEach(([scheme, promise]) => { Promise.resolve(promise).then((conn) => { return worker._manage(scheme, transfer(conn.ports.worker)); }); });}
////////////////////////////////////////////// 🪟 Applet registration////////////////////////////////////////////export type DiffuseApplet<T> = { groupId: string | undefined; scope: AppletScope<T>;
settled(): Promise<void>;
get instanceId(): string; set data(data: T);
codec: Codec<T>; unloadHandler?: () => void;
isMainInstance(): boolean | null; setActionHandler<H extends Function>(actionId: string, actionHandler: H): void;};
export type Codec<T> = { decode(data: any): T; encode(data: T): any;};
export function lookupGroupId() { const url = new URL(location.href); return url.searchParams.get("groupId") || "main";}
export function register<DataType = any>( options: { mode?: "broadcast" | "shared-worker"; worker?: Comlink.Remote<WorkerTasks> } = {},): DiffuseApplet<DataType> { const mode = options.mode ?? "broadcast"; const scope = applets.register<DataType>();
const groupId = lookupGroupId(); const channelId = `${location.host}${location.pathname}/${groupId}`; const instanceId = crypto.randomUUID();
// Codec const codec = { decode: (data: any) => data as DataType, encode: (data: DataType) => data as any, };
// Context const context: DiffuseApplet<DataType> = { groupId, scope,
settled() { return channelContext?.promise.then(() => {}) ?? Promise.resolve(); },
get instanceId() { return instanceId; },
get data() { return scope.data; },
set data(data: DataType) { scope.data = data; },
codec,
isMainInstance() { return channelContext?.mainSignal[0]() ?? null; },
setActionHandler: <H extends Function>(actionId: string, actionHandler: H) => { switch (mode) { case "broadcast": return channelContext?.setActionHandler(actionId, actionHandler);
case "shared-worker": return scope.setActionHandler(actionId, actionHandler); } }, };
if (options.worker) { context.scope.onworkerport = (event) => { if (!event.port) return; options.worker?._listen(transfer(event.port)); }; }
// Channel const channelContext = mode === "broadcast" ? broadcastChannel<DataType>({ channelId, context, instanceId, scope, }) : undefined;
return context;}
function broadcastChannel<DataType>({ channelId, context, instanceId, scope,}: { channelId: string; context: DiffuseApplet<DataType>; instanceId: string; scope: AppletScope<DataType>;}) { const mainSignal = signal<boolean>(true); const [isMain, setIsMain] = mainSignal;
// 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(channelId);
channel.addEventListener("message", async (event) => { switch (event.data?.type) { case "PING": { channel.postMessage({ type: "PONG", instanceId: event.data.instanceId, originInstanceId: instanceId, });
if (isMain() && event.data?.isInitialPing === true) { channel.postMessage({ type: "data", data: context.codec.encode(scope.data), }); } break; }
case "PONG": { if (event.data.instanceId === instanceId) { setIsMain(false); } break; }
case "UNLOADED": { if (!context.isMainInstance()) { // We need to wait until the other side is actually unloaded 🤷♀️ setTimeout(async () => { const promised = await makeMainPromise(); setIsMain(promised.isMain); if (promised.isMain) context.unloadHandler?.(); }, 250); } break; }
case "action": { if (isMain()) { const result = await scope.actionHandlers[event.data.actionId]?.(...event.data.arguments); channel.postMessage({ type: "actioncomplete", actionInstanceId: event.data.actionInstanceId, result, }); } break; }
case "data": { scope.data = context.codec.decode(event.data.data); break; } } });
// Promise that fullfills whenever it figures out its the main instance or not. let pinged = false;
function makeMainPromise(timeoutDuration: number = 500) { return new Promise<{ isMain: boolean }>((resolve) => { const timeoutId = setTimeout(() => { channel.removeEventListener("message", handler); resolve({ isMain: true }); }, timeoutDuration);
const handler = (event: MessageEvent) => { if ( (event.data?.type === "PONG" || event.data?.type === "PING") && event.data?.instanceId === instanceId ) { clearTimeout(timeoutId); channel.removeEventListener("message", handler); resolve({ isMain: false }); } };
channel.addEventListener("message", handler); channel.postMessage({ type: "PING", instanceId, isInitialPing: !pinged, });
pinged = true; }); }
const promise = makeMainPromise();
// If the data on the main instance changes, // pass it on to other instances. scope.addEventListener("data", async (event: AppletEvent) => { await promise;
if (isMain()) { channel.postMessage({ type: "data", data: context.codec.encode(event.data), }); } });
// Action handler const setActionHandler = <H extends Function>(actionId: string, actionHandler: H) => { const handler = async (...args: any) => { if (isMain()) { return actionHandler(...args); }
// Check if a main instance is still available, // if not, then this is the new main. const promised = await makeMainPromise(); setIsMain(promised.isMain);
if (isMain()) { return actionHandler(...args); }
const actionMessage = { actionInstanceId: crypto.randomUUID(), actionId, type: "action", arguments: args, };
return await new Promise((resolve) => { const actionCallback = (event: MessageEvent) => { if ( event.data?.type === "actioncomplete" && event.data?.actionInstanceId === actionMessage.actionInstanceId ) { channel.removeEventListener("message", actionCallback); resolve(event.data.result); } };
channel.addEventListener("message", actionCallback); channel.postMessage(actionMessage); }); };
scope.setActionHandler(actionId, handler); };
// Before unload self.addEventListener("beforeunload", (event) => { if (context.isMainInstance()) { channel.postMessage({ type: "UNLOADED", }); } });
// Fin return { channel, mainSignal, promise, setActionHandler, };}
////////////////////////////////////////////// 🔮 Reactive state management////////////////////////////////////////////export function reactive<D, T>( applet: Applet<D> | AppletScope<D>, dataFn: (data: D) => T, effectFn: (t: T) => void,) { let value = dataFn(applet.data); effectFn(value);
applet.addEventListener("data", (event: AppletEvent) => { const newData = dataFn(event.data); if (newData !== value) { value = newData; effectFn(value); } });}
////////////////////////////////////////////// ⚡️ COMMON ACTION CALLS////////////////////////////////////////////
export async function inputUrl(input: Applet, uri: string, method = "GET") { return await input.sendAction<ResolvedUri>( "resolve", { method, uri, }, { timeoutDuration: 60000 * 5, worker: true, }, );}
////////////////////////////////////////////// 🛠️////////////////////////////////////////////export function addScope<O extends object>(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 hs( tag: string, astroScope: string, props?: Record<string, unknown> | Signal<Record<string, unknown>>, configure?: ElementConfigurator,) { const propsWithScope = props && isSignal(props) ? () => addScope(astroScope, props()) : addScope(astroScope, props || {});
return h(tag, propsWithScope, configure);}
export function wait<A>(applet: Applet<A>, dataFn: (a: A | undefined) => boolean): Promise<void> { 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); });}