1
0
silverbullet/web/components/widget_sandbox_iframe.ts

213 lines
6.1 KiB
TypeScript
Raw Normal View History

import { WidgetContent } from "$sb/app_event.ts";
import { Client } from "../client.ts";
import { panelHtml } from "./panel_html.ts";
2023-11-12 10:50:49 +00:00
/**
* Implements sandbox widgets using iframe with a pooling mechanism to speed up loading
*/
type PreloadedIFrame = {
// The wrapped iframe element
iframe: HTMLIFrameElement;
// Has the iframe been used yet?
used: boolean;
// Is it ready (that is: has the initial load happened)
ready: Promise<void>;
};
const iframePool = new Set<PreloadedIFrame>();
2023-11-15 08:31:52 +00:00
const desiredPoolSize = 3;
2023-11-12 10:50:49 +00:00
2023-11-15 08:31:52 +00:00
updatePool();
2023-11-12 10:50:49 +00:00
2023-11-15 08:31:52 +00:00
function updatePool(exclude?: PreloadedIFrame) {
let availableFrames = 0;
2023-11-12 10:50:49 +00:00
// Iterate over all iframes
for (const preloadedIframe of iframePool) {
2023-11-15 08:31:52 +00:00
if (preloadedIframe === exclude) {
continue;
}
2023-11-12 10:50:49 +00:00
if (
// Is this iframe in use, but has it since been removed from the DOM?
preloadedIframe.used && !document.body.contains(preloadedIframe.iframe)
) {
// Ditch it
2023-11-16 08:59:37 +00:00
// console.log("Garbage collecting iframe", preloadedIframe);
2023-11-12 10:50:49 +00:00
iframePool.delete(preloadedIframe);
}
2023-11-15 08:31:52 +00:00
if (!preloadedIframe.used) {
availableFrames++;
}
}
// And after, add more iframes if needed
for (let i = 0; i < desiredPoolSize - availableFrames; i++) {
iframePool.add(prepareSandboxIFrame());
2023-11-12 10:50:49 +00:00
}
2023-11-15 08:31:52 +00:00
}
2023-11-12 10:50:49 +00:00
export function prepareSandboxIFrame(): PreloadedIFrame {
2023-11-16 08:59:37 +00:00
// console.log("Preloading iframe");
2023-11-12 10:50:49 +00:00
const iframe = document.createElement("iframe");
2023-11-15 08:31:52 +00:00
// Empty page with current origin. Handled this differently before, but "dock apps" in Safari (PWA implementation) seem to have various restrictions
// This one works in all browsers, although it's probably less secure
2023-11-12 10:50:49 +00:00
iframe.src = "about:blank";
const ready = new Promise<void>((resolve) => {
iframe.onload = () => {
iframe.contentDocument!.write(panelHtml);
2023-11-15 08:31:52 +00:00
// Now ready to use
2023-11-12 10:50:49 +00:00
resolve();
};
});
return {
iframe,
used: false,
ready,
};
}
function claimIFrame(): PreloadedIFrame {
for (const preloadedIframe of iframePool) {
if (!preloadedIframe.used) {
2023-11-16 08:59:37 +00:00
// console.log("Took iframe from pool");
2023-11-12 10:50:49 +00:00
preloadedIframe.used = true;
2023-11-15 08:31:52 +00:00
updatePool(preloadedIframe);
2023-11-12 10:50:49 +00:00
return preloadedIframe;
}
}
// Nothing available in the pool, let's spin up a new one and add it to the pool
2023-11-15 09:08:21 +00:00
console.warn("Had to create a new iframe on the fly, this shouldn't happen");
2023-11-12 10:50:49 +00:00
const newPreloadedIFrame = prepareSandboxIFrame();
newPreloadedIFrame.used = true;
iframePool.add(newPreloadedIFrame);
return newPreloadedIFrame;
}
2023-11-15 09:08:21 +00:00
export function broadcastReload() {
for (const preloadedIframe of iframePool) {
2023-11-21 12:26:48 +00:00
if (preloadedIframe.used && preloadedIframe.iframe?.contentWindow) {
2023-11-15 09:08:21 +00:00
// Send a message to the global object, which the iframe is listening to
globalThis.dispatchEvent(
new MessageEvent("message", {
source: preloadedIframe.iframe.contentWindow,
data: {
type: "reload",
},
}),
);
}
}
}
2023-11-12 10:50:49 +00:00
export function mountIFrame(
preloadedIFrame: PreloadedIFrame,
client: Client,
widgetHeightCacheKey: string | null,
content: WidgetContent | Promise<WidgetContent>,
onMessage?: (message: any) => void,
) {
2023-11-12 10:50:49 +00:00
const iframe = preloadedIFrame.iframe;
2023-11-16 08:59:37 +00:00
2023-11-12 10:50:49 +00:00
preloadedIFrame.ready.then(async () => {
const messageListener = (evt: any) => {
(async () => {
if (evt.source !== iframe.contentWindow) {
return;
}
const data = evt.data;
if (!data) {
return;
}
switch (data.type) {
case "syscall": {
const { id, name, args } = data;
try {
const result = await client.system.localSyscall(name, args);
if (!iframe.contentWindow) {
// iFrame already went away
return;
}
iframe.contentWindow!.postMessage({
type: "syscall-response",
id,
result,
});
} catch (e: any) {
if (!iframe.contentWindow) {
// iFrame already went away
return;
}
iframe.contentWindow!.postMessage({
type: "syscall-response",
id,
error: e.message,
});
}
2023-11-12 10:50:49 +00:00
break;
}
2023-11-12 10:50:49 +00:00
case "setHeight":
2023-11-16 08:59:37 +00:00
iframe.height = data.height + "px";
2023-11-12 10:50:49 +00:00
if (widgetHeightCacheKey) {
client.setCachedWidgetHeight(
2023-11-12 10:50:49 +00:00
widgetHeightCacheKey,
data.height,
);
}
break;
default:
if (onMessage) {
onMessage(data);
}
}
2023-11-12 10:50:49 +00:00
})().catch((e) => {
console.error("Message listener error", e);
});
};
2023-10-09 19:02:57 +00:00
// Subscribe to message event on global object (to receive messages from iframe)
globalThis.addEventListener("message", messageListener);
// Only run this code once
iframe.onload = null;
2023-11-12 10:50:49 +00:00
const resolvedContent = await Promise.resolve(content);
if (!iframe.contentWindow) {
console.warn("Iframe went away or content was not loaded");
return;
}
if (resolvedContent.html) {
iframe.contentWindow!.postMessage({
type: "html",
html: resolvedContent.html,
script: resolvedContent.script,
theme: document.getElementsByTagName("html")[0].dataset.theme,
});
} else if (resolvedContent.url) {
iframe.contentWindow!.location.href = resolvedContent.url;
if (resolvedContent.height) {
2023-11-16 08:59:37 +00:00
iframe.height = resolvedContent.height + "px";
2023-10-10 07:38:06 +00:00
}
2023-11-12 10:50:49 +00:00
if (resolvedContent.width) {
2023-11-16 08:59:37 +00:00
iframe.width = resolvedContent.width + "px";
}
2023-11-12 10:50:49 +00:00
}
}).catch(console.error);
}
2023-11-12 10:50:49 +00:00
export function createWidgetSandboxIFrame(
client: Client,
widgetHeightCacheKey: string | null,
content: WidgetContent | Promise<WidgetContent>,
onMessage?: (message: any) => void,
) {
// console.log("Claiming iframe");
const preloadedIFrame = claimIFrame();
mountIFrame(
preloadedIFrame,
client,
widgetHeightCacheKey,
content,
onMessage,
);
return preloadedIFrame.iframe;
}