/** * The two Vite plugins this package hands to app builds. * * `innerRendererPlugin` supplies `virtual:executor-inner-renderer`: the shell * renders model-written JSX inside a nested `srcDoc` iframe whose whole program * is inlined as a string. That string is the inner renderer, bundled to an IIFE * by esbuild — it cannot be a normal import, because it must exist as *source * text* to be embedded in the sandboxed frame. * * `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 * and speaks the MCP-Apps protocol to it, exactly as a real MCP-Apps client * does with the `ui://executor/shell.html` resource. * * Both live here rather than in `@executor-js/vite-plugin` because that package * is on the plugin-system kill list, and because esbuild, Vite and the shell * entry are all this package's own — resolving them from here needs no * cross-package path guessing. Apps that embed the shell add both plugins to * their vite config; the standalone shell build uses the inner-renderer one * too, so there is one definition of how the inner renderer is built. */ import { createHash } from "node:crypto"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { MCP_APPS_SHELL_DOCUMENT_MARKER, MCP_APPS_SHELL_STABLE_ASSET_PATH, } from "./shell-asset-path.ts"; /** The structural shape of the Vite plugin object, declared locally so this * module imports no Vite types (apps pin their own Vite version). */ export interface InnerRendererVitePlugin { readonly name: string; readonly enforce?: "pre"; readonly resolveId: (id: string) => string | undefined; readonly load: (id: string) => Promise; } const VIRTUAL_ID = "virtual:executor-inner-renderer"; const RESOLVED_ID = `\0${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 { build } = await import("esbuild"); const result = await build({ entryPoints: [innerRendererEntry()], absWorkingDir: packageRoot, bundle: true, write: false, format: "iife", platform: "browser", target: "es2022", jsx: "automatic", define: { "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"), }, }); 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."); } return js.text; }; export const smokeHarnessEntry = (): string => path.resolve(packageRoot, "src/shell/smoke-harness.tsx"); /** * Bundle the artifact smoke-render harness to a self-contained IIFE string. * * The same trick as the inner renderer, for the same reason: this program has * to exist as SOURCE TEXT, because its destination is a QuickJS sandbox that * takes a string of code and has no module resolution of its own. React, * react-dom/server, the component barrel and the tools proxy all end up inside * it, which is exactly right — the whole point is that none of them are loaded * in the trusted host process to render a model's artifact. * * Built for `browser` rather than `node` so react-dom resolves its * `server.browser` build: that entry needs no `node:` builtins, which QuickJS * does not have. `production` is pinned regardless of the ambient NODE_ENV — * React's development build reaches for far more host surface, and the smoke * render's answer must not depend on how the host was started. */ export const bundleSmokeHarness = async (): Promise => { const { build } = await import("esbuild"); const result = await build({ entryPoints: [smokeHarnessEntry()], absWorkingDir: packageRoot, bundle: true, write: false, format: "iife", platform: "browser", target: "es2022", jsx: "automatic", // NOT minified, deliberately. React builds the component stack from // function names, and that stack is most of the value of what the model is // told: "at ChartTooltipContent, at BarChart, at App" is a fix, "at cie, at // Sg" is nothing. Minifying saves ~1MB of source text the sandbox parses in // a few hundred milliseconds; the stack is worth more than that. minify: false, define: { "process.env.NODE_ENV": JSON.stringify("production") }, }); const js = result.outputFiles[0]; if (!js) { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build helpers report failure by throwing throw new Error("Failed to bundle the artifact smoke-render harness."); } return js.text; }; /** Add to an app's `plugins` array to make the shell bundleable in that app. */ 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), load: async (id) => { if (id !== RESOLVED_ID) return undefined; return `export default ${JSON.stringify(await bundleInnerRenderer())};`; }, }); // --------------------------------------------------------------------------- // The in-frame Tailwind compiler, as source text // --------------------------------------------------------------------------- const TAILWIND_VIRTUAL_ID = "virtual:executor-tailwind-browser"; const TAILWIND_RESOLVED_ID = `\0${TAILWIND_VIRTUAL_ID}`; /** * Supply `@tailwindcss/browser`'s bundle as a STRING. * * The shell inlines it into the sandboxed renderer frame, which compiles the * utilities the model's code uses — the build-time stylesheet can only contain * classes that existed at build time, and the model's did not. That frame runs * under `default-src 'none'` with no `connect-src`, so the compiler cannot be * fetched and has to travel as bytes. * * A plugin rather than an `?raw` import because the package's `exports` map * publishes only its module entry, so a deep path into `dist/` fails to * resolve. Reading the resolved entry's file is the supported way to its bytes. */ export const tailwindBrowserSourcePlugin = (): InnerRendererVitePlugin => ({ name: "executor-tailwind-browser-source", enforce: "pre", resolveId: (id) => (id === TAILWIND_VIRTUAL_ID ? TAILWIND_RESOLVED_ID : undefined), load: async (id) => { if (id !== TAILWIND_RESOLVED_ID) return undefined; const fs = await import("node:fs/promises"); const { createRequire } = await import("node:module"); const require = createRequire(path.join(packageRoot, "package.json")); const entry = require.resolve("@tailwindcss/browser"); return `export default ${JSON.stringify(await fs.readFile(entry, "utf-8"))};`; }, }); // --------------------------------------------------------------------------- // The built shell document, as an app asset // --------------------------------------------------------------------------- const SHELL_VIRTUAL_ID = "virtual:executor-mcp-apps-shell-html"; const SHELL_RESOLVED_ID = `\0${SHELL_VIRTUAL_ID}`; const DEV_SHELL_URL = "/assets/executor-mcp-apps-shell-dev.html"; // The document's BYTES for the Worker in dev, where the stable asset does not // exist yet (assets are only emitted by a build) and the dev ASSETS binding // resolves against files on disk. `makeAssetsShellHtmlLoader` imports this // lazily, so the shell only builds when an artifact resource is actually read. const DEV_HTML_VIRTUAL_ID = "virtual:executor-mcp-apps-shell-dev-html"; const DEV_HTML_RESOLVED_ID = `\0${DEV_HTML_VIRTUAL_ID}`; /** Where `build:shell` writes the self-contained shell document. */ export const mcpAppsShellHtmlPath = (): string => path.resolve(packageRoot, "dist/mcp-app.html"); /** * Build the shell document and return its bytes. * * The shell is its own Vite build (`vite.config.shell.ts`), not an entry of the * app build: `vite-plugin-singlefile` inlines every chunk into one document, * which is only correct for a build whose sole output is that document. It runs * as a CHILD PROCESS rather than an in-process `vite.build()` because the caller * is itself a Vite build — nesting two builds in one process has them fight over * the singleton plugin state that `@tailwindcss/vite` and `@vitejs/plugin-react` * keep, and the shell's own Tailwind scan would then see the host app's sources. * * Always rebuilds rather than reusing a stale `dist/mcp-app.html`: the document * is what the artifact page renders, so serving yesterday's bytes is exactly the * class of bug this seam exists to prevent. The build takes about a second. */ export const buildMcpAppsShellHtml = async (): Promise => { const fs = await import("node:fs/promises"); const { execFile } = await import("node:child_process"); const { promisify } = await import("node:util"); await promisify(execFile)( process.execPath, [ path.resolve(packageRoot, "node_modules/vite/bin/vite.js"), "build", "--config", "vite.config.shell.ts", ], { cwd: packageRoot, maxBuffer: 32 * 1024 * 1024 }, ); return fs.readFile(mcpAppsShellHtmlPath(), "utf-8"); }; /** The structural shape of the shell-asset plugin — same reasoning as above: * no Vite types cross this module's boundary. */ export interface McpAppsShellAssetVitePlugin { readonly name: string; readonly enforce?: "pre"; readonly configResolved: (config: { readonly command: "build" | "serve"; readonly build?: { readonly ssr?: boolean | string }; }) => void; readonly resolveId: (id: string) => string | undefined; readonly load: (id: string) => Promise; readonly configureServer: (server: { readonly middlewares: { use: ( path: string, handler: ( req: unknown, res: { setHeader: (name: string, value: string) => void; end: (body: string) => void; }, next: () => void, ) => void, ) => void; }; }) => void; /** Rollup calls this with its plugin context as `this` — declared here so the * implementation can reach `emitFile` without casting. */ readonly generateBundle: ( this: RollupEmitContext, options: unknown, bundle: unknown, isWrite: boolean, ) => Promise | void; readonly closeBundle: () => Promise; } /** What this plugin needs from the hook context Vite calls it with. */ interface RollupEmitContext { readonly emitFile: (file: { readonly type: "asset"; readonly fileName: string; readonly source: string; }) => void; /** Vite's per-hook environment. `consumer` is "client" for the browser build * and "server" for an SSR/worker one. */ readonly environment?: { readonly config?: { readonly consumer?: string } }; } /** * Serve the built shell document to the app at a stable URL. * * The artifact page needs the shell as a DOCUMENT, not as modules linked into * the console's own bundle — that is the whole point of the fix. So the shell * cannot travel through the app's module graph; it has to arrive as a URL the * iframe can load, with its own origin-less document scope. * * Under `serve` the document is built on first request and served from a dev * middleware. Under `build` it is emitted into the client output next to the * app's other assets. Either way the virtual module exports only the URL, so * the 4.5 MB document never enters a JS chunk. * * Content-hashed so a stale shell can never be served from a cached URL after * a rebuild, matching how `workerBundlerArtifact` addresses its own artifacts. */ export const mcpAppsShellAsset = (): McpAppsShellAssetVitePlugin => { let command: "build" | "serve" = "build"; let ssr = false; let cached: { readonly html: string; readonly url: string } | undefined; const load = async (): Promise<{ readonly html: string; readonly url: string }> => { if (cached) return cached; const html = await buildMcpAppsShellHtml(); // Build-blocking: runtime loaders authenticate the document by this // fragment (an ASSETS-binding miss under SPA not-found handling answers // with index.html and a 200), so a shell that lost it would make every // deployed resource read fail. Catch that here, in the build. if (!html.includes(MCP_APPS_SHELL_DOCUMENT_MARKER)) { // 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( `Built MCP-Apps shell is missing its identity marker (${MCP_APPS_SHELL_DOCUMENT_MARKER}); ` + "check the in src/shell/mcp-app.html.", ); } const digest = createHash("sha256").update(html).digest("hex").slice(0, 12); cached = { html, url: `/assets/executor-mcp-apps-shell-${digest}.html` }; return cached; }; return { name: "executor-mcp-apps-shell-asset", enforce: "pre", configResolved(config) { command = config.command; // Recorded per environment, but ONE plugin instance sees every // environment's `configResolved` before any of their bundles are // generated — so the last write wins and this cannot be trusted at emit // time. `generateBundle` reads Rollup's own output options instead; this // only carries the single-environment case, where the two agree. ssr = Boolean(config.build?.ssr); }, resolveId: (id) => { if (id === SHELL_VIRTUAL_ID) return SHELL_RESOLVED_ID; if (id === DEV_HTML_VIRTUAL_ID) return DEV_HTML_RESOLVED_ID; return undefined; }, load: async (id) => { if (id === DEV_HTML_RESOLVED_ID) { // The Worker's dev-only escape hatch: under `build` it exports // `undefined` and `makeAssetsShellHtmlLoader` uses the ASSETS binding; // under `serve` it carries the built document inline, because no // assets have been emitted for the binding to find. Guarded the same // way as the URL module below — the build must not run at startup. if (command !== "serve") { return "export const devShellHtml = undefined;\nexport default devShellHtml;\n"; } const { html } = await load(); return `export const devShellHtml = ${JSON.stringify(html)};\nexport default devShellHtml;\n`; } if (id !== SHELL_RESOLVED_ID) return undefined; // Resolving the virtual module is part of Vite's startup graph. Building // the multi-megabyte shell here makes every dev server pay that cost // before it can answer even an unrelated route. In serve mode the // middleware below owns the shell's lifetime, so hand the client a // stable URL now and build only if that URL is actually requested. if (command === "serve") { return `export const shellHtmlUrl = ${JSON.stringify(DEV_SHELL_URL)};\nexport default shellHtmlUrl;\n`; } const { url } = await load(); return `export const shellHtmlUrl = ${JSON.stringify(url)};\nexport default shellHtmlUrl;\n`; }, configureServer(server) { // Dev has no emitted assets directory, so the document is served straight // from memory at the same URL the virtual module advertises. server.middlewares.use("/assets", (req, res, next) => { const url = (req as { url?: string }).url ?? ""; if (!url.startsWith("/executor-mcp-apps-shell-")) { next(); return; } void load().then(({ html }) => { res.setHeader("content-type", "text/html; charset=utf-8"); res.setHeader("cache-control", "no-store"); res.end(html); }); }); }, async generateBundle(_options, _bundle, _isWrite) { if (command !== "build") return; // Client output only. A multi-environment build (TanStack Start on cloud // emits `dist/client` and `dist/server`) runs this hook once per // environment, and `dist/server` IS the deployed Worker — emitting 4.5 MB // of browser document there would inflate the Worker bundle with // something only the browser ever fetches, over the ASSETS binding, from // `dist/client`. // // The hook's own environment is the authority, because it is the only // signal that is per-bundle: `configResolved`'s `build.ssr` is recorded // once per environment on this shared plugin instance, so by the time any // bundle is generated it holds whichever environment resolved last. A // single-environment build (selfhost) reports no environment, and falls // back to that flag, where the two do agree. const consumer = this.environment?.config?.consumer; if (consumer === undefined ? ssr : consumer !== "client") return; const { html, url } = await load(); // `emitFile` puts the document through Rollup's own output writer, so it // lands in whatever client outDir the app (or its framework plugin) // configured — which `process.cwd()`-relative writes get wrong for // TanStack Start, where the client build writes to `dist/client`. this.emitFile({ type: "asset", fileName: url.replace(/^\//, ""), source: html }); // A second copy at the STABLE name, for the server side: a Workers host // serves the `ui://executor/shell.html` MCP resource by fetching this // through its ASSETS binding (`makeAssetsShellHtmlLoader`), and the // self-host image reads it from the SPA dist on disk — neither can know // the content hash the client build minted. See `./shell-asset-path`. this.emitFile({ type: "asset", fileName: MCP_APPS_SHELL_STABLE_ASSET_PATH.replace(/^\//, ""), source: html, }); }, async closeBundle() { cached = undefined; }, }; };