/** * Tina4 Debug — Rich error overlay for development mode. * * Renders a rich HTML error page (exception type + message, the full stack with a * seven-line source window per frame, request details, environment) when an unhandled * exception reaches the server dispatch in development. * * import { renderErrorOverlay, isDebugMode } from "./errorOverlay.js"; * * try { * await handler(req, res); * } catch (err) { * if (isDebugMode()) res.html(renderErrorOverlay(err as Error, req), 500); * } * * Dev-only: the caller gates this on isDebugMode() (TINA4_DEBUG). The production 500 is * NOT rendered here — the server dispatch renders errors/500.twig with an empty * error_message (CWE-209), so the exception detail stays in the server log only. * * Sensitive request fields (Authorization / Cookie / Set-Cookie headers and * password-like body/param keys) are redacted even in the dev overlay, the frame count * is capped, and the caller wraps this render in a guard, so a broken overlay or a * recursive stack still yields a bounded, safe 500. */ import { readFileSync, statSync } from "node:fs"; import { resolve } from "node:path"; import { isTruthy } from "./dotenv.js"; // OVERLAY-DEC-03: cap the rendered frames so a deep/recursive stack yields a bounded // page, not one source-file read per frame. const MAX_FRAMES = 50; // OVERLAY-DEC-02: request fields whose KEY matches this are masked in the dev overlay // (Authorization/Cookie/Set-Cookie headers via authorization|cookie; password/token/ // secret/api_key body/param keys via the rest). Over-matching a benign field is the // SAFE direction in a dev tool — over-masking leaks nothing; under-masking leaks. const SENSITIVE_KEY_RE = /password|passwd|secret|token|authorization|cookie|key/i; const REDACTED = "[redacted]"; function redact(key: string, value: string): string { return SENSITIVE_KEY_RE.test(key) ? REDACTED : value; } // ── Colour palette (Catppuccin Mocha) ──────────────────────────────────── const BG = "#1e1e2e"; const SURFACE = "#313244"; const OVERLAY = "#45475a"; const TEXT = "#cdd6f4"; const SUBTEXT = "#a6adc8"; const RED = "#f38ba8"; const YELLOW = "#f9e2af"; const BLUE = "#89b4fa"; const GREEN = "#a6e3a1"; const LAVENDER = "#b4befe"; const PEACH = "#fab387"; const ERROR_LINE_BG = "rgba(243,139,168,0.15)"; const CONTEXT_LINES = 7; interface StackFrame { file: string; line: number; column: number; func: string; } function esc(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function parseStack(stack: string): StackFrame[] { const frames: StackFrame[] = []; const lines = stack.split("\n"); for (const line of lines) { // Match: " at functionName (file:line:col)" let match = line.match(/^\s*at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/); if (match) { frames.push({ func: match[1], file: match[2], line: parseInt(match[3], 10), column: parseInt(match[4], 10) }); continue; } // Match: " at file:line:col" match = line.match(/^\s*at\s+(.+?):(\d+):(\d+)/); if (match) { frames.push({ func: "{anonymous}", file: match[1], line: parseInt(match[2], 10), column: parseInt(match[3], 10) }); } } return frames; } function readSourceLines(filename: string, lineno: number): Array<[number, string, boolean]> { try { const absPath = resolve(filename); const content = readFileSync(absPath, "utf-8"); const allLines = content.split("\n"); const start = Math.max(0, lineno - CONTEXT_LINES - 1); const end = Math.min(allLines.length, lineno + CONTEXT_LINES); const result: Array<[number, string, boolean]> = []; for (let i = start; i < end; i++) { const num = i + 1; result.push([num, allLines[i] ?? "", num === lineno]); } return result; } catch { return []; } } function formatSourceBlock(filename: string, lineno: number): string { const lines = readSourceLines(filename, lineno); if (lines.length === 0) return ""; const rows = lines.map(([num, text, isError]) => { const bg = isError ? `background:${ERROR_LINE_BG};` : ""; const marker = isError ? "▶" : " "; return `
` + `${num}` + `${marker}` + `${esc(text)}` + `
`; }).join("\n"); return `
` + rows + `
`; } /** * Render one stack frame. * * When the file was modified AFTER `capturedAt`, append a peach * "FILE MODIFIED" badge so a stale browser-cached overlay can't lie * about what the source looks like now. The AI coder often rewrites * files in place between page loads, leaving the overlay's source * view showing different code than what raised the error. * * `capturedAt` is in seconds (Date.now() / 1000) for parity with * Python's time.time(). */ function formatFrame(frame: StackFrame, capturedAt = 0): string { const source = frame.file && frame.line > 0 ? formatSourceBlock(frame.file, frame.line) : ""; let staleBadge = ""; if (capturedAt && frame.file) { try { const absPath = resolve(frame.file); const mtime = statSync(absPath).mtimeMs / 1000; if (mtime > capturedAt + 0.5) { // 0.5s margin for fs noise const d = new Date(mtime * 1000); const mtimeIso = `${String(d.getUTCHours()).padStart(2, "0")}:` + `${String(d.getUTCMinutes()).padStart(2, "0")}:` + `${String(d.getUTCSeconds()).padStart(2, "0")}`; staleBadge = ` ` + `FILE MODIFIED @ ${mtimeIso} UTC — source may not match what failed`; } } catch { // best-effort — ignore missing files / permission errors } } return `
` + `
` + `${esc(frame.file)}` + ` : ` + `${frame.line}` + ` in ` + `${esc(frame.func)}` + staleBadge + `
` + source + `
`; } function collapsible(title: string, content: string, openByDefault = false): string { const open = openByDefault ? " open" : ""; return `
` + `${esc(title)}` + `
${content}
` + `
`; } function table(pairs: Array<[string, string]>): string { if (pairs.length === 0) return `None`; const rows = pairs.map(([key, val]) => `` + `${esc(key)}` + `${esc(val)}` + `` ).join(""); return `${rows}
`; } /** * Render a rich HTML error overlay. * * @param error - The caught error. * @param request - Optional request object with method, url, headers, etc. * @returns Complete HTML page string. */ export function renderErrorOverlay(error: Error, request?: any): string { // Stamp ONCE per render — every frame compares against this. Seconds-since-epoch // matches Python's time.time() so frames stale by < 0.5s of fs noise don't trip. const capturedAt = Date.now() / 1000; const excType = error.constructor?.name ?? "Error"; const excMsg = error.message ?? String(error); const frames = error.stack ? parseStack(error.stack) : []; // ── Stack trace ── // Each frame compares its source file's mtime to capturedAt and flags itself // if the file has been modified since — protects against the "browser cached // an old overlay, then the AI rewrote the file" confusion where displayed // source no longer matches what actually raised the error. // OVERLAY-DEC-03: cap the rendered frames. A recursive stack of thousands of frames // would otherwise do one source-file read per frame and emit an unbounded page; // render only the innermost MAX_FRAMES and note the rest. let framesHtml = ""; for (const frame of frames.slice(0, MAX_FRAMES)) { framesHtml += formatFrame(frame, capturedAt); } const hidden = frames.length - Math.min(frames.length, MAX_FRAMES); if (hidden > 0) { framesHtml += `
` + `… ${hidden} more stack frames hidden (truncated at ${MAX_FRAMES})
`; } // ── Request info ── const requestPairs: Array<[string, string]> = []; if (request != null) { for (const attr of ["method", "url", "path", "ip"]) { requestPairs.push([attr, request[attr] != null ? String(request[attr]) : "(none)"]); } const dictFields: Array<[string, unknown]> = [ ["headers", request.headers], ["params", request.params], ["query", request.query], ["body", request.body], ]; for (const [label, val] of dictFields) { if (val != null && typeof val === "object" && Object.keys(val as object).length > 0) { for (const [k, v] of Object.entries(val as Record)) { const pairKey = `${label}.${k}`; requestPairs.push([pairKey, redact(pairKey, String(v))]); } } else if (val != null && typeof val === "string" && val !== "") { requestPairs.push([label, val]); } else { requestPairs.push([label, val == null ? "(none)" : "(empty)"]); } } } const requestSection = requestPairs.length > 0 ? collapsible("Request Details", table(requestPairs)) : ""; // ── Environment ── const envPairs: Array<[string, string]> = [ ["Framework", "Tina4 Node.js"], ["Node.js", process.version], ["Platform", process.platform], ["Arch", process.arch], ["Debug", process.env.TINA4_DEBUG ?? "false"], ["Log Level", process.env.TINA4_LOG_LEVEL ?? "ERROR"], ]; const envSection = collapsible("Environment", table(envPairs)); const stackSection = collapsible("Stack Trace", framesHtml, true); return ` Tina4 Error — ${esc(excType)}
Error Tina4 Debug Overlay

${esc(excType)}

${esc(excMsg)}

${stackSection} ${requestSection} ${envSection}
Tina4 Debug Overlay — This page is only shown in debug mode. Set TINA4_DEBUG=false in production.
`; } /** * Check if TINA4_DEBUG is enabled. */ export function isDebugMode(): boolean { return isTruthy(process.env.TINA4_DEBUG); }