mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
/.next/
|
||||
/out/
|
||||
/lite-image/
|
||||
# scripts/build-plugin-sandbox.mjs
|
||||
/public/plugin-sandbox-assets/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<html lang="en">
|
||||
<body style={{ margin: 0, padding: 0, background: 'transparent' }}>
|
||||
{/* 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' && (
|
||||
<style>{'nextjs-portal{display:none!important}'}</style>
|
||||
)}
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 <SandboxRuntime />;
|
||||
}
|
||||
@@ -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<Response> {
|
||||
return sandboxDocument(request);
|
||||
}
|
||||
@@ -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 <SandboxRuntime />;
|
||||
}
|
||||
@@ -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<Response> {
|
||||
return sandboxDocument(request);
|
||||
}
|
||||
@@ -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<string, { bytes: number; entryPoint?: string; inputs: Record<string, unknown>; 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<string>();
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const files = vi.hoisted(() => new Map<string, string>());
|
||||
vi.mock('node:fs/promises', async (orig) => ({
|
||||
...(await orig<typeof import('node:fs/promises')>()),
|
||||
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('<script type="module" nonce="n0nce" src="/plugin-sandbox-assets/runtime-ABC.js"></script>');
|
||||
expect(html).toContain('<link rel="stylesheet" href="/plugin-sandbox-assets/kit-123.css">');
|
||||
// Fetched with the script, not after it has been parsed.
|
||||
expect(html).toContain('<link rel="modulepreload" nonce="n0nce" href="/plugin-sandbox-assets/chunk-ICONS.js">');
|
||||
expect(html).toContain('<div id="plugin-sandbox-root"></div>');
|
||||
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"><script>alert(1)</script>'))).text();
|
||||
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html.match(/<script/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('says what to run when the assets were not built', async () => {
|
||||
files.clear();
|
||||
const res = await untrusted(request());
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
expect(await res.text()).toContain('build-plugin-sandbox');
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,8 @@ const STATIC_ASSETS = [
|
||||
'/branding/logo.png',
|
||||
'/notification/cheerful-527.mp3',
|
||||
'/demo/prototype-v3.png',
|
||||
// The plugin sandbox document's script (scripts/build-plugin-sandbox.mjs).
|
||||
'/plugin-sandbox-assets/runtime-ABC123.js',
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -196,5 +198,7 @@ describe('isStaticAssetPath', () => {
|
||||
expect(isStaticAssetPath('/en')).toBe(false);
|
||||
expect(isStaticAssetPath('/en/mail')).toBe(false);
|
||||
expect(isStaticAssetPath('/brandingx/logo.png')).toBe(false);
|
||||
// The sandbox documents themselves keep the sandbox CSP.
|
||||
expect(isStaticAssetPath('/plugin-sandbox')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Theme bridge for plugin slot iframes.
|
||||
//
|
||||
// Slot iframes run with an opaque ("null") origin, so they can't load the
|
||||
// host's globals.css or web fonts cross-origin (see app/(sandbox)/layout.tsx).
|
||||
// host's globals.css or web fonts cross-origin (see sandbox-document.ts).
|
||||
// The result: plugin slot UIs fall back to the UA default serif font and have
|
||||
// no knowledge of the host's light/dark (or custom) theme.
|
||||
//
|
||||
|
||||
@@ -325,6 +325,6 @@ export const SANDBOX_PATH = '/plugin-sandbox';
|
||||
* Path used for the privileged (same-origin) sandbox iframe `src`. A distinct
|
||||
* route so the iframe gets `allow-same-origin` (real WebCrypto + IndexedDB)
|
||||
* while keeping the same CSP relaxations as the untrusted sandbox. Matched in
|
||||
* `proxy.ts`. Renders the identical `SandboxRuntime`.
|
||||
* `proxy.ts`. Serves the same document (sandbox-document.ts).
|
||||
*/
|
||||
export const SANDBOX_PRIVILEGED_PATH = '/plugin-sandbox-privileged';
|
||||
|
||||
@@ -1296,33 +1296,41 @@ function handleHostMessage(ev: MessageEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── React entry ─────────────────────────────────────────────
|
||||
// ─── Entry ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Bind to the host and tell it the frame is ready. The sandbox document
|
||||
* (sandbox-document.ts) runs this from its script, next to an existing
|
||||
* #plugin-sandbox-root. Returns the teardown.
|
||||
*/
|
||||
export function startSandboxRuntime(): () => void {
|
||||
// Not framed by a same-origin host: stay inert. No listener, no ping -
|
||||
// nothing a window.open()-ing site could talk to.
|
||||
const host = resolveHost();
|
||||
if (!host) return () => {};
|
||||
parentWindow = host.window;
|
||||
parentOrigin = host.origin;
|
||||
initFloatingDropdownBridge(
|
||||
(msg) => sendToHost(msg),
|
||||
() => !!parentWindow,
|
||||
);
|
||||
window.addEventListener('message', handleHostMessage);
|
||||
// Initial ping, targeted at the host origin so it can only land on the
|
||||
// page that framed us. Guard at module scope so React strict mode's
|
||||
// double-invoke doesn't re-post (and so a re-post can't race with the
|
||||
// parent's init reply).
|
||||
if (!readyPosted) {
|
||||
readyPosted = true;
|
||||
sendToHost({ type: 'sandbox-ready' });
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('message', handleHostMessage);
|
||||
};
|
||||
}
|
||||
|
||||
/** The runtime as a React component, for tests that render it. */
|
||||
export function SandboxRuntime(): React.JSX.Element {
|
||||
useEffect(() => {
|
||||
// Not framed by a same-origin host: stay inert. No listener, no ping -
|
||||
// nothing a window.open()-ing site could talk to.
|
||||
const host = resolveHost();
|
||||
if (!host) return;
|
||||
parentWindow = host.window;
|
||||
parentOrigin = host.origin;
|
||||
initFloatingDropdownBridge(
|
||||
(msg) => sendToHost(msg),
|
||||
() => !!parentWindow,
|
||||
);
|
||||
window.addEventListener('message', handleHostMessage);
|
||||
// Initial ping, targeted at the host origin so it can only land on the
|
||||
// page that framed us. Guard at module scope so React strict mode's
|
||||
// double-invoke doesn't re-post (and so a re-post can't race with the
|
||||
// parent's init reply).
|
||||
if (!readyPosted) {
|
||||
readyPosted = true;
|
||||
sendToHost({ type: 'sandbox-ready' });
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('message', handleHostMessage);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => startSandboxRuntime(), []);
|
||||
return <div id="plugin-sandbox-root" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// 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' },
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
}),
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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 /<locale>/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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user