From 8ad5a9bf19e43f9f637e2460bece085960f434c8 Mon Sep 17 00:00:00 2001 From: fforres Date: Sun, 18 Oct 2026 00:14:43 -0700 Subject: [PATCH 1/3] feat(mcp-apps-shell): add server-side artifact precompiler Add a React-free precompileArtifact() that runs the Sucrase JSX transform and a Tailwind build against the same input the sandboxed frame compiles at runtime. Class candidates come from a bracket-aware source scanner, and code that builds class names dynamically is detected so it keeps the runtime path. A generated fingerprint over the compiler, extractor, theme and pinned versions marks compiled output as current. Nothing is wired yet. Pin tailwindcss and @tailwindcss/browser to one catalog version so the precompiled CSS and the runtime fallback agree. Claude-Session: https://claude.ai/code/session_019ZE3j9YzRKxT4LaiDCBv6S --- bun.lock | 9 +- package.json | 3 +- packages/hosts/mcp-apps-shell/package.json | 17 +++- .../scripts/gen-precompile-assets.ts | 93 ++++++++++++++++++ .../src/precompile/assets.gen.ts | 16 ++++ .../src/precompile/compile-fingerprint.gen.ts | 8 ++ .../compile-fingerprint.pin.test.ts | 24 +++++ .../src/precompile/extract-candidates.test.ts | 62 ++++++++++++ .../src/precompile/extract-candidates.ts | 81 ++++++++++++++++ .../src/precompile/precompile.test.ts | 81 ++++++++++++++++ .../src/precompile/precompile.ts | 95 +++++++++++++++++++ .../precompile.worker-compat.test.ts | 36 +++++++ .../mcp-apps-shell/src/shell/compile-jsx.ts | 21 ++++ .../src/shell/component-runtime.ts | 13 +-- 14 files changed, 539 insertions(+), 20 deletions(-) create mode 100644 packages/hosts/mcp-apps-shell/scripts/gen-precompile-assets.ts create mode 100644 packages/hosts/mcp-apps-shell/src/precompile/assets.gen.ts create mode 100644 packages/hosts/mcp-apps-shell/src/precompile/compile-fingerprint.gen.ts create mode 100644 packages/hosts/mcp-apps-shell/src/precompile/compile-fingerprint.pin.test.ts create mode 100644 packages/hosts/mcp-apps-shell/src/precompile/extract-candidates.test.ts create mode 100644 packages/hosts/mcp-apps-shell/src/precompile/extract-candidates.ts create mode 100644 packages/hosts/mcp-apps-shell/src/precompile/precompile.test.ts create mode 100644 packages/hosts/mcp-apps-shell/src/precompile/precompile.ts create mode 100644 packages/hosts/mcp-apps-shell/src/precompile/precompile.worker-compat.test.ts create mode 100644 packages/hosts/mcp-apps-shell/src/shell/compile-jsx.ts diff --git a/bun.lock b/bun.lock index b2431ad8d7..cbf476dde0 100644 --- a/bun.lock +++ b/bun.lock @@ -730,13 +730,14 @@ "effect": "catalog:", "esbuild": "^0.27.7", "sucrase": "^3.35.1", + "tailwindcss": "catalog:", }, "devDependencies": { "@effect/vitest": "catalog:", "@executor-js/execution": "workspace:*", "@executor-js/host-mcp": "workspace:*", "@executor-js/sdk": "workspace:*", - "@tailwindcss/browser": "^4.2.2", + "@tailwindcss/browser": "catalog:", "@tailwindcss/vite": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", @@ -748,7 +749,6 @@ "react": "catalog:", "react-dom": "catalog:", "recharts": "3.8.0", - "tailwindcss": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vite-plugin-singlefile": "^2.3.2", @@ -1287,6 +1287,7 @@ "@jitl/quickjs-wasmfile-release-sync": "0.31.0", "@libsql/client": "^0.17.3", "@libsql/kysely-libsql": "^0.4.1", + "@tailwindcss/browser": "4.2.2", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-router": "^1.170.41", "@tanstack/react-start": "^1.168.60", @@ -1308,7 +1309,7 @@ "quickjs-emscripten": "^0.31.0", "react": "^19.1.0", "react-dom": "^19.1.0", - "tailwindcss": "^4.2.2", + "tailwindcss": "4.2.2", "tsup": "^8.5.0", "typescript": "^5.9.3", "vite": "^8.0.0", @@ -2967,7 +2968,7 @@ "@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="], - "@tailwindcss/browser": ["@tailwindcss/browser@4.3.3", "", {}, "sha512-fFaqX18SiyiukLoEI7/cVc0B65mkTslDNo2TxazSDZ9rKs5umOEoujmEkpy8z6GrMdoVzBDbWD8j5DTKLyAWWw=="], + "@tailwindcss/browser": ["@tailwindcss/browser@4.2.2", "", {}, "sha512-JVdJBWWtvKxliBI9CtpOrqxgk8aijUWGsn0STxcOcXAYsUFAbOxN5PwawOJ+v0PoQfPcOK9eZoj9LTwvBV0Vmw=="], "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], diff --git a/package.json b/package.json index 4ef32460ed..2276087655 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,8 @@ "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "typescript": "^5.9.3", - "tailwindcss": "^4.2.2", + "tailwindcss": "4.2.2", + "@tailwindcss/browser": "4.2.2", "quickjs-emscripten": "^0.31.0", "@jitl/quickjs-wasmfile-release-sync": "0.31.0", "tsup": "^8.5.0", diff --git a/packages/hosts/mcp-apps-shell/package.json b/packages/hosts/mcp-apps-shell/package.json index 64d8d07256..c994f074c0 100644 --- a/packages/hosts/mcp-apps-shell/package.json +++ b/packages/hosts/mcp-apps-shell/package.json @@ -56,10 +56,19 @@ "types": "./src/shell/smoke-render.ts", "default": "./src/shell/smoke-render.ts" }, + "./precompile": { + "types": "./src/precompile/precompile.ts", + "default": "./src/precompile/precompile.ts" + }, + "./compile-fingerprint": { + "types": "./src/precompile/compile-fingerprint.gen.ts", + "default": "./src/precompile/compile-fingerprint.gen.ts" + }, "./globals.css": "./src/shell/globals.css" }, "scripts": { - "build:shell": "bun run gen:smoke-harness && vite build --config vite.config.shell.ts", + "build:shell": "bun run gen:precompile && bun run gen:smoke-harness && vite build --config vite.config.shell.ts", + "gen:precompile": "bun scripts/gen-precompile-assets.ts", "gen:provided-globals": "bun scripts/gen-provided-globals.ts", "fonts:subset": "bun scripts/subset-fonts.ts", "typecheck": "tsgo --noEmit", @@ -77,14 +86,15 @@ "@tanstack/react-query": "^5.99.0", "effect": "catalog:", "esbuild": "^0.27.7", - "sucrase": "^3.35.1" + "sucrase": "^3.35.1", + "tailwindcss": "catalog:" }, "devDependencies": { "@effect/vitest": "catalog:", "@executor-js/execution": "workspace:*", "@executor-js/host-mcp": "workspace:*", "@executor-js/sdk": "workspace:*", - "@tailwindcss/browser": "^4.2.2", + "@tailwindcss/browser": "catalog:", "@tailwindcss/vite": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", @@ -96,7 +106,6 @@ "react": "catalog:", "react-dom": "catalog:", "recharts": "3.8.0", - "tailwindcss": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vite-plugin-singlefile": "^2.3.2", diff --git a/packages/hosts/mcp-apps-shell/scripts/gen-precompile-assets.ts b/packages/hosts/mcp-apps-shell/scripts/gen-precompile-assets.ts new file mode 100644 index 0000000000..eecbd47695 --- /dev/null +++ b/packages/hosts/mcp-apps-shell/scripts/gen-precompile-assets.ts @@ -0,0 +1,93 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +import { ARTIFACT_JSX_TRANSFORM_OPTIONS } from "../src/shell/compile-jsx"; + +/** + * Generates the two committed files the server precompiler needs: + * - `assets.gen.ts`: the theme source and Tailwind's four CSS sources, as + * strings, because a Worker has no filesystem to read them from. + * - `compile-fingerprint.gen.ts`: one hash over everything that can change a + * compiled artifact. The shell imports only this file. + * + * `compile-fingerprint.pin.test.ts` regenerates both in memory and fails when + * the committed files are stale. Run with `bun run gen:precompile`. + */ + +const packageRoot = path.resolve(import.meta.dirname, ".."); +const require = createRequire(path.join(packageRoot, "package.json")); + +const readPackageVersion = (name: string): string => { + let dir = path.dirname(require.resolve(name)); + while (!fs.existsSync(path.join(dir, "package.json"))) dir = path.dirname(dir); + const manifest: { readonly version: string } = JSON.parse( + fs.readFileSync(path.join(dir, "package.json"), "utf8"), + ); + return manifest.version; +}; + +const tailwindDir = path.dirname(require.resolve("tailwindcss/package.json")); +const readTailwindCss = (file: string) => fs.readFileSync(path.join(tailwindDir, file), "utf8"); +const read = (relative: string) => fs.readFileSync(path.join(packageRoot, relative), "utf8"); + +export const buildPrecompileAssets = (): { + readonly assets: string; + readonly fingerprint: string; +} => { + const themeSource = read("src/shell/theme.css"); + const tailwindSources = { + index: readTailwindCss("index.css"), + preflight: readTailwindCss("preflight.css"), + theme: readTailwindCss("theme.css"), + utilities: readTailwindCss("utilities.css"), + }; + + const hash = createHash("sha256"); + hash.update( + JSON.stringify({ + sucrase: readPackageVersion("sucrase"), + tailwindcss: readPackageVersion("tailwindcss"), + jsx: ARTIFACT_JSX_TRANSFORM_OPTIONS, + themeSource, + tailwindSources, + compileJsx: read("src/shell/compile-jsx.ts"), + extractCandidates: read("src/precompile/extract-candidates.ts"), + }), + ); + const fingerprint = hash.digest("hex"); + + const assets = `/** + * GENERATED by \`scripts/gen-precompile-assets.ts\`. Do not hand-edit: + * \`compile-fingerprint.pin.test.ts\` fails if this drifts. Regenerate with + * \`bun run gen:precompile\`. + */ + +// oxfmt-ignore +export const THEME_SOURCE: string = ${JSON.stringify(themeSource)}; + +// oxfmt-ignore +export const TAILWIND_SOURCES = ${JSON.stringify(tailwindSources, null, 2)} as const; +`; + + return { assets, fingerprint }; +}; + +if (import.meta.main) { + const { assets, fingerprint } = buildPrecompileAssets(); + const dir = path.join(packageRoot, "src/precompile"); + fs.writeFileSync(path.join(dir, "assets.gen.ts"), assets); + fs.writeFileSync( + path.join(dir, "compile-fingerprint.gen.ts"), + `/** + * GENERATED by \`scripts/gen-precompile-assets.ts\`. Do not hand-edit: + * \`compile-fingerprint.pin.test.ts\` fails if this drifts. Regenerate with + * \`bun run gen:precompile\`. + */ + +// oxfmt-ignore +export const ARTIFACT_COMPILE_FINGERPRINT = ${JSON.stringify(fingerprint)}; +`, + ); +} diff --git a/packages/hosts/mcp-apps-shell/src/precompile/assets.gen.ts b/packages/hosts/mcp-apps-shell/src/precompile/assets.gen.ts new file mode 100644 index 0000000000..7e200ef0ad --- /dev/null +++ b/packages/hosts/mcp-apps-shell/src/precompile/assets.gen.ts @@ -0,0 +1,16 @@ +/** + * GENERATED by `scripts/gen-precompile-assets.ts`. Do not hand-edit: + * `compile-fingerprint.pin.test.ts` fails if this drifts. Regenerate with + * `bun run gen:precompile`. + */ + +// oxfmt-ignore +export const THEME_SOURCE: string = "/*\n * Executor's design tokens, as Tailwind theme SOURCE.\n *\n * Split out of `globals.css` because this file has two consumers:\n *\n * 1. The shell's own build, which `@import`s it and compiles it ahead of time.\n * 2. The sandboxed inner frame, which embeds it VERBATIM in a\n * ` + ` + : ``; + const frameCompiler = + plan.js === null ? `\n ` : ""; return ` @@ -241,12 +284,11 @@ const buildRendererSrcDoc = (token: string): string => { - - + ${artifactStyles}
- + ${frameCompiler} `; }; @@ -556,6 +598,7 @@ export function McpAppsShell({ postToRenderer({ type: "executor.render", code: current.code, + ...(current.compiledJs === null ? {} : { compiledJs: current.compiledJs }), theme: hostContext?.theme, // The inner frame is the last link in the height chain, and it cannot // see the host context — so the mode is told to it, the same way the @@ -700,13 +743,15 @@ export function McpAppsShell({ }, [fill, postToRenderer, renderer]); /** Render a JSX code string in the sandboxed inner iframe. */ - const renderCode = useCallback((code: string) => { + const renderCode = useCallback((code: string, compiled?: unknown) => { try { const token = createRendererToken(); + const plan = readRenderPlan(compiled); const nextRenderer = { token, code, - srcDoc: buildRendererSrcDoc(token), + compiledJs: plan.js, + srcDoc: buildRendererSrcDoc(token, plan), config: {}, height: 240, }; @@ -739,7 +784,7 @@ export function McpAppsShell({ if (typeof artifactId === "string") artifactIdRef.current = artifactId; const code = params.arguments?.code; if (code && typeof code === "string") { - renderCode(code); + renderCode(code, params.arguments?.compiled); } }; @@ -752,7 +797,7 @@ export function McpAppsShell({ if (typeof artifactId === "string") artifactIdRef.current = artifactId; if (code && typeof code === "string") { - renderCode(code); + renderCode(code, structured?.compiled); return; } diff --git a/packages/hosts/mcp-apps-shell/src/shell/smoke-harness.tsx b/packages/hosts/mcp-apps-shell/src/shell/smoke-harness.tsx index df15e0a523..fe354f803c 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/smoke-harness.tsx +++ b/packages/hosts/mcp-apps-shell/src/shell/smoke-harness.tsx @@ -29,7 +29,8 @@ import { StrictMode, createElement } from "react"; import { renderToReadableStream } from "react-dom/server.browser"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { compileJsx, evaluateComponent } from "./component-runtime"; +import { compileJsx } from "./compile-jsx"; +import { evaluateComponent } from "./component-runtime"; import * as Components from "./components"; import { createToolsProxy } from "./tools-proxy"; import type { SmokeRenderResult } from "./smoke-render-result"; diff --git a/packages/hosts/mcp-apps-shell/src/vite.test.ts b/packages/hosts/mcp-apps-shell/src/vite.test.ts index 6dcbea0069..556f5efc58 100644 --- a/packages/hosts/mcp-apps-shell/src/vite.test.ts +++ b/packages/hosts/mcp-apps-shell/src/vite.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { mcpAppsShellAsset } from "./vite"; +import { innerRendererPlugin, mcpAppsShellAsset } from "./vite"; describe("mcpAppsShellAsset", () => { it("resolves the dev asset URL without building the shell during server startup", async () => { @@ -26,3 +26,19 @@ describe("mcpAppsShellAsset", () => { ); }); }); + +describe("innerRendererPlugin", () => { + it("serves the JSX compiler as its own program, apart from the renderer", async () => { + const plugin = innerRendererPlugin(); + const compilerId = plugin.resolveId("virtual:executor-inner-compiler"); + const rendererId = plugin.resolveId("virtual:executor-inner-renderer"); + expect(compilerId).toBeDefined(); + expect(rendererId).toBeDefined(); + + const compiler = await plugin.load(compilerId ?? ""); + const renderer = await plugin.load(rendererId ?? ""); + expect(compiler).toContain("__executorCompileJsx"); + expect(compiler).toContain("jsxRuntime"); + expect(renderer).not.toContain("jsxRuntime"); + }); +}); diff --git a/packages/hosts/mcp-apps-shell/src/vite.ts b/packages/hosts/mcp-apps-shell/src/vite.ts index d67f31fbd8..8b34086605 100644 --- a/packages/hosts/mcp-apps-shell/src/vite.ts +++ b/packages/hosts/mcp-apps-shell/src/vite.ts @@ -7,6 +7,10 @@ * by esbuild — it cannot be a normal import, because it must exist as *source * text* to be embedded in the sandboxed frame. * + * `virtual:executor-inner-compiler` is the Sucrase half of that frame, bundled + * the same way but kept out of the renderer: a frame whose artifact was + * precompiled on the server never loads it. + * * `mcpAppsShellAsset` supplies `virtual:executor-mcp-apps-shell-html`: the URL * of the BUILT, self-contained shell document, emitted into the app's client * assets. The console's artifact page loads that URL into a sandboxed iframe @@ -41,22 +45,18 @@ export interface InnerRendererVitePlugin { const VIRTUAL_ID = "virtual:executor-inner-renderer"; const RESOLVED_ID = `\0${VIRTUAL_ID}`; +const COMPILER_VIRTUAL_ID = "virtual:executor-inner-compiler"; +const COMPILER_RESOLVED_ID = `\0${COMPILER_VIRTUAL_ID}`; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); export const innerRendererEntry = (): string => path.resolve(packageRoot, "src/shell/inner-renderer.tsx"); -/** - * Bundle the inner renderer to a self-contained IIFE string. - * - * Exported on its own so a build script or test can produce the same bytes the - * plugin serves without standing up Vite. - */ -export const bundleInnerRenderer = async (): Promise => { +const bundleInnerProgram = async (entry: string, failure: string): Promise => { const { build } = await import("esbuild"); const result = await build({ - entryPoints: [innerRendererEntry()], + entryPoints: [entry], absWorkingDir: packageRoot, bundle: true, write: false, @@ -75,11 +75,27 @@ export const bundleInnerRenderer = async (): Promise => { const js = result.outputFiles[0]; if (!js) { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: Vite plugin hooks report build failures by throwing - throw new Error("Failed to bundle the MCP-Apps inner renderer."); + throw new Error(failure); } return js.text; }; +/** + * Bundle the inner renderer to a self-contained IIFE string. + * + * Exported on its own so a build script or test can produce the same bytes the + * plugin serves without standing up Vite. + */ +export const bundleInnerRenderer = (): Promise => + bundleInnerProgram(innerRendererEntry(), "Failed to bundle the MCP-Apps inner renderer."); + +export const innerCompilerEntry = (): string => + path.resolve(packageRoot, "src/shell/inner-compiler.ts"); + +/** Bundle the in-frame JSX compiler to a self-contained IIFE string. */ +export const bundleInnerCompiler = (): Promise => + bundleInnerProgram(innerCompilerEntry(), "Failed to bundle the MCP-Apps inner compiler."); + export const smokeHarnessEntry = (): string => path.resolve(packageRoot, "src/shell/smoke-harness.tsx"); @@ -132,10 +148,17 @@ export const innerRendererPlugin = (): InnerRendererVitePlugin => ({ name: "executor-inner-renderer-source", // `pre` so the virtual id resolves before any generic resolver claims it. enforce: "pre", - resolveId: (id) => (id === VIRTUAL_ID ? RESOLVED_ID : undefined), + resolveId: (id) => { + if (id === VIRTUAL_ID) return RESOLVED_ID; + if (id === COMPILER_VIRTUAL_ID) return COMPILER_RESOLVED_ID; + return undefined; + }, load: async (id) => { - if (id !== RESOLVED_ID) return undefined; - return `export default ${JSON.stringify(await bundleInnerRenderer())};`; + if (id === RESOLVED_ID) return `export default ${JSON.stringify(await bundleInnerRenderer())};`; + if (id === COMPILER_RESOLVED_ID) { + return `export default ${JSON.stringify(await bundleInnerCompiler())};`; + } + return undefined; }, }); diff --git a/packages/react/src/api/artifact-renderer.tsx b/packages/react/src/api/artifact-renderer.tsx index a13cbfb723..d0ef2e74ba 100644 --- a/packages/react/src/api/artifact-renderer.tsx +++ b/packages/react/src/api/artifact-renderer.tsx @@ -31,6 +31,19 @@ import * as React from "react"; export interface ArtifactRendererProps { /** The artifact's stored JSX source. */ readonly code: string; + /** + * The server's precompile of `code` (JS and Tailwind CSS), when the host has + * one. The shell trusts it only if its fingerprint matches the shell's own + * compiler, and otherwise compiles `code` itself. + */ + readonly compiled?: + | { + readonly fingerprint: string; + readonly js: string | null; + readonly css: string | null; + } + | null + | undefined; /** Which artifact this is. The source addresses integrations by role, so the * server needs the row to resolve a role to a connection; this is how the id * reaches `execute-action`. */ diff --git a/packages/react/src/pages/artifact-detail.tsx b/packages/react/src/pages/artifact-detail.tsx index 7ce115a8a1..0b3f45023e 100644 --- a/packages/react/src/pages/artifact-detail.tsx +++ b/packages/react/src/pages/artifact-detail.tsx @@ -7,7 +7,11 @@ import { toast } from "sonner"; import type { ArtifactId } from "@executor-js/sdk/shared"; import { trackEvent } from "../api/analytics"; -import { useArtifactRenderer, usePreloadArtifactRenderer } from "../api/artifact-renderer"; +import { + useArtifactRenderer, + usePreloadArtifactRenderer, + type ArtifactRendererProps, +} from "../api/artifact-renderer"; import { artifactAtom, artifactsOptimisticAtom, @@ -219,7 +223,9 @@ export function ArtifactDetailPage(props: { readonly artifactId: ArtifactId }) { /> ), - onSuccess: ({ value }) => , + onSuccess: ({ value }) => ( + + ), }) )} @@ -241,7 +247,11 @@ export function ArtifactDetailPage(props: { readonly artifactId: ArtifactId }) { * A host that registers no renderer still gets a page that explains itself * instead of crashing. */ -function ArtifactStage(props: { readonly code: string; readonly artifactId: string }) { +function ArtifactStage(props: { + readonly code: string; + readonly compiled: ArtifactRendererProps["compiled"]; + readonly artifactId: string; +}) { const Renderer = useArtifactRenderer(); // One host per mount: it holds no state, but a new identity each render would // retrigger the shell's host effects. @@ -314,6 +324,7 @@ function ArtifactStage(props: { readonly code: string; readonly artifactId: stri