Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/host-cloudflare/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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)),
}),
Expand Down
4 changes: 4 additions & 0 deletions packages/hosts/mcp-apps-shell/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions packages/hosts/mcp-apps-shell/src/shell/artifact-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLIFrameElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
Expand Down
3 changes: 3 additions & 0 deletions packages/hosts/mcp-apps-shell/src/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ export const bundleInnerRenderer = async (): Promise<string> => {
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"),
},
Expand Down
43 changes: 42 additions & 1 deletion packages/react/src/api/artifact-renderer.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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([]);
});
});
28 changes: 26 additions & 2 deletions packages/react/src/api/artifact-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,12 @@ export type ArtifactRenderer = React.ComponentType<ArtifactRendererProps>;
* 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<ArtifactRendererLoader | null>(null);

Expand Down Expand Up @@ -122,6 +127,23 @@ export function useArtifactRenderer(): ArtifactRenderer | null {
*/
const preloaded = new WeakSet<ArtifactRendererLoader>();

/**
* 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 `<link>` per URL for the life of the page.
*/
const prefetchedDocuments = new Set<string>();

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.
*
Expand Down Expand Up @@ -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
Expand Down
Loading