Experiment to rebuild Diffuse using web applets.
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}