2 Commits

Author SHA1 Message Date
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
a39ed37a03 Enable source maps for readable stack traces
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 17:05:18 -05:00
6 changed files with 439 additions and 159 deletions

View File

@@ -1,171 +1,30 @@
import express, {
type Request as ExpressRequest,
type Response as ExpressResponse,
} from "express";
import { match } from "path-to-regexp";
import { Session } from "./diachron/auth";
import { cli } from "./diachron/cli";
import { contentTypes } from "./diachron/content-types";
import { runWithContext } from "./diachron/context";
import{cli}from'./diachron/cli'
import { formatError } from './diachron/errors';
process.on('uncaughtException', (err) => {
console.error(formatError(err));
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
console.error(formatError(reason));
});
import { core } from "./diachron/core";
import { httpCodes } from "./diachron/http-codes";
import { request } from "./diachron/request";
// import { URLPattern } from 'node:url';
import {
AuthenticationRequired,
AuthorizationDenied,
type Call,
type InternalHandler,
isRedirect,
type Method,
massageMethod,
methodParser,
type ProcessedRoute,
type Result,
type Route,
} from "./diachron/types";
import { routes } from "./routes";
import {makeApp}from'./diachron/app'
const app = express();
// Parse request bodies
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const app = makeApp({routes});
core.logging.log({ source: "logging", text: ["1"] });
const processedRoutes: { [K in Method]: ProcessedRoute[] } = {
GET: [],
POST: [],
PUT: [],
PATCH: [],
DELETE: [],
};
function _isPromise<T>(value: T | Promise<T>): value is Promise<T> {
return typeof (value as any)?.then === "function";
}
routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => {
// const pattern /*: URLPattern */ = new URLPattern({ pathname: route.path });
const matcher = match<Record<string, string>>(route.path);
const methodList = route.methods;
const handler: InternalHandler = async (
expressRequest: ExpressRequest,
): Promise<Result> => {
const method = massageMethod(expressRequest.method);
console.log("method", method);
if (!methodList.includes(method)) {
// XXX: Worth asserting this?
}
console.log("request.originalUrl", expressRequest.originalUrl);
// Authenticate the request
const auth = await request.auth.validateRequest(expressRequest);
const req: Call = {
pattern: route.path,
path: expressRequest.originalUrl,
method,
parameters: { one: 1, two: 2 },
request: expressRequest,
user: auth.user,
session: new Session(auth.session, auth.user),
};
try {
const retval = await runWithContext({ user: auth.user }, () =>
route.handler(req),
);
return retval;
} catch (error) {
// Handle authentication errors
if (error instanceof AuthenticationRequired) {
return {
code: httpCodes.clientErrors.Unauthorized,
contentType: contentTypes.application.json,
result: JSON.stringify({
error: "Authentication required",
}),
};
}
if (error instanceof AuthorizationDenied) {
return {
code: httpCodes.clientErrors.Forbidden,
contentType: contentTypes.application.json,
result: JSON.stringify({ error: "Access denied" }),
};
}
throw error;
}
};
for (const [_idx, method] of methodList.entries()) {
const pr: ProcessedRoute = { matcher, method, handler };
processedRoutes[method].push(pr);
}
});
async function handler(
req: ExpressRequest,
_res: ExpressResponse,
): Promise<Result> {
const method = await methodParser.parseAsync(req.method);
const byMethod = processedRoutes[method];
console.log(
"DEBUG: req.path =",
JSON.stringify(req.path),
"method =",
method,
);
for (const [_idx, pr] of byMethod.entries()) {
const match = pr.matcher(req.path);
console.log("DEBUG: trying pattern, match result =", match);
if (match) {
console.log("match", match);
const resp = await pr.handler(req);
return resp;
}
}
const retval: Result = {
code: httpCodes.clientErrors.NotFound,
contentType: contentTypes.text.plain,
result: "not found!",
};
return retval;
}
app.use(async (req: ExpressRequest, res: ExpressResponse) => {
const result0 = await handler(req, res);
const code = result0.code.code;
const result = result0.result;
console.log(result);
// Set any cookies from the result
if (result0.cookies) {
for (const cookie of result0.cookies) {
res.cookie(cookie.name, cookie.value, cookie.options ?? {});
}
}
if (isRedirect(result0)) {
res.redirect(code, result0.redirect);
} else {
res.status(code).send(result);
}
});
process.title = `diachron:${cli.listen.port}`;
app.listen(cli.listen.port, cli.listen.host, () => {
console.log(`Listening on ${cli.listen.host}:${cli.listen.port}`);

View File

@@ -6,4 +6,4 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$DIR"
../cmd pnpm ncc build ./app.ts -o dist
../cmd pnpm ncc build ./app.ts -o dist --source-map

190
backend/diachron/app.ts Normal file
View File

@@ -0,0 +1,190 @@
// FIXME: rename this to make-app.ts and adjust imports accordingly
import{contentTypes} from './content-types'
import{httpCodes}from'./http-codes'
import express, {
type NextFunction,
type Request as ExpressRequest,
type Response as ExpressResponse,
} from "express";
import { formatError, formatErrorHtml } from "./errors";
import {isRedirect, InternalHandler, AuthenticationRequired,
AuthorizationDenied, Call,type Method, type ProcessedRoute,methodParser, type Result, type Route,massageMethod } from "./types";
import { runWithContext } from "./context";
import { Session } from "./auth";import { request } from "./request";
import { match } from "path-to-regexp";
import { cli } from "./cli";
type ProcessedRoutes= {[K in Method]: ProcessedRoute[] }
const processRoutes=(routes:Route[]) :ProcessedRoutes => {
const retval:ProcessedRoutes= {
GET: [],
POST: [],
PUT: [],
PATCH: [],
DELETE: [],
};
routes.forEach((route: Route, _idx: number, _allRoutes: Route[]) => {
// const pattern /*: URLPattern */ = new URLPattern({ pathname: route.path });
const matcher = match<Record<string, string>>(route.path);
const methodList = route.methods;
const handler: InternalHandler = async (
expressRequest: ExpressRequest,
): Promise<Result> => {
const method = massageMethod(expressRequest.method);
console.log("method", method);
if (!methodList.includes(method)) {
// XXX: Worth asserting this?
}
console.log("request.originalUrl", expressRequest.originalUrl);
// Authenticate the request
const auth = await request.auth.validateRequest(expressRequest);
const req: Call = {
pattern: route.path,
path: expressRequest.originalUrl,
method,
parameters: { one: 1, two: 2 },
request: expressRequest,
user: auth.user,
session: new Session(auth.session, auth.user),
};
try {
const retval = await runWithContext({ user: auth.user }, () =>
route.handler(req),
);
return retval;
} catch (error) {
// Handle authentication errors
if (error instanceof AuthenticationRequired) {
return {
code: httpCodes.clientErrors.Unauthorized,
contentType: contentTypes.application.json,
result: JSON.stringify({
error: "Authentication required",
}),
};
}
if (error instanceof AuthorizationDenied) {
return {
code: httpCodes.clientErrors.Forbidden,
contentType: contentTypes.application.json,
result: JSON.stringify({ error: "Access denied" }),
};
}
throw error;
}
};
for (const [_idx, method] of methodList.entries()) {
const pr: ProcessedRoute = { matcher, method, handler };
retval[method].push(pr);
}
});
return retval;
}
type MakeAppArgs={routes:Route[],
processTitle?: string,
}
const makeApp = ({routes, processTitle}: MakeAppArgs) => {
if (process.title) {
process.title = `diachron:${cli.listen.port}`;
}
const processedRoutes = processRoutes(routes)
async function handler(
req: ExpressRequest,
_res: ExpressResponse,
): Promise<Result> {
const method = await methodParser.parseAsync(req.method);
const byMethod = processedRoutes[method];
console.log(
"DEBUG: req.path =",
JSON.stringify(req.path),
"method =",
method,
);
for (const [_idx, pr] of byMethod.entries()) {
const match = pr.matcher(req.path);
console.log("DEBUG: trying pattern, match result =", match);
if (match) {
console.log("match", match);
const resp = await pr.handler(req);
return resp;
}
}
const retval: Result = {
code: httpCodes.clientErrors.NotFound,
contentType: contentTypes.text.plain,
result: "not found!",
};
return retval;
}
const app = express();
app.use(express.json())
app.use(express.urlencoded({ extended: true }));
app.use(async (req: ExpressRequest, res: ExpressResponse) => {
const result0 = await handler(req, res);
const code = result0.code.code;
const result = result0.result;
console.log(result);
// Set any cookies from the result
if (result0.cookies) {
for (const cookie of result0.cookies) {
res.cookie(cookie.name, cookie.value, cookie.options ?? {});
}
}
if (isRedirect(result0)) {
res.redirect(code, result0.redirect);
} else {
res.status(code).send(result);
}
});
app.use(
(
err: Error,
_req: ExpressRequest,
res: ExpressResponse,
_next: NextFunction,
) => {
console.error(formatError(err));
res.status(500).type("html").send(formatErrorHtml(err));
},
);
return app;
}
export{makeApp};
function _isPromise<T>(value: T | Promise<T>): value is Promise<T> {
return typeof (value as any)?.then === "function";
}

View File

@@ -1,5 +1,6 @@
import nunjucks from "nunjucks";
import { db, migrate, migrationStatus } from "../database";
import { formatError, formatErrorHtml } from "../errors";
import { getLogs, log } from "../logging";
// FIXME: This doesn't belong here; move it somewhere else.
@@ -40,6 +41,7 @@ const misc = {
const core = {
conf,
database,
errors: { formatError, formatErrorHtml },
logging,
misc,
random,

229
backend/diachron/errors.ts Normal file
View File

@@ -0,0 +1,229 @@
// 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 };

View File

@@ -6,4 +6,4 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$DIR"
exec ../cmd node dist/index.js "$@"
exec ../cmd node --enable-source-maps dist/index.js "$@"