// The ambient declaration for `virtual:executor-mcp-apps-shell-dev-html`
// travels with this module into the Workers hosts that import it (their
// tsconfig `include` covers only their own sources) — same trick as
// `shell/artifact-renderer.tsx` and its `shell-html-url.d.ts`.
///
/**
* The shell loader for Workers hosts, where `loadMcpAppsShellHtml`'s
* `fs.readFile` can never succeed: a deployed isolate has no filesystem, so
* the document arrives the way #1378 delivers the worker-bundler artifact —
* emitted into the client assets at build time and fetched through the
* `ASSETS` binding at runtime.
*
* Kept free of the shell's own module graph (React, Recharts, Tailwind): this
* entry ships only a fetch and two constants, so a Worker can import it
* without dragging browser code into its bundle.
*/
import {
MCP_APPS_SHELL_DOCUMENT_MARKER,
MCP_APPS_SHELL_STABLE_ASSET_PATH,
} from "./shell-asset-path";
/** The structural shape of a Workers static-assets binding (`env.ASSETS`). */
export interface ShellAssetsBinding {
readonly fetch: (request: Request) => Promise;
}
/**
* Build a `loadAppShellHtml` over the `ASSETS` binding.
*
* `devShellHtml` carries the document inline during `vite dev`, where no
* assets have been emitted yet: pass a thunk that dynamically imports
* `virtual:executor-mcp-apps-shell-dev-html` — the built shell under `serve`,
* `undefined` under `build`. A THUNK, not the value: under `serve` that
* virtual module builds the multi-megabyte shell in a child process, a cost
* only the first artifact resource read should pay, never dev-server
* startup. Same dual-shape seam #1378 uses for the worker-bundler artifact.
*
* Failure REJECTS, deliberately, on either a non-OK response or a document
* that is not the shell. The second check is load-bearing: under SPA
* not-found handling the binding answers a miss with the console's
* `index.html` and a 200, and serving THAT as the `ui://` resource is the
* silent every-client-hangs failure this seam exists to kill — the client
* loads a real document that never starts the MCP-Apps handshake. A rejected
* read surfaces as a JSON-RPC error naming the missing asset instead.
*/
export const makeAssetsShellHtmlLoader = (input: {
readonly assets: ShellAssetsBinding;
readonly devShellHtml?: () => Promise;
}): (() => Promise) => {
let cache: string | undefined;
return async () => {
if (cache !== undefined) return cache;
const devHtml = await input.devShellHtml?.();
if (devHtml !== undefined) {
cache = devHtml;
return cache;
}
const response = await input.assets.fetch(
new Request(new URL(MCP_APPS_SHELL_STABLE_ASSET_PATH, "https://assets.local")),
);
const html = response.ok ? await response.text() : undefined;
if (html === undefined || !html.includes(MCP_APPS_SHELL_DOCUMENT_MARKER)) {
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: a Worker without the emitted shell asset must fail the resource read loudly; any other document served as the shell hangs every MCP-Apps client silently
throw new Error(
`MCP-Apps shell asset ${MCP_APPS_SHELL_STABLE_ASSET_PATH} is missing from this deployment's ` +
`static assets (${html === undefined ? `ASSETS fetch returned ${response.status}` : "the fetch returned a different document"}). ` +
"The app build emits it via mcpAppsShellAsset (@executor-js/mcp-apps-shell/vite); rebuild and redeploy.",
);
}
cache = html;
return cache;
};
};