diff --git a/README.md b/README.md index ca6fba6..b448bfd 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,30 @@ console.log(captureStackTrace()) // [{ // function: undefined, // source: 'file:///code/danielroe/errx/playground/index.js', -// line: '5', -// column: '13' +// line: 5, +// column: 13 // }] ``` +### `ParsedTrace` + +```ts +interface ParsedTrace { + column?: number + function?: string + line?: number + source: string + isAsync?: boolean + isConstructor?: boolean + isEval?: boolean + isNative?: boolean +} +``` + +`function` holds the bare function name; V8's `async` and `new` prefixes are surfaced as `isAsync` and `isConstructor` instead. Frames with no resolvable location (`at Array.map ()`, `at moduleEvaluation (native)`) are kept, with `isNative` set and no `line`/`column`. For `eval` frames (`at eval (eval at fn (file.js:1:2), :3:4)`), `isEval` is set and `source`/`line`/`column` point at the innermost real file location rather than the position inside the evaluated code. + +The `is*` flags are only present when `true`. + ## 💻 Development - Clone this repository diff --git a/src/index.ts b/src/index.ts index 7f2aa68..57a491d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ const SOURCE_RE = /^(?.+):(?\d+):(?\d+)$/u * frame using string scanning rather than a regular expression, since the equivalent pattern * requires backtracking and is vulnerable to polynomial-time matching on hostile input. */ -function parseTraceLine(line: string): { function?: string, source: string } | undefined { +function parseTraceLine(line: string): ParsedFrame | undefined { let start = 0 while (start < line.length && (line[start] === ' ' || line[start] === '\t')) { start++ @@ -15,22 +15,64 @@ function parseTraceLine(line: string): { function?: string, source: string } | u return } - const rest = line.slice(start + 3) + let rest = line.slice(start + 3) + const flags: Omit = {} + + if (rest.startsWith('async ')) { + flags.isAsync = true + rest = rest.slice(6) + } + if (rest.startsWith('new ')) { + flags.isConstructor = true + rest = rest.slice(4) + } + if (rest.endsWith(')')) { const open = rest.lastIndexOf(' (') if (open > 0) { const source = rest.slice(open + 2, -1) if (source.length > 0 && !source.includes(')')) { - return { function: rest.slice(0, open), source } + return { ...flags, function: rest.slice(0, open), source } } } - return + return parseEvalTraceLine(rest, flags) } if (rest.length === 0 || /\s/u.test(rest)) { return } - return { source: rest } + return { ...flags, source: rest } +} + +/** + * Extracts the innermost real source location from an `eval` frame, such as + * `eval (eval at fn (file:1:2), :3:4)`, discarding the position within the + * evaluated code since it cannot be resolved to a file. + */ +function parseEvalTraceLine(rest: string, flags: Omit): ParsedFrame | undefined { + const evalAt = rest.lastIndexOf('eval at ') + if (evalAt < 0) { + return + } + + const open = rest.indexOf('(', evalAt) + const close = open < 0 ? -1 : rest.indexOf(')', open) + if (close < 0) { + return + } + + const source = rest.slice(open + 1, close) + if (source.length === 0) { + return + } + + const origin = rest.indexOf(' (eval at ') + return { + ...flags, + isEval: true, + function: origin > 0 ? rest.slice(0, origin) : undefined, + source, + } } export interface ParsedTrace { @@ -38,8 +80,18 @@ export interface ParsedTrace { function?: string line?: number source: string + /** Set when the frame is an `await` resumption point (`at async fn (...)`). */ + isAsync?: boolean + /** Set when the frame is a constructor call (`at new Foo (...)`). */ + isConstructor?: boolean + /** Set when the frame is inside evaluated code (`at eval (eval at fn (...), :1:1)`). */ + isEval?: boolean + /** Set when the frame has no resolvable source, i.e. `` or `native`. */ + isNative?: boolean } +type ParsedFrame = Partial & { source: string } + export function captureRawStackTrace(): string | undefined { if (!Error.captureStackTrace) { return @@ -64,14 +116,19 @@ export function parseRawStackTrace(stacktrace: string): ParsedTrace[] { if (!match?.source) { continue } - const parsed: Partial> & { source: string } = { + const parsed: ParsedFrame = { function: undefined, ...match, } const parsedSource = SOURCE_RE.exec(parsed.source)?.groups if (parsedSource) { - Object.assign(parsed, parsedSource) + parsed.source = parsedSource.source! + parsed.line = Number(parsedSource.line) + parsed.column = Number(parsedSource.column) + } + else if (parsed.source === '' || parsed.source === 'native') { + parsed.isNative = true } if (IS_ABSOLUTE_RE.test(parsed.source)) { @@ -82,13 +139,6 @@ export function parseRawStackTrace(stacktrace: string): ParsedTrace[] { continue } - for (const key of ['line', 'column'] as const) { - if (parsed[key]) { - // @ts-expect-error assigning number to string - parsed[key] = Number(parsed[key]) - } - } - trace.push(parsed as ParsedTrace) } diff --git a/test/__snapshots__/bun.json5 b/test/__snapshots__/bun.json5 index d44dac3..7ca6073 100644 --- a/test/__snapshots__/bun.json5 +++ b/test/__snapshots__/bun.json5 @@ -1,26 +1,32 @@ [ { "function": "moduleEvaluation", + "isNative": true, "source": "native", }, { "function": "moduleEvaluation", + "isNative": true, "source": "native", }, { "function": "", + "isNative": true, "source": "native", }, { "function": "asyncFunctionResume", + "isNative": true, "source": "native", }, { "function": "promiseReactionJobWithoutPromiseUnwrapAsyncContext", + "isNative": true, "source": "native", }, { "function": "promiseReactionJob", + "isNative": true, "source": "native", }, ] \ No newline at end of file diff --git a/test/index.test.ts b/test/index.test.ts index 2286990..a02c9a8 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -38,7 +38,9 @@ describe('errx', () => { }, { "column": undefined, - "function": "new Promise", + "function": "Promise", + "isConstructor": true, + "isNative": true, "line": undefined, "source": "", }, @@ -56,7 +58,9 @@ describe('errx', () => { }, { "column": undefined, - "function": "new Promise", + "function": "Promise", + "isConstructor": true, + "isNative": true, "line": undefined, "source": "", }, @@ -110,6 +114,203 @@ Error at captureStackTrace (file:///some/path:19:9) at file://${sourcePath}:59:13` +const asyncTrace = `Error: x + at asyncFn (${import.meta.url}:1:46) + at async outer (${import.meta.url}:2:26) + at async node:internal/modules/esm/loader:643:26 + at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)` + +const constructorTrace = `Error: ctor + at new Foo (${import.meta.url}:5:35) + at ${import.meta.url}:6:7` + +const nativeTrace = `Error: m + at ${import.meta.url}:8:29 + at Array.map () + at JSON.parse () + at new Promise () + at ` + +const evalTrace = `Error: ev + at eval (eval at evalHost (${import.meta.url}:10:30), :1:20) + at eval (eval at inner (eval at outer (${import.meta.url}:11:3), :2:9), :1:1) + at evalHost (${import.meta.url}:10:30)` + +const schemeTrace = `Error + at f (file:///x/y.js?v=abc:1:2) + at g (webpack://src/x.js:1:2) + at h (virtual:mod:1:2) + at Object. (/x/y.js:16:1) + at Module._compile (node:internal/modules/cjs/loader:1376:14) + at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:135:12) + at internal/main/run_main_module:28:49` + +describe('frame metadata', () => { + it('should unwrap async frames', () => { + expect(parseRawStackTrace(asyncTrace)).toMatchInlineSnapshot(` + [ + { + "column": 46, + "function": "asyncFn", + "line": 1, + "source": "${import.meta.url}", + }, + { + "column": 26, + "function": "outer", + "isAsync": true, + "line": 2, + "source": "${import.meta.url}", + }, + { + "column": 26, + "function": undefined, + "isAsync": true, + "line": 643, + "source": "node:internal/modules/esm/loader", + }, + { + "column": 5, + "function": "asyncRunEntryPointWithESMLoader", + "isAsync": true, + "line": 101, + "source": "node:internal/modules/run_main", + }, + ] + `) + }) + + it('should unwrap constructor frames', () => { + expect(parseRawStackTrace(constructorTrace)).toMatchInlineSnapshot(` + [ + { + "column": 35, + "function": "Foo", + "isConstructor": true, + "line": 5, + "source": "${import.meta.url}", + }, + { + "column": 7, + "function": undefined, + "line": 6, + "source": "${import.meta.url}", + }, + ] + `) + }) + + it('should preserve frames without a resolvable source', () => { + expect(parseRawStackTrace(nativeTrace)).toMatchInlineSnapshot(` + [ + { + "column": 29, + "function": undefined, + "line": 8, + "source": "${import.meta.url}", + }, + { + "function": "Array.map", + "isNative": true, + "source": "", + }, + { + "function": "JSON.parse", + "isNative": true, + "source": "", + }, + { + "function": "Promise", + "isConstructor": true, + "isNative": true, + "source": "", + }, + { + "function": undefined, + "isNative": true, + "source": "", + }, + ] + `) + }) + + it('should preserve eval frames, using the innermost real source', () => { + expect(parseRawStackTrace(evalTrace)).toMatchInlineSnapshot(` + [ + { + "column": 30, + "function": "eval", + "isEval": true, + "line": 10, + "source": "${import.meta.url}", + }, + { + "column": 3, + "function": "eval", + "isEval": true, + "line": 11, + "source": "${import.meta.url}", + }, + { + "column": 30, + "function": "evalHost", + "line": 10, + "source": "${import.meta.url}", + }, + ] + `) + }) + + it('should round-trip sources with query strings and non-file schemes', () => { + expect(parseRawStackTrace(schemeTrace)).toMatchInlineSnapshot(` + [ + { + "column": 2, + "function": "f", + "line": 1, + "source": "file:///x/y.js?v=abc", + }, + { + "column": 2, + "function": "g", + "line": 1, + "source": "webpack://src/x.js", + }, + { + "column": 2, + "function": "h", + "line": 1, + "source": "virtual:mod", + }, + { + "column": 1, + "function": "Object.", + "line": 16, + "source": "file:///x/y.js", + }, + { + "column": 14, + "function": "Module._compile", + "line": 1376, + "source": "node:internal/modules/cjs/loader", + }, + { + "column": 12, + "function": "Function.executeUserEntryPoint [as runMain]", + "line": 135, + "source": "node:internal/modules/run_main", + }, + { + "column": 49, + "function": undefined, + "line": 28, + "source": "internal/main/run_main_module", + }, + ] + `) + }) +}) + describe('parseStackTrace', () => { it('parses vitest', () => { expect(parseRawStackTrace(vitestTrace)).toMatchFileSnapshot('__snapshots__/vitest.json5')