// 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[]; /** Chunks of the renderer (React DOM and the UI kit), for slot frames. */ slotPreload: string[]; } let cached: Promise | null = null; function loadAssets(): Promise { // `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; if (typeof m.script !== 'string' || typeof m.style !== 'string') throw new Error('bad manifest'); const names = (v: unknown) => (Array.isArray(v) ? v.filter((p): p is string => typeof p === 'string') : []); return { script: m.script, style: m.style, preload: names(m.preload), slotPreload: names(m.slotPreload) }; }); cached.catch(() => { cached = null; }); return cached; } const escapeAttr = (v: string) => v.replace(/[&"<>]/g, (c) => `&#${c.charCodeAt(0)};`); export async function sandboxDocument(request: Request): Promise { 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 slot = new URL(request.url).searchParams.get('frame') === 'slot'; const preload = slot ? [...assets.preload, ...assets.slotPreload] : assets.preload; const html = '' + 'Plugin sandbox' + `` + preload.map((p) => ``).join('') + `` + '
'; return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }, }); }