From cd988f1930b18b9a8a7f443f104b45569efb5f6d Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sun, 28 Sep 2025 02:52:02 -0700 Subject: [PATCH] feat(lib/log): custom stack trace printer the default stack trace formatters in node.js, deno, and bun are all extremely low quality. they don't highlight the most important information, and spend too much space on showing source code. this printer puts the error metadata at the top. each stack frame gets two lines: the absolute path or url, and then one line of source code. having five lines of context on just the top frame is useless, since five lines is often not enough context to understand the entire block of code. one line is all you need to remind yourself what the code on that line was doing. and if you can see every frame's source code, you can much more easily figure out what the bug is before opening the editor. to make understanding runtime code easier, `node:*` and `ext:deno` paths are resolved into GitHub source code URLs, and node builtins will also display their source code inline (via `process.binding("natives")`). i'm unaware of any api on bun or deno to do the correct remapping, but best effort is done to point to something that can actually be read. with zero required imports, it can be safely included as a part of the headless log and widget host without worrying about if the module can import successfully. because of this, i'm enabling this printer by default everywhere. --- lib/deno.lock | 17 ++ lib/log/ansi.ts | 15 +- lib/log/headless.ts | 23 ++- lib/log/stack.ts | 374 ++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 7 +- package.json | 1 + run.js | 9 +- 7 files changed, 436 insertions(+), 10 deletions(-) create mode 100644 lib/deno.lock create mode 100644 lib/log/stack.ts diff --git a/lib/deno.lock b/lib/deno.lock new file mode 100644 index 0000000000000000000000000000000000000000..9b2906da8b9572972445b7e0e70a1a53ccd90b56 --- /dev/null +++ b/lib/deno.lock @@ -0,0 +1,17 @@ +{ + "version": "5", + "specifiers": { + "npm:@types/node@*": "24.2.0" + }, + "npm": { + "@types/node@24.2.0": { + "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", + "dependencies": [ + "undici-types" + ] + }, + "undici-types@7.10.0": { + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" + } + } +} diff --git a/lib/log/ansi.ts b/lib/log/ansi.ts index 4dbce083c9f3e14303f77339c549269d9438cd8f..0986cc9460a99164b842cf8e9769dd6a6f4227e6 100644 --- a/lib/log/ansi.ts +++ b/lib/log/ansi.ts @@ -1,5 +1,9 @@ export const reset: string = `\x1b[0m`; +export const bold: string = `\x1b[1m`; +export const dim: string = `\x1b[2m`; +export const resetWeight: string = `\x1b[22m`; + export const fgReset: string = `\x1b[39m`; export const fgBlack: string = `\x1b[30m`; export const fgRed: string = `\x1b[31m`; @@ -69,8 +73,15 @@ export function style(code: string, text: string): string { code = mergeStyles(code); const fg = /[\[;][39]/.test(code); const bg = /[\[;](?:4|10)/.test(code); + const bold = /[\[;](?:1)[;m]/.test(code); + const dim = /[\[;](?:2)[;m]/.test(code); return code + text + - mergeStyles((fg ? fgReset : "") + (bg ? bgReset : "")); + mergeStyles( + (fg ? fgReset : "") + + (bg ? bgReset : "") + + (bold ? resetWeight : "") + + (dim ? resetWeight : ""), + ); } /** @@ -103,7 +114,7 @@ export function debugAnsi(text: string): string { "fgBlue", "fgMagenta", "fgCyan", "fgWhite", "fgBrightBlack", "fgBrightRed", "fgBrightGreen", "fgBrightYellow", "fgBrightBlue", "fgBrightMagenta", "fgBrightCyan", "fgBrightWhite", "clearToEndOfLine", "clearToStartOfLine", - "clearFullLine", "syncStart", "syncEnd" + "clearFullLine", "syncStart", "syncEnd", "bold", "dim", "resetWeight" ]; for (const key of verbaitim) { text = text.replaceAll( diff --git a/lib/log/headless.ts b/lib/log/headless.ts index fad1c34d6fb351eecaa33e733bf03ad1b2c1bb5b..75cfeabfe1c571b74dbcfb26a333a520659d5a0c 100644 --- a/lib/log/headless.ts +++ b/lib/log/headless.ts @@ -85,7 +85,12 @@ export function widgetHost(env: WidgetHostEnv): WidgetHost { let next = Infinity; if (widgets[0]) { for (let w = 0, { length } = widgets; w < length; w += 1) { - const outText = UNWRAP(widgets[w]).format(lastFlush, false); + const outText = UNWRAP(widgets[w]).format({ + now: lastFlush, + columns, + rows, + colors: true, + }); if (!outText) { widgets.splice(w, 1); UNWRAP(internals.splice(w, 1)[0]).unsub?.(); @@ -287,11 +292,16 @@ export function logger(env: LoggerOptions): log.Scope { const logFn = "writeLog" in log ? log.writeLog : ((prefix: string, level: Level, ...args: unknown[]) => { - log.writeLine( - (prefix - ? prefix + (level > 0 ? levels[level] + colon : "") - : (levels[level] + colon)) + util.format(...args), - ); + if (args.length === 0) return log.writeLine(""); + const start = prefix + ? prefix + (level > 0 ? levels[level] + colon : "") + : (levels[level] + colon); + // TODO: make a more general "inspect" function + if (args[0] instanceof Error) { + log.writeLine(start + stack.format(args[0], colors)); + } else { + log.writeLine(start + util.format(...args)); + } }); function scoped(name: string): log.Scope { @@ -318,6 +328,7 @@ type Level = 0 | 1 | 2; import * as ansi from "lib/log/ansi.ts"; import * as string from "lib/string.ts"; import * as ts from "lib/ts.ts"; +import * as stack from "lib/log/stack.ts"; import * as util from "node:util"; import { UNWRAP } from "lib/assert.ts"; import type * as log from "../log.ts"; diff --git a/lib/log/stack.ts b/lib/log/stack.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c8e7a70af9130044204776d5a2f39e19721dd20 --- /dev/null +++ b/lib/log/stack.ts @@ -0,0 +1,374 @@ +// the default stack trace formatters in node.js, deno, and bun are all +// extremely low quality. they don't highlight the most important +// information, and spend too much space on showing source code. +// +// this printer puts the error metadata at the top. each stack frame gets +// two lines: the absolute path or url, and then one line of source code. +// having five lines of context on just the top frame is useless, since +// five lines is often not enough context to understand the entire block of +// code. one line is all you need to remind yourself what the code on that +// line was doing. and if you can see every frame's source code, you can +// much more easily figure out what the bug is before opening the editor. +// +// to make understanding runtime code easier, `node:*` and `ext:deno` paths +// are resolved into GitHub source code URLs, and node builtins will also +// display their source code inline (via `process.binding("natives")`). +// +// with zero required imports, it can be safely included as a part of the +// headless log and widget host without worrying about if the module can +// import successfully. because of this, 'lib/log' uses this by default. +// +// parsing code is derived from clover's work on bun's DevServer, which she +// derived from stacktracejs. this file is more refined than those two: +// https://github.com/oven-sh/bun/blob/b5f31a6ee2f52ea67eabeb61f6e6e71215d55b26/src/bake/client/stack-trace.ts +// https://github.com/stacktracejs/error-stack-parser/blob/9f33c224b5d7b607755eb277f9d51fcdb7287e24/error-stack-parser.js +export interface Frame { + fn: string | null; + file: string | null; + line: number | null; + col: number | null; +} + +const regexV8Stack = /^\s*at .*(\S+:\d+|\(native\))/m; +const regexLocation = /(.+?)(?::(\d+))?(?::(\d+))?$/; + +export function parse(error: Error): null | Frame[] { + const stack = error?.stack; + if (typeof stack === "string") { + if (stack.match(regexV8Stack)) { + return parseV8OrIe(stack); + } + return parseJscOrSpidermonkey(stack); + } + return null; +} + +export function format(error: Error, colors = false) { + const { name, message, stack: _, ...payload } = error; + + const headingLine = (colors ? ansi.fgRed : "") + + (name && name !== "Error" ? `[${name}] ` : "") + + message + (colors ? ansi.fgReset : "") + "\n"; + + const payloadLines = Object.entries(payload) + .map(([k, v]) => + " " + k + + (colors ? ansi.style(ansi.dim, ":") : ":") + + " " + JSON.stringify(v) + "\n" + ) + .join(""); + + let frameLines = "[failed to parse stack trace string]"; + let frames = parse(error) ?? []; + if ("Bun" in globalThis) { + frames = frames.filter((frame) => + frame.file !== "native" || ![ + "moduleEvaluation", + "moduleEvaluation", + "loadAndEvaluateModule", + "processTicksAndRejections", + ].includes(frame.fn) + ); + } + if (frames.length) { + frameLines = frames.map((frame) => formatFrame(frame, colors)).join("\n"); + } + + return headingLine + payloadLines + frameLines + "\n"; +} + +export function formatFrame({ fn, file, line, col }: Frame, colors: boolean) { + let out = ""; + if (file === "native") file = null; + const internal = !file || file.startsWith("node:") || + file.startsWith("bun:") || + file.startsWith("internal:") || + file.startsWith("ext:"); + + if (file) { + if (file.startsWith("file://")) file = new URL(file).pathname; + const root = getPackageRoot(file); + + // filename + if ( + internal && + // all bun internals do not line map + ("Bun" in globalThis || + // deno transpiled internals do not line map + ("Deno" in globalThis && root && root[2].endsWith(".ts"))) + ) { + line = 0; + } + + if (colors) { + if (root) { + const [pathTo, project, sub] = root; + out += ansi.dim + pathTo + + ansi.mergeStyles(ansi.resetWeight + ansi.fgCyan) + + project + ansi.fgReset; + } else if (internal) { + out += ansi.dim; + } + out += root?.[2] ?? file; + } else out += root ? root.join("") : file; + + // location + if (line) { + if (colors) out += ansi.mergeStyles(ansi.dim + ansi.fgYellow); + if (root?.[0]?.match(/^https:\/\/github.com/)) { + out += "#L" + line; + } else { + out += ":"; + out += line; + if (line) { + out += ":"; + out += col; + } + } + if (colors) { + out += fn + ? ansi.fgReset + : ansi.mergeStyles(ansi.fgReset + ansi.resetWeight); + } + } else if (fn) { + out += ansi.dim; + } + } else { + if (colors) out += ansi.dim; + out += "[unknown source]"; + if (colors && !fn) out += ansi.resetWeight; + } + + if (fn) { + if (internal) { + out += " at " + fn + ansi.resetWeight; + } else { + out += " at " + + ansi.mergeStyles(ansi.resetWeight + ansi.fgCyan) + + fn + ansi.fgReset; + } + } + + preview: { + if (!file || !line) break preview; + const code = getSourceCode(file); + if (!code) break preview; + const text = code[line - 1]; + if (!text) break preview; + out += "\n"; + if (colors) { + if (col) { + // get a selection where the error happened + let l = col - 1; + let r = col; + while (l > 0 && !/[a-zA-Z0-9_$\s]/.test(text[l]!)) l -= 1; + while (l > 1 && /[a-zA-Z0-9_$]/.test(text[l - 1]!)) l -= 1; + while (r < text.length && /[a-zA-Z0-9_$]/.test(text[r]!)) r += 1; + // expand to special cases + let center = text.slice(l, r); + if (center === "new") { + r += text.slice(r).match(/\s*[a-zA-Z0-9_$]+/)?.[0]?.length ?? 0; + } + // print it all red, but use dim and bold to highlight the problem + const left = text.slice(0, l); + center = text.slice(l, r); + const right = text.slice(r); + out += (left + ? ansi.mergeStyles(ansi.fgRed + ansi.dim) + left + + ansi.mergeStyles(ansi.resetWeight + ansi.bold) + : ansi.mergeStyles(ansi.fgRed + ansi.bold)) + + center + (right + ? ansi.mergeStyles(ansi.resetWeight + ansi.dim) + + right + : "") + + ansi.mergeStyles(ansi.resetWeight + ansi.fgReset); + } else { + out += ansi.fgRed + text + ansi.fgReset; + } + } else { + out += text; + } + } + + return out; +} + +const existCache = new Map(); +function getPackageRoot(absPath: string) { + const fs = nodeBuiltin("node:fs"); + const path = nodeBuiltin("node:path"); + const process = globalThis.process; + if (!fs || !path || !process) return null; + + if ( + "Bun" in globalThis && + (absPath.startsWith("node:") || absPath.startsWith("internal:")) + ) { + return [ + `https://github.com/oven-sh/bun/blob/bun-v${process.versions.bun}/src/js/`, + "", + absPath.replace(":", "/") + ".ts", + ]; + } + if (absPath.startsWith("node:")) { + return [ + `https://github.com/nodejs/node/blob/${process.version}/lib/`, + "", + absPath.slice(5) + ".js", + ]; + } + if ( + absPath.startsWith("ext:deno_") && + process.versions.deno && + // these files are transpiled without source maps. + !absPath.endsWith(".ts.js") + ) { + const node = absPath.startsWith("ext:deno_node"); + const filename = node + ? absPath.slice("ext:deno_node/".length) + : absPath.slice("ext:deno_".length); + return [ + `https://github.com/denoland/deno/blob/v${process.versions.deno}/ext/` + + (node ? "node/polyfills/" : ""), + "", + node && filename.endsWith(".ts.js") ? filename.slice(0, -3) : filename, + ]; + } + + if (!path.isAbsolute(absPath)) return null; + let dir = path.dirname(absPath); + do { + let exists = existCache.get(dir); + if (exists == null) { + existCache.set( + dir, + exists = fs.existsSync(path.join(dir, "package.json")) || + fs.existsSync(path.join(dir, "deno.json")), + ); + } + if (exists) { + return [ + path.dirname(dir) + path.sep, + path.basename(dir), + absPath.slice(dir.length), + ] as const; + } + } while (dir !== (dir = path.dirname(dir))); + return ""; +} + +const codeCache = new Map(); +function getSourceCode(file: string) { + const fs = nodeBuiltin("node:fs"); + const path = nodeBuiltin("node:path"); + if (!fs || !path) return null; + if (file.startsWith("node:")) { + // node.js has a secret binding for all the internal source codes + type Process = { binding(name: "natives"): Record }; + return (globalThis.process as unknown as Process) + ?.binding?.("natives")[file.slice(5)]?.split("\n") ?? null; + } else { + // bun and deno do not offer a way to get source code of internal modules, + // probably for good reason. + // + // i originally had an idea to download and transpile the version to + // provide a reference and source code, but it's undocumented how deno + // compiles their code and i don't think bun's line numbers actually remap + // correctly. + } + if (!path.isAbsolute(file)) return null; + let cache = codeCache.get(file); + if (cache == null) { + try { + codeCache.set(file, cache = fs.readFileSync(file, "utf-8").split("\n")); + } catch {} + } + return cache; +} + +function parseV8OrIe(stack: string): Frame[] { + return stack + .split("\n") + .filter((line) => !!line.match(regexV8Stack)) + .map(function (line) { + let sanitizedLine = line + .replace(/^\s+/, "") + .replace(/\(eval code/g, "(") + .replace(/^.*?\s+/, ""); + + // capture and preserve the parenthesized location "(/foo/my bar.js:12:87)" in + // case it has spaces in it, as the string is split on \s+ later on + let loc = sanitizedLine.match(/ (\(.+\)$)/); + + // remove the parenthesized location from the line, if it was matched + sanitizedLine = loc ? sanitizedLine.replace(loc[0], "") : sanitizedLine; + + // if a location was matched, pass it to extractLocation() otherwise pass all sanitizedLine + // because this line doesn't have function name + let locationParts = extractLocation(loc?.[1] ?? sanitizedLine); + let functionName = (loc && sanitizedLine) || undefined; + let fileName = ["eval", ""].indexOf(locationParts[0]) > -1 + ? undefined + : locationParts[0]; + + return { + fn: functionName, + file: fileName, + line: 0 | locationParts[1], + col: 0 | locationParts[2], + } satisfies Frame; + }); +} + +function parseJscOrSpidermonkey(stack: string): Frame[] { + // Using string literal "\n" does not work in Safari. + return stack.split(/\n/g).map((source, i) => { + let fn = ""; + let file: string | null = null; + let line: number | null = null; + let col: number | null = null; + if (source.endsWith("@")) { + // Safari eval frames only have function names and nothing else + fn = source.slice(0, -1); + } else if (source.indexOf("@") === -1 && source.indexOf(":") === -1) { + // Safari eval frames only have function names and nothing else + fn = source.endsWith("@") ? source.slice(0, -1) : source; + } else { + var functionNameRegex = /((.*".+"[^@]*)?[^@]*)(?:@)/; + var matches = source.match(functionNameRegex); + var functionName = matches && matches[1] ? matches[1] : undefined; + var locationParts = extractLocation( + source.replace(functionNameRegex, ""), + ); + fn = functionName!; + file = locationParts[0]; + line = 0 | locationParts[1]; + col = 0 | locationParts[2]; + } + if (fn === "module code") fn = null; + return { + fn, + file, + line, + col, + }; + }); +} + +function extractLocation(urlLike: string) { + // Fail-fast but return locations like "(native)" + if (urlLike.indexOf(":") === -1) { + return [urlLike]; + } + + const parts: any = regexLocation.exec(urlLike.replace(/[()]/g, "")); + return [parts[1], parts[2] || undefined, parts[3] || undefined]; +} + +function nodeBuiltin(name: "node:path"): typeof import("node:path") | null; +function nodeBuiltin(name: "node:fs"): typeof import("node:fs") | null; +function nodeBuiltin(name: string) { + return globalThis.process?.getBuiltinModule(name) ?? null; +} + +import * as ansi from "lib/log/ansi.ts"; diff --git a/package-lock.json b/package-lock.json index 24bde917b2824667c012bbc01668d5f28355a296..012b678a5715c8f4cad13834f5b6cbf68772c886 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,7 +4,6 @@ "requires": true, "packages": { "": { - "name": "sitegen", "dependencies": { "@hono/node-server": "^1.14.3", "@mdx-js/mdx": "^3.1.0", @@ -15,6 +14,7 @@ "esbuild": "^0.25.5", "hls.js": "^1.6.5", "hono": "^4.7.11", + "lib": "file:lib", "marko": "^6.0.20", "msgpackr": "^1.11.5", "puppeteer": "^24.10.1", @@ -29,6 +29,7 @@ "typescript": "^5.8.3" } }, + "lib": {}, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -3008,6 +3009,10 @@ "lasso-caching-fs": "^1.0.0" } }, + "node_modules/lib": { + "resolved": "lib", + "link": true + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", diff --git a/package.json b/package.json index ae6219eb327a8cbad1e0d1501ca1588059c36ef7..ab2769027c19c7da54eb51dff4206bcb7ef9612f 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "esbuild": "^0.25.5", "hls.js": "^1.6.5", "hono": "^4.7.11", + "lib": "file:lib", "marko": "^6.0.20", "msgpackr": "^1.11.5", "puppeteer": "^24.10.1", diff --git a/run.js b/run.js index 8a5f55ec4cdd078395ce6bb2786832489241c71f..a421ea4cb2b01e5ac07c18fca9b9f9ebd24cc4e8 100644 --- a/run.js +++ b/run.js @@ -46,7 +46,14 @@ console.info = log.info; console.warn = log.warn; console.error = log.error; console.debug = log.scoped("debug"); -console.log = console.debug; +console["log"] = console.debug; + +process.on("uncaughtException", (error) => { + console.error(error); + process.exit(1); +}); + +hot.load("node:fs").readFileSync(undefined); // Load with hooks if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) { -- 2.54.0