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
37 if (opts.groupId) {
38 query = { groupId: opts.groupId };
39 }
40
41 if (query) {
42 src = QS.stringifyUrl({ url: src, query });
43 }
44
45 const context = opts.newInstance ? self : opts.context || self.top || self.parent;
46 const existingFrame: HTMLIFrameElement | null = opts.newInstance
47 ? null
48 : context.document.querySelector(`[src="${src}"]`);
49
50 let frame;
51
52 if (existingFrame) {
53 frame = existingFrame;
54 } else {
55 frame = document.createElement("iframe");
56 frame.loading = "eager";
57 frame.src = src;
58 if (opts.frameId) frame.id = opts.frameId;
59
60 if (opts.container) {
61 opts.container.appendChild(frame);
62 } else {
63 context.document.body.appendChild(frame);
64 }
65 }
66
67 if (frame.contentWindow === null) {
68 throw new Error("iframe does not have a contentWindow");
69 }
70
71 const applet = await applets.connect<D>(frame.contentWindow, { context }).catch((err) => {
72 console.error("Error connecting to " + src, err);
73 throw err;
74 });
75
76 if (opts.setHeight) {
77 applet.onresize = () => {
78 frame.height = `${applet.height}px`;
79 frame.classList.add("has-loaded");
80 };
81 } else {
82 if (frame.contentDocument?.readyState === "complete") {
83 frame.classList.add("has-loaded");
84 }
85
86 frame.addEventListener("load", () => {
87 frame.classList.add("has-loaded");
88 });
89 }
90
91 return applet;
92}
93
94export function tunnel(
95 worker: Comlink.Remote<WorkerTasks>,
96 connections: Record<string, Applet | Promise<Applet>>,
97) {
98 Object.entries(connections).forEach(([scheme, promise]) => {
99 Promise.resolve(promise).then((conn) => {
100 return worker._manage(scheme, transfer(conn.ports.worker));
101 });
102 });
103}
104
105////////////////////////////////////////////
106// 🪟 Applet registration
107////////////////////////////////////////////
108export type DiffuseApplet<T> = {
109 groupId: string | undefined;
110 scope: AppletScope<T>;
111
112 settled(): Promise<void>;
113
114 get instanceId(): string;
115 set data(data: T);
116
117 codec: Codec<T>;
118
119 isMainInstance(): boolean | null;
120 setActionHandler<H extends Function>(actionId: string, actionHandler: H): void;
121};
122
123export type Codec<T> = {
124 decode(data: any): T;
125 encode(data: T): any;
126};
127
128export function lookupGroupId() {
129 const url = new URL(location.href);
130 return url.searchParams.get("groupId") || "main";
131}
132
133export function register<DataType = any>(
134 options: { mode?: "broadcast" | "shared-worker"; worker?: Comlink.Remote<WorkerTasks> } = {},
135): DiffuseApplet<DataType> {
136 const mode = options.mode ?? "broadcast";
137 const scope = applets.register<DataType>();
138
139 const groupId = lookupGroupId();
140 const channelId = `${location.host}${location.pathname}/${groupId}`;
141 const instanceId = crypto.randomUUID();
142
143 // Codec
144 const codec = {
145 decode: (data: any) => data as DataType,
146 encode: (data: DataType) => data as any,
147 };
148
149 // Channel
150 const channelContext =
151 mode === "broadcast"
152 ? broadcastChannel({
153 channelId,
154 codec,
155 instanceId,
156 scope,
157 })
158 : undefined;
159
160 // Context
161 const context: DiffuseApplet<DataType> = {
162 groupId,
163 scope,
164
165 settled() {
166 return channelContext?.promise.then(() => {}) ?? Promise.resolve();
167 },
168
169 get instanceId() {
170 return instanceId;
171 },
172
173 get data() {
174 return scope.data;
175 },
176
177 set data(data: DataType) {
178 scope.data = data;
179 },
180
181 codec,
182
183 isMainInstance() {
184 return channelContext?.mainSignal[0]() ?? null;
185 },
186
187 setActionHandler: <H extends Function>(actionId: string, actionHandler: H) => {
188 switch (mode) {
189 case "broadcast":
190 return channelContext?.setActionHandler(actionId, actionHandler);
191
192 case "shared-worker":
193 return scope.setActionHandler(actionId, actionHandler);
194 }
195 },
196 };
197
198 if (options.worker) {
199 context.scope.onworkerport = (event) => {
200 if (!event.port) return;
201 options.worker?._listen(transfer(event.port));
202 };
203 }
204
205 return context;
206}
207
208function broadcastChannel<DataType>({
209 channelId,
210 codec,
211 instanceId,
212 scope,
213}: {
214 channelId: string;
215 codec: Codec<DataType>;
216 instanceId: string;
217 scope: AppletScope<DataType>;
218}) {
219 const mainSignal = signal<boolean>(true);
220 const [isMain, setIsMain] = mainSignal;
221
222 // One instance to rule them all
223 //
224 // Ping other instances to see if there are any.
225 // As long as there aren't any, it is considered the main instance.
226 //
227 // Actions are performed on the main instance,
228 // and data is replicated from main to the other instances.
229 const channel = new BroadcastChannel(channelId);
230
231 channel.addEventListener("message", async (event) => {
232 switch (event.data?.type) {
233 case "PING": {
234 channel.postMessage({
235 type: "PONG",
236 instanceId: event.data.instanceId,
237 });
238
239 if (isMain()) {
240 channel.postMessage({
241 type: "data",
242 data: codec.encode(scope.data),
243 });
244 }
245 break;
246 }
247
248 case "PONG": {
249 if (event.data.instanceId === instanceId) {
250 setIsMain(false);
251 }
252 break;
253 }
254
255 case "action": {
256 if (isMain()) {
257 const result = await scope.actionHandlers[event.data.actionId]?.(...event.data.arguments);
258 channel.postMessage({
259 type: "actioncomplete",
260 actionInstanceId: event.data.actionInstanceId,
261 result,
262 });
263 }
264 break;
265 }
266
267 case "data": {
268 scope.data = codec.decode(event.data.data);
269 break;
270 }
271 }
272 });
273
274 // Promise that fullfills whenever it figures out its the main instance or not.
275 function makeMainPromise() {
276 return new Promise<{ isMain: boolean }>((resolve) => {
277 const timeoutId = setTimeout(() => {
278 channel.removeEventListener("message", handler);
279 resolve({ isMain: true });
280 }, 5000);
281
282 const handler = (event: MessageEvent) => {
283 if (event.data?.type === "PONG" || event.data?.type === "PING") {
284 clearTimeout(timeoutId);
285 channel.removeEventListener("message", handler);
286 resolve({ isMain: false });
287 }
288 };
289
290 channel.addEventListener("message", handler);
291 channel.postMessage({
292 type: "PING",
293 instanceId,
294 });
295 });
296 }
297
298 const promise = makeMainPromise();
299
300 // If the data on the main instance changes,
301 // pass it on to other instances.
302 scope.addEventListener("data", async (event: AppletEvent) => {
303 await promise;
304
305 if (isMain()) {
306 channel.postMessage({
307 type: "data",
308 data: codec.encode(event.data),
309 });
310 }
311 });
312
313 // Action handler
314 const setActionHandler = <H extends Function>(actionId: string, actionHandler: H) => {
315 const handler = async (...args: any) => {
316 if (isMain()) {
317 return actionHandler(...args);
318 }
319
320 // Check if a main instance is still available,
321 // if not, then this is the new main.
322 const promised = await makeMainPromise();
323 setIsMain(promised.isMain);
324
325 if (isMain()) {
326 return actionHandler(...args);
327 }
328
329 const actionMessage = {
330 actionInstanceId: crypto.randomUUID(),
331 actionId,
332 type: "action",
333 arguments: args,
334 };
335
336 return await new Promise((resolve) => {
337 const actionCallback = (event: MessageEvent) => {
338 if (
339 event.data?.type === "actioncomplete" &&
340 event.data?.actionInstanceId === actionMessage.actionInstanceId
341 ) {
342 channel.removeEventListener("message", actionCallback);
343 resolve(event.data.result);
344 }
345 };
346
347 channel.addEventListener("message", actionCallback);
348 channel.postMessage(actionMessage);
349 });
350 };
351
352 scope.setActionHandler(actionId, handler);
353 };
354
355 // Fin
356 return {
357 channel,
358 mainSignal,
359 promise,
360 setActionHandler,
361 };
362}
363
364////////////////////////////////////////////
365// 🔮 Reactive state management
366////////////////////////////////////////////
367export function reactive<D, T>(
368 applet: Applet<D> | AppletScope<D>,
369 dataFn: (data: D) => T,
370 effectFn: (t: T) => void,
371) {
372 let value = dataFn(applet.data);
373 effectFn(value);
374
375 applet.addEventListener("data", (event: AppletEvent) => {
376 const newData = dataFn(event.data);
377 if (newData !== value) {
378 value = newData;
379 effectFn(value);
380 }
381 });
382}
383
384////////////////////////////////////////////
385// ⚡️ COMMON ACTION CALLS
386////////////////////////////////////////////
387
388export async function inputUrl(input: Applet, uri: string, method = "GET") {
389 return await input.sendAction<ResolvedUri>(
390 "resolve",
391 {
392 method,
393 uri,
394 },
395 {
396 timeoutDuration: 60000 * 5,
397 worker: true,
398 },
399 );
400}
401
402////////////////////////////////////////////
403// 🛠️
404////////////////////////////////////////////
405export function addScope<O extends object>(astroScope: string, object: O): O {
406 return {
407 ...object,
408 attrs: {
409 ...((object as any).attrs || {}),
410 [`data-astro-cid-${astroScope}`]: "",
411 },
412 };
413}
414
415export function appletScopePort() {
416 let port: MessagePort | undefined;
417
418 function connection(event: AppletEvent) {
419 if (event.data?.type === "appletconnect") {
420 window.removeEventListener("message", connection);
421 port = (event as any).ports[0];
422 }
423 }
424
425 window.addEventListener("message", connection);
426
427 return () => port;
428}
429
430export function hs(
431 tag: string,
432 astroScope: string,
433 props?: Record<string, unknown> | Signal<Record<string, unknown>>,
434 configure?: ElementConfigurator,
435) {
436 const propsWithScope =
437 props && isSignal(props)
438 ? () => addScope(astroScope, props())
439 : addScope(astroScope, props || {});
440
441 return h(tag, propsWithScope, configure);
442}
443
444export function wait<A>(applet: Applet<A>, dataFn: (a: A | undefined) => boolean): Promise<void> {
445 return new Promise((resolve) => {
446 if (dataFn(applet.data) === true) {
447 resolve();
448 return;
449 }
450
451 const callback = (event: AppletEvent) => {
452 if (dataFn(event.data) === true) {
453 applet.removeEventListener("data", callback);
454 resolve();
455 }
456 };
457
458 applet.addEventListener("data", callback);
459 });
460}