iframe widget pooling (experimental)

pull/565/head
Zef Hemel 2023-11-12 11:50:49 +01:00
parent 1b242dea55
commit f3d72ca6f4
5 changed files with 188 additions and 102 deletions

View File

@ -57,7 +57,6 @@ export async function renderMentions() {
"ps",
1,
` <style>${css}</style>
<link rel="stylesheet" href="/.client/main.css" />
<div id="sb-main"><div id="sb-editor"><div class="cm-editor">
<div id="button-bar">
<button id="reload-button" title="Reload"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg></button>

View File

@ -1,9 +1,8 @@
/* Reset SB styles */
html,
body {
height: initial !important;
overflow: initial !important;
background-color: var(--root-background-color);
font-family: var(--editor-font);
color: var(--root-color);
}
#sb-main {
@ -26,11 +25,6 @@ body {
height: initial !important;
}
body {
font-family: var(--editor-font);
color: var(--root-color);
}
ul,
ol {
margin-top: 0;

View File

@ -33,8 +33,6 @@ export async function wrapHTML(html: string): Promise<string> {
const css = await asset.readAsset("assets/markdown_widget.css");
return `
<!-- Load SB's own CSS here too -->
<link rel="stylesheet" href="/.client/main.css" />
<!-- In addition to some custom CSS -->
<style>${css}</style>
<!-- Wrap the whole thing in something SB-like to get access to styles -->

View File

@ -99,6 +99,16 @@ function loadJsByUrl(url) {
});
}
</script>
<!-- Load SB's own CSS here too -->
<link rel="stylesheet" href="/.client/main.css" />
<style>
html,
body {
height: initial !important;
overflow: initial !important;
background-color: var(--root-background-color);
}
</style>
</head>
<body>

View File

@ -2,100 +2,185 @@ import { WidgetContent } from "$sb/app_event.ts";
import { Client } from "../client.ts";
import { panelHtml } from "./panel_html.ts";
/**
* 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>();
const desiredPoolSize = 5;
const gcInterval = 5000;
function populateIFramePool() {
while (iframePool.size < desiredPoolSize) {
iframePool.add(prepareSandboxIFrame());
}
}
populateIFramePool();
setInterval(() => {
// Iterate over all iframes
for (const preloadedIframe of iframePool) {
if (
// Is this iframe in use, but has it since been removed from the DOM?
preloadedIframe.used && !document.body.contains(preloadedIframe.iframe)
) {
// Ditch it
console.log("Garbage collecting iframe", preloadedIframe);
iframePool.delete(preloadedIframe);
}
}
populateIFramePool();
}, gcInterval);
export function prepareSandboxIFrame(): PreloadedIFrame {
console.log("Preloading iframe");
const iframe = document.createElement("iframe");
iframe.src = "about:blank";
const ready = new Promise<void>((resolve) => {
iframe.onload = () => {
iframe.contentDocument!.write(panelHtml);
resolve();
};
});
return {
iframe,
used: false,
ready,
};
}
function claimIFrame(): PreloadedIFrame {
for (const preloadedIframe of iframePool) {
if (!preloadedIframe.used) {
console.log("Took iframe from pool");
preloadedIframe.used = true;
return preloadedIframe;
}
}
// Nothing available in the pool, let's spin up a new one and add it to the pool
const newPreloadedIFrame = prepareSandboxIFrame();
newPreloadedIFrame.used = true;
iframePool.add(newPreloadedIFrame);
return newPreloadedIFrame;
}
export function mountIFrame(
preloadedIFrame: PreloadedIFrame,
client: Client,
widgetHeightCacheKey: string | null,
content: WidgetContent | Promise<WidgetContent>,
onMessage?: (message: any) => void,
) {
const iframe = preloadedIFrame.iframe;
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,
});
}
break;
}
case "setHeight":
iframe.style.height = data.height + "px";
if (widgetHeightCacheKey) {
client.space.setCachedWidgetHeight(
widgetHeightCacheKey,
data.height,
);
}
break;
default:
if (onMessage) {
onMessage(data);
}
}
})().catch((e) => {
console.error("Message listener error", e);
});
};
// Subscribe to message event on global object (to receive messages from iframe)
globalThis.addEventListener("message", messageListener);
// Only run this code once
iframe.onload = null;
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) {
iframe.style.height = resolvedContent.height + "px";
}
if (resolvedContent.width) {
iframe.style.width = resolvedContent.width + "px";
}
}
}).catch(console.error);
}
export function createWidgetSandboxIFrame(
client: Client,
widgetHeightCacheKey: string | null,
content: WidgetContent | Promise<WidgetContent>,
onMessage?: (message: any) => void,
) {
const iframe = document.createElement("iframe");
iframe.src = "about:blank";
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,
});
}
break;
}
case "setHeight":
iframe.style.height = data.height + "px";
if (widgetHeightCacheKey) {
client.space.setCachedWidgetHeight(
widgetHeightCacheKey,
data.height,
);
}
break;
default:
if (onMessage) {
onMessage(data);
}
}
})().catch((e) => {
console.error("Message listener error", e);
});
};
iframe.onload = () => {
iframe.contentDocument!.write(panelHtml);
// Subscribe to message event on global object (to receive messages from iframe)
globalThis.addEventListener("message", messageListener);
// Only run this code once
iframe.onload = null;
Promise.resolve(content).then((content) => {
if (!iframe.contentWindow) {
console.warn("Iframe went away or content was not loaded");
return;
}
if (content.html) {
iframe.contentWindow!.postMessage({
type: "html",
html: content.html,
script: content.script,
theme: document.getElementsByTagName("html")[0].dataset.theme,
});
} else if (content.url) {
iframe.contentWindow!.location.href = content.url;
if (content.height) {
iframe.style.height = content.height + "px";
}
if (content.width) {
iframe.style.width = content.width + "px";
}
}
}).catch(console.error);
};
return iframe;
// console.log("Claiming iframe");
const preloadedIFrame = claimIFrame();
mountIFrame(
preloadedIFrame,
client,
widgetHeightCacheKey,
content,
onMessage,
);
return preloadedIFrame.iframe;
}