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))) {