/**
* 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 ``;
}
/**
* 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);
}