From e38e326efb5b736de8e5a4ab3ac1a3102acfe18a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:01:47 +0100 Subject: [PATCH] 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. --- .dockerignore | 3 + .github/workflows/perf-guard.yml | 2 +- .gitignore | 2 + Dockerfile | 1 + app/(sandbox)/layout.tsx | 32 ----- app/(sandbox)/plugin-kit.css | 2 +- .../plugin-sandbox-privileged/page.tsx | 15 -- .../plugin-sandbox-privileged/route.ts | 12 ++ app/(sandbox)/plugin-sandbox/page.tsx | 10 -- app/(sandbox)/plugin-sandbox/route.ts | 9 ++ lib/__tests__/plugin-sandbox-build.test.ts | 46 ++++++ lib/__tests__/plugin-sandbox-document.test.ts | 73 ++++++++++ lib/__tests__/proxy-security-headers.test.ts | 4 + lib/plugin-sandbox/host-theme.ts | 2 +- lib/plugin-sandbox/protocol.ts | 2 +- lib/plugin-sandbox/runtime.tsx | 58 ++++---- lib/plugin-sandbox/sandbox-document.ts | 63 +++++++++ lib/plugin-sandbox/sandbox-entry.ts | 7 + next.config.ts | 10 ++ package.json | 4 +- proxy.ts | 4 +- scripts/build-plugin-sandbox.mjs | 133 ++++++++++++++++++ 22 files changed, 404 insertions(+), 90 deletions(-) delete mode 100644 app/(sandbox)/layout.tsx delete mode 100644 app/(sandbox)/plugin-sandbox-privileged/page.tsx create mode 100644 app/(sandbox)/plugin-sandbox-privileged/route.ts delete mode 100644 app/(sandbox)/plugin-sandbox/page.tsx create mode 100644 app/(sandbox)/plugin-sandbox/route.ts create mode 100644 lib/__tests__/plugin-sandbox-build.test.ts create mode 100644 lib/__tests__/plugin-sandbox-document.test.ts create mode 100644 lib/plugin-sandbox/sandbox-document.ts create mode 100644 lib/plugin-sandbox/sandbox-entry.ts create mode 100644 scripts/build-plugin-sandbox.mjs diff --git a/.dockerignore b/.dockerignore index b6764a50f..68da62e5b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,6 +9,9 @@ lite-image !.env.dev.example scripts/ !scripts/lite/ +!scripts/build-plugin-sandbox.mjs +# Built in the image by scripts/build-plugin-sandbox.mjs +public/plugin-sandbox-assets/ TODO.md *.md !README.md diff --git a/.github/workflows/perf-guard.yml b/.github/workflows/perf-guard.yml index 3d4e08e4f..4336d783c 100644 --- a/.github/workflows/perf-guard.yml +++ b/.github/workflows/perf-guard.yml @@ -41,7 +41,7 @@ jobs: DEV_MOCK_JMAP: "true" NEXT_TELEMETRY_DISABLED: "1" GIT_COMMIT: ${{ github.sha }} - run: npx next build --webpack + run: node scripts/build-plugin-sandbox.mjs && npx next build --webpack - name: Run perf guard env: diff --git a/.gitignore b/.gitignore index 276b85604..dd9bc4281 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ /.next/ /out/ /lite-image/ +# scripts/build-plugin-sandbox.mjs +/public/plugin-sandbox-assets/ # production /build diff --git a/Dockerfile b/Dockerfile index c9efc1cfa..f41759013 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,7 @@ RUN rm -rf "app/api/dev-jmap" && \ find . -path ./node_modules -prune -o \ \( -name __tests__ -o -name "*.test.ts" -o -name "*.test.tsx" \) \ -prune -exec rm -rf {} \; && \ + node scripts/build-plugin-sandbox.mjs && \ npx next build --webpack FROM node:24-alpine AS runner diff --git a/app/(sandbox)/layout.tsx b/app/(sandbox)/layout.tsx deleted file mode 100644 index 032544b81..000000000 --- a/app/(sandbox)/layout.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { Metadata } from 'next'; -import type { ReactNode } from 'react'; -import './plugin-kit.css'; - -// The plugin sandbox iframe runs with an opaque origin (the `sandbox` -// attribute in production excludes `allow-same-origin` for isolation). Any -// asset request from this layout is then cross-origin from the "null" origin -// to the host origin. Fonts need CORS, so there are no font imports. The one -// stylesheet is plugin-kit.css, the styles of the plugin UI kit: Next serves -// it from /_next/static, which answers with Access-Control-Allow-Origin: * -// (next.config.ts), and the sandbox CSP allows styles from 'self'. Plugin -// bundles and host API calls travel over the postMessage RPC bridge. - -export const metadata: Metadata = { - title: 'Plugin sandbox', - robots: { index: false, follow: false }, -}; - -export default function PluginSandboxLayout({ children }: { children: ReactNode }) { - return ( - - - {/* next dev puts its indicator into every document, so each plugin - slot showed its own badge. Plugins have no use for it. */} - {process.env.NODE_ENV === 'development' && ( - - )} - {children} - - - ); -} diff --git a/app/(sandbox)/plugin-kit.css b/app/(sandbox)/plugin-kit.css index 6265d9eae..ff06f66b4 100644 --- a/app/(sandbox)/plugin-kit.css +++ b/app/(sandbox)/plugin-kit.css @@ -1,5 +1,5 @@ /* Styles for the plugin UI kit (lib/plugin-sandbox/ui-kit), loaded into - every plugin iframe. Built from the same Tailwind classes as the app's own + every plugin iframe; scripts/build-plugin-sandbox.mjs compiles it. Built from the same Tailwind classes as the app's own components, so a kit Button is the app's Button. Only the kit's sources are scanned, which keeps the file small. diff --git a/app/(sandbox)/plugin-sandbox-privileged/page.tsx b/app/(sandbox)/plugin-sandbox-privileged/page.tsx deleted file mode 100644 index 0f3d431d3..000000000 --- a/app/(sandbox)/plugin-sandbox-privileged/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime'; - -// Privileged-tier sandbox route. Identical runtime to /plugin-sandbox, but the -// host loads it into a same-origin (`allow-same-origin`) iframe so the bundle -// gets real `crypto.subtle` + IndexedDB. The trust gate (signature + admin -// approval) is enforced host-side before this route is ever framed; the page -// itself carries no extra privilege. -// -// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in -// Next's injected hydration/chunk scripts. -export const dynamic = 'force-dynamic'; - -export default function PrivilegedPluginSandboxPage() { - return ; -} diff --git a/app/(sandbox)/plugin-sandbox-privileged/route.ts b/app/(sandbox)/plugin-sandbox-privileged/route.ts new file mode 100644 index 000000000..5ed0a733b --- /dev/null +++ b/app/(sandbox)/plugin-sandbox-privileged/route.ts @@ -0,0 +1,12 @@ +import { sandboxDocument } from '@/lib/plugin-sandbox/sandbox-document'; + +// Privileged-tier sandbox document. Identical to /plugin-sandbox, but the +// host loads it into a same-origin (`allow-same-origin`) iframe so the bundle +// gets real `crypto.subtle` + IndexedDB. The trust gate (signature + admin +// approval) is enforced host-side before this route is ever framed; the +// document itself carries no extra privilege. +export const dynamic = 'force-dynamic'; + +export function GET(request: Request): Promise { + return sandboxDocument(request); +} diff --git a/app/(sandbox)/plugin-sandbox/page.tsx b/app/(sandbox)/plugin-sandbox/page.tsx deleted file mode 100644 index fa9576eed..000000000 --- a/app/(sandbox)/plugin-sandbox/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime'; - -// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in -// Next's injected hydration/chunk scripts. With force-static, those scripts -// render without a nonce and the strict sandbox CSP blocks them. -export const dynamic = 'force-dynamic'; - -export default function PluginSandboxPage() { - return ; -} diff --git a/app/(sandbox)/plugin-sandbox/route.ts b/app/(sandbox)/plugin-sandbox/route.ts new file mode 100644 index 000000000..c02b187cd --- /dev/null +++ b/app/(sandbox)/plugin-sandbox/route.ts @@ -0,0 +1,9 @@ +import { sandboxDocument } from '@/lib/plugin-sandbox/sandbox-document'; + +// The untrusted plugin iframe document (sandbox="allow-scripts", opaque +// origin). Dynamic: the CSP nonce from proxy.ts is per request. +export const dynamic = 'force-dynamic'; + +export function GET(request: Request): Promise { + return sandboxDocument(request); +} diff --git a/lib/__tests__/plugin-sandbox-build.test.ts b/lib/__tests__/plugin-sandbox-build.test.ts new file mode 100644 index 000000000..71c6128df --- /dev/null +++ b/lib/__tests__/plugin-sandbox-build.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment node +import { describe, it, expect, afterAll } from 'vitest'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { buildPluginSandbox } from '../../scripts/build-plugin-sandbox.mjs'; + +type Outputs = Record; imports: Array<{ path: string; kind: string }> }>; + +const dirs: string[] = []; +afterAll(async () => { + for (const d of dirs) await rm(d, { recursive: true, force: true }); +}); + +// Every plugin frame parses what its document loads up front, again in every +// frame (opaque origins get no code cache). +describe('plugin sandbox build', () => { + it('loads React, the runtime and the kit up front, and every other icon only on demand', async () => { + const outdir = await mkdtemp(path.join(tmpdir(), 'plugin-sandbox-')); + dirs.push(outdir); + const { script, style, outputs } = await buildPluginSandbox({ outdir }) as { script: string; style: string; outputs: Outputs }; + + const byName = new Map(Object.entries(outputs).map(([file, out]) => [path.basename(file), out])); + const eager = new Set(); + const walk = (name: string) => { + if (eager.has(name)) return; + eager.add(name); + for (const imp of byName.get(name)!.imports) if (imp.kind === 'import-statement') walk(path.basename(imp.path)); + }; + walk(script); + const eagerBytes = [...eager].reduce((sum, n) => sum + byName.get(n)!.bytes, 0); + const eagerInputs = [...eager].flatMap((n) => Object.keys(byName.get(n)!.inputs)).map((f) => f.replace(/\\/g, '/')); + + // The first build put all 5000 Tabler icons (2.6 MB) in an eager chunk. + expect(eagerBytes).toBeLessThan(600_000); + expect(eagerInputs.filter((f) => f.includes('node_modules/next/'))).toEqual([]); + expect(eagerInputs.filter((f) => /(^|\/)stores\//.test(f))).toEqual([]); + // ...but they are still there for an icon name the kit doesn't ship. + const lazy = [...byName.keys()].filter((n) => n.endsWith('.js') && !eager.has(n)); + expect(lazy.some((n) => byName.get(n)!.bytes > 1_000_000)).toBe(true); + + const manifest = JSON.parse(await readFile(path.join(outdir, 'manifest.json'), 'utf8')); + expect(manifest).toEqual({ script, style, preload: [...eager].filter((n) => n !== script) }); + expect(await readFile(path.join(outdir, style), 'utf8')).toContain('.bk'); + }, 120_000); +}); diff --git a/lib/__tests__/plugin-sandbox-document.test.ts b/lib/__tests__/plugin-sandbox-document.test.ts new file mode 100644 index 000000000..22f19d8d4 --- /dev/null +++ b/lib/__tests__/plugin-sandbox-document.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const files = vi.hoisted(() => new Map()); +vi.mock('node:fs/promises', async (orig) => ({ + ...(await orig()), + readFile: vi.fn(async (p: string) => { + const key = String(p).replace(/\\/g, '/').split('/public/')[1]; + if (key && files.has(key)) return files.get(key)!; + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }), +})); + +import { GET as untrusted } from '@/app/(sandbox)/plugin-sandbox/route'; +import { GET as privileged } from '@/app/(sandbox)/plugin-sandbox-privileged/route'; + +const MANIFEST = 'plugin-sandbox-assets/manifest.json'; + +function request(nonce = 'n0nce'): Request { + return new Request('http://localhost:3000/plugin-sandbox', { headers: { 'x-nonce': nonce } }); +} + +beforeEach(() => { + files.clear(); + files.set(MANIFEST, JSON.stringify({ script: 'runtime-ABC.js', style: 'kit-123.css', preload: ['chunk-ICONS.js'] })); + vi.stubEnv('NODE_ENV', 'development'); // re-read the manifest for every test +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +// Plugin frames used to load a Next page: its client router and hydration +// in every frame, and a server render per frame. +describe('plugin sandbox document', () => { + it.each([['/plugin-sandbox', untrusted], ['/plugin-sandbox-privileged', privileged]])( + '%s is a plain page with the runtime script and the kit stylesheet', async (_path, GET) => { + const res = await GET(request()); + const html = await res.text(); + + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + expect(html).toContain(''); + expect(html).toContain(''); + // Fetched with the script, not after it has been parsed. + expect(html).toContain(''); + expect(html).toContain('
'); + expect(html).not.toContain('/_next/'); + }); + + it('serves the assets under the base path', async () => { + vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/webmail'); + const html = await (await untrusted(request())).text(); + + expect(html).toContain('src="/webmail/plugin-sandbox-assets/runtime-ABC.js"'); + expect(html).toContain('href="/webmail/plugin-sandbox-assets/kit-123.css"'); + }); + + it('does not let the nonce header break out of its attribute', async () => { + const html = await (await untrusted(request('x">'))).text(); + + expect(html).not.toContain(''); + expect(html.match(/` + + '
'; + return new Response(html, { + headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }, + }); +} diff --git a/lib/plugin-sandbox/sandbox-entry.ts b/lib/plugin-sandbox/sandbox-entry.ts new file mode 100644 index 000000000..1d2241119 --- /dev/null +++ b/lib/plugin-sandbox/sandbox-entry.ts @@ -0,0 +1,7 @@ +// Script of the plugin sandbox document (see sandbox-document.ts), bundled by +// scripts/build-plugin-sandbox.mjs. The document already has the root +// element, so the runtime only has to bind to its host. + +import { startSandboxRuntime } from './runtime'; + +startSandboxRuntime(); diff --git a/next.config.ts b/next.config.ts index fec618fa1..75a2d03bd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -81,6 +81,16 @@ const nextConfig: NextConfig = { source: "/_next/static/:path*", headers: [{ key: "Access-Control-Allow-Origin", value: "*" }], }, + { + // The plugin sandbox document's script and stylesheet + // (scripts/build-plugin-sandbox.mjs): module scripts, so the + // opaque-origin frames fetch them in CORS mode. Content-hashed. + source: "/plugin-sandbox-assets/:path*", + headers: [ + { key: "Access-Control-Allow-Origin", value: "*" }, + { key: "Cache-Control", value: "public, max-age=31536000, immutable" }, + ], + }, ]; }, }), diff --git a/package.json b/package.json index 8abe1a163..0a06a4575 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "typescript" ], "scripts": { - "dev": "next dev --turbopack", - "build": "next build --turbopack", + "dev": "node scripts/build-plugin-sandbox.mjs && next dev --turbopack", + "build": "node scripts/build-plugin-sandbox.mjs && next build --turbopack", "build:lite": "node scripts/lite/build.mjs", "lite:serve": "node scripts/lite/serve.mjs out 4173", "test:lite-smoke": "playwright test -c scripts/lite/smoke/playwright.config.ts", diff --git a/proxy.ts b/proxy.ts index c4c1f5b67..fce4ef7b9 100644 --- a/proxy.ts +++ b/proxy.ts @@ -56,13 +56,13 @@ const PROXY_SKIP_PATTERN = /^\/(?:api|_next)(?:\/|$)/; // What Next serves from public/: a single dotted segment at the root // (/sw.js, /favicon.ico, /manifest.webmanifest) and the branding/, -// notification/ and demo/ asset folders. The upstream matcher treated EVERY path whose +// notification/, demo/ and plugin-sandbox-assets/ asset folders. The upstream matcher treated EVERY path whose // last segment has a dot as a static file, but the [[...segments]] catch-alls // under //mail, /calendar, /contacts and /files make // /en/mail/folder/inbox/statement.pdf a real signed-in page, and it rendered // without a CSP or any other security header (GHSA-xvjh-v9c6-qcvc). Only what // is genuinely static may skip locale routing and the headers. -const STATIC_ASSET_PATTERN = /^\/[^/]+\.[^/]+$|^\/(?:branding|notification|demo)\//; +const STATIC_ASSET_PATTERN = /^\/[^/]+\.[^/]+$|^\/(?:branding|notification|demo|plugin-sandbox-assets)\//; export function isStaticAssetPath(pathname: string): boolean { return STATIC_ASSET_PATTERN.test(pathname); diff --git a/scripts/build-plugin-sandbox.mjs b/scripts/build-plugin-sandbox.mjs new file mode 100644 index 000000000..4ead605c1 --- /dev/null +++ b/scripts/build-plugin-sandbox.mjs @@ -0,0 +1,133 @@ +// Builds the plugin sandbox document's script and stylesheet into +// public/plugin-sandbox-assets/, with a manifest.json naming them for +// lib/plugin-sandbox/sandbox-document.ts. +// +// Every plugin iframe loads these. They used to come from a Next page, so +// each frame also loaded, parsed and hydrated Next's client router, and the +// server rendered the page for every frame. Sandboxed frames have opaque +// origins, so the browser's code cache never helps: what a frame loads, it +// parses again, on the host page's main thread. +// +// node scripts/build-plugin-sandbox.mjs (npm run build and dev run it) + +import { build } from 'esbuild'; +import postcss from 'postcss'; +import tailwind from '@tailwindcss/postcss'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +export const DEFAULT_OUTDIR = path.join(root, 'public', 'plugin-sandbox-assets'); + +// components/icons imports a few hundred Tabler icons by name, and for any +// other name `import('@tabler/icons-react')` loads all of them. Both went +// through the package's entry module, which also exports the namespace of +// every icon, so all 5000 icons (2.6 MB) ended up in a chunk every frame +// loaded. Like Next's optimizePackageImports, named imports and re-exports +// are pointed at the icons' own files; only the dynamic import gets them all. +const tablerDir = path.join(root, 'node_modules/@tabler/icons-react/dist/esm'); +const tablerFiles = new Map(); +for (const m of (await readFile(path.join(tablerDir, 'tabler-icons-react.mjs'), 'utf8')) + .matchAll(/export \{([^}]+)\} from '\.\/icons\/([^']+)';/g)) { + for (const spec of m[1].split(',')) { + const name = spec.trim().match(/^default as (\w+)$/)?.[1]; + if (name) tablerFiles.set(name, path.join(tablerDir, 'icons', m[2]).replace(/\\/g, '/')); + } +} +function splitTablerSpecifiers(keyword, specs, from) { + return specs.split(',').map((s) => s.trim()).filter(Boolean).map((spec) => { + const [imported, local = imported] = spec.split(/\s+as\s+/); + const file = tablerFiles.get(imported); + if (!file) return `${keyword} { ${spec} } from '${from}';`; + return keyword === 'import' + ? `import ${local} from '${file}';` + : `export { default as ${local} } from '${file}';`; + }).join('\n'); +} +const tablerPerIcon = { + name: 'tabler-per-icon', + setup(b) { + b.onResolve({ filter: /^@tabler\/icons-react$/ }, (args) => + args.kind === 'dynamic-import' ? { path: 'all-icons', namespace: 'tabler-all' } : undefined); + b.onLoad({ filter: /.*/, namespace: 'tabler-all' }, () => ({ + contents: `export * as icons from '${tablerDir.replace(/\\/g, '/')}/icons/index.mjs';\n` + + `export * as iconsList from '${tablerDir.replace(/\\/g, '/')}/icons-list.mjs';\n`, + resolveDir: root, + })); + b.onLoad({ filter: /[\\/](components|lib)[\\/].*\.tsx?$/ }, async (args) => { + const source = await readFile(args.path, 'utf8'); + if (!source.includes('@tabler/icons-react')) return undefined; + const contents = source.replace( + /\b(import|export)\s*\{([^}]*)\}\s*from\s*['"]@tabler\/icons-react['"];?/g, + (_, keyword, specs) => splitTablerSpecifiers(keyword, specs, '@tabler/icons-react'), + ); + return { contents, loader: args.path.endsWith('.tsx') ? 'tsx' : 'ts' }; + }); + }, +}; + +/** + * Build into `outdir` (emptied first). Resolves with the file names and + * esbuild's metafile outputs. + */ +export async function buildPluginSandbox({ outdir = DEFAULT_OUTDIR } = {}) { + await rm(outdir, { recursive: true, force: true }); + await mkdir(outdir, { recursive: true }); + + // ES modules so the rarely used parts (every Tabler icon, for an icon name + // the kit doesn't ship) stay in chunks that load on demand. + const js = await build({ + entryPoints: { runtime: path.join(root, 'lib/plugin-sandbox/sandbox-entry.ts') }, + bundle: true, + splitting: true, + format: 'esm', + platform: 'browser', + target: 'es2022', + outdir, + entryNames: '[name]-[hash]', + chunkNames: 'chunk-[hash]', + minify: true, + legalComments: 'none', + jsx: 'automatic', + define: { 'process.env.NODE_ENV': '"production"' }, + alias: { '@': root }, + loader: { '.css': 'empty' }, + plugins: [tablerPerIcon], + metafile: true, + logLevel: 'warning', + }); + const script = Object.entries(js.metafile.outputs).find(([, out]) => out.entryPoint)?.[0]; + if (!script) throw new Error('esbuild produced no entry chunk'); + + // The UI kit's styles, through the same Tailwind setup as the app's CSS. + const cssPath = path.join(root, 'app/(sandbox)/plugin-kit.css'); + const css = await postcss([tailwind({ optimize: { minify: true } })]).process(await readFile(cssPath, 'utf8'), { from: cssPath }); + const style = `kit-${createHash('sha256').update(css.css).digest('hex').slice(0, 12)}.css`; + await writeFile(path.join(outdir, style), css.css); + + // The chunks the script imports statically, so the document can fetch them + // with it instead of after it has been parsed. + const outputs = new Map(Object.entries(js.metafile.outputs).map(([f, o]) => [path.basename(f), o])); + const preload = []; + const visit = (name) => { + for (const imp of outputs.get(name)?.imports ?? []) { + const dep = path.basename(imp.path); + if (imp.kind !== 'import-statement' || preload.includes(dep)) continue; + preload.push(dep); + visit(dep); + } + }; + visit(path.basename(script)); + + const manifest = { script: path.basename(script), style, preload }; + await writeFile(path.join(outdir, 'manifest.json'), `${JSON.stringify(manifest)}\n`); + return { ...manifest, outputs: js.metafile.outputs, css: css.css }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { outputs, style, css } = await buildPluginSandbox(); + const sizes = Object.entries(outputs).filter(([f]) => f.endsWith('.js')).map(([f, o]) => `${path.basename(f)} ${o.bytes}`); + console.log(`[build-plugin-sandbox] ${sizes.join(', ')}, ${style} ${css.length}`); +}