diff --git a/.gitignore b/.gitignore index 3f31ac2..0489afb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ *.un~ /node_modules + +.DS_Store diff --git a/Readme.md b/Readme.md index 0a73ce7..96525a0 100644 --- a/Readme.md +++ b/Readme.md @@ -41,8 +41,8 @@ certain properties can be retrieved with it as noted in the API docs below. When parsing an `err.stack` that has crossed the event loop boundary, a `CallSite` object is created whose `getFileName()` returns the full dashed separator line from the stack, including any leading whitespace such as -indentation. All other methods of the event loop boundary call site return -`null`. +indentation. The other getter-style methods of the event loop boundary call site +return `null`. Historically this behavior was often observed together with [long-stack-traces](https://github.com/tlrobinson/long-stack-traces), but that package is unmaintained. This module does not depend on it and still supports parsing dashed event-loop boundary markers when diff --git a/__tests__/get-test.js b/__tests__/get-test.js index 51dbe14..735cef5 100644 --- a/__tests__/get-test.js +++ b/__tests__/get-test.js @@ -1,6 +1,6 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { get } from "../index.js"; +import { get, parse } from "../index.js"; describe("get", () => { it("basic", () => { @@ -48,4 +48,34 @@ describe("get", () => { })(); })(); }); + + // Verification for https://github.com/felixge/node-stack-trace/issues/25 + // V8 async stack traces (enabled by default since Node 12) ensure async/await + // callers appear in the captured stack. + it("async/await stack traces include caller frames", async () => { + // Verify issue #25: parse() handles async/await stack frames. We throw across + // an actual async suspension point (await Promise.resolve()) so the stack + // genuinely requires V8 async-stack-trace reconstruction. + async function innerAsync() { + await Promise.resolve(); // cross a real async boundary before throwing + throw new Error('async trace'); + } + async function outerAsync() { + await innerAsync(); + } + + let trace = []; + try { + await outerAsync(); + } catch (err) { + trace = parse(err); + } + + const hasInner = trace.some(t => t.getFunctionName() === 'innerAsync'); + // V8 async frames are prefixed with 'async ' in the stack string, so the + // parsed function name is "async outerAsync" — match both forms. + const hasOuter = trace.some(t => (t.getFunctionName() || '').includes('outerAsync')); + assert.strictEqual(hasInner, true, 'should include innerAsync frame'); + assert.strictEqual(hasOuter, true, 'should include outerAsync frame'); + }); }); \ No newline at end of file diff --git a/__tests__/long-stack-trace-test.js b/__tests__/long-stack-trace-test.js index 45b1650..6acc47d 100644 --- a/__tests__/long-stack-trace-test.js +++ b/__tests__/long-stack-trace-test.js @@ -32,5 +32,12 @@ describe("long stack trace", () => { assert.notStrictEqual(boundary, undefined); assert.match(boundary.getFileName(), /-----/); + assert.strictEqual(boundary.getFunctionName(), null); + assert.strictEqual(boundary.getMethodName(), null); + assert.strictEqual(boundary.getTypeName(), null); + assert.strictEqual(boundary.getLineNumber(), null); + assert.strictEqual(boundary.getColumnNumber(), null); + assert.strictEqual(boundary.getEvalOrigin(), null); + assert.strictEqual(boundary.isNative(), null); }); }); \ No newline at end of file diff --git a/__tests__/parse-test.js b/__tests__/parse-test.js index 601d638..fdea75d 100644 --- a/__tests__/parse-test.js +++ b/__tests__/parse-test.js @@ -15,6 +15,23 @@ describe("parse", () => { assert.strictEqual(trace[1].getFileName(), "timers.js"); }); + // Regression test for https://github.com/felixge/node-stack-trace/issues/13 + it("[object Object] as type name in function", () => { + const err = {}; + err.stack = + 'Error: Could not do something\n' + + ' at [object Object].foo.bar (foo.js:1:2)\n'; + + const trace = parse(err); + assert.strictEqual(trace[0].getFileName(), 'foo.js'); + assert.strictEqual(trace[0].getFunctionName(), '[object Object].foo.bar'); + assert.strictEqual(trace[0].getTypeName(), '[object Object].foo'); + assert.strictEqual(trace[0].getMethodName(), 'bar'); + assert.strictEqual(trace[0].getLineNumber(), 1); + assert.strictEqual(trace[0].getColumnNumber(), 2); + assert.strictEqual(trace[0].isNative(), false); + }); + it("basic", () => { (function testBasic() { const err = new Error('something went wrong'); @@ -117,14 +134,10 @@ describe("parse", () => { userFrames++; } - function compare(method, exceptions) { - let realValue = real[method](); + function compare(method) { + const realValue = real[method](); const parsedValue = parsed[method](); - if (exceptions && typeof exceptions[i] != 'undefined') { - realValue = exceptions[i]; - } - assert.strictEqual(realValue, parsedValue); } @@ -212,4 +225,304 @@ describe("parse", () => { assert.strictEqual(callSite0.getColumnNumber(), 14); assert.strictEqual(callSite0.isNative(), false); }); + + // --------------------------------------------------------------------------- + // Issue #29: SyntaxError source location + // --------------------------------------------------------------------------- + // When Node.js encounters a SyntaxError in a CJS module (via require()), + // V8 prepends the source location as the very first line of err.stack: + // + // /path/to/file.cjs:1 <- first line: file:lineNumber + // const x = @invalid; <- offending code + // ^ <- pointer + // <- blank line + // SyntaxError: Invalid or unexpected token + // at wrapSafe (node:internal/modules/cjs/loader:1762:18) + // at Module._compile (node:internal/modules/cjs/loader:1803:20) + // + // This format is produced by Node 20+ (verified on v25.9.0). + // ESM SyntaxErrors produce a standard "SyntaxError: message" first line instead + // (no prepended source location), so no change is needed for the ESM case. + // + // CJS SyntaxError - fixture matches actual Node 20+ output + it("SyntaxError CJS: source location captured as first frame", () => { + // Fixture derived from real Node 20+/25 CJS SyntaxError stack: + // require('/path/to/bad.cjs') where bad.cjs contains "const x = @invalid;" + const err = {}; + err.stack = + '/path/to/bad.cjs:1\n' + + 'const x = @invalid;\n' + + ' ^\n' + + '\n' + + 'SyntaxError: Invalid or unexpected token\n' + + ' at wrapSafe (node:internal/modules/cjs/loader:1762:18)\n' + + ' at Module._compile (node:internal/modules/cjs/loader:1803:20)'; + + const trace = parse(err); + + // Frame 0: the source location line + assert.strictEqual(trace[0].getFileName(), '/path/to/bad.cjs'); + assert.strictEqual(trace[0].getLineNumber(), 1); + assert.strictEqual(trace[0].getColumnNumber(), null); + assert.strictEqual(trace[0].getFunctionName(), null); + assert.strictEqual(trace[0].getTypeName(), null); + assert.strictEqual(trace[0].getMethodName(), null); + assert.strictEqual(trace[0].isNative(), false); + + // Frame 1+: normal at-frames + assert.strictEqual(trace[1].getFunctionName(), 'wrapSafe'); + assert.strictEqual(trace[1].getFileName(), 'node:internal/modules/cjs/loader'); + assert.strictEqual(trace[1].getLineNumber(), 1762); + assert.strictEqual(trace[1].getColumnNumber(), 18); + assert.strictEqual(trace[2].getFunctionName(), 'Module._compile'); + assert.strictEqual(trace[2].getFileName(), 'node:internal/modules/cjs/loader'); + assert.strictEqual(trace[2].getLineNumber(), 1803); + }); + + // CJS SyntaxError with column in source location + it("SyntaxError CJS: source location with column number captured", () => { + const err = {}; + err.stack = + '/path/to/bad.cjs:22:5\n' + + 'unexpected code here\n' + + ' ^\n' + + '\n' + + 'SyntaxError: Unexpected identifier\n' + + ' at wrapSafe (node:internal/modules/cjs/loader:1762:18)'; + + const trace = parse(err); + + assert.strictEqual(trace[0].getFileName(), '/path/to/bad.cjs'); + assert.strictEqual(trace[0].getLineNumber(), 22); + assert.strictEqual(trace[0].getColumnNumber(), 5); + assert.strictEqual(trace[0].getFunctionName(), null); + assert.strictEqual(trace[0].isNative(), false); + + assert.strictEqual(trace[1].getFunctionName(), 'wrapSafe'); + assert.strictEqual(trace[1].getLineNumber(), 1762); + }); + + // columnNumber of 0 must not be coerced to null (0 is falsy) + it("SyntaxError CJS: column number 0 is preserved (not coerced to null)", () => { + const err = {}; + err.stack = + '/path/to/bad.cjs:1:0\n' + + 'SyntaxError: Unexpected token\n' + + ' at wrapSafe (node:internal/modules/cjs/loader:1762:18)'; + + const trace = parse(err); + + assert.strictEqual(trace[0].getFileName(), '/path/to/bad.cjs'); + assert.strictEqual(trace[0].getLineNumber(), 1); + assert.strictEqual(trace[0].getColumnNumber(), 0, 'columnNumber 0 must not be coerced to null'); + }); + it("SyntaxError CJS: Windows drive-letter path captured", () => { + // Windows CJS SyntaxError: C:\path\to\file.cjs:15 + const err = {}; + err.stack = + 'C:\\Users\\dev\\project\\index.cjs:15\n' + + 'const x = @invalid;\n' + + ' ^\n' + + '\n' + + 'SyntaxError: Invalid or unexpected token\n' + + ' at wrapSafe (node:internal/modules/cjs/loader:1762:18)'; + + const trace = parse(err); + + assert.strictEqual(trace[0].getFileName(), 'C:\\Users\\dev\\project\\index.cjs'); + assert.strictEqual(trace[0].getLineNumber(), 15); + assert.strictEqual(trace[0].getColumnNumber(), null); + assert.strictEqual(trace[0].getFunctionName(), null); + + assert.strictEqual(trace[1].getFunctionName(), 'wrapSafe'); + }); + + // file:// source location (possible in some environments) + // Although Node 20+ CJS SyntaxErrors do not produce file:// first lines in practice, + // the parser should handle this form correctly and not exclude it. + it("file:// source location is captured correctly (defensive)", () => { + const err = {}; + err.stack = + 'file:///path/to/bad.js:10\n' + + 'bad code;\n' + + '^\n' + + '\n' + + 'SyntaxError: Unexpected token\n' + + ' at wrapSafe (node:internal/modules/cjs/loader:1762:18)'; + + const trace = parse(err); + + assert.strictEqual(trace[0].getFileName(), 'file:///path/to/bad.js'); + assert.strictEqual(trace[0].getLineNumber(), 10); + assert.strictEqual(trace[0].getColumnNumber(), null); + assert.strictEqual(trace[0].getFunctionName(), null); + assert.strictEqual(trace[0].isNative(), false); + assert.strictEqual(trace[1].getFunctionName(), 'wrapSafe'); + }); + + // ESM SyntaxError produces a standard first line — no source loc frame + // Verified on Node 25.9: "import('/tmp/bad.mjs')" where bad.mjs has invalid syntax + // produces: "SyntaxError: Invalid or unexpected token\n at compileSourceTextModule..." + // The parse() output should be the at-frames only, no prepended source loc frame. + it("ESM SyntaxError: standard message first line, no source loc prepended", () => { + // Actual ESM SyntaxError format from Node 20+ (no source location prefix line) + const err = {}; + err.stack = + 'SyntaxError: Invalid or unexpected token\n' + + ' at compileSourceTextModule (node:internal/modules/esm/utils:354:16)\n' + + ' at ModuleLoader.moduleStrategy (node:internal/modules/esm/translators:91:18)'; + + const trace = parse(err); + + // No source location frame — first frame is the first at-frame + assert.strictEqual(trace.length, 2); + assert.strictEqual(trace[0].getFunctionName(), 'compileSourceTextModule'); + assert.strictEqual(trace[0].getFileName(), 'node:internal/modules/esm/utils'); + assert.strictEqual(trace[0].getLineNumber(), 354); + assert.strictEqual(trace[1].getFunctionName(), 'ModuleLoader.moduleStrategy'); + }); + + // Normal errors are not affected by source location detection + it("normal Error stack is not affected by source location detection", () => { + const err = {}; + err.stack = + 'Error: something went wrong\n' + + ' at foo (/path/to/file.js:10:5)\n' + + ' at bar (/path/to/file.js:20:3)'; + + const trace = parse(err); + + assert.strictEqual(trace.length, 2); + assert.strictEqual(trace[0].getFunctionName(), 'foo'); + assert.strictEqual(trace[0].getFileName(), '/path/to/file.js'); + assert.strictEqual(trace[1].getFunctionName(), 'bar'); + }); + + // TypeError not affected by source location detection + it("TypeError stack is not affected by source location detection", () => { + // Actual Node 20+ TypeError format + const err = {}; + err.stack = + 'TypeError: Cannot read properties of null (reading \'x\')\n' + + ' at Object.method (/app/index.js:5:10)'; + + const trace = parse(err); + + assert.strictEqual(trace.length, 1); + assert.strictEqual(trace[0].getFunctionName(), 'Object.method'); + }); + + // RangeError not affected by source location detection + it("RangeError stack is not affected by source location detection", () => { + const err = {}; + err.stack = + 'RangeError: Maximum call stack size exceeded\n' + + ' at recursive (/app/index.js:3:5)'; + + const trace = parse(err); + + assert.strictEqual(trace.length, 1); + assert.strictEqual(trace[0].getFunctionName(), 'recursive'); + }); + + // Custom exception types with messages containing colon+digits are not affected + it("custom error type with message ending in digits is not treated as source loc", () => { + // "MyException: /path:10" has ": " so guard correctly excludes it + const err = {}; + err.stack = + 'MyException: /path/to/file.js:10\n' + + ' at fn (file.js:1:2)'; + + const trace = parse(err); + assert.strictEqual(trace.length, 1); + assert.strictEqual(trace[0].getFunctionName(), 'fn'); + }); + + // URL schemes are excluded from source location detection (http/https/ftp/data/blob) + it("http URL is not treated as source loc", () => { + const err = {}; + err.stack = 'http://localhost:3000\n at fn (file.js:1:2)'; + const trace = parse(err); + assert.strictEqual(trace.length, 1); + assert.strictEqual(trace[0].getFunctionName(), 'fn'); + }); + + it("https URL is not treated as source loc", () => { + const err = {}; + err.stack = 'https://example.com:443\n at fn (file.js:1:2)'; + const trace = parse(err); + assert.strictEqual(trace.length, 1); + assert.strictEqual(trace[0].getFunctionName(), 'fn'); + }); + + it("node: specifier is not treated as source loc", () => { + // node:internal/... paths use 'node:' (no '//') — ensure the guard catches this + const err = {}; + err.stack = 'node:internal/modules/cjs/loader:1762:18\n at fn (file.js:1:2)'; + const trace = parse(err); + assert.strictEqual(trace.length, 1); + assert.strictEqual(trace[0].getFunctionName(), 'fn'); + }); + + it("error message with colon+digits is not treated as source loc", () => { + const err = {}; + err.stack = 'MyFault: status:404\n at fn (file.js:1:2)'; + const trace = parse(err); + assert.strictEqual(trace.length, 1); + assert.strictEqual(trace[0].getFunctionName(), 'fn'); + }); + + it("empty first line does not produce source loc frame", () => { + const err = {}; + err.stack = '\n at fn (file.js:1:2)'; + const trace = parse(err); + assert.strictEqual(trace.length, 1); + assert.strictEqual(trace[0].getFunctionName(), 'fn'); + }); + + // forEach+push is equivalent to map+filter: no falsy entries in output + it("non-parseable lines produce no entries in output array", () => { + // Ensures the forEach+push refactor correctly skips non-matching lines, + // equivalent to the prior map().filter(Boolean) implementation. + const err = {}; + err.stack = + 'Error: test\n' + + ' some junk line\n' + + ' more junk\n' + + ' at fn (file.js:1:2)\n' + + ' ~~~not valid~~~\n' + + ' at bar (file.js:5:3)'; + + const trace = parse(err); + assert.strictEqual(trace.length, 2); + assert.strictEqual(trace[0].getFunctionName(), 'fn'); + assert.strictEqual(trace[1].getFunctionName(), 'bar'); + trace.forEach(site => { + assert.notStrictEqual(site, undefined); + assert.notStrictEqual(site, null); + }); + }); + + it("returns a new array each call (no shared state)", () => { + const err = { stack: 'Error: x\n at fn (file.js:1:2)' }; + const trace1 = parse(err); + const trace2 = parse(err); + assert.notStrictEqual(trace1, trace2); + assert.strictEqual(trace1.length, trace2.length); + }); + + it("at-line regex does not hang on adversarial input", { timeout: 1000 }, () => { + const adversarial = ' at ' + 'a'.repeat(10000) + '(' + 'b'.repeat(10000) + ')'; + const err = { stack: 'Error: test\n' + adversarial }; + const trace = parse(err); + assert.strictEqual(trace.length, 1); + }); + + it("source-loc regex does not hang on adversarial first line", { timeout: 1000 }, () => { + const longPath = 'a'.repeat(50000); + const err = { stack: longPath + ':1\n at fn (file.js:1:2)' }; + const trace = parse(err); + assert.strictEqual(trace[0].getFileName(), longPath); + assert.strictEqual(trace[0].getLineNumber(), 1); + }); }); \ No newline at end of file diff --git a/index.js b/index.js index e794ed3..08b4ce8 100644 --- a/index.js +++ b/index.js @@ -22,11 +22,35 @@ export function parse(err) { return []; } - const lines = err.stack.split('\n').slice(1); - return lines - .map(function(line) { + const allLines = err.stack.split('\n'); + const frames = []; + + // If the first line looks like a source location (path:line or path:line:col) + // rather than an error message, capture it as the first frame. V8 prepends + // source locations for CJS SyntaxError stacks. Two guards prevent false positives: + // 1. /:\s/ — error messages contain ": " (colon+space); source paths don't. + // 2. Scheme exclusion — network URLs and node: specifiers are not file paths. + // file:// is intentionally allowed. + const firstLine = allLines[0]; + const sourceLocMatch = firstLine && firstLine.match(/^(.+?):(\d+)(?::(\d+))?$/); + if (sourceLocMatch && !firstLine.match(/:\s/) && !firstLine.match(/^(?:https?|ftp|data|blob):\/\//) && !firstLine.startsWith('node:')) { + const parsedLine = parseInt(sourceLocMatch[2], 10); + const parsedCol = parseInt(sourceLocMatch[3], 10); + frames.push(createParsedCallSite({ + fileName: sourceLocMatch[1], + lineNumber: Number.isNaN(parsedLine) ? null : parsedLine, + functionName: null, + typeName: null, + methodName: null, + columnNumber: Number.isNaN(parsedCol) ? null : parsedCol, + 'native': false, + })); + } + + const lines = allLines.slice(1); + lines.forEach(function(line) { if (line.match(/^\s*[-]{4,}$/)) { - return createParsedCallSite({ + frames.push(createParsedCallSite({ fileName: line, lineNumber: null, functionName: null, @@ -34,7 +58,8 @@ export function parse(err) { methodName: null, columnNumber: null, 'native': null, - }); + })); + return; } const lineMatch = line.match(/at (?:(.+?)\s+\()?(?:(.+?):(\d+)(?::(\d+))?|([^)]+))\)?/); @@ -75,21 +100,22 @@ export function parse(err) { functionName = null; } + const parsedLine = parseInt(lineMatch[3], 10); + const parsedCol = parseInt(lineMatch[4], 10); const properties = { fileName: lineMatch[2] || null, - lineNumber: parseInt(lineMatch[3], 10) || null, + lineNumber: Number.isNaN(parsedLine) ? null : parsedLine, functionName: functionName, typeName: typeName, methodName: methodName, - columnNumber: parseInt(lineMatch[4], 10) || null, + columnNumber: Number.isNaN(parsedCol) ? null : parsedCol, 'native': isNative, }; - return createParsedCallSite(properties); - }) - .filter(function(callSite) { - return !!callSite; - }); + frames.push(createParsedCallSite(properties)); + }); + + return frames; } function CallSite(properties) {