Experiment to rebuild Diffuse using web applets.
1import type { Applet, AppletEvent, AppletScope } from "@web-applets/sdk";
2import * 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 newInstance?: boolean;
24 setHeight?: boolean;
25 } = {},
26): Promise<Applet<D>> {
27 src = `${src}${
28 src.endsWith("/")
29 ? ""
30 : opts.addSlashSuffix === undefined || opts.addSlashSuffix === true
31 ? "/"
32 : ""
33 }`;
34
35 let query: undefined | Record<string, string>;
36 query = { groupId: opts.groupId || "main" };
37
38 if (query) {
39 src = QS.stringifyUrl({ url: src, query });
40 }
41
42 const context = opts.newInstance ? self : opts.context || self.top || self.parent;
43 const existingFrame: HTMLIFrameElement | null = opts.newInstance
44 ? null
45 : 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 DiffuseApplet<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: Codec<T>;
115 unloadHandler?: () => void;
116
117 isMainInstance(): boolean | null;
118 setActionHandler<H extends Function>(actionId: string, actionHandler: H): void;
119};
120
121export type Codec<T> = {
122 decode(data: any): T;
123 encode(data: T): any;
124};
125
126export function lookupGroupId() {
127 const url = new URL(location.href);
128 return url.searchParams.get("groupId") || "main";
129}
130
131export function register<DataType = any>(
132 options: { mode?: "broadcast" | "shared-worker"; worker?: Comlink.Remote<WorkerTasks> } = {},
133): DiffuseApplet<DataType> {
134 const mode = options.mode ?? "broadcast";
135 const scope = applets.register<DataType>();
136
137 const groupId = lookupGroupId();
138 const channelId = `${location.host}${location.pathname}/${groupId}`;
139 const instanceId = crypto.randomUUID();
140
141 // Codec
142 const codec = {
143 decode: (data: any) => data as DataType,
144 encode: (data: DataType) => data as any,
145 };
146
147 // Context
148 const context: DiffuseApplet<DataType> = {
149 groupId,
150 scope,
151
152 settled() {
153 return channelContext?.promise.then(() => {}) ?? Promise.resolve();
154 },
155
156 get instanceId() {
157 return instanceId;
158 },
159
160 get data() {
161 return scope.data;
162 },
163
164 set data(data: DataType) {
165 scope.data = data;
166 },
167
168 codec,
169
170 isMainInstance() {
171 return channelContext?.mainSignal[0]() ?? null;
172 },
173
174 setActionHandler: <H extends Function>(actionId: string, actionHandler: H) => {
175 switch (mode) {
176 case "broadcast":
177 return channelContext?.setActionHandler(actionId, actionHandler);
178
179 case "shared-worker":
180 return scope.setActionHandler(actionId, actionHandler);
181 }
182 },
183 };
184
185 if (options.worker) {
186 context.scope.onworkerport = (event) => {
187 if (!event.port) return;
188 options.worker?._listen(transfer(event.port));
189 };
190 }
191
192 // Channel
193 const channelContext =
194 mode === "broadcast"
195 ? broadcastChannel<DataType>({
196 channelId,
197 context,
198 instanceId,
199 scope,
200 })
201 : undefined;
202
203 return context;
204}
205
206function broadcastChannel<DataType>({
207 channelId,
208 context,
209 instanceId,
210 scope,
211}: {
212 channelId: string;
213 context: DiffuseApplet<DataType>;
214 instanceId: string;
215 scope: AppletScope<DataType>;
216}) {
217 const mainSignal = signal<boolean>(true);
218 const [isMain, setIsMain] = mainSignal;
219
220 // One instance to rule them all
221 //
222 // Ping other instances to see if there are any.
223 // As long as there aren't any, it is considered the main instance.
224 //
225 // Actions are performed on the main instance,
226 // and data is replicated from main to the other instances.
227 const channel = new BroadcastChannel(channelId);
228
229 channel.addEventListener("message", async (event) => {
230 switch (event.data?.type) {
231 case "PING": {
232 channel.postMessage({
233 type: "PONG",
234 instanceId: event.data.instanceId,
235 originInstanceId: instanceId,
236 });
237
238 if (isMain() && event.data?.isInitialPing === true) {
239 channel.postMessage({
240 type: "data",
241 data: context.codec.encode(scope.data),
242 });
243 }
244 break;
245 }
246
247 case "PONG": {
248 if (event.data.instanceId === instanceId) {
249 setIsMain(false);
250 }
251 break;
252 }
253
254 case "UNLOADED": {
255 if (!context.isMainInstance()) {
256 // We need to wait until the other side is actually unloaded 🤷♀️
257 setTimeout(async () => {
258 const promised = await makeMainPromise();
259 setIsMain(promised.isMain);
260 if (promised.isMain) context.unloadHandler?.();
261 }, 250);
262 }
263 break;
264 }
265
266 case "action": {
267 if (isMain()) {
268 const result = await scope.actionHandlers[event.data.actionId]?.(...event.data.arguments);
269 channel.postMessage({
270 type: "actioncomplete",
271 actionInstanceId: event.data.actionInstanceId,
272 result,
273 });
274 }
275 break;
276 }
277
278 case "data": {
279 scope.data = context.codec.decode(event.data.data);
280 break;
281 }
282 }
283 });
284
285 // Promise that fullfills whenever it figures out its the main instance or not.
286 let pinged = false;
287
288 function makeMainPromise(timeoutDuration: number = 500) {
289 return new Promise<{ isMain: boolean }>((resolve) => {
290 const timeoutId = setTimeout(() => {
291 channel.removeEventListener("message", handler);
292 resolve({ isMain: true });
293 }, timeoutDuration);
294
295 const handler = (event: MessageEvent) => {
296 if (
297 (event.data?.type === "PONG" || event.data?.type === "PING") &&
298 event.data?.instanceId === instanceId
299 ) {
300 clearTimeout(timeoutId);
301 channel.removeEventListener("message", handler);
302 resolve({ isMain: false });
303 }
304 };
305
306 channel.addEventListener("message", handler);
307 channel.postMessage({
308 type: "PING",
309 instanceId,
310 isInitialPing: !pinged,
311 });
312
313 pinged = true;
314 });
315 }
316
317 const promise = makeMainPromise();
318
319 // If the data on the main instance changes,
320 // pass it on to other instances.
321 scope.addEventListener("data", async (event: AppletEvent) => {
322 await promise;
323
324 if (isMain()) {
325 channel.postMessage({
326 type: "data",
327 data: context.codec.encode(event.data),
328 });
329 }
330 });
331
332 // Action handler
333 const setActionHandler = <H extends Function>(actionId: string, actionHandler: H) => {
334 const handler = async (...args: any) => {
335 if (isMain()) {
336 return actionHandler(...args);
337 }
338
339 // Check if a main instance is still available,
340 // if not, then this is the new main.
341 const promised = await makeMainPromise();
342 setIsMain(promised.isMain);
343
344 if (isMain()) {
345 return actionHandler(...args);
346 }
347
348 const actionMessage = {
349 actionInstanceId: crypto.randomUUID(),
350 actionId,
351 type: "action",
352 arguments: args,
353 };
354
355 return await new Promise((resolve) => {
356 const actionCallback = (event: MessageEvent) => {
357 if (
358 event.data?.type === "actioncomplete" &&
359 event.data?.actionInstanceId === actionMessage.actionInstanceId
360 ) {
361 channel.removeEventListener("message", actionCallback);
362 resolve(event.data.result);
363 }
364 };
365
366 channel.addEventListener("message", actionCallback);
367 channel.postMessage(actionMessage);
368 });
369 };
370
371 scope.setActionHandler(actionId, handler);
372 };
373
374 // Before unload
375 self.addEventListener("beforeunload", (event) => {
376 if (context.isMainInstance()) {
377 channel.postMessage({
378 type: "UNLOADED",
379 });
380 }
381 });
382
383 // Fin
384 return {
385 channel,
386 mainSignal,
387 promise,
388 setActionHandler,
389 };
390}
391
392////////////////////////////////////////////
393// 🔮 Reactive state management
394////////////////////////////////////////////
395export function reactive<D, T>(
396 applet: Applet<D> | AppletScope<D>,
397 dataFn: (data: D) => T,
398 effectFn: (t: T) => void,
399) {
400 let value = dataFn(applet.data);
401 effectFn(value);
402
403 applet.addEventListener("data", (event: AppletEvent) => {
404 const newData = dataFn(event.data);
405 if (newData !== value) {
406 value = newData;
407 effectFn(value);
408 }
409 });
410}
411
412////////////////////////////////////////////
413// ⚡️ COMMON ACTION CALLS
414////////////////////////////////////////////
415
416export async function inputUrl(input: Applet, uri: string, method = "GET") {
417 return await input.sendAction<ResolvedUri>(
418 "resolve",
419 {
420 method,
421 uri,
422 },
423 {
424 timeoutDuration: 60000 * 5,
425 worker: true,
426 },
427 );
428}
429
430////////////////////////////////////////////
431// 🛠️
432////////////////////////////////////////////
433export function addScope<O extends object>(astroScope: string, object: O): O {
434 return {
435 ...object,
436 attrs: {
437 ...((object as any).attrs || {}),
438 [`data-astro-cid-${astroScope}`]: "",
439 },
440 };
441}
442
443export function appletScopePort() {
444 let port: MessagePort | undefined;
445
446 function connection(event: AppletEvent) {
447 if (event.data?.type === "appletconnect") {
448 window.removeEventListener("message", connection);
449 port = (event as any).ports[0];
450 }
451 }
452
453 window.addEventListener("message", connection);
454
455 return () => port;
456}
457
458export function hs(
459 tag: string,
460 astroScope: string,
461 props?: Record<string, unknown> | Signal<Record<string, unknown>>,
462 configure?: ElementConfigurator,
463) {
464 const propsWithScope =
465 props && isSignal(props)
466 ? () => addScope(astroScope, props())
467 : addScope(astroScope, props || {});
468
469 return h(tag, propsWithScope, configure);
470}
471
472export function wait<A>(applet: Applet<A>, dataFn: (a: A | undefined) => boolean): Promise<void> {
473 return new Promise((resolve) => {
474 if (dataFn(applet.data) === true) {
475 resolve();
476 return;
477 }
478
479 const callback = (event: AppletEvent) => {
480 if (dataFn(event.data) === true) {
481 applet.removeEventListener("data", callback);
482 resolve();
483 }
484 };
485
486 applet.addEventListener("data", callback);
487 });
488}