Experiment to rebuild Diffuse using web applets.
1import type { Applet, AppletEvent, AppletScope } from "@web-applets/sdk";
2import type * 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 setHeight?: boolean;
24 } = {},
25): Promise<Applet<D>> {
26 src = `${src}${
27 src.endsWith("/")
28 ? ""
29 : opts.addSlashSuffix === undefined || opts.addSlashSuffix === true
30 ? "/"
31 : ""
32 }`;
33
34 let query: undefined | Record<string, string>;
35
36 if (opts.groupId) {
37 query = { groupId: opts.groupId };
38 }
39
40 if (query) {
41 src = QS.stringifyUrl({ url: src, query });
42 }
43
44 const context = opts.context || self.top || self.parent;
45 const existingFrame: HTMLIFrameElement | null = 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
91////////////////////////////////////////////
92// 🪟 Applet registration
93////////////////////////////////////////////
94export type BroadcastedApplet<T> = {
95 groupId: string | undefined;
96 scope: AppletScope<T>;
97
98 settled(): Promise<void>;
99
100 get instanceId(): string;
101 set data(data: T);
102
103 codec: {
104 decode(data: any): T;
105 encode(data: T): any;
106 };
107
108 isMainInstance(): boolean;
109 setActionHandler<H extends Function>(actionId: string, actionHandler: H): void;
110};
111
112export function register<DataType = any>(
113 options: { worker?: Comlink.Remote<WorkerTasks> } = {},
114): BroadcastedApplet<DataType> {
115 const url = new URL(location.href);
116 const scope = applets.register<DataType>();
117
118 const groupId = url.searchParams.get("groupId") || "main";
119 const channelId = `${location.host}${location.pathname}/${groupId}`;
120 const instanceId = crypto.randomUUID();
121
122 let isMainInstance = true;
123
124 // One instance to rule them all
125 //
126 // Ping other instances to see if there are any.
127 // As long as there aren't any, it is considered the main instance.
128 //
129 // Actions are performed on the main instance,
130 // and data is replicated from main to the other instances.
131 const channel = new BroadcastChannel(channelId);
132
133 channel.addEventListener("message", async (event) => {
134 switch (event.data?.type) {
135 case "PING": {
136 channel.postMessage({
137 type: "PONG",
138 instanceId: event.data.instanceId,
139 });
140
141 if (isMainInstance) {
142 channel.postMessage({
143 type: "data",
144 data: context.codec.encode(scope.data),
145 });
146 }
147 break;
148 }
149
150 case "PONG": {
151 if (event.data.instanceId === instanceId) {
152 isMainInstance = false;
153 }
154 break;
155 }
156
157 case "action": {
158 if (isMainInstance) {
159 const result = await scope.actionHandlers[event.data.actionId]?.(...event.data.arguments);
160 channel.postMessage({
161 type: "actioncomplete",
162 actionInstanceId: event.data.actionInstanceId,
163 result,
164 });
165 }
166 break;
167 }
168
169 case "data": {
170 scope.data = context.codec.decode(event.data.data);
171 break;
172 }
173 }
174 });
175
176 // Promise that fullfills whenever it figures out its the main instance or not.
177 function makeMainPromise() {
178 return new Promise<{ isMain: boolean }>((resolve) => {
179 const timeoutId = setTimeout(() => {
180 channel.removeEventListener("message", handler);
181 resolve({ isMain: true });
182 }, 1000);
183
184 const handler = (event: MessageEvent) => {
185 if (event.data === "pong" || event.data === "ping") {
186 clearTimeout(timeoutId);
187 channel.removeEventListener("message", handler);
188 resolve({ isMain: false });
189 }
190 };
191
192 channel.addEventListener("message", handler);
193 });
194 }
195
196 const promise = makeMainPromise();
197
198 // Send out ping
199 channel.postMessage({
200 type: "PING",
201 instanceId,
202 });
203
204 // If the data on the main instance changes,
205 // pass it on to other instances.
206 scope.addEventListener("data", async (event: AppletEvent) => {
207 await promise;
208
209 if (isMainInstance) {
210 channel.postMessage({
211 type: "data",
212 data: context.codec.encode(event.data),
213 });
214 }
215 });
216
217 // Context
218 const context: BroadcastedApplet<DataType> = {
219 groupId,
220 scope,
221
222 settled() {
223 return promise.then(() => {});
224 },
225
226 get instanceId() {
227 return instanceId;
228 },
229
230 get data() {
231 return scope.data;
232 },
233
234 set data(data: DataType) {
235 scope.data = data;
236 },
237
238 codec: {
239 decode: (data: any) => data as DataType,
240 encode: (data: DataType) => data as any,
241 },
242
243 isMainInstance() {
244 return isMainInstance;
245 },
246
247 setActionHandler: <H extends Function>(actionId: string, actionHandler: H) => {
248 const handler = async (...args: any) => {
249 if (isMainInstance) {
250 return actionHandler(...args);
251 }
252
253 // Check if a main instance is still available,
254 // if not, then this is the new main.
255 const { isMain } = await makeMainPromise();
256 isMainInstance = isMain;
257
258 if (isMainInstance) {
259 return actionHandler(...args);
260 }
261
262 const actionMessage = {
263 actionInstanceId: crypto.randomUUID(),
264 actionId,
265 type: "action",
266 arguments: args,
267 };
268
269 return await new Promise((resolve) => {
270 const actionCallback = (event: MessageEvent) => {
271 if (
272 event.data?.type === "actioncomplete" &&
273 event.data?.actionInstanceId === actionMessage.actionInstanceId
274 ) {
275 channel.removeEventListener("message", actionCallback);
276 resolve(event.data.result);
277 }
278 };
279
280 channel.addEventListener("message", actionCallback);
281 channel.postMessage(actionMessage);
282 });
283 };
284
285 scope.setActionHandler(actionId, handler);
286 },
287 };
288
289 if (options.worker !== undefined)
290 context.scope.onworkerport = (event) => {
291 if (!event.port) return;
292 options.worker?.listenForActions(transfer(event.port));
293 };
294
295 return context;
296}
297
298////////////////////////////////////////////
299// 🔮 Reactive state management
300////////////////////////////////////////////
301export function reactive<D, T>(
302 applet: Applet<D> | AppletScope<D>,
303 dataFn: (data: D) => T,
304 effectFn: (t: T) => void,
305) {
306 let value = dataFn(applet.data);
307 effectFn(value);
308
309 applet.addEventListener("data", (event: AppletEvent) => {
310 const newData = dataFn(event.data);
311 if (newData !== value) {
312 value = newData;
313 effectFn(value);
314 }
315 });
316}
317
318export function makeConnect<X>(context: BroadcastedApplet<X>) {
319 return <D, T>(applet: Applet<D>, dataFn: (data: D) => T, effectFn: (t: T) => void) => {
320 return reactive(applet, dataFn, (t: T) => {
321 if (context.isMainInstance()) effectFn(t);
322 });
323 };
324}
325
326////////////////////////////////////////////
327// ⚡️ COMMON ACTION CALLS
328////////////////////////////////////////////
329
330export async function inputUrl(input: Applet, uri: string, method = "GET") {
331 return await input.sendAction<ResolvedUri>(
332 "resolve",
333 {
334 method,
335 uri,
336 },
337 {
338 timeoutDuration: 60000 * 5,
339 worker: true,
340 },
341 );
342}
343
344////////////////////////////////////////////
345// 🛠️
346////////////////////////////////////////////
347export function addScope<O extends object>(astroScope: string, object: O): O {
348 return {
349 ...object,
350 attrs: {
351 ...((object as any).attrs || {}),
352 [`data-astro-cid-${astroScope}`]: "",
353 },
354 };
355}
356
357export function appletScopePort() {
358 let port: MessagePort | undefined;
359
360 function connection(event: AppletEvent) {
361 if (event.data?.type === "appletconnect") {
362 window.removeEventListener("message", connection);
363 port = (event as any).ports[0];
364 }
365 }
366
367 window.addEventListener("message", connection);
368
369 return () => port;
370}
371
372export function hs(
373 tag: string,
374 astroScope: string,
375 props?: Record<string, unknown> | Signal<Record<string, unknown>>,
376 configure?: ElementConfigurator,
377) {
378 const propsWithScope =
379 props && isSignal(props)
380 ? () => addScope(astroScope, props())
381 : addScope(astroScope, props || {});
382
383 return h(tag, propsWithScope, configure);
384}
385
386export function wait<A>(applet: Applet<A>, dataFn: (a: A | undefined) => boolean): Promise<void> {
387 return new Promise((resolve) => {
388 if (dataFn(applet.data) === true) {
389 resolve();
390 return;
391 }
392
393 const callback = (event: AppletEvent) => {
394 if (dataFn(event.data) === true) {
395 applet.removeEventListener("data", callback);
396 resolve();
397 }
398 };
399
400 applet.addEventListener("data", callback);
401 });
402}