From 91780b6dcadbe9dadbabf572ba5022f6966a96f8 Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sat, 7 Feb 2026 18:07:41 -0500 Subject: [PATCH] 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 --- backend/app.ts | 173 +++---------------------- backend/diachron/app.ts | 190 +++++++++++++++++++++++++++ backend/diachron/core/index.ts | 2 + backend/diachron/errors.ts | 229 +++++++++++++++++++++++++++++++++ 4 files changed, 437 insertions(+), 157 deletions(-) create mode 100644 backend/diachron/app.ts create mode 100644 backend/diachron/errors.ts diff --git a/backend/app.ts b/backend/app.ts index 49296e1..91d896d 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -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(value: T | Promise): value is Promise { - 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>(route.path); - const methodList = route.methods; - const handler: InternalHandler = async ( - expressRequest: ExpressRequest, - ): Promise => { - 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 { - 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}`); diff --git a/backend/diachron/app.ts b/backend/diachron/app.ts new file mode 100644 index 0000000..35d8fd0 --- /dev/null +++ b/backend/diachron/app.ts @@ -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>(route.path); + const methodList = route.methods; + + const handler: InternalHandler = async ( + expressRequest: ExpressRequest, + ): Promise => { + 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 { + 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(value: T | Promise): value is Promise { + return typeof (value as any)?.then === "function"; +} + diff --git a/backend/diachron/core/index.ts b/backend/diachron/core/index.ts index 0aa8d49..bcea976 100644 --- a/backend/diachron/core/index.ts +++ b/backend/diachron/core/index.ts @@ -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, diff --git a/backend/diachron/errors.ts b/backend/diachron/errors.ts new file mode 100644 index 0000000..84f3b70 --- /dev/null +++ b/backend/diachron/errors.ts @@ -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] ?? ""; + 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(); + 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, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +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 += ` + ${esc(loc)} + at ${esc(group.frame.fn)} +\n`; + } else { + frameRows += ` + ${esc(libSummary(group.count, group.names))} +\n`; + } + } + + return ` + + + +Error + + + + ERROR +
${esc(message)}
+ ${frameRows}
+ +`; +} + +export { formatError, formatErrorHtml };