"use client"; /* Renders a user-authored UI component (Feature 2 - generative UI) inside a SANDBOXED iframe. Mustache interpolates the agent-supplied props into the saved HTML (values are HTML-escaped by Mustache), the CSS is scoped to the iframe document, and a tiny injected bridge reports content height (for auto-sizing) and posts button/form actions back to the host. sandbox="allow-scripts" (NO allow-same-origin) means the component can never reach the parent DOM, cookies, or storage - only postMessage. */ import { useEffect, useMemo, useRef, useState } from "react"; import Mustache from "mustache"; export interface ComponentDef { id?: string; name?: string; html: string; css: string; actions?: Record[]; } // Runs INSIDE the sandboxed iframe. No Date/Math.random (kept deterministic). Reports size // and forwards clicks on [data-forge-action] elements (with any named field values). const BRIDGE = "(function(){function post(m){try{parent.postMessage(Object.assign({__forge:true},m),(window.__FO||'*'))}catch(e){}}" + "function size(){post({type:'size',height:(document.documentElement.scrollHeight||document.body.scrollHeight)})}" + "window.addEventListener('load',function(){size();setTimeout(size,60)});" + "try{new ResizeObserver(size).observe(document.documentElement)}catch(e){}" + "document.addEventListener('click',function(e){var el=e.target&&e.target.closest?e.target.closest('[data-forge-action]'):null;if(!el)return;e.preventDefault();" + "var scope=el.closest('form')||document;var fields={};scope.querySelectorAll('[name]').forEach(function(i){var t=(i.type||'').toLowerCase();if((t==='checkbox'||t==='radio')&&!i.checked)return;if(Object.prototype.hasOwnProperty.call(fields,i.name)){fields[i.name]=[].concat(fields[i.name],i.value)}else{fields[i.name]=i.value}});" + "post({type:'action',action:el.getAttribute('data-forge-action'),fields:fields})});})();"; export function ComponentRenderer({ def, props, onAction, }: { def: ComponentDef; props: Record; onAction?: (action: string, fields: Record, def: ComponentDef) => void; }) { const ref = useRef(null); const [height, setHeight] = useState(60); // Keep latest def/onAction in refs so the message listener registers ONCE - new def/onAction // object identities on each parent render would otherwise re-bind it every render (F19/F33). const defRef = useRef(def); defRef.current = def; const actionRef = useRef(onAction); actionRef.current = onAction; // Rebuild the iframe document only when the template or props actually change - not on every // parent re-render (e.g. while a sibling message streams), which would reload the iframe (F19). const srcDoc = useMemo(() => { let body = ""; try { body = Mustache.render(def.html || "", props || {}); } catch (e: any) { body = `
template error: ${String(e?.message || e)}
`; } // Bake our origin in so the sandboxed (opaque-origin) frame can postMessage back with a // concrete targetOrigin instead of "*" (review hardening). The parent is same-origin as us. const fo = typeof window !== "undefined" ? window.location.origin : "*"; return ( `` + `` + `${body}` ); }, [def.html, def.css, props]); useEffect(() => { function onMsg(e: MessageEvent) { if (!ref.current || e.source !== ref.current.contentWindow) return; // Sandboxed (allow-scripts, no same-origin) iframes post from the opaque "null" origin; // accept that or our own origin, reject anything else (audit F27). if (e.origin !== "null" && e.origin !== window.location.origin) return; const d: any = e.data || {}; if (!d.__forge) return; if (d.type === "size" && typeof d.height === "number") { setHeight(Math.min(2000, Math.max(40, Math.ceil(d.height)))); } else if (d.type === "action") { actionRef.current?.(String(d.action || ""), d.fields || {}, defRef.current); } } window.addEventListener("message", onMsg); return () => window.removeEventListener("message", onMsg); }, []); const actions = def.actions || []; return (