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