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