The deployed cloud Worker has no filesystem, so loadMcpAppsShellHtml's fs.readFile always failed and the ui://executor/shell.html resource silently served a 'Shell not built' placeholder — an inert document that never starts the MCP-Apps handshake, so every artifact widget in every MCP client spun on its loading state forever with no error anywhere. - Workers hosts (cloud, host-cloudflare) now fetch the built shell through the ASSETS binding: mcpAppsShellAsset emits a stable-named copy alongside the content-hashed one, and makeAssetsShellHtmlLoader (new @executor-js/mcp-apps-shell/worker entry) reads and verifies it. Under vite dev the document travels inline via a dev-only virtual module, since no assets are emitted yet. - The shell document carries an identity marker the loaders verify, so an SPA not-found fallback (index.html with a 200) can never be served as the shell. - The self-host image reads the same emitted asset from its SPA dist; the runtime image has no packages/ tree for the old candidates. - The silent placeholder is gone: a host that cannot produce the shell fails the resource read with an actionable error. - App builds now fail when the shell asset is missing or wrong (apps/cloud scripts/build.mjs, assert-shell-asset.mjs on both hosts).
Unified · Split
| @@ -0,0 +1,7 @@ | ||
| 1 | +--- | |
| 2 | +"executor": patch | |
| 3 | +--- | |
| 4 | + | |
| 5 | +**Fix: MCP clients of the cloud host got a "Shell not built" placeholder as the `ui://executor/shell.html` resource, so every artifact rendered as a widget that never finished loading** | |
| 6 | + | |
| 7 | +The deployed Worker has no filesystem, and the shell loader silently fell back to an inert placeholder document when its `fs.readFile` failed. Workers hosts now fetch the built shell through the static-assets binding (the app build emits a stable-named copy alongside the hashed one), the self-host image reads the same emitted asset from its SPA dist, and a host that cannot produce the shell now fails the resource read with an actionable error instead of serving a document that hangs the client. App builds fail if the shell asset was not emitted. | |
| @@ -31,10 +31,10 @@ const resolveQuickJsWasmPath = (): string => { · 30 unmodified lines | ||
| 31 | 31 | // The MCP host serves the shell as the `ui://executor/shell.html` resource, and |
| 32 | 32 | // `@executor-js/mcp-apps-shell` reads it from disk at runtime. That runtime |
| 33 | 33 | // `fs.readFile` is invisible to `bun build --compile`, so without this the |
| 34 | −// packaged binary would serve the "Shell not built" placeholder and every | |
| 35 | −// generated UI would render blank. Build it if missing and copy it next to the | |
| 36 | −// executable, where the loader finds it via `process.execPath` — the same | |
| 37 | −// colocation trick used for `libsql.node` / `emscripten-module.wasm`. | |
| 34 | +// packaged binary would fail every artifact resource read. Build it if missing | |
| 35 | +// and copy it next to the executable, where the loader finds it via | |
| 36 | +// `process.execPath` — the same colocation trick used for `libsql.node` / | |
| 37 | +// `emscripten-module.wasm`. | |
| 38 | 38 | // --------------------------------------------------------------------------- |
| 39 | 39 | |
| 40 | 40 | const mcpAppsShellDir = resolve(repoRoot, "packages/hosts/mcp-apps-shell"); |
| @@ -9,7 +9,7 @@ · 8 unmodified lines | ||
| 9 | 9 | |
| 10 | 10 | import { spawnSync } from "node:child_process"; |
| 11 | 11 | import { randomBytes } from "node:crypto"; |
| 12 | −import { rmSync } from "node:fs"; | |
| 12 | +import { existsSync, readFileSync, rmSync } from "node:fs"; | |
| 13 | 13 | |
| 14 | 14 | if (!process.env.VITE_PUBLIC_ANALYTICS_PATH) { |
| 15 | 15 | process.env.VITE_PUBLIC_ANALYTICS_PATH = randomBytes(4).toString("hex"); |
| @@ -32,3 +32,22 @@ for (const step of steps) { · 16 unmodified lines | ||
| 32 | 32 | process.exit(result.status ?? 1); |
| 33 | 33 | } |
| 34 | 34 | } |
| 35 | + | |
| 36 | +// The deployed Worker serves the MCP-Apps shell (`ui://executor/shell.html`) | |
| 37 | +// by fetching this stable-named asset through the ASSETS binding — it has no | |
| 38 | +// filesystem to read the shell from. A build that failed to emit it would | |
| 39 | +// deploy a Worker whose artifact resource reads all fail, so make the miss a | |
| 40 | +// build failure here instead of a production incident. Same shape as the | |
| 41 | +// worker-bundler asset assertion from #1378. | |
| 42 | +const shellAsset = new URL( | |
| 43 | + "../dist/client/assets/executor-mcp-apps-shell-stable.html", | |
| 44 | + import.meta.url, | |
| 45 | +); | |
| 46 | +if (!existsSync(shellAsset)) { | |
| 47 | + console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`); | |
| 48 | + process.exit(1); | |
| 49 | +} | |
| 50 | +if (!readFileSync(shellAsset, "utf8").includes('name="executor-mcp-apps-shell"')) { | |
| 51 | + console.error(`[build] ${shellAsset.pathname} is not the MCP-Apps shell document`); | |
| 52 | + process.exit(1); | |
| 53 | +} | |
| @@ -26,7 +26,7 @@ import { · 25 unmodified lines | ||
| 26 | 26 | } from "@executor-js/host-mcp/tool-server"; |
| 27 | 27 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; |
| 28 | 28 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; |
| 29 | −import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; | |
| 29 | +import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; | |
| 30 | 30 | import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; |
| 31 | 31 | import { |
| 32 | 32 | McpAgentSessionDOBase, |
| @@ -156,6 +156,18 @@ const makeSessionServices = (dbHandle: CloudSessionDbHandle) => { · 123 unmodified lines | ||
| 156 | 156 | return Layer.mergeAll(DbLive, UserStoreLive, CoreSharedServices); |
| 157 | 157 | }; |
| 158 | 158 | |
| 159 | +// The `ui://executor/shell.html` resource, over the ASSETS binding: the | |
| 160 | +// deployed Worker has no filesystem, so the document is the stable-named | |
| 161 | +// asset the client build emitted (`mcpAppsShellAsset`), fetched at first | |
| 162 | +// artifact resource read. Module scope so the fetch-and-verify happens once | |
| 163 | +// per isolate, not once per session. The dev thunk carries the built shell | |
| 164 | +// inline under `vite dev`, where no assets exist yet for the binding to find. | |
| 165 | +const loadAppShellHtml = makeAssetsShellHtmlLoader({ | |
| 166 | + assets: env.ASSETS, | |
| 167 | + devShellHtml: () => | |
| 168 | + import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml), | |
| 169 | +}); | |
| 170 | + | |
| 159 | 171 | // --------------------------------------------------------------------------- |
| 160 | 172 | // Durable Object |
| 161 | 173 | // --------------------------------------------------------------------------- |
| @@ -268,7 +280,7 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 106 unmodified lines | ||
| 268 | 280 | // flag existed carry no value; absent now means off, same as a fresh |
| 269 | 281 | // connection that did not ask for `?artifacts=true`. |
| 270 | 282 | artifactsEnabled: sessionMeta.artifactsEnabled ?? false, |
| 271 | − loadAppShellHtml: loadMcpAppsShellHtml, | |
| 283 | + loadAppShellHtml, | |
| 272 | 284 | smokeRenderArtifact, |
| 273 | 285 | artifactUrl: artifactUrlFor( |
| 274 | 286 | env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh", |
| @@ -3,8 +3,8 @@ · 2 unmodified lines | ||
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "scripts": { |
| 6 | − "build": "vite build", | |
| 7 | − "deploy": "vite build && wrangler deploy", | |
| 6 | + "build": "vite build && node scripts/assert-shell-asset.mjs", | |
| 7 | + "deploy": "vite build && node scripts/assert-shell-asset.mjs && wrangler deploy", | |
| 8 | 8 | "dev": "wrangler dev", |
| 9 | 9 | "dev:web": "vite dev", |
| 10 | 10 | "typecheck": "tsgo --noEmit", |
| @@ -0,0 +1,17 @@ | ||
| 1 | +// Build-blocking check that the SPA build emitted the stable-named MCP-Apps | |
| 2 | +// shell document. The MCP session DO serves `ui://executor/shell.html` by | |
| 3 | +// fetching this asset through the ASSETS binding — a deployed Worker has no | |
| 4 | +// filesystem — so a build without it ships a Worker whose artifact resource | |
| 5 | +// reads all fail. Fail the build here instead. Same shape as apps/cloud's | |
| 6 | +// assertion in scripts/build.mjs. | |
| 7 | +import { existsSync, readFileSync } from "node:fs"; | |
| 8 | + | |
| 9 | +const shellAsset = new URL("../dist/assets/executor-mcp-apps-shell-stable.html", import.meta.url); | |
| 10 | +if (!existsSync(shellAsset)) { | |
| 11 | + console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`); | |
| 12 | + process.exit(1); | |
| 13 | +} | |
| 14 | +if (!readFileSync(shellAsset, "utf8").includes('name="executor-mcp-apps-shell"')) { | |
| 15 | + console.error(`[build] ${shellAsset.pathname} is not the MCP-Apps shell document`); | |
| 16 | + process.exit(1); | |
| 17 | +} | |
| @@ -21,6 +21,10 @@ export interface CloudflareEnv { · 20 unmodified lines | ||
| 21 | 21 | readonly DB: D1Database; |
| 22 | 22 | /** R2 bucket binding — holds values too large for a D1 row (~1-2MB cap). */ |
| 23 | 23 | readonly BLOBS?: R2Bucket; |
| 24 | + /** Static assets binding (wrangler.jsonc `assets.binding`). The MCP session | |
| 25 | + * DO fetches the built MCP-Apps shell document through it — a deployed | |
| 26 | + * Worker has no filesystem to read the shell from. */ | |
| 27 | + readonly ASSETS: { readonly fetch: (request: Request) => Promise<Response> }; | |
| 24 | 28 | /** MCP session Durable Object namespace — one addressable isolate per MCP |
| 25 | 29 | * session (the DO id IS the session id), so a session survives across the |
| 26 | 30 | * Worker's stateless isolates. */ |
| @@ -74,7 +78,7 @@ export interface CloudflareConfig { · 47 unmodified lines | ||
| 74 | 78 | |
| 75 | 79 | type CloudflareConfigEnv = Omit< |
| 76 | 80 | CloudflareEnv, |
| 77 | − "DB" | "BLOBS" | "MCP_SESSION" | "MCP_EXECUTION_OWNER" | |
| 81 | + "DB" | "BLOBS" | "ASSETS" | "MCP_SESSION" | "MCP_EXECUTION_OWNER" | |
| 78 | 82 | >; |
| 79 | 83 | |
| 80 | 84 | type CloudflareAccessEnv = Pick< |
| @@ -6,7 +6,7 @@ import { · 5 unmodified lines | ||
| 6 | 6 | } from "@executor-js/host-mcp/tool-server"; |
| 7 | 7 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; |
| 8 | 8 | import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; |
| 9 | −import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; | |
| 9 | +import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; | |
| 10 | 10 | import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; |
| 11 | 11 | import type { ExecutorDbHandle } from "@executor-js/api/server"; |
| 12 | 12 | import { |
| @@ -57,6 +57,14 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward · 44 unmodified lines | ||
| 57 | 57 | export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle> { |
| 58 | 58 | private readonly cfEnv: CloudflareEnv; |
| 59 | 59 | private readonly cfConfig: CloudflareConfig; |
| 60 | + /** | |
| 61 | + * The `ui://executor/shell.html` document, over the ASSETS binding: a | |
| 62 | + * deployed Worker has no filesystem, so the shell is the stable-named asset | |
| 63 | + * the SPA build emitted into `./dist` (`mcpAppsShellAsset`). No dev-mode | |
| 64 | + * escape hatch here, unlike cloud: this Worker is bundled by wrangler, not | |
| 65 | + * Vite, and `wrangler dev` serves the same built `./dist` the binding reads. | |
| 66 | + */ | |
| 67 | + private readonly loadAppShellHtml: () => Promise<string>; | |
| 60 | 68 | |
| 61 | 69 | constructor( |
| 62 | 70 | ctx: ConstructorParameters<typeof McpAgentSessionDOBase<CloudflareEnv, CfSessionDbHandle>>[0], |
| @@ -65,6 +73,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 2 unmodified lines | ||
| 65 | 73 | super(ctx, env); |
| 66 | 74 | this.cfEnv = env; |
| 67 | 75 | this.cfConfig = loadConfig(env); |
| 76 | + this.loadAppShellHtml = makeAssetsShellHtmlLoader({ assets: env.ASSETS }); | |
| 68 | 77 | } |
| 69 | 78 | |
| 70 | 79 | protected override executionOwnerDirectory(): McpExecutionOwnerDirectory | null { |
| @@ -140,7 +149,7 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 69 unmodified lines | ||
| 140 | 149 | // flag existed carry no value; absent now means off, same as a fresh |
| 141 | 150 | // connection that did not ask for `?artifacts=true`. |
| 142 | 151 | artifactsEnabled: sessionMeta.artifactsEnabled ?? false, |
| 143 | − loadAppShellHtml: loadMcpAppsShellHtml, | |
| 152 | + loadAppShellHtml: self.loadAppShellHtml, | |
| 144 | 153 | smokeRenderArtifact, |
| 145 | 154 | ...(artifactOrigin |
| 146 | 155 | ? { artifactUrl: artifactUrlFor(artifactOrigin, sessionMeta.organizationSlug) } |
| @@ -15,6 +15,10 @@ · 14 unmodified lines | ||
| 15 | 15 | // Worker instead of the SPA fallback. |
| 16 | 16 | "assets": { |
| 17 | 17 | "directory": "./dist", |
| 18 | + // The MCP session DO reads the built MCP-Apps shell document through this | |
| 19 | + // binding (`ui://executor/shell.html` has no filesystem to come from on a | |
| 20 | + // deployed Worker). | |
| 21 | + "binding": "ASSETS", | |
| 18 | 22 | "not_found_handling": "single-page-application", |
| 19 | 23 | "run_worker_first": ["/api/*", "/mcp", "/mcp/*", "/.well-known/*", "/v1", "/v1/*"], |
| 20 | 24 | }, |
| @@ -10,7 +10,7 @@ · 9 unmodified lines | ||
| 10 | 10 | }, |
| 11 | 11 | "scripts": { |
| 12 | 12 | "dev": "bunx --bun vite dev", |
| 13 | − "build": "turbo run build --filter @executor-js/vite-plugin && vite build", | |
| 13 | + "build": "turbo run build --filter @executor-js/vite-plugin && vite build && node scripts/assert-shell-asset.mjs", | |
| 14 | 14 | "start": "bun run src/serve.ts", |
| 15 | 15 | "typecheck": "tsgo --noEmit", |
| 16 | 16 | "test": "vitest run", |
| @@ -0,0 +1,18 @@ | ||
| 1 | +// Build-blocking check that the SPA build emitted the stable-named MCP-Apps | |
| 2 | +// shell document. The self-host server serves `ui://executor/shell.html` by | |
| 3 | +// reading this file from the SPA dist (`loadMcpAppsShellHtml`'s image-layout | |
| 4 | +// candidate) — the runtime image carries no `packages/` tree for the package | |
| 5 | +// dist candidates to hit — so a build without it ships an image whose | |
| 6 | +// artifact resource reads all fail. Fail the build here instead. Same shape | |
| 7 | +// as apps/cloud's assertion in scripts/build.mjs. | |
| 8 | +import { existsSync, readFileSync } from "node:fs"; | |
| 9 | + | |
| 10 | +const shellAsset = new URL("../dist/assets/executor-mcp-apps-shell-stable.html", import.meta.url); | |
| 11 | +if (!existsSync(shellAsset)) { | |
| 12 | + console.error(`[build] missing MCP-Apps shell asset at ${shellAsset.pathname}`); | |
| 13 | + process.exit(1); | |
| 14 | +} | |
| 15 | +if (!readFileSync(shellAsset, "utf8").includes('name="executor-mcp-apps-shell"')) { | |
| 16 | + console.error(`[build] ${shellAsset.pathname} is not the MCP-Apps shell document`); | |
| 17 | + process.exit(1); | |
| 18 | +} | |
| @@ -12,6 +12,10 @@ · 11 unmodified lines | ||
| 12 | 12 | "types": "./src/shell-html.ts", |
| 13 | 13 | "default": "./src/shell-html.ts" |
| 14 | 14 | }, |
| 15 | + "./worker": { | |
| 16 | + "types": "./src/worker.ts", | |
| 17 | + "default": "./src/worker.ts" | |
| 18 | + }, | |
| 15 | 19 | "./smoke-render": { |
| 16 | 20 | "types": "./src/smoke-render-entry.ts", |
| 17 | 21 | "default": "./src/smoke-render-entry.ts" |
| @@ -2,10 +2,12 @@ · 1 unmodified line | ||
| 2 | 2 | * The MCP-Apps shell: the trusted iframe host that renders model-written JSX. |
| 3 | 3 | * |
| 4 | 4 | * The shell reaches MCP clients as a single self-contained HTML document (the |
| 5 | − * `ui://executor/shell.html` resource). Hosts read it with | |
| 6 | − * `loadMcpAppsShellHtml` and hand it to the MCP host through the | |
| 5 | + * `ui://executor/shell.html` resource), handed to the MCP host through the | |
| 7 | 6 | * `loadAppShellHtml` config seam — the MCP host never imports this package |
| 8 | − * directly, so React/Recharts/Tailwind stay out of the Workers graph. | |
| 7 | + * directly, so React/Recharts/Tailwind stay out of the Workers graph. Hosts | |
| 8 | + * with a filesystem read it with `loadMcpAppsShellHtml`; Workers hosts fetch | |
| 9 | + * it through their ASSETS binding with `makeAssetsShellHtmlLoader` | |
| 10 | + * (`@executor-js/mcp-apps-shell/worker`). | |
| 9 | 11 | */ |
| 10 | 12 | |
| 11 | −export { loadMcpAppsShellHtml, MCP_APPS_SHELL_NOT_BUILT_HTML } from "./shell-html"; | |
| 13 | +export { loadMcpAppsShellHtml } from "./shell-html"; | |
| @@ -0,0 +1,35 @@ | ||
| 1 | +/** | |
| 2 | + * The names the built shell document travels under, shared by the build-time | |
| 3 | + * emitter (`./vite`) and the runtime loaders (`./shell-html`, `./worker`). | |
| 4 | + * | |
| 5 | + * In its own module so runtime loaders can import the constants without | |
| 6 | + * dragging in the build-time plugin machinery (esbuild, child-process Vite). | |
| 7 | + */ | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * Where `mcpAppsShellAsset` emits the STABLE-NAMED copy of the built shell | |
| 11 | + * document, alongside the content-hashed one. | |
| 12 | + * | |
| 13 | + * The hashed name exists for browsers: the console's artifact page loads the | |
| 14 | + * shell by URL, and a content hash is what lets that URL be cached forever. A | |
| 15 | + * Worker serving the `ui://executor/shell.html` MCP resource has the opposite | |
| 16 | + * problem — it fetches the document server-side through the ASSETS binding, | |
| 17 | + * at runtime, from a bundle that cannot know the hash the client build | |
| 18 | + * produced. A second copy at a name both sides agree on ahead of time is the | |
| 19 | + * seam. No staleness risk: the binding serves the current deployment's | |
| 20 | + * assets, not a browser cache keyed on the URL. | |
| 21 | + */ | |
| 22 | +export const MCP_APPS_SHELL_STABLE_ASSET_PATH = "/assets/executor-mcp-apps-shell-stable.html"; | |
| 23 | + | |
| 24 | +/** | |
| 25 | + * A fragment every built shell document contains (the `<meta>` in | |
| 26 | + * `src/shell/mcp-app.html`), and no other document does. | |
| 27 | + * | |
| 28 | + * Exists because a fetch through a Workers ASSETS binding cannot be trusted | |
| 29 | + * by status alone: with `not_found_handling: "single-page-application"` | |
| 30 | + * (host-cloudflare), a miss returns the console's `index.html` with a 200. | |
| 31 | + * Serving THAT as the shell resource would be the exact silent-hang failure | |
| 32 | + * this seam exists to kill — the client loads a real document that never | |
| 33 | + * speaks the MCP-Apps protocol. Loaders verify the marker and fail loudly. | |
| 34 | + */ | |
| 35 | +export const MCP_APPS_SHELL_DOCUMENT_MARKER = 'name="executor-mcp-apps-shell"'; | |
| @@ -0,0 +1,14 @@ | ||
| 1 | +/** | |
| 2 | + * The built shell document's BYTES under `vite dev`, or `undefined` in a | |
| 3 | + * build — supplied by `mcpAppsShellAsset` (`@executor-js/mcp-apps-shell/vite`). | |
| 4 | + * | |
| 5 | + * Only Workers hosts import this, as the `devShellHtml` thunk for | |
| 6 | + * `makeAssetsShellHtmlLoader`: in dev no assets have been emitted for the | |
| 7 | + * ASSETS binding to find, so the document travels inline; in a deployed | |
| 8 | + * build this module is `undefined` and the binding is the real path. | |
| 9 | + */ | |
| 10 | +declare module "virtual:executor-mcp-apps-shell-dev-html" { | |
| 11 | + const devShellHtml: string | undefined; | |
| 12 | + export { devShellHtml }; | |
| 13 | + export default devShellHtml; | |
| 14 | +} | |
| @@ -1,49 +1,67 @@ | ||
| 1 | +import { MCP_APPS_SHELL_STABLE_ASSET_PATH } from "./shell-asset-path"; | |
| 2 | + | |
| 1 | 3 | let shellHtmlCache: string | undefined; |
| 2 | 4 | |
| 3 | 5 | /** |
| 4 | − * The self-contained shell HTML served as the MCP-Apps `ui://` resource. | |
| 6 | + * The self-contained shell HTML served as the MCP-Apps `ui://` resource, read | |
| 7 | + * from the local filesystem. | |
| 5 | 8 | * |
| 6 | 9 | * Hosts inject this through `SharedMcpServerConfig.loadAppShellHtml` rather |
| 7 | 10 | * than the MCP host package importing it directly: the shell drags React, |
| 8 | 11 | * Recharts and Tailwind into whatever graph imports it, and the MCP host also |
| 9 | 12 | * runs on Workers where none of that belongs. |
| 13 | + * | |
| 14 | + * This loader is for hosts WITH a filesystem: the local CLI/desktop binary | |
| 15 | + * (shell copied next to the executable by `apps/cli/src/build.ts`), dev runs | |
| 16 | + * from the repo, and the self-host image (the stable-named copy its SPA build | |
| 17 | + * emits into `dist/assets/`). Workers hosts use `makeAssetsShellHtmlLoader` | |
| 18 | + * from `@executor-js/mcp-apps-shell/worker` instead — `fs.readFile` can never | |
| 19 | + * succeed there. | |
| 20 | + * | |
| 21 | + * A missing shell REJECTS, deliberately. This used to degrade to a "Shell not | |
| 22 | + * built" placeholder, which is the worst possible failure mode: the resource | |
| 23 | + * read succeeds, the client loads an inert document that never starts the | |
| 24 | + * MCP-Apps handshake, and every artifact spins on its loading state forever | |
| 25 | + * with no error anywhere in the chain — which is exactly how production | |
| 26 | + * served the placeholder unnoticed. A rejected read surfaces as a JSON-RPC | |
| 27 | + * error naming the missing file and the command that produces it. | |
| 10 | 28 | */ |
| 11 | 29 | export const loadMcpAppsShellHtml = async (): Promise<string> => { |
| 12 | 30 | if (shellHtmlCache) return shellHtmlCache; |
| 13 | 31 | |
| 14 | − // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: optional prebuilt shell asset is loaded from local filesystem when present | |
| 15 | − try { | |
| 16 | − const fs = await import("node:fs/promises"); | |
| 17 | − const path = await import("node:path"); | |
| 18 | − const candidates = [ | |
| 19 | − // `bun build --compile` can't bundle this runtime `fs.readFile`, so the | |
| 20 | − // binary build (apps/cli/src/build.ts) copies `mcp-app.html` next to the | |
| 21 | − // executable. We find it via `process.execPath`, the same colocation | |
| 22 | − // trick native-bindings.ts uses for `libsql.node` / `keyring.node`. | |
| 23 | − path.join(path.dirname(process.execPath), "mcp-app.html"), | |
| 24 | − // Dev / package-resolved (`bun run`, vitest): the package's own dist. | |
| 25 | − path.join(import.meta.dirname, "../dist/mcp-app.html"), | |
| 26 | − path.join(import.meta.dirname, "../../dist/mcp-app.html"), | |
| 27 | − ]; | |
| 32 | + const fs = await import("node:fs/promises"); | |
| 33 | + const path = await import("node:path"); | |
| 34 | + const candidates = [ | |
| 35 | + // `bun build --compile` can't bundle this runtime `fs.readFile`, so the | |
| 36 | + // binary build (apps/cli/src/build.ts) copies `mcp-app.html` next to the | |
| 37 | + // executable. We find it via `process.execPath`, the same colocation | |
| 38 | + // trick native-bindings.ts uses for `libsql.node` / `keyring.node`. | |
| 39 | + path.join(path.dirname(process.execPath), "mcp-app.html"), | |
| 40 | + // Dev / package-resolved (`bun run`, vitest): the package's own dist. | |
| 41 | + path.join(import.meta.dirname, "../dist/mcp-app.html"), | |
| 42 | + path.join(import.meta.dirname, "../../dist/mcp-app.html"), | |
| 43 | + // The self-host runtime image. Its server is bundled to | |
| 44 | + // `apps/host-selfhost/dist-server/serve.js`, and the sibling `dist/` is | |
| 45 | + // the SPA build, whose `mcpAppsShellAsset` emits the stable-named copy of | |
| 46 | + // the shell into `dist/assets/`. The package-dist candidates above can't | |
| 47 | + // hit there — the image carries no `packages/` tree at all. | |
| 48 | + path.join(import.meta.dirname, "../dist", MCP_APPS_SHELL_STABLE_ASSET_PATH), | |
| 49 | + ]; | |
| 28 | 50 | |
| 29 | − for (const candidate of candidates) { | |
| 30 | − // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: try each possible emitted shell path before falling back | |
| 31 | − try { | |
| 32 | − shellHtmlCache = await fs.readFile(candidate, "utf-8"); | |
| 33 | − return shellHtmlCache; | |
| 34 | − } catch { | |
| 35 | − // Try the next candidate path. | |
| 36 | − } | |
| 51 | + for (const candidate of candidates) { | |
| 52 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: try each possible emitted shell path before failing | |
| 53 | + try { | |
| 54 | + shellHtmlCache = await fs.readFile(candidate, "utf-8"); | |
| 55 | + return shellHtmlCache; | |
| 56 | + } catch { | |
| 57 | + // Try the next candidate path. | |
| 37 | 58 | } |
| 38 | − } catch { | |
| 39 | − // Fall through to the development fallback below. | |
| 40 | 59 | } |
| 41 | 60 | |
| 42 | − shellHtmlCache = MCP_APPS_SHELL_NOT_BUILT_HTML; | |
| 43 | − return shellHtmlCache; | |
| 61 | + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: a host without the built shell must fail the resource read loudly; a placeholder document hangs every MCP-Apps client silently | |
| 62 | + throw new Error( | |
| 63 | + "MCP-Apps shell document not found. Tried:\n" + | |
| 64 | + candidates.map((candidate) => ` ${candidate}`).join("\n") + | |
| 65 | + "\nBuild it with: bun run --cwd packages/hosts/mcp-apps-shell build:shell", | |
| 66 | + ); | |
| 44 | 67 | }; |
| 45 | − | |
| 46 | −/** What the loader serves when no built shell is on disk. Exported so tests can | |
| 47 | − * assert a host is serving the real thing rather than this placeholder. */ | |
| 48 | −export const MCP_APPS_SHELL_NOT_BUILT_HTML = | |
| 49 | − "<!doctype html><html><body><p>Shell not built. Run: bun run --cwd packages/hosts/mcp-apps-shell build:shell</p></body></html>"; | |
| @@ -8,14 +8,16 @@ import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; · 7 unmodified lines | ||
| 8 | 8 | import { MCP_APPS_SHELL_RESOURCE_URI } from "@executor-js/host-mcp/create-artifact"; |
| 9 | 9 | import type { ExecutionEngine } from "@executor-js/execution"; |
| 10 | 10 | |
| 11 | −import { loadMcpAppsShellHtml, MCP_APPS_SHELL_NOT_BUILT_HTML } from "./shell-html"; | |
| 11 | +import { loadMcpAppsShellHtml } from "./shell-html"; | |
| 12 | +import { MCP_APPS_SHELL_DOCUMENT_MARKER } from "./shell-asset-path"; | |
| 12 | 13 | |
| 13 | 14 | // The shipped binary serves the shell from a file on disk (`build:shell` output, |
| 14 | 15 | // copied next to the executable by `apps/cli/src/build.ts`). If that file is |
| 15 | −// missing the loader silently degrades to a placeholder and every generated UI | |
| 16 | −// renders blank — so assert an MCP client reading the resource gets the REAL | |
| 17 | −// built shell, not the placeholder. This replaces a build-time subprocess probe | |
| 18 | −// that hard-slept 7 seconds inside every binary build. | |
| 16 | +// missing the loader rejects the resource read — it used to degrade to a | |
| 17 | +// placeholder document, which hung every MCP-Apps client silently — so assert | |
| 18 | +// an MCP client reading the resource gets the REAL built shell. This replaces | |
| 19 | +// a build-time subprocess probe that hard-slept 7 seconds inside every binary | |
| 20 | +// build. | |
| 19 | 21 | |
| 20 | 22 | const stubEngine: ExecutionEngine<never> = { |
| 21 | 23 | execute: () => Effect.succeed({ result: "ok" }), |
| @@ -34,7 +36,7 @@ const APPS_CAPS = { · 12 unmodified lines | ||
| 34 | 36 | } as unknown as ClientCapabilities; |
| 35 | 37 | |
| 36 | 38 | describe("MCP-Apps shell resource", () => { |
| 37 | − it("serves the built shell to an MCP client, not the 'Shell not built' placeholder", async () => { | |
| 39 | + it("serves the built shell to an MCP client", async () => { | |
| 38 | 40 | const mcpServer = await Effect.runPromise( |
| 39 | 41 | // Artifacts are opt-in per connection; the shell resource only exists on |
| 40 | 42 | // a session that asked for them. |
| @@ -58,9 +60,12 @@ describe("MCP-Apps shell resource", () => { · 17 unmodified lines | ||
| 58 | 60 | // The shell is served as text, never as a blob. |
| 59 | 61 | expect("text" in content).toBe(true); |
| 60 | 62 | const html = "text" in content ? content.text : ""; |
| 61 | − expect(html).not.toBe(MCP_APPS_SHELL_NOT_BUILT_HTML); | |
| 62 | − expect(html).not.toContain("Shell not built"); | |
| 63 | 63 | expect(html).toContain('id="root"'); |
| 64 | + // The identity marker the Workers loader authenticates the document by | |
| 65 | + // (an ASSETS-binding miss under SPA not-found handling answers with | |
| 66 | + // index.html and a 200). Asserted here so the marker leaving the template | |
| 67 | + // fails this suite too, not just the app builds. | |
| 68 | + expect(html).toContain(MCP_APPS_SHELL_DOCUMENT_MARKER); | |
| 64 | 69 | // `vite-plugin-singlefile` inlines every asset: a shell that still points at |
| 65 | 70 | // a sibling bundle would 404 inside the host's sandboxed iframe. |
| 66 | 71 | // |
| @@ -3,6 +3,11 @@ · 2 unmodified lines | ||
| 3 | 3 | <head> |
| 4 | 4 | <meta charset="UTF-8" /> |
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 6 | + <!-- Identity marker for runtime loaders: an ASSETS-binding fetch under | |
| 7 | + SPA not-found handling answers a miss with index.html and a 200, so | |
| 8 | + loaders verify this fragment before serving the document as the | |
| 9 | + ui:// shell resource. See MCP_APPS_SHELL_DOCUMENT_MARKER. --> | |
| 10 | + <meta name="executor-mcp-apps-shell" content="shell" /> | |
| 6 | 11 | <title>Executor Shell</title> |
| 7 | 12 | </head> |
| 8 | 13 | <body> |
| @@ -14,4 +14,15 @@ describe("mcpAppsShellAsset", () => { · 13 unmodified lines | ||
| 14 | 14 | "export default shellHtmlUrl;\n", |
| 15 | 15 | ); |
| 16 | 16 | }); |
| 17 | + | |
| 18 | + it("resolves the dev-html module to undefined in a build, so Workers use the ASSETS binding", async () => { | |
| 19 | + const plugin = mcpAppsShellAsset(); | |
| 20 | + plugin.configResolved({ command: "build" }); | |
| 21 | + | |
| 22 | + const resolved = plugin.resolveId("virtual:executor-mcp-apps-shell-dev-html"); | |
| 23 | + expect(resolved).toBeDefined(); | |
| 24 | + expect(await plugin.load(resolved ?? "")).toBe( | |
| 25 | + "export const devShellHtml = undefined;\nexport default devShellHtml;\n", | |
| 26 | + ); | |
| 27 | + }); | |
| 17 | 28 | }); |
| @@ -25,6 +25,11 @@ import { createHash } from "node:crypto"; · 24 unmodified lines | ||
| 25 | 25 | import path from "node:path"; |
| 26 | 26 | import { fileURLToPath } from "node:url"; |
| 27 | 27 | |
| 28 | +import { | |
| 29 | + MCP_APPS_SHELL_DOCUMENT_MARKER, | |
| 30 | + MCP_APPS_SHELL_STABLE_ASSET_PATH, | |
| 31 | +} from "./shell-asset-path.ts"; | |
| 32 | + | |
| 28 | 33 | /** The structural shape of the Vite plugin object, declared locally so this |
| 29 | 34 | * module imports no Vite types (apps pin their own Vite version). */ |
| 30 | 35 | export interface InnerRendererVitePlugin { |
| @@ -172,6 +177,12 @@ export const tailwindBrowserSourcePlugin = (): InnerRendererVitePlugin => ({ · 141 unmodified lines | ||
| 172 | 177 | const SHELL_VIRTUAL_ID = "virtual:executor-mcp-apps-shell-html"; |
| 173 | 178 | const SHELL_RESOLVED_ID = `\0${SHELL_VIRTUAL_ID}`; |
| 174 | 179 | const DEV_SHELL_URL = "/assets/executor-mcp-apps-shell-dev.html"; |
| 180 | +// The document's BYTES for the Worker in dev, where the stable asset does not | |
| 181 | +// exist yet (assets are only emitted by a build) and the dev ASSETS binding | |
| 182 | +// resolves against files on disk. `makeAssetsShellHtmlLoader` imports this | |
| 183 | +// lazily, so the shell only builds when an artifact resource is actually read. | |
| 184 | +const DEV_HTML_VIRTUAL_ID = "virtual:executor-mcp-apps-shell-dev-html"; | |
| 185 | +const DEV_HTML_RESOLVED_ID = `\0${DEV_HTML_VIRTUAL_ID}`; | |
| 175 | 186 | |
| 176 | 187 | /** Where `build:shell` writes the self-contained shell document. */ |
| 177 | 188 | export const mcpAppsShellHtmlPath = (): string => path.resolve(packageRoot, "dist/mcp-app.html"); |
| @@ -283,6 +294,17 @@ export const mcpAppsShellAsset = (): McpAppsShellAssetVitePlugin => { · 105 unmodified lines | ||
| 283 | 294 | const load = async (): Promise<{ readonly html: string; readonly url: string }> => { |
| 284 | 295 | if (cached) return cached; |
| 285 | 296 | const html = await buildMcpAppsShellHtml(); |
| 297 | + // Build-blocking: runtime loaders authenticate the document by this | |
| 298 | + // fragment (an ASSETS-binding miss under SPA not-found handling answers | |
| 299 | + // with index.html and a 200), so a shell that lost it would make every | |
| 300 | + // deployed resource read fail. Catch that here, in the build. | |
| 301 | + if (!html.includes(MCP_APPS_SHELL_DOCUMENT_MARKER)) { | |
| 302 | + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: Vite plugin hooks report build failures by throwing | |
| 303 | + throw new Error( | |
| 304 | + `Built MCP-Apps shell is missing its identity marker (${MCP_APPS_SHELL_DOCUMENT_MARKER}); ` + | |
| 305 | + "check the <meta> in src/shell/mcp-app.html.", | |
| 306 | + ); | |
| 307 | + } | |
| 286 | 308 | const digest = createHash("sha256").update(html).digest("hex").slice(0, 12); |
| 287 | 309 | cached = { html, url: `/assets/executor-mcp-apps-shell-${digest}.html` }; |
| 288 | 310 | return cached; |
| @@ -300,8 +322,24 @@ export const mcpAppsShellAsset = (): McpAppsShellAssetVitePlugin => { · 11 unmodified lines | ||
| 300 | 322 | // only carries the single-environment case, where the two agree. |
| 301 | 323 | ssr = Boolean(config.build?.ssr); |
| 302 | 324 | }, |
| 303 | − resolveId: (id) => (id === SHELL_VIRTUAL_ID ? SHELL_RESOLVED_ID : undefined), | |
| 325 | + resolveId: (id) => { | |
| 326 | + if (id === SHELL_VIRTUAL_ID) return SHELL_RESOLVED_ID; | |
| 327 | + if (id === DEV_HTML_VIRTUAL_ID) return DEV_HTML_RESOLVED_ID; | |
| 328 | + return undefined; | |
| 329 | + }, | |
| 304 | 330 | load: async (id) => { |
| 331 | + if (id === DEV_HTML_RESOLVED_ID) { | |
| 332 | + // The Worker's dev-only escape hatch: under `build` it exports | |
| 333 | + // `undefined` and `makeAssetsShellHtmlLoader` uses the ASSETS binding; | |
| 334 | + // under `serve` it carries the built document inline, because no | |
| 335 | + // assets have been emitted for the binding to find. Guarded the same | |
| 336 | + // way as the URL module below — the build must not run at startup. | |
| 337 | + if (command !== "serve") { | |
| 338 | + return "export const devShellHtml = undefined;\nexport default devShellHtml;\n"; | |
| 339 | + } | |
| 340 | + const { html } = await load(); | |
| 341 | + return `export const devShellHtml = ${JSON.stringify(html)};\nexport default devShellHtml;\n`; | |
| 342 | + } | |
| 305 | 343 | if (id !== SHELL_RESOLVED_ID) return undefined; |
| 306 | 344 | // Resolving the virtual module is part of Vite's startup graph. Building |
| 307 | 345 | // the multi-megabyte shell here makes every dev server pay that cost |
| @@ -355,6 +393,16 @@ export const mcpAppsShellAsset = (): McpAppsShellAssetVitePlugin => { · 47 unmodified lines | ||
| 355 | 393 | // configured — which `process.cwd()`-relative writes get wrong for |
| 356 | 394 | // TanStack Start, where the client build writes to `dist/client`. |
| 357 | 395 | this.emitFile({ type: "asset", fileName: url.replace(/^\//, ""), source: html }); |
| 396 | + // A second copy at the STABLE name, for the server side: a Workers host | |
| 397 | + // serves the `ui://executor/shell.html` MCP resource by fetching this | |
| 398 | + // through its ASSETS binding (`makeAssetsShellHtmlLoader`), and the | |
| 399 | + // self-host image reads it from the SPA dist on disk — neither can know | |
| 400 | + // the content hash the client build minted. See `./shell-asset-path`. | |
| 401 | + this.emitFile({ | |
| 402 | + type: "asset", | |
| 403 | + fileName: MCP_APPS_SHELL_STABLE_ASSET_PATH.replace(/^\//, ""), | |
| 404 | + source: html, | |
| 405 | + }); | |
| 358 | 406 | }, |
| 359 | 407 | async closeBundle() { |
| 360 | 408 | cached = undefined; |
| @@ -0,0 +1,85 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { makeAssetsShellHtmlLoader, type ShellAssetsBinding } from "./worker"; | |
| 4 | +import { | |
| 5 | + MCP_APPS_SHELL_DOCUMENT_MARKER, | |
| 6 | + MCP_APPS_SHELL_STABLE_ASSET_PATH, | |
| 7 | +} from "./shell-asset-path"; | |
| 8 | + | |
| 9 | +const SHELL_HTML = `<!doctype html><html><head><meta ${MCP_APPS_SHELL_DOCUMENT_MARKER} content="shell"></head><body><div id="root"></div></body></html>`; | |
| 10 | + | |
| 11 | +const bindingReturning = ( | |
| 12 | + responses: readonly Response[], | |
| 13 | +): { readonly binding: ShellAssetsBinding; readonly requests: Request[] } => { | |
| 14 | + const requests: Request[] = []; | |
| 15 | + let call = 0; | |
| 16 | + return { | |
| 17 | + requests, | |
| 18 | + binding: { | |
| 19 | + fetch: (request) => { | |
| 20 | + requests.push(request); | |
| 21 | + const response = responses[Math.min(call, responses.length - 1)]; | |
| 22 | + call += 1; | |
| 23 | + // Running out of scripted responses means the test was wrong about | |
| 24 | + // how many fetches the loader makes; a distinctive 599 makes the | |
| 25 | + // resulting assertion failure say so. | |
| 26 | + return Promise.resolve( | |
| 27 | + response ?? new Response("stub exhausted: unexpected fetch", { status: 599 }), | |
| 28 | + ); | |
| 29 | + }, | |
| 30 | + }, | |
| 31 | + }; | |
| 32 | +}; | |
| 33 | + | |
| 34 | +describe("makeAssetsShellHtmlLoader", () => { | |
| 35 | + it("serves the shell from the stable asset path and caches it", async () => { | |
| 36 | + const { binding, requests } = bindingReturning([new Response(SHELL_HTML)]); | |
| 37 | + const load = makeAssetsShellHtmlLoader({ assets: binding }); | |
| 38 | + | |
| 39 | + expect(await load()).toBe(SHELL_HTML); | |
| 40 | + expect(await load()).toBe(SHELL_HTML); | |
| 41 | + expect(requests).toHaveLength(1); | |
| 42 | + expect(new URL(requests[0]?.url ?? "").pathname).toBe(MCP_APPS_SHELL_STABLE_ASSET_PATH); | |
| 43 | + }); | |
| 44 | + | |
| 45 | + it("rejects on a non-OK response instead of serving a substitute document", async () => { | |
| 46 | + const { binding } = bindingReturning([new Response("not found", { status: 404 })]); | |
| 47 | + const load = makeAssetsShellHtmlLoader({ assets: binding }); | |
| 48 | + | |
| 49 | + await expect(load()).rejects.toThrow(/404/); | |
| 50 | + await expect(load()).rejects.toThrow(MCP_APPS_SHELL_STABLE_ASSET_PATH); | |
| 51 | + }); | |
| 52 | + | |
| 53 | + it("rejects a 200 that is not the shell (SPA not-found fallback)", async () => { | |
| 54 | + // Under `not_found_handling: "single-page-application"` a miss answers | |
| 55 | + // with the console's index.html and a 200 — the exact document that, if | |
| 56 | + // served as the ui:// resource, hangs every client with no error. | |
| 57 | + const { binding } = bindingReturning([ | |
| 58 | + new Response("<!doctype html><html><body><div id='app'>console</div></body></html>"), | |
| 59 | + ]); | |
| 60 | + const load = makeAssetsShellHtmlLoader({ assets: binding }); | |
| 61 | + | |
| 62 | + await expect(load()).rejects.toThrow(/different document/); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + it("prefers the dev document and never touches the binding when it is present", async () => { | |
| 66 | + const { binding, requests } = bindingReturning([]); | |
| 67 | + const load = makeAssetsShellHtmlLoader({ | |
| 68 | + assets: binding, | |
| 69 | + devShellHtml: () => Promise.resolve(SHELL_HTML), | |
| 70 | + }); | |
| 71 | + | |
| 72 | + expect(await load()).toBe(SHELL_HTML); | |
| 73 | + expect(requests).toHaveLength(0); | |
| 74 | + }); | |
| 75 | + | |
| 76 | + it("falls through to the binding when the dev document is undefined (a build)", async () => { | |
| 77 | + const { binding } = bindingReturning([new Response(SHELL_HTML)]); | |
| 78 | + const load = makeAssetsShellHtmlLoader({ | |
| 79 | + assets: binding, | |
| 80 | + devShellHtml: () => Promise.resolve(undefined), | |
| 81 | + }); | |
| 82 | + | |
| 83 | + expect(await load()).toBe(SHELL_HTML); | |
| 84 | + }); | |
| 85 | +}); | |
| @@ -0,0 +1,75 @@ | ||
| 1 | +// The ambient declaration for `virtual:executor-mcp-apps-shell-dev-html` | |
| 2 | +// travels with this module into the Workers hosts that import it (their | |
| 3 | +// tsconfig `include` covers only their own sources) — same trick as | |
| 4 | +// `shell/artifact-renderer.tsx` and its `shell-html-url.d.ts`. | |
| 5 | +/// <reference path="./shell-dev-html.d.ts" /> | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * The shell loader for Workers hosts, where `loadMcpAppsShellHtml`'s | |
| 9 | + * `fs.readFile` can never succeed: a deployed isolate has no filesystem, so | |
| 10 | + * the document arrives the way #1378 delivers the worker-bundler artifact — | |
| 11 | + * emitted into the client assets at build time and fetched through the | |
| 12 | + * `ASSETS` binding at runtime. | |
| 13 | + * | |
| 14 | + * Kept free of the shell's own module graph (React, Recharts, Tailwind): this | |
| 15 | + * entry ships only a fetch and two constants, so a Worker can import it | |
| 16 | + * without dragging browser code into its bundle. | |
| 17 | + */ | |
| 18 | + | |
| 19 | +import { | |
| 20 | + MCP_APPS_SHELL_DOCUMENT_MARKER, | |
| 21 | + MCP_APPS_SHELL_STABLE_ASSET_PATH, | |
| 22 | +} from "./shell-asset-path"; | |
| 23 | + | |
| 24 | +/** The structural shape of a Workers static-assets binding (`env.ASSETS`). */ | |
| 25 | +export interface ShellAssetsBinding { | |
| 26 | + readonly fetch: (request: Request) => Promise<Response>; | |
| 27 | +} | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * Build a `loadAppShellHtml` over the `ASSETS` binding. | |
| 31 | + * | |
| 32 | + * `devShellHtml` carries the document inline during `vite dev`, where no | |
| 33 | + * assets have been emitted yet: pass a thunk that dynamically imports | |
| 34 | + * `virtual:executor-mcp-apps-shell-dev-html` — the built shell under `serve`, | |
| 35 | + * `undefined` under `build`. A THUNK, not the value: under `serve` that | |
| 36 | + * virtual module builds the multi-megabyte shell in a child process, a cost | |
| 37 | + * only the first artifact resource read should pay, never dev-server | |
| 38 | + * startup. Same dual-shape seam #1378 uses for the worker-bundler artifact. | |
| 39 | + * | |
| 40 | + * Failure REJECTS, deliberately, on either a non-OK response or a document | |
| 41 | + * that is not the shell. The second check is load-bearing: under SPA | |
| 42 | + * not-found handling the binding answers a miss with the console's | |
| 43 | + * `index.html` and a 200, and serving THAT as the `ui://` resource is the | |
| 44 | + * silent every-client-hangs failure this seam exists to kill — the client | |
| 45 | + * loads a real document that never starts the MCP-Apps handshake. A rejected | |
| 46 | + * read surfaces as a JSON-RPC error naming the missing asset instead. | |
| 47 | + */ | |
| 48 | +export const makeAssetsShellHtmlLoader = (input: { | |
| 49 | + readonly assets: ShellAssetsBinding; | |
| 50 | + readonly devShellHtml?: () => Promise<string | undefined>; | |
| 51 | +}): (() => Promise<string>) => { | |
| 52 | + let cache: string | undefined; | |
| 53 | + return async () => { | |
| 54 | + if (cache !== undefined) return cache; | |
| 55 | + const devHtml = await input.devShellHtml?.(); | |
| 56 | + if (devHtml !== undefined) { | |
| 57 | + cache = devHtml; | |
| 58 | + return cache; | |
| 59 | + } | |
| 60 | + const response = await input.assets.fetch( | |
| 61 | + new Request(new URL(MCP_APPS_SHELL_STABLE_ASSET_PATH, "https://assets.local")), | |
| 62 | + ); | |
| 63 | + const html = response.ok ? await response.text() : undefined; | |
| 64 | + if (html === undefined || !html.includes(MCP_APPS_SHELL_DOCUMENT_MARKER)) { | |
| 65 | + // 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 | |
| 66 | + throw new Error( | |
| 67 | + `MCP-Apps shell asset ${MCP_APPS_SHELL_STABLE_ASSET_PATH} is missing from this deployment's ` + | |
| 68 | + `static assets (${html === undefined ? `ASSETS fetch returned ${response.status}` : "the fetch returned a different document"}). ` + | |
| 69 | + "The app build emits it via mcpAppsShellAsset (@executor-js/mcp-apps-shell/vite); rebuild and redeploy.", | |
| 70 | + ); | |
| 71 | + } | |
| 72 | + cache = html; | |
| 73 | + return cache; | |
| 74 | + }; | |
| 75 | +}; | |
| @@ -3,6 +3,7 @@ · 2 unmodified lines | ||
| 3 | 3 | "target": "ESNext", |
| 4 | 4 | "module": "ESNext", |
| 5 | 5 | "moduleResolution": "bundler", |
| 6 | + "allowImportingTsExtensions": true, | |
| 6 | 7 | "strict": true, |
| 7 | 8 | "esModuleInterop": true, |
| 8 | 9 | "skipLibCheck": true, |