Files
diachron/backend/diachron/errors.ts
Michael Wolf 91780b6dca Add formatted error pages for console and browser
Errors now show app frames highlighted with relative paths and library
frames collapsed, both in ANSI on the console and as a styled HTML page
in the browser. Process-level uncaughtException/unhandledRejection
handlers also use the formatter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 18:07:41 -05:00

230 lines
5.7 KiB
TypeScript

// ANSI escape codes
const bold = "\x1b[1m";
const red = "\x1b[31m";
const cyan = "\x1b[36m";
const dim = "\x1b[2m";
const reset = "\x1b[0m";
interface ParsedFrame {
raw: string;
fn: string;
file: string;
line: string;
col: string;
isApp: boolean;
}
const frameRe = /^\s*at\s+(?:(.+?)\s+)?\(?((?:\/|[a-zA-Z]:\\).+?):(\d+):(\d+)\)?$/;
function parseFrame(line: string): ParsedFrame | null {
const m = line.match(frameRe);
if (!m) return null;
const fn = m[1] ?? "<anonymous>";
const file = m[2];
const lineNum = m[3];
const col = m[4];
const isApp =
!file.includes("node_modules") && !file.startsWith("node:");
return { raw: line, fn, file, line: lineNum, col, isApp };
}
function relativePath(absPath: string): string {
const marker = "backend/";
const idx = absPath.lastIndexOf(marker);
if (idx !== -1) return absPath.slice(idx);
return absPath;
}
function libraryName(file: string): string {
const nmIdx = file.indexOf("node_modules/");
if (nmIdx === -1) return "node";
const after = file.slice(nmIdx + "node_modules/".length);
// Handle scoped packages like @scope/pkg
if (after.startsWith("@")) {
const parts = after.split("/");
return `${parts[0]}/${parts[1]}`;
}
return after.split("/")[0];
}
interface ParsedError {
message: string;
frames: ParsedFrame[];
}
function parseError(error: unknown): ParsedError {
if (!(error instanceof Error)) {
return { message: String(error), frames: [] };
}
const message = error.message ?? String(error);
const stack = error.stack ?? "";
const lines = stack.split("\n");
const frameLines: string[] = [];
for (const line of lines) {
if (line.trimStart().startsWith("at ")) {
frameLines.push(line);
}
}
const frames = frameLines
.map(parseFrame)
.filter((f): f is ParsedFrame => f !== null);
return { message, frames };
}
// Group consecutive library frames into collapsed runs
type FrameGroup =
| { kind: "app"; frame: ParsedFrame }
| { kind: "lib"; count: number; names: string[] };
function groupFrames(frames: ParsedFrame[]): FrameGroup[] {
const groups: FrameGroup[] = [];
let i = 0;
while (i < frames.length) {
if (frames[i].isApp) {
groups.push({ kind: "app", frame: frames[i] });
i++;
} else {
const libNames = new Set<string>();
let count = 0;
while (i < frames.length && !frames[i].isApp) {
libNames.add(libraryName(frames[i].file));
count++;
i++;
}
groups.push({ kind: "lib", count, names: [...libNames] });
}
}
return groups;
}
function libSummary(count: number, names: string[]): string {
const s = count === 1 ? "" : "s";
return `... ${count} internal frame${s} (${names.join(", ")})`;
}
// --- Console formatting (ANSI) ---
function formatError(error: unknown): string {
const { message, frames } = parseError(error);
if (frames.length === 0) {
return `${bold}${red}ERROR${reset} ${message}`;
}
const parts: string[] = [];
parts.push(`${bold}${red}ERROR${reset} ${message}`);
parts.push("");
for (const group of groupFrames(frames)) {
if (group.kind === "app") {
const rel = relativePath(group.frame.file);
const loc = `${rel}:${group.frame.line}`;
parts.push(
` ${bold}${cyan}${loc.padEnd(24)}${reset}at ${group.frame.fn}`,
);
} else {
parts.push(
` ${dim}${libSummary(group.count, group.names)}${reset}`,
);
}
}
return parts.join("\n");
}
// --- HTML formatting (browser) ---
function esc(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatErrorHtml(error: unknown): string {
const { message, frames } = parseError(error);
const groups = groupFrames(frames);
let frameRows = "";
for (const group of groups) {
if (group.kind === "app") {
const rel = relativePath(group.frame.file);
const loc = `${rel}:${group.frame.line}`;
frameRows += `<tr class="app">
<td class="loc">${esc(loc)}</td>
<td class="fn">at ${esc(group.frame.fn)}</td>
</tr>\n`;
} else {
frameRows += `<tr class="lib">
<td colspan="2">${esc(libSummary(group.count, group.names))}</td>
</tr>\n`;
}
}
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Error</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "SF Mono", "Menlo", "Consolas", monospace;
font-size: 14px;
background: #1a1a2e;
color: #e0e0e0;
padding: 40px;
}
.error-label {
display: inline-block;
background: #e74c3c;
color: #fff;
font-weight: 700;
font-size: 12px;
padding: 2px 8px;
border-radius: 3px;
letter-spacing: 0.5px;
}
.message {
margin-top: 12px;
font-size: 18px;
font-weight: 600;
color: #f8f8f2;
line-height: 1.4;
}
table {
margin-top: 24px;
border-collapse: collapse;
}
tr.app td { padding: 4px 0; }
tr.app .loc {
color: #56d4dd;
font-weight: 600;
padding-right: 24px;
white-space: nowrap;
}
tr.app .fn { color: #ccc; }
tr.lib td {
color: #666;
padding: 4px 0;
font-style: italic;
}
</style>
</head>
<body>
<span class="error-label">ERROR</span>
<div class="message">${esc(message)}</div>
<table>${frameRows}</table>
</body>
</html>`;
}
export { formatError, formatErrorHtml };