| 1 | /** |
| 2 | * the default stack trace formatters in node.js, deno, and bun are all |
| 3 | * extremely low quality. they don't highlight the most important information |
| 4 | * and spend too much space on showing source code. this module provides tools |
| 5 | * to {@linkcode parse} and {@linkcode format} errors. see {@linkcode format}'s |
| 6 | * comment for design decisions. |
| 7 | * |
| 8 | * to get a taste of the formatter: |
| 9 | * ```ts |
| 10 | * try { |
| 11 | * processFile(undefined); |
| 12 | * } catch (err) { |
| 13 | * console.error(stack.parse(err)); |
| 14 | * console.error(stack.format(err, true)); |
| 15 | * } |
| 16 | * |
| 17 | * function processFile(file) { |
| 18 | * return fs.createReadStream(file); |
| 19 | * } |
| 20 | * |
| 21 | * import * as fs from "node:fs"; |
| 22 | * import * as stack from "@clo/lib/log/stack"; |
| 23 | * ``` |
| 24 | * |
| 25 | * @module |
| 26 | */ |
| 27 | |
| 28 | /** retrieved from `parse` */ |
| 29 | export interface Frame { |
| 30 | /** `null` if top-level code or unnamed function. */ |
| 31 | fn: string | null; |
| 32 | /** `null` if from a built in without a module name. */ |
| 33 | file: string | null; |
| 34 | /** `null` if the source provider doesn't have a source location. */ |
| 35 | line: number | null; |
| 36 | /** almost certainly defined if `line` is, but the runtime may omit it */ |
| 37 | col: number | null; |
| 38 | } |
| 39 | |
| 40 | /** |
| 41 | * parse a error stack trace from either v8 (node, deno, chrome), |
| 42 | * jsc (safari, bun), or spidermonkey (firefox). |
| 43 | * |
| 44 | * this function is derived from clover's work on bun's DevServer, which she |
| 45 | * derived from stacktracejs. this file is more refined than those two: |
| 46 | * https://github.com/oven-sh/bun/blob/b5f31a6ee2f52ea67eabeb61f6e6e71215d55b26/src/bake/client/stack-trace.ts |
| 47 | * https://github.com/stacktracejs/error-stack-parser/blob/9f33c224b5d7b607755eb277f9d51fcdb7287e24/error-stack-parser.js |
| 48 | * |
| 49 | * supports parsing v8, JavaScriptCore, SpiderMonkey, and IE error stack |
| 50 | * frames, effectively working in every JavaScript environment. leaves |
| 51 | * filesystem urls and paths intact (won't convert 'file:///' to or from a file |
| 52 | * path) |
| 53 | */ |
| 54 | export function parse(error: Error | string): Frame[] | null { |
| 55 | const stack = (error as Error)?.stack ?? error; |
| 56 | if (typeof stack !== "string") return null; |
| 57 | // a v8 trace begins with the message, which is all there is without frames |
| 58 | if (typeof error !== "string" && stack === String(error)) return []; |
| 59 | return stack.match(regexV8Stack) ? parseV8OrIe(stack) : parseJscOrSpidermonkey(stack); |
| 60 | } |
| 61 | |
| 62 | /** |
| 63 | * capture and store for later, or use to trace the caller. with the |
| 64 | * default `trimStart` of `2`, the caller file is `capture()[0]?.file`. |
| 65 | */ |
| 66 | export function capture(trimStart = 2): Frame[] { |
| 67 | return parse(new Error())?.slice(trimStart) ?? []; |
| 68 | } |
| 69 | |
| 70 | /** an error with a method by this key prints itself, given whether to use colors */ |
| 71 | export const formatSymbol: unique symbol = Symbol.for("@clo/lib/log/stack.format"); |
| 72 | |
| 73 | /** |
| 74 | * convert an error into a human readable and debuggable string. |
| 75 | * |
| 76 | * this printer puts the error metadata at the top. each stack frame gets two |
| 77 | * lines: the absolute path or url, and then one line of source code. having |
| 78 | * five lines of context on just the top frame is useless, since five lines is |
| 79 | * often not enough context to understand the entire block of code. one line is |
| 80 | * all you need to remind yourself what the code on that line was doing. and if |
| 81 | * you can see every frame's source code, you can much more easily figure out |
| 82 | * what the bug is before opening the editor. |
| 83 | * |
| 84 | * to make understanding runtime code easier, `node:*` and `ext:deno` paths are |
| 85 | * resolved into GitHub source code URLs, and node builtins will also display |
| 86 | * their source code inline (via `node:fs` and `process.binding("natives")`). |
| 87 | * |
| 88 | * with zero external imports, it is safely included as a default error |
| 89 | * formatter. it is used not only in `lib/log.ts`, but also in the headless |
| 90 | * logger. |
| 91 | */ |
| 92 | export function format(error: Error, colors = false): string { |
| 93 | if (formatSymbol in error) { |
| 94 | return (error as Error & { [formatSymbol](colors: boolean): string })[formatSymbol](colors); |
| 95 | } |
| 96 | const { name, message, stack: _, ...payload } = error; |
| 97 | |
| 98 | let out = |
| 99 | (colors ? ansi.fgRed : "") + |
| 100 | (name && name !== "Error" && name !== "TypeError" ? `[${name}] ` : "") + |
| 101 | message + |
| 102 | (colors ? ansi.fgReset : "") + |
| 103 | "\n"; |
| 104 | |
| 105 | out += Object.entries(payload) |
| 106 | .map( |
| 107 | ([k, v]) => |
| 108 | " " + k + (colors ? ansi.style(ansi.dim, ":") : ":") + " " + JSON.stringify(v) + "\n", |
| 109 | ) |
| 110 | .join(""); |
| 111 | |
| 112 | let frames = parse(error) ?? []; |
| 113 | if (node.isServer && "Bun" in globalThis) { |
| 114 | frames = frames.filter( |
| 115 | (frame) => |
| 116 | frame.file !== "native" || |
| 117 | !["moduleEvaluation", "loadAndEvaluateModule", "processTicksAndRejections"].includes( |
| 118 | frame.fn ?? "", |
| 119 | ), |
| 120 | ); |
| 121 | } |
| 122 | if (error instanceof AggregateError) { |
| 123 | for (const err of error.errors) { |
| 124 | out += format(err, colors); |
| 125 | } |
| 126 | } |
| 127 | if (frames.length) { |
| 128 | out += frames.map((frame) => formatFrame(frame, colors)).join("\n") + "\n"; |
| 129 | } |
| 130 | |
| 131 | return out; |
| 132 | } |
| 133 | |
| 134 | /** format a single stack trace frame. see `format` for more details. */ |
| 135 | export function formatFrame(frame: Frame, colors: boolean): string { |
| 136 | let { fn, file, line, col } = frame; |
| 137 | let out = ""; |
| 138 | if (file === "native") file = null; |
| 139 | const internal = |
| 140 | !file || |
| 141 | (node.isServer && |
| 142 | (file.startsWith("node:") || |
| 143 | file.startsWith("bun:") || |
| 144 | file.startsWith("internal:") || |
| 145 | file.startsWith("ext:"))); |
| 146 | |
| 147 | if (file) { |
| 148 | if (file.startsWith("file://")) file = new URL(file).pathname; |
| 149 | const root = node.isServer ? getPackageRoot(file) : null; |
| 150 | |
| 151 | // filename |
| 152 | if ( |
| 153 | node.isServer && |
| 154 | internal && |
| 155 | // all bun internals do not line map |
| 156 | ("Bun" in globalThis || |
| 157 | // deno transpiled internals do not line map |
| 158 | ("Deno" in globalThis && root && root[2].endsWith(".ts"))) |
| 159 | ) { |
| 160 | line = 0; |
| 161 | } |
| 162 | |
| 163 | if (colors) { |
| 164 | if (root) { |
| 165 | const [pathTo, project] = root; |
| 166 | out += |
| 167 | ansi.dim + |
| 168 | pathTo + |
| 169 | ansi.mergeStyles(ansi.resetWeight + ansi.fgCyan) + |
| 170 | project + |
| 171 | ansi.fgReset; |
| 172 | } else if (internal) { |
| 173 | out += ansi.dim; |
| 174 | } |
| 175 | out += root?.[2] ?? file; |
| 176 | } else out += root ? root.join("") : file; |
| 177 | |
| 178 | // location |
| 179 | if (line) { |
| 180 | if (colors) out += ansi.mergeStyles(ansi.dim + ansi.fgYellow); |
| 181 | if (root?.[0]?.match(/^https:\/\/github.com/)) { |
| 182 | out += "#L" + line; |
| 183 | } else { |
| 184 | out += ":"; |
| 185 | out += line; |
| 186 | if (line) { |
| 187 | out += ":"; |
| 188 | out += col; |
| 189 | } |
| 190 | } |
| 191 | if (colors) { |
| 192 | out += fn ? ansi.fgReset : ansi.mergeStyles(ansi.fgReset + ansi.resetWeight); |
| 193 | } |
| 194 | } else if (fn) { |
| 195 | out += ansi.dim; |
| 196 | } |
| 197 | } else { |
| 198 | if (colors) out += ansi.dim; |
| 199 | out += "[unknown source]"; |
| 200 | if (colors && !fn) out += ansi.resetWeight; |
| 201 | } |
| 202 | |
| 203 | if (fn) { |
| 204 | if (!colors) { |
| 205 | out += " at " + fn; |
| 206 | } else if (internal) { |
| 207 | out += " at " + fn + ansi.resetWeight; |
| 208 | } else { |
| 209 | out += " at " + ansi.mergeStyles(ansi.resetWeight + ansi.fgCyan) + fn + ansi.fgReset; |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | preview: if (node.isServer) { |
| 214 | if (!file || !line) break preview; |
| 215 | const code = node.isServer ? getSourceCode(file) : undefined; |
| 216 | if (!code) break preview; |
| 217 | const text = code[line - 1]; |
| 218 | if (!text) break preview; |
| 219 | out += "\n"; |
| 220 | if (colors) { |
| 221 | if (col) { |
| 222 | // get a selection where the error happened |
| 223 | let l = col - 1; |
| 224 | let r = col; |
| 225 | while (l > 0 && !/[a-zA-Z0-9_$\s]/.test(text[l]!)) l -= 1; |
| 226 | while (l > 1 && /[a-zA-Z0-9_$]/.test(text[l - 1]!)) l -= 1; |
| 227 | while (r < text.length && /[a-zA-Z0-9_$]/.test(text[r]!)) r += 1; |
| 228 | // expand to special cases |
| 229 | let center = text.slice(l, r); |
| 230 | if (center === "new") { |
| 231 | r += text.slice(r).match(/\s*[a-zA-Z0-9_$]+/)?.[0]?.length ?? 0; |
| 232 | } |
| 233 | // print it all red, but use dim and bold to highlight the problem |
| 234 | const left = text.slice(0, l); |
| 235 | center = text.slice(l, r); |
| 236 | const right = text.slice(r); |
| 237 | const irrelevant = internal || file?.includes("node_modules"); |
| 238 | const fg = !irrelevant ? ansi.fgRed : ""; |
| 239 | const bold = !irrelevant ? ansi.bold : ansi.dim + ansi.bold; |
| 240 | out += |
| 241 | (left |
| 242 | ? ansi.mergeStyles(fg + ansi.dim) + left + ansi.mergeStyles(ansi.resetWeight + bold) |
| 243 | : ansi.mergeStyles(fg + bold)) + |
| 244 | center + |
| 245 | (right ? ansi.mergeStyles(ansi.resetWeight + ansi.dim) + right : "") + |
| 246 | ansi.mergeStyles(ansi.resetWeight + ansi.fgReset); |
| 247 | } else { |
| 248 | out += text; |
| 249 | } |
| 250 | } else { |
| 251 | out += text; |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | return out; |
| 256 | } |
| 257 | |
| 258 | const existCache = new Map<string, boolean>(); |
| 259 | function getPackageRoot(absPath: string) { |
| 260 | const fs = node.builtin("fs"); |
| 261 | const path = node.builtin("path"); |
| 262 | const process = node.process; |
| 263 | if (!fs || !path || !process) return null; |
| 264 | |
| 265 | const isNodeBuiltin = absPath.startsWith("node:"); |
| 266 | if ("Bun" in globalThis && (isNodeBuiltin || absPath.startsWith("internal:"))) { |
| 267 | return [ |
| 268 | `https://github.com/oven-sh/bun/blob/bun-v${process.versions.bun}/src/js/`, |
| 269 | "", |
| 270 | absPath.replace(":", "/") + ".ts", |
| 271 | ]; |
| 272 | } |
| 273 | if (isNodeBuiltin) { |
| 274 | if ("Deno" in globalThis) { |
| 275 | return [ |
| 276 | `https://github.com/denoland/deno/blob/v${process.versions.deno}/ext/node/polyfills/`, |
| 277 | "", |
| 278 | absPath.slice(5) + ".js", |
| 279 | ]; |
| 280 | } |
| 281 | return [ |
| 282 | `https://github.com/nodejs/node/blob/${process.versions.node}/lib/`, |
| 283 | "", |
| 284 | absPath.slice(5) + ".js", |
| 285 | ]; |
| 286 | } |
| 287 | if ( |
| 288 | absPath.startsWith("ext:deno_") && |
| 289 | process.versions.deno && |
| 290 | // these files are transpiled without source maps. |
| 291 | !absPath.endsWith(".ts.js") |
| 292 | ) { |
| 293 | const node = absPath.startsWith("ext:deno_node"); |
| 294 | const filename = node |
| 295 | ? absPath.slice("ext:deno_node/".length) |
| 296 | : absPath.slice("ext:deno_".length); |
| 297 | return [ |
| 298 | `https://github.com/denoland/deno/blob/v${process.versions.deno}/ext/` + |
| 299 | (node ? "node/polyfills/" : ""), |
| 300 | "", |
| 301 | node && filename.endsWith(".ts.js") ? filename.slice(0, -3) : filename, |
| 302 | ]; |
| 303 | } |
| 304 | |
| 305 | if (!path.isAbsolute(absPath)) return null; |
| 306 | let dir = path.dirname(absPath); |
| 307 | do { |
| 308 | let exists = existCache.get(dir); |
| 309 | if (exists == null) { |
| 310 | existCache.set( |
| 311 | dir, |
| 312 | (exists = |
| 313 | fs.existsSync(path.join(dir, "package.json")) || |
| 314 | fs.existsSync(path.join(dir, "deno.json"))), |
| 315 | ); |
| 316 | } |
| 317 | if (exists) { |
| 318 | return [path.dirname(dir) + path.sep, path.basename(dir), absPath.slice(dir.length)] as const; |
| 319 | } |
| 320 | } while (dir !== (dir = path.dirname(dir))); |
| 321 | return ""; |
| 322 | } |
| 323 | |
| 324 | const codeCache = new Map<string, string[]>(); |
| 325 | function getSourceCode(file: string) { |
| 326 | const fs = node.builtin("fs"); |
| 327 | const path = node.builtin("path"); |
| 328 | if (!fs || !path) return null; |
| 329 | if (file.startsWith("node:")) { |
| 330 | // node.js has a secret binding for all the internal source codes |
| 331 | return node.binding("natives")?.[file.slice(5)]?.split("\n") ?? null; |
| 332 | } else { |
| 333 | // bun and deno do not offer a way to get source code of internal modules, |
| 334 | // probably for good reason. |
| 335 | // |
| 336 | // i originally had an idea to download and transpile the version to |
| 337 | // provide a reference and source code, but it's undocumented how deno |
| 338 | // compiles their code and i don't think bun's line numbers actually remap |
| 339 | // correctly. |
| 340 | } |
| 341 | if (!path.isAbsolute(file)) return null; |
| 342 | let cache = codeCache.get(file); |
| 343 | if (cache == null) { |
| 344 | try { |
| 345 | codeCache.set(file, (cache = fs.readFileSync(file, "utf-8").split("\n"))); |
| 346 | } catch {} |
| 347 | } |
| 348 | return cache; |
| 349 | } |
| 350 | |
| 351 | function parseV8OrIe(stack: string): Frame[] { |
| 352 | return stack |
| 353 | .split("\n") |
| 354 | .filter((line) => !!line.match(regexV8Stack)) |
| 355 | .map(function (line) { |
| 356 | let sanitizedLine = line |
| 357 | .replace(/^\s+/, "") |
| 358 | .replace(/\(eval code/g, "(") |
| 359 | .replace(/^.*?\s+/, ""); |
| 360 | |
| 361 | // capture and preserve the parenthesized location "(/foo/my bar.js:12:87)" in |
| 362 | // case it has spaces in it, as the string is split on \s+ later on |
| 363 | const loc = sanitizedLine.match(/ (\(.+\)$)/); |
| 364 | |
| 365 | // remove the parenthesized location from the line, if it was matched |
| 366 | sanitizedLine = loc ? sanitizedLine.replace(loc[0], "") : sanitizedLine; |
| 367 | |
| 368 | // if a location was matched, pass it to extractLocation() otherwise pass all sanitizedLine |
| 369 | // because this line doesn't have function name |
| 370 | // an anonymous async frame is "async <location>" |
| 371 | const locationParts = extractLocation(loc?.[1] ?? sanitizedLine.replace(/^async /, "")); |
| 372 | const functionName = (loc && sanitizedLine) || undefined; |
| 373 | const fileName = |
| 374 | ["eval", "<anonymous>"].indexOf(locationParts[0]) > -1 ? undefined : locationParts[0]; |
| 375 | |
| 376 | return { |
| 377 | fn: functionName ?? null, |
| 378 | file: fileName, |
| 379 | line: 0 | locationParts[1], |
| 380 | col: 0 | locationParts[2], |
| 381 | } satisfies Frame; |
| 382 | }); |
| 383 | } |
| 384 | |
| 385 | function parseJscOrSpidermonkey(stack: string): Frame[] { |
| 386 | // Using string literal "\n" does not work in Safari. |
| 387 | return stack.split(/\n/g).map((source) => { |
| 388 | let fn: string | null = ""; |
| 389 | let file: string | null = null; |
| 390 | let line: number | null = null; |
| 391 | let col: number | null = null; |
| 392 | if (source.endsWith("@")) { |
| 393 | // Safari eval frames only have function names and nothing else |
| 394 | fn = source.slice(0, -1); |
| 395 | } else if (source.indexOf("@") === -1 && source.indexOf(":") === -1) { |
| 396 | // Safari eval frames only have function names and nothing else |
| 397 | fn = source.endsWith("@") ? source.slice(0, -1) : source; |
| 398 | } else { |
| 399 | const matches = source.match(regexFunctionName); |
| 400 | const functionName = matches && matches[1] ? matches[1] : undefined; |
| 401 | const locationParts = extractLocation(source.replace(regexFunctionName, "")); |
| 402 | fn = functionName!; |
| 403 | file = locationParts[0]; |
| 404 | line = 0 | locationParts[1]; |
| 405 | col = 0 | locationParts[2]; |
| 406 | } |
| 407 | if (fn === "module code") fn = null; |
| 408 | return { |
| 409 | fn, |
| 410 | file, |
| 411 | line, |
| 412 | col, |
| 413 | }; |
| 414 | }); |
| 415 | } |
| 416 | |
| 417 | function extractLocation(urlLike: string) { |
| 418 | // Fail-fast but return locations like "(native)" |
| 419 | if (urlLike.indexOf(":") === -1) { |
| 420 | return [urlLike]; |
| 421 | } |
| 422 | |
| 423 | const parts: any = regexLocation.exec(urlLike.replace(/[()]/g, "")); |
| 424 | return [parts[1], parts[2] || undefined, parts[3] || undefined]; |
| 425 | } |
| 426 | |
| 427 | const regexFunctionName = /((.*".+"[^@]*)?[^@]*)(?:@)/; |
| 428 | const regexV8Stack = /^\s*at .*(\S+:\d+|\(native\))/m; |
| 429 | const regexLocation = /(.+?)(?::(\d+))?(?::(\d+))?$/; |
| 430 | |
| 431 | import * as node from "../node.ts"; |
| 432 | import * as ansi from "../string/ansi.ts"; |