Spaces:
Sleeping
Sleeping
| /** | |
| * CodeRunner.tsx — FASE 3.5 | |
| * | |
| * Architettura runtime: | |
| * - Codice "rich" (usa vfs / memory / download / fetch) → main thread sandbox | |
| * - Codice "pure" JS (nessuna API DOM custom) → jsRunner.worker.ts (off-main-thread) | |
| * | |
| * Il runtime effettivo è mostrato nella UI (badge). | |
| * Cancellazione supportata in entrambi i path. | |
| */ | |
| import { useState, useCallback, useRef, useEffect, memo } from "react"; | |
| import { vfsAsync } from "@/lib/vfsDb"; | |
| import { agentMemory } from "@/lib/agentMemory"; | |
| import type { JSRunnerMsg, JSRunnerResult } from "@/workers/jsRunner.worker"; | |
| interface CodeRunnerProps { | |
| code: string; | |
| language?: string; | |
| onResult?: (result: string) => void; | |
| } | |
| type RuntimeLabel = "JS Worker" | "JS Main" | "Python"; | |
| type Status = "idle" | "running" | "done" | "error"; | |
| const MAX_OUTPUT = 8_000; | |
| const JS_TIMEOUT = 15_000; | |
| // ─── Heuristic: codice usa API DOM custom? → main thread ───────────────────── | |
| const MAIN_THREAD_APIS = /\bvfs\b|\bmemory\b|\bdownload\b|\bgeneratePDF\b|\bimportModule\b/; | |
| function needsMainThread(code: string): boolean { | |
| return MAIN_THREAD_APIS.test(code); | |
| } | |
| function truncate(s: string) { | |
| if (s.length <= MAX_OUTPUT) return s; | |
| return s.slice(0, MAX_OUTPUT) + `\n…(troncato: ${s.length} caratteri)`; | |
| } | |
| // ─── Main-thread rich sandbox (retrocompatibile con API DOM custom) ─────────── | |
| async function runInMainThread( | |
| code: string, | |
| onChunk: (s: string) => void, | |
| signal?: AbortSignal, | |
| ): Promise<string> { | |
| const logs: string[] = []; | |
| const logFn = (...args: unknown[]) => { | |
| const line = args.map(a => typeof a === "string" ? a : JSON.stringify(a, null, 2)).join(" "); | |
| logs.push(line); | |
| onChunk(logs.join("\n")); | |
| }; | |
| const downloadFn = (content: string, filename = "file.txt", mimeType = "text/plain") => { | |
| const blob = new Blob([content], { type: mimeType }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; a.download = filename; a.click(); | |
| setTimeout(() => URL.revokeObjectURL(url), 5000); | |
| logs.push(`DL: ${filename}`); | |
| onChunk(logs.join("\n")); | |
| }; | |
| // Gap 1 (S127): window.print() nativo iOS Safari — apre dialog "Salva come PDF". | |
| // Zero librerie esterne, funziona offline, rispetta il font di sistema del dispositivo. | |
| const generatePDF = (html: string, filename = "documento.pdf") => { | |
| const title = filename.replace(/\.pdf$/i, ""); | |
| const newWin = window.open("", "_blank"); | |
| if (!newWin) { | |
| logs.push(`Err generatePDF: popup bloccato — abilita i popup per questo sito`); | |
| onChunk(logs.join("\n")); | |
| return; | |
| } | |
| newWin.document.write( | |
| `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${title}</title>` + | |
| `<style>@page{margin:2cm}@media print{body{margin:0}}` + | |
| `body{font-family:system-ui,sans-serif;max-width:800px;margin:2rem auto;line-height:1.6}</style>` + | |
| `</head><body>${html}</body></html>` | |
| ); | |
| newWin.document.close(); | |
| newWin.focus(); | |
| newWin.print(); | |
| // Su iOS Safari print() è sincrono — aspetta un tick prima di close | |
| setTimeout(() => { try { newWin.close(); } catch { /* non-blocking */ } }, 500); | |
| logs.push(`PDF: ${filename} — dialog di stampa aperto`); | |
| onChunk(logs.join("\n")); | |
| }; | |
| const globals: Record<string, unknown> = { | |
| console: { log: logFn, warn: logFn, error: logFn, info: logFn }, | |
| fetch, | |
| vfs: vfsAsync, | |
| memory: agentMemory, | |
| download: downloadFn, | |
| generatePDF, | |
| sleep: (ms: number) => new Promise<void>(r => setTimeout(r, ms)), | |
| importModule: (spec: string) => import(/* @vite-ignore */ `https://esm.sh/${spec}`), | |
| uuid: () => crypto.randomUUID(), | |
| hash: async (str: string, algo = "SHA-256") => { | |
| const buf = await crypto.subtle.digest(algo, new TextEncoder().encode(str)); | |
| return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join(""); | |
| }, | |
| b64encode: (s: string) => btoa(unescape(encodeURIComponent(s))), | |
| b64decode: (s: string) => decodeURIComponent(escape(atob(s))), | |
| atob, btoa, URL, Promise, JSON, Math, Date, Object, Array, | |
| String, Number, Boolean, parseInt, parseFloat, isNaN, isFinite, | |
| encodeURIComponent, decodeURIComponent, | |
| setTimeout, clearTimeout, setInterval, clearInterval, | |
| TextEncoder, TextDecoder, crypto, Blob, File, FormData, | |
| Headers, Request, Response, ReadableStream, | |
| ArrayBuffer, Uint8Array, Int32Array, Float64Array, | |
| }; | |
| if (signal?.aborted) throw new DOMException("Annullato", "AbortError"); | |
| const AsyncFn = Object.getPrototypeOf(async function () {}).constructor as new ( | |
| ...args: string[] | |
| ) => (...args: unknown[]) => Promise<unknown>; | |
| const fn = new AsyncFn(...Object.keys(globals), `"use strict";\n${code}`); | |
| await fn(...Object.values(globals)); | |
| return logs.join("\n"); | |
| } | |
| // ─── Module-level factory — Vite analizza new URL() staticamente solo qui ───── | |
| // S248-Fix: spostato fuori dalla closure new Promise() — stessa ragione di | |
| // workerManager.ts: Vite non bundla worker creati dentro closure/callbacks. | |
| function _mkJsRunnerWorker(): Worker { | |
| return new Worker( | |
| new URL("../workers/jsRunner.worker.ts", import.meta.url), | |
| { type: "module" }, | |
| ); | |
| } | |
| // ─── Worker sandbox (off-main-thread, non-blocking, timeout built-in) ───────── | |
| function runInWorker( | |
| code: string, | |
| onChunk: (s: string) => void, | |
| signal?: AbortSignal, | |
| ): Promise<string> { | |
| return new Promise((resolve, reject) => { | |
| const worker = _mkJsRunnerWorker(); | |
| const runId = crypto.randomUUID(); | |
| const lines: string[] = []; | |
| worker.onmessage = (e: MessageEvent<JSRunnerResult>) => { | |
| const msg = e.data; | |
| if (msg.id !== runId) return; | |
| if (msg.type === "stdout") { | |
| lines.push(msg.text.trimEnd()); | |
| onChunk(lines.join("\n")); | |
| } else if (msg.type === "stderr") { | |
| lines.push(`[err] ${msg.text.trimEnd()}`); | |
| onChunk(lines.join("\n")); | |
| } else if (msg.type === "done") { | |
| worker.terminate(); | |
| if (msg.exitCode === 0) { | |
| resolve(lines.join("\n") || "(nessun output)"); | |
| } else if (msg.exitCode === 124) { | |
| reject(new Error(`Timeout: esecuzione oltre ${JS_TIMEOUT / 1000}s`)); | |
| } else { | |
| reject(new Error(lines.join("\n") || "Errore runtime")); | |
| } | |
| } else if (msg.type === "error") { | |
| worker.terminate(); | |
| reject(new Error(msg.message)); | |
| } | |
| }; | |
| worker.onerror = (e) => { | |
| worker.terminate(); | |
| reject(new Error(e.message ?? "Worker error")); | |
| }; | |
| const abortHandler = () => { | |
| worker.postMessage({ type: "cancel", id: runId } satisfies JSRunnerMsg); | |
| setTimeout(() => worker.terminate(), 200); | |
| reject(new DOMException("Annullato", "AbortError")); | |
| }; | |
| signal?.addEventListener("abort", abortHandler, { once: true }); | |
| worker.postMessage({ type: "run", id: runId, code, timeout: JS_TIMEOUT } satisfies JSRunnerMsg); | |
| }); | |
| } | |
| // ─── Component ──────────────────────────────────────────────────────────────── | |
| const CodeRunner = memo(function CodeRunner({ code, language, onResult }: CodeRunnerProps) { | |
| const [status, setStatus] = useState<Status>("idle"); | |
| const [output, setOutput] = useState(""); | |
| const [runtime, setRuntime] = useState<RuntimeLabel | null>(null); | |
| const [elapsed, setElapsed] = useState(0); | |
| const abortRef = useRef<AbortController | null>(null); | |
| const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); | |
| const startRef = useRef<number>(0); | |
| useEffect(() => () => { | |
| abortRef.current?.abort(); | |
| if (timerRef.current) clearInterval(timerRef.current); | |
| }, []); | |
| const stopTimer = () => { | |
| if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } | |
| }; | |
| const run = useCallback(async () => { | |
| abortRef.current?.abort(); | |
| const ctrl = new AbortController(); | |
| abortRef.current = ctrl; | |
| setStatus("running"); | |
| setOutput(""); | |
| setElapsed(0); | |
| const lang = language?.toLowerCase() ?? ""; | |
| const isPython = lang === "python" || lang === "py"; | |
| const useWorker = !isPython && !needsMainThread(code); | |
| const rtLabel: RuntimeLabel = isPython ? "Python" : useWorker ? "JS Worker" : "JS Main"; | |
| setRuntime(rtLabel); | |
| startRef.current = Date.now(); | |
| timerRef.current = setInterval(() => | |
| setElapsed(Math.floor((Date.now() - startRef.current) / 1000)), 1000 | |
| ); | |
| try { | |
| let result: string; | |
| if (isPython) { | |
| result = "[!] Esecuzione Python: usa il backend (HF Spaces) per Pyodide."; | |
| } else if (useWorker) { | |
| result = await runInWorker(code, s => setOutput(truncate(s)), ctrl.signal); | |
| } else { | |
| result = await runInMainThread(code, s => setOutput(truncate(s)), ctrl.signal); | |
| } | |
| stopTimer(); | |
| const final = truncate(result || "(nessun output)"); | |
| setOutput(final); | |
| setStatus("done"); | |
| onResult?.(final); | |
| } catch (err: unknown) { | |
| stopTimer(); | |
| const msg = err instanceof Error ? `${err.name}: ${err.message}` : String(err); | |
| setOutput(`Err: ${msg}`); | |
| setStatus(err instanceof DOMException && err.name === "AbortError" ? "idle" : "error"); | |
| onResult?.(`Errore: ${msg}`); | |
| } | |
| }, [code, language, onResult]); | |
| const cancel = useCallback(() => { | |
| abortRef.current?.abort(); | |
| stopTimer(); | |
| setStatus("idle"); | |
| setOutput(""); | |
| }, []); | |
| const reset = useCallback(() => { | |
| abortRef.current?.abort(); | |
| stopTimer(); | |
| setStatus("idle"); | |
| setOutput(""); | |
| setRuntime(null); | |
| setElapsed(0); | |
| }, []); | |
| return ( | |
| <div style={{ | |
| background: "#0d0d18", border: "1px solid #222236", | |
| borderRadius: 10, marginBottom: "0.6em", overflow: "hidden", | |
| }}> | |
| {/* Header */} | |
| <div style={{ | |
| display: "flex", alignItems: "center", justifyContent: "space-between", | |
| padding: "0.4rem 0.75rem", | |
| background: "#0a0a14", borderBottom: "1px solid #1a1a2e", | |
| }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 6, fontSize: "0.72rem", color: "#6b6b7b" }}> | |
| <span style={{ fontWeight: 600 }}>Esecuzione codice</span> | |
| {/* Runtime badge */} | |
| {runtime && ( | |
| <span style={{ | |
| fontSize: "0.63rem", fontFamily: "monospace", fontWeight: 500, | |
| padding: "1px 5px", borderRadius: 6, | |
| background: runtime === "JS Worker" | |
| ? "rgba(34,197,94,0.12)" : runtime === "Python" | |
| ? "rgba(250,204,21,0.12)" : "rgba(148,163,184,0.12)", | |
| color: runtime === "JS Worker" | |
| ? "#4ade80" : runtime === "Python" | |
| ? "#facc15" : "#94a3b8", | |
| border: `1px solid ${runtime === "JS Worker" | |
| ? "rgba(74,222,128,0.2)" : runtime === "Python" | |
| ? "rgba(250,204,21,0.2)" : "rgba(148,163,184,0.2)"}`, | |
| }}> | |
| {runtime} | |
| </span> | |
| )} | |
| {status === "running" && ( | |
| <span style={{ color: "#6b6b7b", fontSize: "0.68rem" }}> | |
| {elapsed > 0 && `${elapsed}s`} | |
| </span> | |
| )} | |
| {status === "done" && <span style={{ color: "#22c55e" }}>ok</span>} | |
| {status === "error" && <span style={{ color: "#ef4444" }}>err</span>} | |
| </div> | |
| <div style={{ display: "flex", gap: 4 }}> | |
| {status === "idle" && ( | |
| <button onClick={run} style={{ | |
| all: "unset", cursor: "pointer", fontSize: "0.72rem", fontWeight: 600, | |
| color: "#4f8ef7", padding: "2px 8px", borderRadius: 8, | |
| background: "rgba(79,142,247,0.1)", border: "1px solid rgba(79,142,247,0.2)", | |
| }}>▶ Esegui</button> | |
| )} | |
| {status === "running" && ( | |
| <button onClick={cancel} style={{ | |
| all: "unset", cursor: "pointer", fontSize: "0.72rem", fontWeight: 600, | |
| color: "#f87171", padding: "2px 8px", borderRadius: 8, | |
| background: "rgba(248,113,113,0.1)", border: "1px solid rgba(248,113,113,0.2)", | |
| }}>■ Stop</button> | |
| )} | |
| {(status === "done" || status === "error") && ( | |
| <button onClick={reset} style={{ | |
| all: "unset", cursor: "pointer", fontSize: "0.7rem", | |
| color: "#6b6b7b", padding: "2px 6px", | |
| }}>↺ Reset</button> | |
| )} | |
| </div> | |
| </div> | |
| {/* Body */} | |
| {status === "running" && !output && ( | |
| <div style={{ padding: "0.6rem 0.9rem", color: "#6b6b7b", fontSize: "0.78rem" }}> | |
| Esecuzione in corso… | |
| </div> | |
| )} | |
| {output && ( | |
| <pre style={{ | |
| margin: 0, padding: "0.7rem 0.9rem", fontSize: "0.78rem", lineHeight: 1.55, | |
| fontFamily: "ui-monospace, monospace", whiteSpace: "pre-wrap", wordBreak: "break-word", | |
| color: status === "error" ? "#fca5a5" : "#c0f0c0", | |
| maxHeight: 300, overflow: "auto", | |
| }}> | |
| {output} | |
| </pre> | |
| )} | |
| </div> | |
| ); | |
| }); | |
| export default CodeRunner; | |