From 98e2e0cd92e6dcd021d2d7439870712ec93b05c5 Mon Sep 17 00:00:00 2001 From: fforres Date: Sat, 17 Oct 2026 19:14:04 -0700 Subject: [PATCH 1/3] perf(host-cloudflare): cache content-hashed assets as immutable Emit a _headers file into the host-cloudflare dist only. /assets/* is cached for a year; the stable-named shell copy keeps revalidating. Claude-Session: https://claude.ai/code/session_019ZE3j9YzRKxT4LaiDCBv6S --- apps/host-cloudflare/vite.config.ts | 26 ++++++++++++++++++++++ packages/hosts/mcp-apps-shell/package.json | 4 ++++ 2 files changed, 30 insertions(+) diff --git a/apps/host-cloudflare/vite.config.ts b/apps/host-cloudflare/vite.config.ts index aea34e7210..04e94a1d99 100644 --- a/apps/host-cloudflare/vite.config.ts +++ b/apps/host-cloudflare/vite.config.ts @@ -6,6 +6,7 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import executorVitePlugin from "@executor-js/vite-plugin"; +import { MCP_APPS_SHELL_STABLE_ASSET_PATH } from "@executor-js/mcp-apps-shell/shell-asset-path"; import { innerRendererPlugin, mcpAppsShellAsset } from "@executor-js/mcp-apps-shell/vite"; import { routes } from "./tsr.routes"; @@ -33,6 +34,30 @@ const EXECUTOR_VERSION = cliPackage.version; // API together. // --------------------------------------------------------------------------- +// Workers Static Assets reads `_headers` from the assets directory. Emitted here +// rather than into the shared `publicDir` so only this host gets it. Everything +// under /assets is content-hashed and safe to cache forever, except the +// stable-named shell copy the MCP session reads, which must keep revalidating. +const immutableAssetHeaders = (): Plugin => ({ + name: "executor-host-cloudflare-headers", + apply: "build", + generateBundle() { + this.emitFile({ + type: "asset", + fileName: "_headers", + source: [ + "/assets/*", + " Cache-Control: public, max-age=31536000, immutable", + "", + MCP_APPS_SHELL_STABLE_ASSET_PATH, + " ! Cache-Control", + " Cache-Control: public, max-age=0, must-revalidate", + "", + ].join("\n"), + }); + }, +}); + const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url)); export default defineConfig({ @@ -67,6 +92,7 @@ export default defineConfig({ // sandboxed inner frame from `virtual:executor-inner-renderer`. mcpAppsShellAsset() as Plugin, innerRendererPlugin(), + immutableAssetHeaders(), executorVitePlugin({ configPath: fileURLToPath(new URL("./executor.config.ts", import.meta.url)), }), diff --git a/packages/hosts/mcp-apps-shell/package.json b/packages/hosts/mcp-apps-shell/package.json index e1615bdcc2..64d8d07256 100644 --- a/packages/hosts/mcp-apps-shell/package.json +++ b/packages/hosts/mcp-apps-shell/package.json @@ -12,6 +12,10 @@ "types": "./src/shell-html.ts", "default": "./src/shell-html.ts" }, + "./shell-asset-path": { + "types": "./src/shell-asset-path.ts", + "default": "./src/shell-asset-path.ts" + }, "./worker": { "types": "./src/worker.ts", "default": "./src/worker.ts" From c85b9067bcb3ea6af1dc4864f865cfd2d9a4ace5 Mon Sep 17 00:00:00 2001 From: fforres Date: Sat, 17 Oct 2026 19:14:04 -0700 Subject: [PATCH 2/3] perf(mcp-apps-shell): minify the inner renderer bundle in production Claude-Session: https://claude.ai/code/session_019ZE3j9YzRKxT4LaiDCBv6S --- packages/hosts/mcp-apps-shell/src/vite.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/hosts/mcp-apps-shell/src/vite.ts b/packages/hosts/mcp-apps-shell/src/vite.ts index a3d313e3dc..d67f31fbd8 100644 --- a/packages/hosts/mcp-apps-shell/src/vite.ts +++ b/packages/hosts/mcp-apps-shell/src/vite.ts @@ -64,6 +64,9 @@ export const bundleInnerRenderer = async (): Promise => { platform: "browser", target: "es2022", jsx: "automatic", + // The IIFE is embedded as a string in the shell document and again in every + // srcdoc, so production pays for its size on each view. + minify: process.env.NODE_ENV === "production", define: { "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"), }, From 8d57fdba85873e8682e902db611e8cb1e4cbcd97 Mon Sep 17 00:00:00 2001 From: fforres Date: Sat, 17 Oct 2026 19:14:04 -0700 Subject: [PATCH 3/3] perf(react): prefetch the shell document when the artifact renderer is preloaded Claude-Session: https://claude.ai/code/session_019ZE3j9YzRKxT4LaiDCBv6S --- .../src/shell/artifact-renderer.tsx | 4 ++ .../react/src/api/artifact-renderer.test.tsx | 43 ++++++++++++++++++- packages/react/src/api/artifact-renderer.tsx | 28 +++++++++++- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx b/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx index 9a585dea48..be0ddbf8e9 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx +++ b/packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx @@ -137,6 +137,10 @@ const hostContextFor = (theme: "light" | "dark", viewport: number | null): McpUi [RENDER_SIGNAL_HOST_CONTEXT_KEY]: true, }); +// Read by `preloadArtifactRenderer` so the document download starts with the +// chunk instead of when the iframe mounts. +export { shellHtmlUrl }; + export default function ArtifactShell(props: ArtifactRendererProps) { const frameRef = useRef(null); const containerRef = useRef(null); diff --git a/packages/react/src/api/artifact-renderer.test.tsx b/packages/react/src/api/artifact-renderer.test.tsx index 52b3949aa9..d626bf8821 100644 --- a/packages/react/src/api/artifact-renderer.test.tsx +++ b/packages/react/src/api/artifact-renderer.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it } from "@effect/vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; import { preloadArtifactRenderer, type ArtifactRendererLoader } from "./artifact-renderer"; @@ -91,3 +91,44 @@ describe("preloadArtifactRenderer", () => { expect(second.calls()).toBe(1); }); }); + +describe("preloadArtifactRenderer shell document prefetch", () => { + // The suite runs without a DOM, so a minimal `document` records what is + // appended to the head. + const appended: Array<{ rel: string; as: string; href: string }> = []; + const settle = async () => { + for (let i = 0; i < 4; i += 1) await Promise.resolve(); + }; + + beforeEach(() => { + appended.length = 0; + vi.stubGlobal("document", { + createElement: () => ({ rel: "", as: "", href: "" }), + head: { appendChild: (node: (typeof appended)[number]) => appended.push(node) }, + }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("adds one prefetch link for the shell document, however many loaders announce it", async () => { + const url = "/assets/executor-mcp-apps-shell-test.html"; + const loaderFor = (): ArtifactRendererLoader => async () => ({ + default: (() => null) as never, + shellHtmlUrl: url, + }); + + preloadArtifactRenderer(loaderFor()); + preloadArtifactRenderer(loaderFor()); + await settle(); + + expect(appended).toEqual([{ rel: "prefetch", as: "document", href: url }]); + }); + + it("adds no link when the renderer does not announce a document", async () => { + preloadArtifactRenderer(countingLoader().loader); + await settle(); + + expect(appended).toEqual([]); + }); +}); diff --git a/packages/react/src/api/artifact-renderer.tsx b/packages/react/src/api/artifact-renderer.tsx index d082b04dc2..c5da707d52 100644 --- a/packages/react/src/api/artifact-renderer.tsx +++ b/packages/react/src/api/artifact-renderer.tsx @@ -63,7 +63,12 @@ export type ArtifactRenderer = React.ComponentType; * Resolves the renderer on demand, in the browser only. Apps pass a dynamic * `import()`; the module is never touched during SSR. */ -export type ArtifactRendererLoader = () => Promise<{ readonly default: ArtifactRenderer }>; +export type ArtifactRendererLoader = () => Promise<{ + readonly default: ArtifactRenderer; + /** URL of the document the renderer loads into its frame. When present, + * preloading prefetches it alongside the chunk. */ + readonly shellHtmlUrl?: string; +}>; const ArtifactRendererContext = React.createContext(null); @@ -122,6 +127,23 @@ export function useArtifactRenderer(): ArtifactRenderer | null { */ const preloaded = new WeakSet(); +/** + * Shell document URLs already hinted. The document is several megabytes and the + * iframe only mounts after the artifact row arrives, so the hint lets the two + * fetches overlap. One `` per URL for the life of the page. + */ +const prefetchedDocuments = new Set(); + +function prefetchDocument(url: string): void { + if (typeof document === "undefined" || prefetchedDocuments.has(url)) return; + prefetchedDocuments.add(url); + const link = document.createElement("link"); + link.rel = "prefetch"; + link.as = "document"; + link.href = url; + document.head.appendChild(link); +} + /** * Start fetching the renderer chunk before anything needs to render it. * @@ -150,7 +172,9 @@ export function preloadArtifactRenderer(loader: ArtifactRendererLoader | null): if (!loader || preloaded.has(loader)) return; preloaded.add(loader); void loader().then( - () => undefined, + (renderer) => { + if (renderer.shellHtmlUrl) prefetchDocument(renderer.shellHtmlUrl); + }, () => { // Let a failed speculative fetch be tried again for real. Without this a // transient network blip during the preload would mark the loader done