Spaces:
Runtime error
Runtime error
| 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"; | |
| interface PreviewFrameProps { | |
| files: VirtualFile[]; | |
| version: number; | |
| /** | |
| * Hugging Face access token from the parent app. When set, the preview | |
| * injects it as `window.__REACHY_MINI_PREVIEW_TOKEN__` so the generated | |
| * app can skip its own OAuth flow (which can't work from a srcDoc | |
| * iframe - the redirect URI would be `about:srcdoc`). | |
| */ | |
| accessToken?: 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 `<script src=...>`, inline the target file. Compile | |
| * `.ts`/`.tsx`/`.jsx` through esbuild-wasm before injection. | |
| * 3. Inline local CSS via `<style>` tags. | |
| * 4. Inject an error reporter at the top of `<head>` that forwards | |
| * runtime errors to the parent via `postMessage`. | |
| * 5. Inject the parent's HF token (`__REACHY_MINI_PREVIEW_TOKEN__`) so | |
| * the app can bypass OAuth, which would otherwise fail in a srcDoc | |
| * iframe (redirect URI would be `about:srcdoc`). | |
| */ | |
| async function buildPreviewDoc( | |
| files: VirtualFile[], | |
| accessToken: string | null | undefined, | |
| docId: number, | |
| onCompileError?: (d: Diagnostic) => void, | |
| ): Promise<string | null> { | |
| const fileMap = new Map(files.map((f) => [f.path, f.content])); | |
| const rawHtml = fileMap.get("index.html"); | |
| if (!rawHtml) return null; | |
| const assetRegex = | |
| /<(?:link|script)[^>]*\b(href|src)\s*=\s*(["'])([^"']+)\2[^>]*>(?:\s*<\/script>)?/gi; | |
| const matches: Array<{ | |
| match: string; | |
| attr: string; | |
| rawPath: string; | |
| index: number; | |
| }> = []; | |
| for (const m of rawHtml.matchAll(assetRegex)) { | |
| matches.push({ | |
| match: m[0], | |
| attr: m[1], | |
| rawPath: m[3], | |
| index: m.index ?? 0, | |
| }); | |
| } | |
| const replacements = new Map<string, string>(); | |
| await Promise.all( | |
| matches.map(async ({ match, attr, rawPath }) => { | |
| if (/^(?:https?:)?\/\//i.test(rawPath) || rawPath.startsWith("data:")) { | |
| return; | |
| } | |
| const normalized = rawPath.replace(/^\.?\/+/, ""); | |
| const content = fileMap.get(normalized); | |
| const isHref = attr.toLowerCase() === "href"; | |
| // The iframe is a `srcdoc` doc with `allow-same-origin`, so relative | |
| // URLs fall through to the PARENT's origin. A missing local asset | |
| // would therefore fetch the vibe-coder SPA's index.html and the | |
| // browser would parse HTML as JS/CSS - spamming the console with | |
| // `Unexpected token '<'`. We pre-empt that by neutralising the tag. | |
| if (content === undefined) { | |
| const kind = isHref | |
| ? normalized.endsWith(".css") | |
| ? "stylesheet" | |
| : "href asset" | |
| : "script"; | |
| const msg = `Missing ${kind} in virtual FS: ${rawPath}`; | |
| onCompileError?.({ | |
| docId, | |
| kind: "compile-error", | |
| message: msg, | |
| file: rawPath, | |
| line: 1, | |
| col: 1, | |
| timestamp: Date.now(), | |
| }); | |
| // Replace with an inert stub - we already surfaced the error via | |
| // onCompileError above; emitting a console.error from the stub | |
| // would double-report through the iframe's postMessage channel. | |
| replacements.set( | |
| match, | |
| `<!-- [preview] missing ${kind}: ${rawPath} -->`, | |
| ); | |
| return; | |
| } | |
| if (isHref && normalized.endsWith(".css")) { | |
| replacements.set( | |
| match, | |
| `<style data-inlined="${normalized}">\n${content}\n</style>`, | |
| ); | |
| return; | |
| } | |
| if (attr.toLowerCase() !== "src") return; | |
| if (isCompilable(normalized)) { | |
| const result = await compileScript(normalized, content); | |
| if (result.ok) { | |
| replacements.set( | |
| match, | |
| `<script type="module" data-inlined="${normalized}">\n${result.code}\n</script>`, | |
| ); | |
| } 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, | |
| `<script>throw new SyntaxError(${escaped});</script>`, | |
| ); | |
| } | |
| return; | |
| } | |
| if (normalized.endsWith(".js") || normalized.endsWith(".mjs")) { | |
| replacements.set( | |
| match, | |
| `<script type="module" data-inlined="${normalized}">\n${content}\n</script>`, | |
| ); | |
| } | |
| }), | |
| ); | |
| const inlined = rawHtml.replace(assetRegex, (m) => replacements.get(m) ?? m); | |
| const tokenScript = accessToken | |
| ? `<script>window.__REACHY_MINI_PREVIEW_TOKEN__ = ${JSON.stringify( | |
| accessToken, | |
| )};</script>` | |
| : ""; | |
| const reporterScript = `<script> | |
| (() => { | |
| var DOC_ID = ${JSON.stringify(docId)}; | |
| var send = function(payload){ | |
| try { parent.postMessage(Object.assign({ source: "reachy-preview", docId: DOC_ID }, payload), "*"); } catch(_e){} | |
| }; | |
| send({ kind: "ready" }); | |
| addEventListener("error", function(e){ | |
| send({ | |
| kind: "error", | |
| message: e.message || String(e.error || ""), | |
| stack: e.error && e.error.stack, | |
| file: e.filename, | |
| line: e.lineno, | |
| col: e.colno, | |
| }); | |
| }); | |
| addEventListener("unhandledrejection", function(e){ | |
| var reason = e.reason; | |
| send({ | |
| kind: "unhandledrejection", | |
| message: (reason && (reason.message || String(reason))) || "unhandled rejection", | |
| stack: reason && reason.stack, | |
| }); | |
| }); | |
| var origErr = console.error.bind(console); | |
| console.error = function(){ | |
| try { | |
| var args = Array.prototype.slice.call(arguments).map(function(a){ | |
| if (a instanceof Error) return (a.message || "") + (a.stack ? "\\n" + a.stack : ""); | |
| if (typeof a === "object") { try { return JSON.stringify(a); } catch(_e){ return String(a); } } | |
| return String(a); | |
| }); | |
| send({ kind: "console.error", message: args.join(" ") }); | |
| } catch(_e){} | |
| origErr.apply(null, arguments); | |
| }; | |
| })(); | |
| </script>`; | |
| const headInjections = `${reporterScript}${tokenScript ? "\n" + tokenScript : ""}`; | |
| const withInjections = /<head[^>]*>/i.test(inlined) | |
| ? inlined.replace(/<head[^>]*>/i, (m) => `${m}\n${headInjections}`) | |
| : `${headInjections}\n${inlined}`; | |
| const bannerDoc = `<!-- Preview generated by reachy-mini-vibe-coder (docId=${docId}) -->`; | |
| return `${bannerDoc}\n${withInjections}`; | |
| } | |
| export function PreviewFrame({ | |
| files, | |
| version, | |
| accessToken, | |
| onDocReady, | |
| onCompileError, | |
| }: PreviewFrameProps) { | |
| const [docHtml, setDocHtml] = useState<string | null>(null); | |
| const [docId, setDocId] = useState<number>(0); | |
| const timerRef = useRef<number | null>(null); | |
| const buildSeqRef = useRef<number>(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 doc = await buildPreviewDoc( | |
| files, | |
| accessToken, | |
| nextDocId, | |
| onCompileError, | |
| ); | |
| if (seq !== buildSeqRef.current) return; // stale build superseded | |
| setDocId(nextDocId); | |
| setDocHtml(doc); | |
| if (doc !== null) onDocReady?.(nextDocId); | |
| }, 300); | |
| return () => { | |
| if (timerRef.current) window.clearTimeout(timerRef.current); | |
| }; | |
| }, [files, version, accessToken, onDocReady, onCompileError]); | |
| if (!hasIndex) { | |
| return ( | |
| <div className="preview-empty"> | |
| <p>No <code>index.html</code> in the virtual FS yet.</p> | |
| <p style={{ marginTop: 8 }}>Ask the agent to create one.</p> | |
| </div> | |
| ); | |
| } | |
| if (docHtml === null) { | |
| return <div className="preview-empty">Building preview…</div>; | |
| } | |
| return ( | |
| <iframe | |
| key={docId} | |
| className="preview-frame" | |
| title="App preview" | |
| srcDoc={docHtml} | |
| sandbox="allow-scripts allow-same-origin allow-forms allow-modals allow-popups" | |
| /> | |
| ); | |
| } | |