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