From 506cbcf881c77eb0ca1ded21698b47927044ae7b Mon Sep 17 00:00:00 2001 From: Chengzhong Wu Date: Tue, 2 Sep 2025 11:31:09 +0100 Subject: [PATCH 1/2] lib,src: refactor assert to load error source from memory The source code is available from V8 API and assert can avoid reading the source file from the filesystem and parse the file again. --- lib/internal/assert/utils.js | 214 +----------------- lib/internal/errors/error_source.js | 133 +++++++++++ src/node_errors.cc | 43 ++++ ...ssert-builtins-not-read-from-filesystem.js | 48 ---- test/parallel/test-assert.js | 8 +- 5 files changed, 189 insertions(+), 257 deletions(-) create mode 100644 lib/internal/errors/error_source.js delete mode 100644 test/parallel/test-assert-builtins-not-read-from-filesystem.js diff --git a/lib/internal/assert/utils.js b/lib/internal/assert/utils.js index 13e41d67c635c2..cf7e45fe375f3e 100644 --- a/lib/internal/assert/utils.js +++ b/lib/internal/assert/utils.js @@ -1,38 +1,21 @@ 'use strict'; const { - ArrayPrototypeShift, Error, ErrorCaptureStackTrace, - FunctionPrototypeBind, - RegExpPrototypeSymbolReplace, - SafeMap, StringPrototypeCharCodeAt, - StringPrototypeIncludes, StringPrototypeReplace, - StringPrototypeSlice, - StringPrototypeSplit, - StringPrototypeStartsWith, } = primordials; -const { Buffer } = require('buffer'); const { isErrorStackTraceLimitWritable, - overrideStackTrace, } = require('internal/errors'); const AssertionError = require('internal/assert/assertion_error'); -const { openSync, closeSync, readSync } = require('fs'); -const { EOL } = require('internal/constants'); -const { BuiltinModule } = require('internal/bootstrap/realm'); const { isError } = require('internal/util'); -const errorCache = new SafeMap(); -const { fileURLToPath } = require('internal/url'); - -let parseExpressionAt; -let findNodeAround; -let tokenizer; -let decoder; +const { + getErrorSourceExpression, +} = require('internal/errors/error_source'); // Escape control characters but not \n and \t to keep the line breaks and // indentation intact. @@ -50,111 +33,7 @@ const meta = [ const escapeFn = (str) => meta[StringPrototypeCharCodeAt(str, 0)]; -function findColumn(fd, column, code) { - if (code.length > column + 100) { - try { - return parseCode(code, column); - } catch { - // End recursion in case no code could be parsed. The expression should - // have been found after 2500 characters, so stop trying. - if (code.length - column > 2500) { - // eslint-disable-next-line no-throw-literal - throw null; - } - } - } - // Read up to 2500 bytes more than necessary in columns. That way we address - // multi byte characters and read enough data to parse the code. - const bytesToRead = column - code.length + 2500; - const buffer = Buffer.allocUnsafe(bytesToRead); - const bytesRead = readSync(fd, buffer, 0, bytesToRead); - code += decoder.write(buffer.slice(0, bytesRead)); - // EOF: fast path. - if (bytesRead < bytesToRead) { - return parseCode(code, column); - } - // Read potentially missing code. - return findColumn(fd, column, code); -} - -function getCode(fd, line, column) { - let bytesRead = 0; - if (line === 0) { - // Special handle line number one. This is more efficient and simplifies the - // rest of the algorithm. Read more than the regular column number in bytes - // to prevent multiple reads in case multi byte characters are used. - return findColumn(fd, column, ''); - } - let lines = 0; - // Prevent blocking the event loop by limiting the maximum amount of - // data that may be read. - let maxReads = 32; // bytesPerRead * maxReads = 512 KiB - const bytesPerRead = 16384; - // Use a single buffer up front that is reused until the call site is found. - let buffer = Buffer.allocUnsafe(bytesPerRead); - while (maxReads-- !== 0) { - // Only allocate a new buffer in case the needed line is found. All data - // before that can be discarded. - buffer = lines < line ? buffer : Buffer.allocUnsafe(bytesPerRead); - bytesRead = readSync(fd, buffer, 0, bytesPerRead); - // Read the buffer until the required code line is found. - for (let i = 0; i < bytesRead; i++) { - if (buffer[i] === 10 && ++lines === line) { - // If the end of file is reached, directly parse the code and return. - if (bytesRead < bytesPerRead) { - return parseCode(buffer.toString('utf8', i + 1, bytesRead), column); - } - // Check if the read code is sufficient or read more until the whole - // expression is read. Make sure multi byte characters are preserved - // properly by using the decoder. - const code = decoder.write(buffer.slice(i + 1, bytesRead)); - return findColumn(fd, column, code); - } - } - } -} - -function parseCode(code, offset) { - // Lazy load acorn. - if (parseExpressionAt === undefined) { - const Parser = require('internal/deps/acorn/acorn/dist/acorn').Parser; - ({ findNodeAround } = require('internal/deps/acorn/acorn-walk/dist/walk')); - - parseExpressionAt = FunctionPrototypeBind(Parser.parseExpressionAt, Parser); - tokenizer = FunctionPrototypeBind(Parser.tokenizer, Parser); - } - let node; - let start; - // Parse the read code until the correct expression is found. - for (const token of tokenizer(code, { ecmaVersion: 'latest' })) { - start = token.start; - if (start > offset) { - // No matching expression found. This could happen if the assert - // expression is bigger than the provided buffer. - break; - } - try { - node = parseExpressionAt(code, start, { ecmaVersion: 'latest' }); - // Find the CallExpression in the tree. - node = findNodeAround(node, offset, 'CallExpression'); - if (node?.node.end >= offset) { - return [ - node.node.start, - StringPrototypeReplace(StringPrototypeSlice(code, - node.node.start, node.node.end), - escapeSequencesRegExp, escapeFn), - ]; - } - // eslint-disable-next-line no-unused-vars - } catch (err) { - continue; - } - } - // eslint-disable-next-line no-throw-literal - throw null; -} - -function getErrMessage(message, fn) { +function getErrMessage(fn) { const tmpLimit = Error.stackTraceLimit; const errorStackTraceLimitIsWritable = isErrorStackTraceLimitWritable(); // Make sure the limit is set to 1. Otherwise it could fail (<= 0) or it @@ -166,85 +45,10 @@ function getErrMessage(message, fn) { ErrorCaptureStackTrace(err, fn); if (errorStackTraceLimitIsWritable) Error.stackTraceLimit = tmpLimit; - overrideStackTrace.set(err, (_, stack) => stack); - const call = err.stack[0]; - - let filename = call.getFileName(); - const line = call.getLineNumber() - 1; - let column = call.getColumnNumber() - 1; - let identifier; - - if (filename) { - identifier = `${filename}${line}${column}`; - - // Skip Node.js modules! - if (StringPrototypeStartsWith(filename, 'node:') && - BuiltinModule.exists(StringPrototypeSlice(filename, 5))) { - errorCache.set(identifier, undefined); - return; - } - } else { - return message; - } - - if (errorCache.has(identifier)) { - return errorCache.get(identifier); - } - - let fd; - try { - // Set the stack trace limit to zero. This makes sure unexpected token - // errors are handled faster. - if (errorStackTraceLimitIsWritable) Error.stackTraceLimit = 0; - - if (decoder === undefined) { - const { StringDecoder } = require('string_decoder'); - decoder = new StringDecoder('utf8'); - } - - // ESM file prop is a file proto. Convert that to path. - // This ensure opensync will not throw ENOENT for ESM files. - const fileProtoPrefix = 'file://'; - if (StringPrototypeStartsWith(filename, fileProtoPrefix)) { - filename = fileURLToPath(filename); - } - - fd = openSync(filename, 'r', 0o666); - // Reset column and message. - ({ 0: column, 1: message } = getCode(fd, line, column)); - // Flush unfinished multi byte characters. - decoder.end(); - - // Always normalize indentation, otherwise the message could look weird. - if (StringPrototypeIncludes(message, '\n')) { - if (EOL === '\r\n') { - message = RegExpPrototypeSymbolReplace(/\r\n/g, message, '\n'); - } - const frames = StringPrototypeSplit(message, '\n'); - message = ArrayPrototypeShift(frames); - for (let i = 0; i < frames.length; i++) { - const frame = frames[i]; - let pos = 0; - while (pos < column && (frame[pos] === ' ' || frame[pos] === '\t')) { - pos++; - } - message += `\n ${StringPrototypeSlice(frame, pos)}`; - } - } - message = `The expression evaluated to a falsy value:\n\n ${message}\n`; - // Make sure to always set the cache! No matter if the message is - // undefined or not - errorCache.set(identifier, message); - - return message; - } catch { - // Invalidate cache to prevent trying to read this part again. - errorCache.set(identifier, undefined); - } finally { - // Reset limit. - if (errorStackTraceLimitIsWritable) Error.stackTraceLimit = tmpLimit; - if (fd !== undefined) - closeSync(fd); + let source = getErrorSourceExpression(err); + if (source) { + source = StringPrototypeReplace(source, escapeSequencesRegExp, escapeFn); + return `The expression evaluated to a falsy value:\n\n ${source}\n`; } } @@ -257,7 +61,7 @@ function innerOk(fn, argLen, value, message) { message = 'No value argument passed to `assert.ok()`'; } else if (message == null) { generatedMessage = true; - message = getErrMessage(message, fn); + message = getErrMessage(fn); } else if (isError(message)) { throw message; } diff --git a/lib/internal/errors/error_source.js b/lib/internal/errors/error_source.js new file mode 100644 index 00000000000000..9384d4a8361c74 --- /dev/null +++ b/lib/internal/errors/error_source.js @@ -0,0 +1,133 @@ +'use strict'; + +const { + FunctionPrototypeBind, + StringPrototypeSlice, +} = primordials; + +const { + getErrorSourcePositions, +} = internalBinding('errors'); + +/** + * Get the source location of an error. + * + * The `error.stack` must not have been accessed. The resolution is based on the structured + * error stack data. + * @param {Error|object} error An error object, or an object being invoked with ErrorCaptureStackTrace + * @returns {{sourceLine: string, startColumn: number}|undefined} + */ +function getErrorSourceLocation(error) { + const pos = getErrorSourcePositions(error); + const { + sourceLine, + startColumn, + } = pos; + + return { sourceLine, startColumn }; +} + +const memberAccessTokens = [ '.', '?.', '[', ']' ]; +const memberNameTokens = [ 'name', 'string', 'num' ]; +let tokenizer; +/** + * Get the first expression in a code string at the startColumn. + * @param {string} code source code line + * @param {number} startColumn which column the error is constructed + * @returns {string} + */ +function getFirstExpression(code, startColumn) { + // Lazy load acorn. + if (tokenizer === undefined) { + const Parser = require('internal/deps/acorn/acorn/dist/acorn').Parser; + tokenizer = FunctionPrototypeBind(Parser.tokenizer, Parser); + } + + let lastToken; + let firstMemberAccessNameToken; + let terminatingCol; + let parenLvl = 0; + // Tokenize the line to locate the expression at the startColumn. + // The source line may be an incomplete JavaScript source, so do not parse the source line. + for (const token of tokenizer(code, { ecmaVersion: 'latest' })) { + // Peek before the startColumn. + if (token.start < startColumn) { + // There is a semicolon. This is a statement before the startColumn, so reset the memo. + if (token.type.label === ';') { + firstMemberAccessNameToken = null; + continue; + } + // Try to memo the member access expressions before the startColumn, so that the + // returned source code contains more info: + // assert.ok(value) + // ^ startColumn + // The member expression can also be like + // assert['ok'](value) or assert?.ok(value) + // ^ startColumn ^ startColumn + if (memberAccessTokens.includes(token.type.label) && lastToken?.type.label === 'name') { + // First member access name token must be a 'name'. + firstMemberAccessNameToken ??= lastToken; + } else if (!memberAccessTokens.includes(token.type.label) && + !memberNameTokens.includes(token.type.label)) { + // Reset the memo if it is not a simple member access. + // For example: assert[(() => 'ok')()](value) + // ^ startColumn + firstMemberAccessNameToken = null; + } + lastToken = token; + continue; + } + // Now after the startColumn, this must be an expression. + if (token.type.label === '(') { + parenLvl++; + continue; + } + if (token.type.label === ')') { + parenLvl--; + if (parenLvl === 0) { + // A matched closing parenthesis found after the startColumn, + // terminate here. Include the token. + // (assert.ok(false), assert.ok(true)) + // ^ startColumn + terminatingCol = token.start + 1; + break; + } + continue; + } + if (token.type.label === ';') { + // A semicolon found after the startColumn, terminate here. + // assert.ok(false); assert.ok(true)); + // ^ startColumn + terminatingCol = token; + break; + } + // If no semicolon found after the startColumn. The string after the + // startColumn must be the expression. + // assert.ok(false) + // ^ startColumn + } + const start = firstMemberAccessNameToken?.start ?? startColumn; + return StringPrototypeSlice(code, start, terminatingCol); +} + +/** + * Get the source expression of an error. + * + * The `error.stack` must not have been accessed, or the source location may be incorrect. The + * resolution is based on the structured error stack data. + * @param {Error|object} error An error object, or an object being invoked with ErrorCaptureStackTrace + * @returns {string|undefined} + */ +function getErrorSourceExpression(error) { + const loc = getErrorSourceLocation(error); + if (loc === undefined) { + return; + } + const { sourceLine, startColumn } = loc; + return getFirstExpression(sourceLine, startColumn); +} + +module.exports = { + getErrorSourceLocation, + getErrorSourceExpression, +}; diff --git a/src/node_errors.cc b/src/node_errors.cc index 5f51add4cdf68a..ae8553ee2022d6 100644 --- a/src/node_errors.cc +++ b/src/node_errors.cc @@ -1041,6 +1041,46 @@ void PerIsolateMessageListener(Local message, Local error) { } } +void GetErrorSourcePositions(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + HandleScope scope(isolate); + Realm* realm = Realm::GetCurrent(args); + Local context = realm->context(); + + CHECK(args[0]->IsObject()); + + Local msg = Exception::CreateMessage(isolate, args[0]); + + // Message::GetEndColumn may not reflect the actual end column in all cases. + // So only expose startColumn to JS land. + Local names[] = { + OneByteString(isolate, "sourceLine"), + OneByteString(isolate, "scriptResourceName"), + OneByteString(isolate, "lineNumber"), + OneByteString(isolate, "startColumn"), + }; + + Local source_line; + if (!msg->GetSourceLine(context).ToLocal(&source_line)) { + return; + } + int line_number; + if (!msg->GetLineNumber(context).To(&line_number)) { + return; + } + + Local values[] = { + source_line, + msg->GetScriptOrigin().ResourceName(), + v8::Integer::New(isolate, line_number), + v8::Integer::New(isolate, msg->GetStartColumn()), + }; + Local info = + Object::New(isolate, v8::Null(isolate), names, values, arraysize(names)); + + args.GetReturnValue().Set(info); +} + void SetPrepareStackTraceCallback(const FunctionCallbackInfo& args) { Realm* realm = Realm::GetCurrent(args); CHECK(args[0]->IsFunction()); @@ -1106,6 +1146,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(SetEnhanceStackForFatalException); registry->Register(NoSideEffectsToString); registry->Register(TriggerUncaughtException); + registry->Register(GetErrorSourcePositions); } void Initialize(Local target, @@ -1133,6 +1174,8 @@ void Initialize(Local target, context, target, "noSideEffectsToString", NoSideEffectsToString); SetMethod( context, target, "triggerUncaughtException", TriggerUncaughtException); + SetMethod( + context, target, "getErrorSourcePositions", GetErrorSourcePositions); Isolate* isolate = context->GetIsolate(); Local exit_codes = Object::New(isolate); diff --git a/test/parallel/test-assert-builtins-not-read-from-filesystem.js b/test/parallel/test-assert-builtins-not-read-from-filesystem.js deleted file mode 100644 index 7a713a2ea432c1..00000000000000 --- a/test/parallel/test-assert-builtins-not-read-from-filesystem.js +++ /dev/null @@ -1,48 +0,0 @@ -'use strict'; - -// Do not read filesystem when creating AssertionError messages for code in -// builtin modules. - -require('../common'); -const assert = require('assert'); -const EventEmitter = require('events'); -const e = new EventEmitter(); -e.on('hello', assert); - -if (process.argv[2] !== 'child') { - const tmpdir = require('../common/tmpdir'); - tmpdir.refresh(); - const { spawnSync } = require('child_process'); - - let threw = false; - try { - e.emit('hello', false); - } catch (err) { - const frames = err.stack.split('\n'); - const [, filename, line, column] = frames[1].match(/\((.+):(\d+):(\d+)\)/); - // Spawn a child process to avoid the error having been cached in the assert - // module's `errorCache` Map. - - const { output, status, error } = - spawnSync(process.execPath, - [process.argv[1], 'child', filename, line, column], - { cwd: tmpdir.path, env: process.env }); - assert.ifError(error); - assert.strictEqual(status, 0, `Exit code: ${status}\n${output}`); - threw = true; - } - assert.ok(threw); -} else { - const { writeFileSync } = require('fs'); - const [, , , filename, line, column] = process.argv; - const data = `${'\n'.repeat(line - 1)}${' '.repeat(column - 1)}` + - 'ok(failed(badly));'; - - writeFileSync(filename, data); - assert.throws( - () => e.emit('hello', false), - { - message: 'false == true' - } - ); -} diff --git a/test/parallel/test-assert.js b/test/parallel/test-assert.js index ae3df139cd68b7..ee48c0e1e24817 100644 --- a/test/parallel/test-assert.js +++ b/test/parallel/test-assert.js @@ -624,7 +624,7 @@ test('Test strict assert', () => { code: 'ERR_ASSERTION', constructor: strict.AssertionError, message: 'The expression evaluated to a falsy value:\n\n ' + - "strict.ok(\n typeof 123 === 'string'\n )\n" + 'strict.ok(\n' } ); Error.stackTraceLimit = tmpLimit; @@ -1017,20 +1017,20 @@ test('Additional asserts', () => { } ); - // Works in eval. + // Works in eval. Source code in eval can be shown. assert.throws( () => new Function('assert', 'assert(1 === 2);')(assert), { code: 'ERR_ASSERTION', constructor: assert.AssertionError, - message: 'false == true' + message: 'The expression evaluated to a falsy value:\n\n assert(1 === 2)\n' } ); assert.throws( () => eval('console.log("FOO");\nassert.ok(1 === 2);'), { code: 'ERR_ASSERTION', - message: 'false == true' + message: 'The expression evaluated to a falsy value:\n\n assert.ok(1 === 2)\n' } ); From 9e516a35bd638ce1b29a57439e4f0b0763865708 Mon Sep 17 00:00:00 2001 From: Chengzhong Wu Date: Wed, 3 Sep 2025 21:49:02 +0100 Subject: [PATCH 2/2] lib: add source map support for assert messages Map source lines in assert messages with cached source maps. --- lib/internal/errors/error_source.js | 38 +++++++++++- .../source_map/prepare_stack_trace.js | 52 ++-------------- lib/internal/source_map/source_map_cache.js | 61 ++++++++++++++++++- .../source_map_assert_source_line.snapshot | 20 ++++++ .../output/source_map_assert_source_line.ts | 14 +++++ .../source_map_enclosing_function.snapshot | 1 - .../output/source_map_eval.snapshot | 1 - .../source_map_reference_error_tabs.snapshot | 1 - ...ource_map_throw_async_stack_trace.snapshot | 1 - .../source_map_throw_construct.snapshot | 1 - .../source_map_throw_first_tick.snapshot | 1 - .../output/source_map_throw_icu.snapshot | 1 - .../source_map_throw_set_immediate.snapshot | 1 - test/parallel/test-node-output-sourcemaps.mjs | 1 + 14 files changed, 135 insertions(+), 59 deletions(-) create mode 100644 test/fixtures/source-map/output/source_map_assert_source_line.snapshot create mode 100644 test/fixtures/source-map/output/source_map_assert_source_line.ts diff --git a/lib/internal/errors/error_source.js b/lib/internal/errors/error_source.js index 9384d4a8361c74..eddd6af230801b 100644 --- a/lib/internal/errors/error_source.js +++ b/lib/internal/errors/error_source.js @@ -8,9 +8,15 @@ const { const { getErrorSourcePositions, } = internalBinding('errors'); +const { + getSourceMapsSupport, + findSourceMap, + getSourceLine, +} = require('internal/source_map/source_map_cache'); /** - * Get the source location of an error. + * Get the source location of an error. If source map is enabled, resolve the source location + * based on the source map. * * The `error.stack` must not have been accessed. The resolution is based on the structured * error stack data. @@ -21,10 +27,35 @@ function getErrorSourceLocation(error) { const pos = getErrorSourcePositions(error); const { sourceLine, + scriptResourceName, + lineNumber, startColumn, } = pos; - return { sourceLine, startColumn }; + // Source map is not enabled. Return the source line directly. + if (!getSourceMapsSupport().enabled) { + return { sourceLine, startColumn }; + } + + const sm = findSourceMap(scriptResourceName); + if (sm === undefined) { + return; + } + const { + originalLine, + originalColumn, + originalSource, + } = sm.findEntry(lineNumber - 1, startColumn); + const originalSourceLine = getSourceLine(sm, originalSource, originalLine, originalColumn); + + if (!originalSourceLine) { + return; + } + + return { + sourceLine: originalSourceLine, + startColumn: originalColumn, + }; } const memberAccessTokens = [ '.', '?.', '[', ']' ]; @@ -111,7 +142,8 @@ function getFirstExpression(code, startColumn) { } /** - * Get the source expression of an error. + * Get the source expression of an error. If source map is enabled, resolve the source location + * based on the source map. * * The `error.stack` must not have been accessed, or the source location may be incorrect. The * resolution is based on the structured error stack data. diff --git a/lib/internal/source_map/prepare_stack_trace.js b/lib/internal/source_map/prepare_stack_trace.js index 814ea396f60144..da2f9dcd74b2bb 100644 --- a/lib/internal/source_map/prepare_stack_trace.js +++ b/lib/internal/source_map/prepare_stack_trace.js @@ -1,11 +1,9 @@ 'use strict'; const { - ArrayPrototypeIndexOf, ArrayPrototypeJoin, ArrayPrototypeMap, ErrorPrototypeToString, - RegExpPrototypeSymbolSplit, SafeStringIterator, StringPrototypeRepeat, StringPrototypeSlice, @@ -16,8 +14,7 @@ let debug = require('internal/util/debuglog').debuglog('source_map', (fn) => { debug = fn; }); const { getStringWidth } = require('internal/util/inspect'); -const { readFileSync } = require('fs'); -const { findSourceMap } = require('internal/source_map/source_map_cache'); +const { findSourceMap, getSourceLine } = require('internal/source_map/source_map_cache'); const { kIsNodeError, } = require('internal/errors'); @@ -155,21 +152,13 @@ function getErrorSource( originalLine, originalColumn, ) { - const originalSourcePathNoScheme = - StringPrototypeStartsWith(originalSourcePath, 'file://') ? - fileURLToPath(originalSourcePath) : originalSourcePath; - const source = getOriginalSource( - sourceMap.payload, - originalSourcePath, - ); - if (typeof source !== 'string') { - return; - } - const lines = RegExpPrototypeSymbolSplit(/\r?\n/, source, originalLine + 1); - const line = lines[originalLine]; + const line = getSourceLine(sourceMap, originalSourcePath, originalLine); if (!line) { return; } + const originalSourcePathNoScheme = + StringPrototypeStartsWith(originalSourcePath, 'file://') ? + fileURLToPath(originalSourcePath) : originalSourcePath; // Display ^ in appropriate position, regardless of whether tabs or // spaces are used: @@ -182,39 +171,10 @@ function getErrorSource( prefix = StringPrototypeSlice(prefix, 0, -1); // The last character is '^'. const exceptionLine = - `${originalSourcePathNoScheme}:${originalLine + 1}\n${line}\n${prefix}^\n\n`; + `${originalSourcePathNoScheme}:${originalLine + 1}\n${line}\n${prefix}^\n`; return exceptionLine; } -/** - * Retrieve the original source code from the source map's `sources` list or disk. - * @param {import('internal/source_map/source_map').SourceMap.payload} payload - * @param {string} originalSourcePath - path or url of the original source - * @returns {string | undefined} - the source content or undefined if file not found - */ -function getOriginalSource(payload, originalSourcePath) { - let source; - // payload.sources has been normalized to be an array of absolute urls. - const sourceContentIndex = - ArrayPrototypeIndexOf(payload.sources, originalSourcePath); - if (payload.sourcesContent?.[sourceContentIndex]) { - // First we check if the original source content was provided in the - // source map itself: - source = payload.sourcesContent[sourceContentIndex]; - } else if (StringPrototypeStartsWith(originalSourcePath, 'file://')) { - // If no sourcesContent was found, attempt to load the original source - // from disk: - debug(`read source of ${originalSourcePath} from filesystem`); - const originalSourcePathNoScheme = fileURLToPath(originalSourcePath); - try { - source = readFileSync(originalSourcePathNoScheme, 'utf8'); - } catch (err) { - debug(err); - } - } - return source; -} - /** * Retrieve exact line in the original source code from the source map's `sources` list or disk. * @param {string} fileName - actual file name diff --git a/lib/internal/source_map/source_map_cache.js b/lib/internal/source_map/source_map_cache.js index 670e53890a3099..a4a95ad0ae49c3 100644 --- a/lib/internal/source_map/source_map_cache.js +++ b/lib/internal/source_map/source_map_cache.js @@ -1,13 +1,16 @@ 'use strict'; const { + ArrayPrototypeIndexOf, ArrayPrototypePush, JSONParse, ObjectFreeze, RegExpPrototypeExec, + RegExpPrototypeSymbolSplit, SafeMap, StringPrototypeCodePointAt, StringPrototypeSplit, + StringPrototypeStartsWith, } = primordials; // See https://tc39.es/ecma426/ for SourceMap V3 specification. @@ -16,6 +19,7 @@ let debug = require('internal/util/debuglog').debuglog('source_map', (fn) => { debug = fn; }); +const { readFileSync } = require('fs'); const { validateBoolean, validateObject } = require('internal/validators'); const { setSourceMapsEnabled: setSourceMapsNative, @@ -277,8 +281,7 @@ function lineLengths(content) { */ function sourceMapFromFile(mapURL) { try { - const fs = require('fs'); - const content = fs.readFileSync(fileURLToPath(mapURL), 'utf8'); + const content = readFileSync(fileURLToPath(mapURL), 'utf8'); const data = JSONParse(content); return sourcesToAbsolute(mapURL, data); } catch (err) { @@ -400,8 +403,62 @@ function findSourceMap(sourceURL) { } } +/** + * Retrieve the original source code from the source map's `sources` list or disk. + * @param {import('internal/source_map/source_map').SourceMap.payload} payload + * @param {string} originalSourcePath - path or url of the original source + * @returns {string | undefined} - the source content or undefined if file not found + */ +function getOriginalSource(payload, originalSourcePath) { + let source; + // payload.sources has been normalized to be an array of absolute urls. + const sourceContentIndex = + ArrayPrototypeIndexOf(payload.sources, originalSourcePath); + if (payload.sourcesContent?.[sourceContentIndex]) { + // First we check if the original source content was provided in the + // source map itself: + source = payload.sourcesContent[sourceContentIndex]; + } else if (StringPrototypeStartsWith(originalSourcePath, 'file://')) { + // If no sourcesContent was found, attempt to load the original source + // from disk: + debug(`read source of ${originalSourcePath} from filesystem`); + const originalSourcePathNoScheme = fileURLToPath(originalSourcePath); + try { + source = readFileSync(originalSourcePathNoScheme, 'utf8'); + } catch (err) { + debug(err); + } + } + return source; +} + +/** + * Get the line of source in the source map. + * @param {import('internal/source_map/source_map').SourceMap} sourceMap + * @param {string} originalSourcePath path or url of the original source + * @param {number} originalLine line number in the original source + * @returns {string|undefined} source line if found + */ +function getSourceLine( + sourceMap, + originalSourcePath, + originalLine, +) { + const source = getOriginalSource( + sourceMap.payload, + originalSourcePath, + ); + if (typeof source !== 'string') { + return; + } + const lines = RegExpPrototypeSymbolSplit(/\r?\n/, source, originalLine + 1); + const line = lines[originalLine]; + return line; +} + module.exports = { findSourceMap, + getSourceLine, getSourceMapsSupport, setSourceMapsSupport, maybeCacheSourceMap, diff --git a/test/fixtures/source-map/output/source_map_assert_source_line.snapshot b/test/fixtures/source-map/output/source_map_assert_source_line.snapshot new file mode 100644 index 00000000000000..fe11794e9c032d --- /dev/null +++ b/test/fixtures/source-map/output/source_map_assert_source_line.snapshot @@ -0,0 +1,20 @@ +AssertionError [ERR_ASSERTION]: The expression evaluated to a falsy value: + + assert(false) + + at Object. (*/test/fixtures/source-map/output/source_map_assert_source_line.ts:11:3) + * + * + * + * + at TracingChannel.traceSync (node:diagnostics_channel:322:14) + * + * + * + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: false, + expected: true, + operator: '==', + diff: 'simple' +} diff --git a/test/fixtures/source-map/output/source_map_assert_source_line.ts b/test/fixtures/source-map/output/source_map_assert_source_line.ts new file mode 100644 index 00000000000000..f4d242aa508248 --- /dev/null +++ b/test/fixtures/source-map/output/source_map_assert_source_line.ts @@ -0,0 +1,14 @@ +// Flags: --enable-source-maps --experimental-transform-types --no-warnings + +require('../../../common'); +const assert = require('node:assert'); + +enum Bar { + makeSureTransformTypes, +} + +try { + assert(false); +} catch (e) { + console.log(e); +} diff --git a/test/fixtures/source-map/output/source_map_enclosing_function.snapshot b/test/fixtures/source-map/output/source_map_enclosing_function.snapshot index b60f988be3214e..a215a1ab9c0d2b 100644 --- a/test/fixtures/source-map/output/source_map_enclosing_function.snapshot +++ b/test/fixtures/source-map/output/source_map_enclosing_function.snapshot @@ -2,7 +2,6 @@ throw err ^ - Error: an error! at functionD (*/test/fixtures/source-map/enclosing-call-site.js:16:17) at functionC (*/test/fixtures/source-map/enclosing-call-site.js:10:3) diff --git a/test/fixtures/source-map/output/source_map_eval.snapshot b/test/fixtures/source-map/output/source_map_eval.snapshot index ff636e44063aa7..c808fb414473d6 100644 --- a/test/fixtures/source-map/output/source_map_eval.snapshot +++ b/test/fixtures/source-map/output/source_map_eval.snapshot @@ -2,7 +2,6 @@ alert "I knew it!" ^ - ReferenceError: alert is not defined at Object.eval (*/synthesized/workspace/tabs-source-url.coffee:26:2) at eval (*/synthesized/workspace/tabs-source-url.coffee:1:14) diff --git a/test/fixtures/source-map/output/source_map_reference_error_tabs.snapshot b/test/fixtures/source-map/output/source_map_reference_error_tabs.snapshot index 2043bd0a88e897..fce296c1d56848 100644 --- a/test/fixtures/source-map/output/source_map_reference_error_tabs.snapshot +++ b/test/fixtures/source-map/output/source_map_reference_error_tabs.snapshot @@ -2,7 +2,6 @@ alert "I knew it!" ^ - ReferenceError: alert is not defined at Object. (*/test/fixtures/source-map/tabs.coffee:26:2) at Object. (*/test/fixtures/source-map/tabs.coffee:1:14) diff --git a/test/fixtures/source-map/output/source_map_throw_async_stack_trace.snapshot b/test/fixtures/source-map/output/source_map_throw_async_stack_trace.snapshot index f53aec68ce8bb3..2c9bd70cf4d45f 100644 --- a/test/fixtures/source-map/output/source_map_throw_async_stack_trace.snapshot +++ b/test/fixtures/source-map/output/source_map_throw_async_stack_trace.snapshot @@ -2,7 +2,6 @@ throw new Error('message') ^ - Error: message at Throw (*/test/fixtures/source-map/output/source_map_throw_async_stack_trace.mts:13:9) at async Promise.all (index 3) diff --git a/test/fixtures/source-map/output/source_map_throw_construct.snapshot b/test/fixtures/source-map/output/source_map_throw_construct.snapshot index dc28053240aa0b..292eb0b2eac353 100644 --- a/test/fixtures/source-map/output/source_map_throw_construct.snapshot +++ b/test/fixtures/source-map/output/source_map_throw_construct.snapshot @@ -2,7 +2,6 @@ throw new Error('message'); ^ - Error: message at new Foo (*/test/fixtures/source-map/output/source_map_throw_construct.mts:13:11) at (*/test/fixtures/source-map/output/source_map_throw_construct.mts:17:1) diff --git a/test/fixtures/source-map/output/source_map_throw_first_tick.snapshot b/test/fixtures/source-map/output/source_map_throw_first_tick.snapshot index e129d73ef1581c..29346a81836160 100644 --- a/test/fixtures/source-map/output/source_map_throw_first_tick.snapshot +++ b/test/fixtures/source-map/output/source_map_throw_first_tick.snapshot @@ -3,7 +3,6 @@ reachable throw Error('an exception'); ^ - Error: an exception at branch (*/test/fixtures/source-map/typescript-throw.ts:18:11) at Object. (*/test/fixtures/source-map/typescript-throw.ts:24:1) diff --git a/test/fixtures/source-map/output/source_map_throw_icu.snapshot b/test/fixtures/source-map/output/source_map_throw_icu.snapshot index 4b2853479b9576..8b556a584fec2e 100644 --- a/test/fixtures/source-map/output/source_map_throw_icu.snapshot +++ b/test/fixtures/source-map/output/source_map_throw_icu.snapshot @@ -2,7 +2,6 @@ ("あ 🐕 🐕", throw Error("an error")); ^ - Error: an error at Object.createElement (*/test/fixtures/source-map/icu.jsx:3:23) at Object. (*/test/fixtures/source-map/icu.jsx:9:5) diff --git a/test/fixtures/source-map/output/source_map_throw_set_immediate.snapshot b/test/fixtures/source-map/output/source_map_throw_set_immediate.snapshot index 4cf4d52a16ea93..053ed5692d6a80 100644 --- a/test/fixtures/source-map/output/source_map_throw_set_immediate.snapshot +++ b/test/fixtures/source-map/output/source_map_throw_set_immediate.snapshot @@ -2,7 +2,6 @@ throw Error('goodbye'); ^ - Error: goodbye at Hello (*/test/fixtures/source-map/uglify-throw-original.js:5:9) at Immediate. (*/test/fixtures/source-map/uglify-throw-original.js:9:3) diff --git a/test/parallel/test-node-output-sourcemaps.mjs b/test/parallel/test-node-output-sourcemaps.mjs index c11c2c36735dae..2d0e784e206b0b 100644 --- a/test/parallel/test-node-output-sourcemaps.mjs +++ b/test/parallel/test-node-output-sourcemaps.mjs @@ -14,6 +14,7 @@ describe('sourcemaps output', { concurrency: !process.env.TEST_PARALLEL }, () => ); const tests = [ + { name: 'source-map/output/source_map_assert_source_line.ts' }, { name: 'source-map/output/source_map_disabled_by_api.js' }, { name: 'source-map/output/source_map_disabled_by_process_api.js' }, { name: 'source-map/output/source_map_enabled_by_api.js' },