import { build } from "esbuild"; import { Effect } from "effect"; import { ToolSandboxError } from "../seams/tool-sandbox"; const unknownMessage = (cause: unknown): string => { // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: esbuild rejects with unknown host errors return cause instanceof Error ? cause.message : String(cause); }; // --------------------------------------------------------------------------- // UI shell: wrap a published ui view's compiled bundle into a COMPLETE HTML // document that a real MCP-Apps host (Claude / ChatGPT, or the sunpeak host // simulation) can mount in a sandboxed iframe and render. // // The published ui bundle is CJS with `react` / `react-dom` / `executor:ui` / // `executor:ui/components` left EXTERNAL (as `require(...)`). A browser host // cannot run that directly: it needs React + the `executor:ui` runtime + the // component primitives, then it must execute the module and mount its default // export. This module produces that self-booting document. // // The runtime is hermetic: React and the executor:ui runtime are bundled INTO // the document by esbuild (no CDN, so it renders under a strict sandbox CSP with // no network). Live data is delivered as a data island the server writes at read // time (`window.__EXECUTOR_UI__.rows`), so `useQuery` returns real scope-db rows // on mount. Refetch is wired to the MCP-Apps host bridge + a lightweight polling // fallback; the SSE-driven live refetch into the mounted widget is a documented // follow-up (see APPS_DESIGN.md). // --------------------------------------------------------------------------- /** The MCP-Apps resource MIME type real hosts key inline rendering on. */ export const UI_APP_MIME = "text/html;profile=mcp-app"; // The browser runtime, bundled once (React + ReactDOM + the executor:ui shim + // the mount driver). The `__PUBLISHED_BUNDLE__;` marker statement is replaced // with the published component's CJS at document-assembly time. const runtimeSource = ` import React from "react"; import { createRoot } from "react-dom/client"; // --- executor:ui runtime --------------------------------------------------- // A real, minimal implementation of the author-facing hooks. \`useQuery\` returns // the rows the host delivered in the data island and re-runs when the host // signals an invalidation (postMessage from the MCP-Apps bridge). \`useTool\` // posts a tool-call request to the host and resolves on its reply. const __data = (globalThis.__EXECUTOR_UI__ || { rows: [], title: "", ready: false }); function useQuery(fn) { const [state, setState] = React.useState(() => ({ data: __data.rows || [], isLoading: false, error: null, })); const refetch = React.useCallback(() => { // Ask the host to re-read; if it answers we update, else keep current rows. try { window.parent.postMessage({ type: "executor:ui/refetch" }, "*"); } catch (_e) { /* sandboxed: ignore */ } return Promise.resolve(state.data); }, [state.data]); React.useEffect(() => { const onMessage = (event) => { const msg = event && event.data; if (msg && msg.type === "executor:ui/rows" && Array.isArray(msg.rows)) { setState({ data: msg.rows, isLoading: false, error: null }); } }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, []); return { data: state.data, isLoading: state.isLoading, error: state.error, refetch }; } function useTool(name) { const [isRunning, setRunning] = React.useState(false); const run = React.useCallback(async (args) => { setRunning(true); try { window.parent.postMessage({ type: "executor:ui/tool", tool: name, args: args }, "*"); } catch (_e) { /* sandboxed */ } setRunning(false); return undefined; }, [name]); return { run, isRunning }; } function config() { /* metadata read at publish; a no-op in the browser */ } // --- executor:ui/components (minimal, styled primitives) ------------------- const h = React.createElement; const box = (tag, base) => (props) => { const { className, children, ...rest } = props || {}; return h(tag, { className: (base + " " + (className || "")).trim(), ...rest }, children); }; const components = { Card: box("div", "ex-card"), CardHeader: box("div", "ex-card-header"), CardTitle: box("div", "ex-card-title"), CardContent: box("div", "ex-card-content"), Badge: box("span", "ex-badge"), Button: (props) => { const { className, children, ...rest } = props || {}; return h("button", { className: ("ex-button " + (className || "")).trim(), ...rest }, children); }, Input: (props) => h("input", { className: "ex-input", ...(props || {}) }), }; // --- CJS require shim: satisfy the bundle's externals ---------------------- function __require(id) { if (id === "react") return React; if (id === "react/jsx-runtime") return { jsx: h, jsxs: h, Fragment: React.Fragment }; if (id === "react-dom" || id === "react-dom/client") return { createRoot }; if (id === "executor:ui") return { config, useQuery, useTool }; if (id === "executor:ui/components") return components; throw new Error("module not available in ui runtime: " + id); } // --- run the published component bundle + mount ---------------------------- function __runBundle() { const module = { exports: {} }; const exports = module.exports; const require = __require; // The published component's CJS is spliced in below. It is built from the same // virtual entry the tool/workflow collect path uses, which assigns the author // module onto \`globalThis.__artifact\` (NOT module.exports). We read the default // export from there, falling back to module.exports for robustness. (function (module, exports, require) { __PUBLISHED_BUNDLE__; })(module, exports, require); const artifact = globalThis.__artifact; return ( (artifact && (artifact.default || artifact)) || (module.exports && (module.exports.default || module.exports)) ); } function __mount() { const App = __runBundle(); const root = createRoot(document.getElementById("root")); root.render(h(App)); // Signal paint so a host waiting on first render can proceed. try { window.parent.postMessage({ type: "executor:ui/mounted" }, "*"); } catch (_e) {} } try { __mount(); } catch (err) { // Surface a mount failure in the document itself so a host (and tests) see it // instead of a blank frame. const root = document.getElementById("root"); if (root) root.textContent = "ui mount error: " + ((err && err.message) || String(err)); try { window.parent.postMessage({ type: "executor:ui/error", message: String(err && err.message || err) }, "*"); } catch (_e) {} } `; const HTML_STYLES = ` :root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; } body { margin: 0; padding: 12px; } .ex-card { border: 1px solid rgba(0,0,0,0.12); border-radius: 8px; margin-top: 8px; } .ex-card-header { padding: 8px 12px; border-bottom: 1px solid rgba(0,0,0,0.08); } .ex-card-title { font-weight: 600; } .ex-card-content { padding: 8px 12px; display: flex; flex-direction: column; gap: 6px; } .ex-badge { display: inline-block; font-size: 11px; padding: 1px 6px; border-radius: 6px; background: rgba(0,0,0,0.08); margin-left: 4px; } .ex-button { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(0,0,0,0.2); background: #f5f5f5; cursor: pointer; } .ex-input { padding: 4px 8px; border-radius: 6px; border: 1px solid rgba(0,0,0,0.2); } a { color: inherit; text-decoration: none; } `; /** Bundle the browser runtime (React + shim + mount) with the published * component's CJS spliced in, then wrap it in a complete HTML document. */ export const buildUiDocument = (input: { readonly compiledBundle: string; readonly title: string; readonly maxHeight?: number; readonly rows: readonly unknown[]; }): Effect.Effect => Effect.tryPromise({ try: async () => { // Splice the published CJS in place of the marker statement. Use a // function replacer so `$`-sequences in the bundle are not interpreted. const runtimeWithBundle = runtimeSource.replace( "__PUBLISHED_BUNDLE__;", () => input.compiledBundle, ); const result = await build({ stdin: { contents: runtimeWithBundle, loader: "tsx", resolveDir: process.cwd(), }, bundle: true, write: false, format: "iife", platform: "browser", target: "es2022", minify: true, jsx: "automatic", logLevel: "silent", // React is resolved from this package's node_modules and inlined. define: { "process.env.NODE_ENV": '"production"' }, }); const runtimeJs = result.outputFiles?.[0]?.text; // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: esbuild adapter reports an impossible missing output through the existing tryPromise path if (runtimeJs === undefined) throw new Error("ui runtime produced no output"); const dataIsland = safeJsonForScript({ rows: input.rows, title: input.title, ready: true, }); // A complete, self-contained MCP-Apps document. No external network: React, // the runtime, and the initial rows are all inline, so it renders under a // strict sandbox CSP. return [ "", '', '', `${escapeHtml(input.title)}`, ``, ``, "", '
', ``, "", ].join(""); }, catch: (cause) => new ToolSandboxError({ kind: "bundle", message: `ui document build failed: ${unknownMessage(cause)}`, cause, }), }); // Serialize a value as JSON safe to embed in an inline `` (or `