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` */
29export 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 */
54export 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 */
66export 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 */
89export 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. */
127export 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
250const existCache = new Map<string, boolean>();
251function 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
322const codeCache = new Map<string, string[]>();
323function 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
349function 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
383function 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
417function 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
427const regexFunctionName = /((.*".+"[^@]*)?[^@]*)(?:@)/;
428const regexV8Stack = /^\s*at .*(\S+:\d+|\(native\))/m;
429const regexLocation = /(.+?)(?::(\d+))?(?::(\d+))?$/;
430
431import * as node from "../node.ts";
432import * as ansi from "../string/ansi.ts";