| 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 | return stack.match(regexV8Stack) |
| 58 | ? parseV8OrIe(stack) |
| 59 | : 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 | /** |
| 71 | * convert an error into a human readable and debuggable string. |
| 72 | * |
| 73 | * this printer puts the error metadata at the top. each stack frame gets two |
| 74 | * lines: the absolute path or url, and then one line of source code. having |
| 75 | * five lines of context on just the top frame is useless, since five lines is |
| 76 | * often not enough context to understand the entire block of code. one line is |
| 77 | * all you need to remind yourself what the code on that line was doing. and if |
| 78 | * you can see every frame's source code, you can much more easily figure out |
| 79 | * what the bug is before opening the editor. |
| 80 | * |
| 81 | * to make understanding runtime code easier, `node:*` and `ext:deno` paths are |
| 82 | * resolved into GitHub source code URLs, and node builtins will also display |
| 83 | * their source code inline (via `node:fs` and `process.binding("natives")`). |
| 84 | * |
| 85 | * with zero external imports, it is safely included as a default error |
| 86 | * formatter. it is used not only in `lib/log.ts`, but also in the headless |
| 87 | * logger. |
| 88 | */ |
| 89 | export function format(error: Error, colors = false): string { |
| 90 | const { name, message, stack: _, ...payload } = error; |
| 91 | |
| 92 | let out = (colors ? ansi.fgRed : "") |
| 93 | + (name && name !== "Error" ? `[${name}] ` : "") |
| 94 | + message + (colors ? ansi.fgReset : "") + "\n"; |
| 95 | |
| 96 | out += Object.entries(payload) |
| 97 | .map(([k, v]) => |
| 98 | " " + k |
| 99 | + (colors ? ansi.style(ansi.dim, ":") : ":") |
| 100 | + " " + JSON.stringify(v) + "\n" |
| 101 | ) |
| 102 | .join(""); |
| 103 | |
| 104 | let frames = parse(error) ?? []; |
| 105 | if (node.isServer && ("Bun" in globalThis)) { |
| 106 | frames = frames.filter((frame) => |
| 107 | frame.file !== "native" || ![ |
| 108 | "moduleEvaluation", |
| 109 | "loadAndEvaluateModule", |
| 110 | "processTicksAndRejections", |
| 111 | ].includes(frame.fn ?? "") |
| 112 | ); |
| 113 | } |
| 114 | if (error instanceof AggregateError) { |
| 115 | for (const err of error.errors) { |
| 116 | out += format(err, true); |
| 117 | } |
| 118 | } |
| 119 | if (frames.length) { |
| 120 | out += frames.map((frame) => formatFrame(frame, colors)).join("\n"); |
| 121 | } |
| 122 | |
| 123 | return out + "\n"; |
| 124 | } |
| 125 | |
| 126 | /** format a single stack trace frame. see `format` for more details. */ |
| 127 | export function formatFrame(frame: Frame, colors: boolean): string { |
| 128 | let { fn, file, line, col } = frame; |
| 129 | let out = ""; |
| 130 | if (file === "native") file = null; |
| 131 | const internal = !file || (node.isServer && ( |
| 132 | file.startsWith("node:") |
| 133 | || file.startsWith("bun:") |
| 134 | || file.startsWith("internal:") |
| 135 | || file.startsWith("ext:") |
| 136 | )); |
| 137 | |
| 138 | if (file) { |
| 139 | if (file.startsWith("file://")) file = new URL(file).pathname; |
| 140 | const root = node.isServer ? getPackageRoot(file) : null; |
| 141 | |
| 142 | // filename |
| 143 | if ( |
| 144 | node.isServer |
| 145 | && internal |
| 146 | // all bun internals do not line map |
| 147 | && ("Bun" in globalThis |
| 148 | // deno transpiled internals do not line map |
| 149 | || ("Deno" in globalThis && root && root[2].endsWith(".ts"))) |
| 150 | ) { |
| 151 | line = 0; |
| 152 | } |
| 153 | |
| 154 | if (colors) { |
| 155 | if (root) { |
| 156 | const [pathTo, project] = root; |
| 157 | out += ansi.dim + pathTo |
| 158 | + ansi.mergeStyles(ansi.resetWeight + ansi.fgCyan) |
| 159 | + project + ansi.fgReset; |
| 160 | } else if (internal) { |
| 161 | out += ansi.dim; |
| 162 | } |
| 163 | out += root?.[2] ?? file; |
| 164 | } else out += root ? root.join("") : file; |
| 165 | |
| 166 | // location |
| 167 | if (line) { |
| 168 | if (colors) out += ansi.mergeStyles(ansi.dim + ansi.fgYellow); |
| 169 | if (root?.[0]?.match(/^https:\/\/github.com/)) { |
| 170 | out += "#L" + line; |
| 171 | } else { |
| 172 | out += ":"; |
| 173 | out += line; |
| 174 | if (line) { |
| 175 | out += ":"; |
| 176 | out += col; |
| 177 | } |
| 178 | } |
| 179 | if (colors) { |
| 180 | out += fn |
| 181 | ? ansi.fgReset |
| 182 | : ansi.mergeStyles(ansi.fgReset + ansi.resetWeight); |
| 183 | } |
| 184 | } else if (fn) { |
| 185 | out += ansi.dim; |
| 186 | } |
| 187 | } else { |
| 188 | if (colors) out += ansi.dim; |
| 189 | out += "[unknown source]"; |
| 190 | if (colors && !fn) out += ansi.resetWeight; |
| 191 | } |
| 192 | |
| 193 | if (fn) { |
| 194 | if (internal) { |
| 195 | out += " at " + fn + ansi.resetWeight; |
| 196 | } else { |
| 197 | out += " at " |
| 198 | + ansi.mergeStyles(ansi.resetWeight + ansi.fgCyan) |
| 199 | + fn + ansi.fgReset; |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | preview: if (node.isServer) { |
| 204 | if (!file || !line) break preview; |
| 205 | const code = node.isServer ? getSourceCode(file) : undefined; |
| 206 | if (!code) break preview; |
| 207 | const text = code[line - 1]; |
| 208 | if (!text) break preview; |
| 209 | out += "\n"; |
| 210 | if (colors) { |
| 211 | if (col) { |
| 212 | // get a selection where the error happened |
| 213 | let l = col - 1; |
| 214 | let r = col; |
| 215 | while (l > 0 && !/[a-zA-Z0-9_$\s]/.test(text[l]!)) l -= 1; |
| 216 | while (l > 1 && /[a-zA-Z0-9_$]/.test(text[l - 1]!)) l -= 1; |
| 217 | while (r < text.length && /[a-zA-Z0-9_$]/.test(text[r]!)) r += 1; |
| 218 | // expand to special cases |
| 219 | let center = text.slice(l, r); |
| 220 | if (center === "new") { |
| 221 | r += text.slice(r).match(/\s*[a-zA-Z0-9_$]+/)?.[0]?.length ?? 0; |
| 222 | } |
| 223 | // print it all red, but use dim and bold to highlight the problem |
| 224 | const left = text.slice(0, l); |
| 225 | center = text.slice(l, r); |
| 226 | const right = text.slice(r); |
| 227 | const irrelevant = internal || file?.includes("node_modules"); |
| 228 | const fg = !irrelevant ? ansi.fgRed : ""; |
| 229 | const bold = !irrelevant ? ansi.bold : ansi.dim + ansi.bold; |
| 230 | out += (left |
| 231 | ? ansi.mergeStyles(fg + ansi.dim) + left |
| 232 | + ansi.mergeStyles(ansi.resetWeight + bold) |
| 233 | : ansi.mergeStyles(fg + bold)) |
| 234 | + center + (right |
| 235 | ? ansi.mergeStyles(ansi.resetWeight + ansi.dim) |
| 236 | + right |
| 237 | : "") |
| 238 | + ansi.mergeStyles(ansi.resetWeight + ansi.fgReset); |
| 239 | } else { |
| 240 | out += text; |
| 241 | } |
| 242 | } else { |
| 243 | out += text; |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | return out; |
| 248 | } |
| 249 | |
| 250 | const existCache = new Map<string, boolean>(); |
| 251 | function getPackageRoot(absPath: string) { |
| 252 | const fs = node.builtin("fs"); |
| 253 | const path = node.builtin("path"); |
| 254 | const process = node.process; |
| 255 | if (!fs || !path || !process) return null; |
| 256 | |
| 257 | const isNodeBuiltin = absPath.startsWith("node:"); |
| 258 | if ( |
| 259 | "Bun" in globalThis |
| 260 | && (isNodeBuiltin || absPath.startsWith("internal:")) |
| 261 | ) { |
| 262 | return [ |
| 263 | `https://github.com/oven-sh/bun/blob/bun-v${process.versions.bun}/src/js/`, |
| 264 | "", |
| 265 | absPath.replace(":", "/") + ".ts", |
| 266 | ]; |
| 267 | } |
| 268 | if (isNodeBuiltin) { |
| 269 | if ("Deno" in globalThis) { |
| 270 | return [ |
| 271 | `https://github.com/denoland/deno/blob/v${process.versions.deno}/ext/node/polyfills/`, |
| 272 | "", |
| 273 | absPath.slice(5) + ".js", |
| 274 | ]; |
| 275 | } |
| 276 | return [ |
| 277 | `https://github.com/nodejs/node/blob/${process.versions.node}/lib/`, |
| 278 | "", |
| 279 | absPath.slice(5) + ".js", |
| 280 | ]; |
| 281 | } |
| 282 | if ( |
| 283 | absPath.startsWith("ext:deno_") |
| 284 | && process.versions.deno |
| 285 | // these files are transpiled without source maps. |
| 286 | && !absPath.endsWith(".ts.js") |
| 287 | ) { |
| 288 | const node = absPath.startsWith("ext:deno_node"); |
| 289 | const filename = node |
| 290 | ? absPath.slice("ext:deno_node/".length) |
| 291 | : absPath.slice("ext:deno_".length); |
| 292 | return [ |
| 293 | `https://github.com/denoland/deno/blob/v${process.versions.deno}/ext/` |
| 294 | + (node ? "node/polyfills/" : ""), |
| 295 | "", |
| 296 | node && filename.endsWith(".ts.js") ? filename.slice(0, -3) : filename, |
| 297 | ]; |
| 298 | } |
| 299 | |
| 300 | if (!path.isAbsolute(absPath)) return null; |
| 301 | let dir = path.dirname(absPath); |
| 302 | do { |
| 303 | let exists = existCache.get(dir); |
| 304 | if (exists == null) { |
| 305 | existCache.set( |
| 306 | dir, |
| 307 | exists = fs.existsSync(path.join(dir, "package.json")) |
| 308 | || fs.existsSync(path.join(dir, "deno.json")), |
| 309 | ); |
| 310 | } |
| 311 | if (exists) { |
| 312 | return [ |
| 313 | path.dirname(dir) + path.sep, |
| 314 | path.basename(dir), |
| 315 | absPath.slice(dir.length), |
| 316 | ] as const; |
| 317 | } |
| 318 | } while (dir !== (dir = path.dirname(dir))); |
| 319 | return ""; |
| 320 | } |
| 321 | |
| 322 | const codeCache = new Map<string, string[]>(); |
| 323 | function getSourceCode(file: string) { |
| 324 | const fs = node.builtin("fs"); |
| 325 | const path = node.builtin("path"); |
| 326 | if (!fs || !path) return null; |
| 327 | if (file.startsWith("node:")) { |
| 328 | // node.js has a secret binding for all the internal source codes |
| 329 | return node.binding("natives")?.[file.slice(5)]?.split("\n") ?? null; |
| 330 | } else { |
| 331 | // bun and deno do not offer a way to get source code of internal modules, |
| 332 | // probably for good reason. |
| 333 | // |
| 334 | // i originally had an idea to download and transpile the version to |
| 335 | // provide a reference and source code, but it's undocumented how deno |
| 336 | // compiles their code and i don't think bun's line numbers actually remap |
| 337 | // correctly. |
| 338 | } |
| 339 | if (!path.isAbsolute(file)) return null; |
| 340 | let cache = codeCache.get(file); |
| 341 | if (cache == null) { |
| 342 | try { |
| 343 | codeCache.set(file, cache = fs.readFileSync(file, "utf-8").split("\n")); |
| 344 | } catch {} |
| 345 | } |
| 346 | return cache; |
| 347 | } |
| 348 | |
| 349 | function parseV8OrIe(stack: string): Frame[] { |
| 350 | return stack |
| 351 | .split("\n") |
| 352 | .filter((line) => !!line.match(regexV8Stack)) |
| 353 | .map(function(line) { |
| 354 | let sanitizedLine = line |
| 355 | .replace(/^\s+/, "") |
| 356 | .replace(/\(eval code/g, "(") |
| 357 | .replace(/^.*?\s+/, ""); |
| 358 | |
| 359 | // capture and preserve the parenthesized location "(/foo/my bar.js:12:87)" in |
| 360 | // case it has spaces in it, as the string is split on \s+ later on |
| 361 | const loc = sanitizedLine.match(/ (\(.+\)$)/); |
| 362 | |
| 363 | // remove the parenthesized location from the line, if it was matched |
| 364 | sanitizedLine = loc ? sanitizedLine.replace(loc[0], "") : sanitizedLine; |
| 365 | |
| 366 | // if a location was matched, pass it to extractLocation() otherwise pass all sanitizedLine |
| 367 | // because this line doesn't have function name |
| 368 | const locationParts = extractLocation(loc?.[1] ?? sanitizedLine); |
| 369 | const functionName = (loc && sanitizedLine) || undefined; |
| 370 | const fileName = ["eval", "<anonymous>"].indexOf(locationParts[0]) > -1 |
| 371 | ? undefined |
| 372 | : locationParts[0]; |
| 373 | |
| 374 | return { |
| 375 | fn: functionName ?? null, |
| 376 | file: fileName, |
| 377 | line: 0 | locationParts[1], |
| 378 | col: 0 | locationParts[2], |
| 379 | } satisfies Frame; |
| 380 | }); |
| 381 | } |
| 382 | |
| 383 | function parseJscOrSpidermonkey(stack: string): Frame[] { |
| 384 | // Using string literal "\n" does not work in Safari. |
| 385 | return stack.split(/\n/g).map((source) => { |
| 386 | let fn: string | null = ""; |
| 387 | let file: string | null = null; |
| 388 | let line: number | null = null; |
| 389 | let col: number | null = null; |
| 390 | if (source.endsWith("@")) { |
| 391 | // Safari eval frames only have function names and nothing else |
| 392 | fn = source.slice(0, -1); |
| 393 | } else if (source.indexOf("@") === -1 && source.indexOf(":") === -1) { |
| 394 | // Safari eval frames only have function names and nothing else |
| 395 | fn = source.endsWith("@") ? source.slice(0, -1) : source; |
| 396 | } else { |
| 397 | const matches = source.match(regexFunctionName); |
| 398 | const functionName = matches && matches[1] ? matches[1] : undefined; |
| 399 | const locationParts = extractLocation( |
| 400 | source.replace(regexFunctionName, ""), |
| 401 | ); |
| 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"; |