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 // 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 */
66export 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 */
71export 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 */
92export 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. */
135export 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
258const existCache = new Map<string, boolean>();
259function 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
324const codeCache = new Map<string, string[]>();
325function 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
351function 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
385function 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
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";