AUDIT / src /components /CodeRunner.tsx
Arypulka98's picture
feat(audit): deploy full backend cluster node
aea470f verified
Raw
History Blame Contribute Delete
13.4 kB
/**
* 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;