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