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(/`
+ + '