Files
webmail/lib/plugin-sandbox/sandbox-document.ts
T
Linus Rath e38e326efb perf: load plugin frames as a plain page instead of a Next app
Every plugin iframe loaded /plugin-sandbox, a force-dynamic Next page.
Each frame then fetched, parsed and hydrated Next's client router on top
of React and the runtime, and the server rendered the page once per
frame, one after another when a dozen plugins started together.
Sandboxed frames have opaque origins, so the browser's code cache never
helps, and they run on the host page's main thread: with 12 plugins
the frames took 2.5-3 s to come up after the app had loaded.

The sandbox routes now return a small HTML page with the UI kit's
stylesheet and the runtime as an ES module, both built ahead of time:

- `scripts/build-plugin-sandbox.mjs` bundles the runtime with esbuild
  and compiles `plugin-kit.css` with the app's Tailwind setup into
  `public/plugin-sandbox-assets/`, with a manifest. Named Tabler imports
  are pointed at the icons' own files, so the full icon set stays in a
  chunk that only loads for an icon name the kit doesn't ship.
- `npm run build`, `npm run dev`, the Dockerfile and the perf-guard
  workflow run it before `next build`.
- `lib/plugin-sandbox/sandbox-document.ts` writes the page; proxy.ts
  still sets the sandbox CSP and the script carries its nonce.
- The assets are served from public/ with CORS and immutable caching.

A frame on an idle page is ready in 255 ms instead of 362 ms, eleven at
once in 1.0 s instead of 2.1 s.
2026-10-25 02:01:47 +01:00

64 lines
2.9 KiB
TypeScript

// The document of every plugin iframe (/plugin-sandbox and
// /plugin-sandbox-privileged): a root element, the UI kit's stylesheet and
// the runtime script, built by scripts/build-plugin-sandbox.mjs into
// public/plugin-sandbox-assets/.
//
// It used to be a Next page. Each frame then loaded and hydrated Next's
// client router as well (~250 kB more to parse in every frame, on the host
// page's main thread), and the server rendered the page per frame, one after
// another when a dozen plugins started at once. proxy.ts still sets the
// sandbox CSP on this response; the script carries its nonce.
import { readFile } from 'node:fs/promises';
import path from 'node:path';
export const SANDBOX_ASSETS_PATH = '/plugin-sandbox-assets';
interface SandboxAssets {
script: string;
style: string;
/** Chunks the script imports statically. */
preload: string[];
}
let cached: Promise<SandboxAssets> | null = null;
function loadAssets(): Promise<SandboxAssets> {
// `next dev` keeps running while the assets are rebuilt.
if (cached && process.env.NODE_ENV !== 'development') return cached;
cached = readFile(path.join(process.cwd(), 'public', SANDBOX_ASSETS_PATH, 'manifest.json'), 'utf8')
.then((text) => {
const m = JSON.parse(text) as Partial<SandboxAssets>;
if (typeof m.script !== 'string' || typeof m.style !== 'string') throw new Error('bad manifest');
const preload = Array.isArray(m.preload) ? m.preload.filter((p): p is string => typeof p === 'string') : [];
return { script: m.script, style: m.style, preload };
});
cached.catch(() => { cached = null; });
return cached;
}
const escapeAttr = (v: string) => v.replace(/[&"<>]/g, (c) => `&#${c.charCodeAt(0)};`);
export async function sandboxDocument(request: Request): Promise<Response> {
let assets: SandboxAssets;
try {
assets = await loadAssets();
} catch {
return new Response('The plugin sandbox is not built. Run `node scripts/build-plugin-sandbox.mjs`.', {
status: 503,
headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' },
});
}
const nonce = escapeAttr(request.headers.get('x-nonce') ?? '');
const base = `${process.env.NEXT_PUBLIC_BASE_PATH ?? ''}${SANDBOX_ASSETS_PATH}`;
const html = '<!doctype html><html lang="en"><head><meta charset="utf-8">'
+ '<meta name="robots" content="noindex, nofollow"><title>Plugin sandbox</title>'
+ `<link rel="stylesheet" href="${escapeAttr(`${base}/${assets.style}`)}">`
+ assets.preload.map((p) => `<link rel="modulepreload" nonce="${nonce}" href="${escapeAttr(`${base}/${p}`)}">`).join('')
+ `<script type="module" nonce="${nonce}" src="${escapeAttr(`${base}/${assets.script}`)}"></script>`
+ '</head><body style="margin:0;padding:0;background:transparent"><div id="plugin-sandbox-root"></div></body></html>';
return new Response(html, {
headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
});
}