Experiment to rebuild Diffuse using web applets.
at broadcast 279 lines 7.1 kB view raw
1import type { Applet, AppletEvent } from "@web-applets/sdk"; 2 3import QS from "query-string"; 4import { applets } from "@web-applets/sdk"; 5import { type ElementConfigurator, h } from "spellcaster/hyperscript.js"; 6import { effect, isSignal, Signal, signal } from "spellcaster/spellcaster.js"; 7import { xxh32 } from "xxh32"; 8 9//////////////////////////////////////////// 10// 🪟 Applet connector 11//////////////////////////////////////////// 12export async function applet<D>( 13 src: string, 14 opts: { 15 addSlashSuffix?: boolean; 16 applets?: Record<string, string>; 17 container?: HTMLElement | Element; 18 id?: string; 19 setHeight?: boolean; 20 } = {}, 21): Promise<Applet<D>> { 22 src = `${src}${ 23 src.endsWith("/") 24 ? "" 25 : opts.addSlashSuffix === undefined || opts.addSlashSuffix === true 26 ? "/" 27 : "" 28 }`; 29 30 if (opts.applets) { 31 src = QS.stringifyUrl({ url: src, query: opts.applets }); 32 } 33 34 const existingFrame: HTMLIFrameElement | null = window.document.querySelector(`[src="${src}"]`); 35 36 let frame; 37 38 if (existingFrame) { 39 frame = existingFrame; 40 } else { 41 frame = document.createElement("iframe"); 42 frame.src = src; 43 if (opts.id) frame.id = opts.id; 44 45 if (opts.container) { 46 opts.container.appendChild(frame); 47 } else { 48 window.document.body.appendChild(frame); 49 } 50 } 51 52 if (frame.contentWindow === null) { 53 throw new Error("iframe does not have a contentWindow"); 54 } 55 56 const applet = await applets.connect<D>(frame.contentWindow).catch((err) => { 57 console.error("Error connecting to " + src, err); 58 throw err; 59 }); 60 61 if (opts.setHeight) { 62 applet.onresize = () => { 63 frame.height = `${applet.height}px`; 64 frame.classList.add("has-loaded"); 65 }; 66 } else { 67 if (frame.contentDocument?.readyState === "complete") { 68 frame.classList.add("has-loaded"); 69 } 70 71 frame.addEventListener("load", () => { 72 frame.classList.add("has-loaded"); 73 }); 74 } 75 76 return applet; 77} 78 79//////////////////////////////////////////// 80// 🪟 Applet registration 81//////////////////////////////////////////// 82export function register<DataType = any>() { 83 const id = `${location.host}${location.pathname}`; 84 const scope = applets.register<DataType>(); 85 86 let isMainInstance = true; 87 let waitingForPong = true; 88 89 // One instance to rule them all 90 // 91 // Ping other instances to see if there are any. 92 // As long as there aren't any, it is considered the main instance. 93 // 94 // Actions are performed on the main instance, 95 // and data is replicated from main to the other instances. 96 const channel = new BroadcastChannel(id); 97 98 channel.addEventListener("message", async (event) => { 99 if (event.data === "PING") { 100 channel.postMessage("PONG"); 101 } else if (event.data?.type === "data") { 102 scope.data = context.codec.decode(event.data.data); 103 } else if (waitingForPong && event.data === "PONG") { 104 waitingForPong = false; 105 isMainInstance = false; 106 } else if (isMainInstance && event.data?.type === "action" && event.data?.actionId) { 107 const result = await scope.actionHandlers[event.data.actionId]?.(...event.data.arguments); 108 channel.postMessage({ 109 type: "actioncomplete", 110 id: event.data.id, 111 result, 112 }); 113 } 114 }); 115 116 setTimeout(() => (waitingForPong = false), 1000); 117 118 channel.postMessage("PING"); 119 120 scope.ondata = (event) => { 121 if (isMainInstance) { 122 channel.postMessage({ 123 type: "data", 124 data: context.codec.encode(event.data), 125 }); 126 } 127 }; 128 129 const context = { 130 scope, 131 132 get id() { 133 return id; 134 }, 135 136 get data() { 137 return scope.data; 138 }, 139 140 set data(data: DataType) { 141 scope.data = data; 142 }, 143 144 codec: { 145 decode: (data: any) => data as DataType, 146 encode: (data: DataType) => data as any, 147 }, 148 149 isMainInstance() { 150 return isMainInstance; 151 }, 152 153 setActionHandler: <H extends Function>(actionId: string, actionHandler: H) => { 154 const handler = (...args: any) => { 155 if (isMainInstance) { 156 return actionHandler(...args); 157 } 158 159 const actionMessage = { 160 id: crypto.randomUUID(), 161 type: "action", 162 actionId, 163 arguments: args, 164 }; 165 166 return new Promise((resolve) => { 167 const actionCallback = (event: MessageEvent) => { 168 if (event.data?.type === "actioncomplete" && event.data?.id === actionMessage.id) { 169 channel.removeEventListener("message", actionCallback); 170 resolve(event.data.result); 171 } 172 }; 173 174 channel.addEventListener("message", actionCallback); 175 channel.postMessage(actionMessage); 176 }); 177 }; 178 179 scope.setActionHandler(actionId, handler); 180 }, 181 }; 182 183 return context; 184} 185 186//////////////////////////////////////////// 187// 🔮 Reactive state management 188//////////////////////////////////////////// 189export function reactive<D, T>( 190 applet: Applet<D>, 191 dataFn: (data: D) => T, 192 effectFn: (t: T) => void, 193) { 194 const [getter, setter] = signal(dataFn(applet.data)); 195 196 effect(() => { 197 effectFn(getter()); 198 return undefined; 199 }); 200 201 applet.addEventListener("data", (event: AppletEvent) => { 202 setter(dataFn(event.data)); 203 }); 204} 205 206//////////////////////////////////////////// 207// 🛠️ 208//////////////////////////////////////////// 209export function addScope<O extends object>(astroScope: string, object: O): O { 210 return { 211 ...object, 212 attrs: { 213 ...((object as any).attrs || {}), 214 [`data-astro-cid-${astroScope}`]: "", 215 }, 216 }; 217} 218 219export function appletScopePort() { 220 let port: MessagePort | undefined; 221 222 function connection(event: AppletEvent) { 223 if (event.data?.type === "appletconnect") { 224 window.removeEventListener("message", connection); 225 port = (event as any).ports[0]; 226 } 227 } 228 229 window.addEventListener("message", connection); 230 231 return () => port; 232} 233 234export function comparable(value: unknown) { 235 return xxh32(JSON.stringify(value)); 236} 237 238export function hs( 239 tag: string, 240 astroScope: string, 241 props?: Record<string, unknown> | Signal<Record<string, unknown>>, 242 configure?: ElementConfigurator, 243) { 244 const propsWithScope = 245 props && isSignal(props) 246 ? () => addScope(astroScope, props()) 247 : addScope(astroScope, props || {}); 248 249 return h(tag, propsWithScope, configure); 250} 251 252export function isPrimitive(test: unknown) { 253 return test !== Object(test); 254} 255 256export function waitUntilAppletData<A>( 257 applet: Applet<A>, 258 dataFn: (a: A | undefined) => boolean, 259): Promise<void> { 260 return new Promise((resolve) => { 261 if (dataFn(applet.data) === true) { 262 resolve(); 263 return; 264 } 265 266 const callback = (event: AppletEvent) => { 267 if (dataFn(event.data) === true) { 268 applet.removeEventListener("data", callback); 269 resolve(); 270 } 271 }; 272 273 applet.addEventListener("data", callback); 274 }); 275} 276 277export function waitUntilAppletIsReady(applet: Applet): Promise<void> { 278 return waitUntilAppletData(applet, (data) => !!data?.ready); 279}