import { useEffect, useMemo, useRef, useState } from "react"; import type { VirtualFile } from "../hooks/useVirtualFS"; import { compileScript, isCompilable } from "../lib/bundler"; import type { Diagnostic } from "../hooks/usePreviewDiagnostics"; import { buildPreviewAuthScript } from "../lib/preview-auth"; import { svgToDataUrl } from "../lib/sticker"; interface PreviewFrameProps { files: VirtualFile[]; version: number; /** * Hugging Face access token from the parent app. When set, the preview * seeds it into the SDK's sessionStorage keys so a generated app's * `mountHost()` / `connectToHost()` authenticates from cache and skips * the OAuth round-trip (which can't complete in a srcDoc iframe). This is * what lets the preview render the REAL host shell (top bar + picker) * even without a live robot. See `lib/preview-auth.ts`. */ accessToken?: string | null; /** HF username, seeded alongside the token so the host top bar shows it. */ userName?: string | null; /** * Called with the latest `docId` as soon as a new preview HTML document * is prepared. The parent uses this to discard stale postMessage * diagnostics from an older iframe that may still be flushing after a * re-render. */ onDocReady?: (docId: number) => void; /** * Called with compile errors produced by esbuild-wasm BEFORE the iframe * starts running. Runtime errors come through the `postMessage` channel * instead and are collected by the parent's diagnostics hook. */ onCompileError?: (diagnostic: Diagnostic) => void; } /** * Render the virtual FS inside a sandboxed iframe. * * Pipeline: * 1. Take `index.html` from the virtual FS. * 2. For each local ``, ); } else { const { file, line, column, text } = result.error; onCompileError?.({ docId, kind: "compile-error", message: text, file, line, col: column, timestamp: Date.now(), }); const escaped = JSON.stringify( `[${file}:${line}:${column}] ${text}`, ); replacements.set( match, ``, ); } return; } if (normalized.endsWith(".js") || normalized.endsWith(".mjs")) { replacements.set( match, ``, ); } }), ); const inlined = rawHtml.replace(assetRegex, (m) => replacements.get(m) ?? m); // Seed the HF session into the SDK's storage keys so the generated app // authenticates from cache and renders the real host shell in preview. const tokenScript = buildPreviewAuthScript(accessToken, userName); const reporterScript = ``; // Reflect the REAL app icon in preview: if the VFS carries an `icon.svg` // (agent-generated or user-provided), expose it as a data URL global the // template reads for `mountHost({ appIconUrl })`. Without this the host top // bar would fetch `/icon.svg` (the static default) and never show // the generated one. Only emitted when an icon.svg actually exists, so it // stays undefined otherwise and the app falls back to the default. const iconContent = fileMap.get("icon.svg") ?? fileMap.get("./icon.svg"); const iconScript = iconContent && /]/i.test(iconContent) ? `` : ""; const headInjections = `${reporterScript}${tokenScript ? "\n" + tokenScript : ""}${iconScript ? "\n" + iconScript : ""}`; const withInjections = /]*>/i.test(inlined) ? inlined.replace(/]*>/i, (m) => `${m}\n${headInjections}`) : `${headInjections}\n${inlined}`; const bannerDoc = ``; return `${bannerDoc}\n${withInjections}`; } /** * Wrap the base preview doc (which runs the generated app) into the STANDALONE * doc that the preview actually loads, enabling a LIVE host session in preview. * * Why: the app's standalone path calls `mountHost()`, and when the user picks * their (real, listed) robot the host embeds an iframe at * `new URL(embedPath, window.location.origin)` (ReachyHostShell.tsx). The * default `embedPath` is `/?embedded=1` - but this Space is `sdk: static`, so * that URL serves the vibe-coder SPA, NOT the generated app: the embed * handshake never happens and the host hangs on the "link" step forever. * * Fix: inject, BEFORE the app module runs, a bootstrap that turns the SAME base * doc into a same-origin `blob:` URL and exposes it as * `window.__REACHY_MINI_EMBED_URL__`. The generated app passes that as * `mountHost({ embedPath })`, so the host embeds the ACTUAL app. Because the * blob is created INSIDE this document, a child iframe can load it; because it * inherits the parent (Space) origin, it satisfies the host bridge's * same-origin check (useHostBridge.ts) and the SDK's `#creds=` fragment * survives, so the full protocol-v1 handshake (embed:ready/host:init) runs and * the app connects to the listed robot over HF central signaling. Verified end * to end in tests/e2e/host-embed-handshake.spec.ts. * * The embedded doc is the base doc WITHOUT this bootstrap, so there is no * infinite nesting. * * `baseOrigin` (optional): when given, a `` is injected * so relative URLs in the host shell resolve to the Space origin instead of the * `blob:` document. This is what lets the host top bar's `` * load the default icon we serve at `/icon.svg` in preview - a blob * document has no fetchable relative base, so without it the icon never loads. */ export function wrapStandaloneWithEmbed( baseDoc: string, baseOrigin?: string, ): string { const embedLiteral = JSON.stringify(baseDoc) // `` inside the literal would prematurely close the bootstrap // `; // `` must come first in so it governs every relative URL. const headInjection = `${baseTag}${baseTag ? "\n" : ""}${bootstrap}`; return /]*>/i.test(baseDoc) ? baseDoc.replace(/]*>/i, (m) => `${m}\n${headInjection}`) : `${headInjection}\n${baseDoc}`; } export function PreviewFrame({ files, version, accessToken, userName, onDocReady, onCompileError, }: PreviewFrameProps) { const [docHtml, setDocHtml] = useState(null); const [docId, setDocId] = useState(0); // The preview is loaded from a same-origin `blob:` URL rather than // `srcdoc`. Reason: an `about:srcdoc` document has an OPAQUE origin, so // `window.location.origin` serialises to the string "null". The Reachy // host shell computes the embed iframe URL with // `new URL(embedPath, window.location.origin)` (ReachyHostShell.tsx), // which throws `Failed to construct 'URL': Invalid base URL` on a "null" // base the moment a robot is picked -> the whole preview goes black. A // blob URL created here inherits the PARENT's real origin (with // `allow-same-origin` in the sandbox), so `location.origin` is a valid // absolute origin and that `new URL(...)` resolves cleanly. const [blobUrl, setBlobUrl] = useState(null); const timerRef = useRef(null); const buildSeqRef = useRef(0); const hasIndex = useMemo( () => files.some((f) => f.path === "index.html"), [files], ); useEffect(() => { if (timerRef.current) window.clearTimeout(timerRef.current); timerRef.current = window.setTimeout(async () => { const seq = ++buildSeqRef.current; const nextDocId = Date.now(); const base = await buildPreviewDoc( files, accessToken, nextDocId, onCompileError, userName, ); if (seq !== buildSeqRef.current) return; // stale build superseded // Wrap the base doc so the standalone `mountHost()` path can embed the // REAL app (same-origin blob) and run a live session in preview. The // Space origin is injected as `` so the host top bar's // relative `icon.svg` loads the default icon we serve at `/icon.svg`. const doc = base === null ? null : wrapStandaloneWithEmbed(base, window.location.origin); setDocId(nextDocId); setDocHtml(doc); if (doc !== null) onDocReady?.(nextDocId); }, 300); return () => { if (timerRef.current) window.clearTimeout(timerRef.current); }; }, [files, version, accessToken, userName, onDocReady, onCompileError]); // Turn the built document into a same-origin blob URL and revoke the // previous one so we don't leak object URLs across rebuilds. useEffect(() => { if (docHtml === null) { setBlobUrl(null); return; } const url = URL.createObjectURL( new Blob([docHtml], { type: "text/html" }), ); setBlobUrl(url); return () => URL.revokeObjectURL(url); }, [docHtml]); if (!hasIndex) { return (

No index.html in the virtual FS yet.

Ask the agent to create one.

); } if (docHtml === null || blobUrl === null) { return
Building preview…
; } return (