From c4bd0b81646ffc1237dafe4f9dc8a313c7d1e19c Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Sat, 22 Aug 2026 12:30:29 +0000 Subject: [PATCH] lib: defer source map payload decoding until first use With source maps enabled, every module that carries a sourceMappingURL had its map decoded (or read from disk), JSON-parsed and its sources resolved to absolute URLs while the module was being loaded, and the per-line length table used for coverage was built with a per-code-point loop. None of that is needed unless a stack trace is later mapped. Keep the URL on the cache entry and resolve the payload on the first findSourceMap() for that file. Under NODE_V8_COVERAGE the payload is still resolved at load time, since the cache is serialized during shutdown. lineLengths() now splits on '\n' with indexOf and only falls back to the code point walk when the source contains U+2028/U+2029. Signed-off-by: Shelley Vohr --- benchmark/module/module-require-source-map.js | 77 +++++++++++++++++++ lib/internal/source_map/source_map_cache.js | 60 +++++++++++---- 2 files changed, 122 insertions(+), 15 deletions(-) create mode 100644 benchmark/module/module-require-source-map.js diff --git a/benchmark/module/module-require-source-map.js b/benchmark/module/module-require-source-map.js new file mode 100644 index 000000000000..8353ab39fc82 --- /dev/null +++ b/benchmark/module/module-require-source-map.js @@ -0,0 +1,77 @@ +'use strict'; + +// Loading modules that carry source maps, with source map support enabled. +// This is the cost paid at startup by applications bundled or transpiled with +// source maps; the maps are only consulted if a stack trace is generated. + +const fs = require('fs'); +const path = require('path'); +const common = require('../common.js'); +const tmpdir = require('../../test/common/tmpdir'); +const benchmarkDirectory = tmpdir.resolve('nodejs-benchmark-module-source-map'); + +const bench = common.createBenchmark(main, { + sourceMap: ['none', 'inline', 'external'], + n: [1000], +}, { + setup(configs) { + tmpdir.refresh(); + const maxN = configs.reduce((max, c) => Math.max(max, c.n), 0); + createModules(maxN); + }, +}); + +function moduleSource(i) { + const methods = []; + for (let m = 0; m < 40; m++) { + methods.push(` method${m}(input) { return [].concat(input).map((item) => ({ item, m: ${m}, service: ${i} })); }`); + } + return `'use strict'; +class Service${i} { + constructor(options = {}) { this.options = { retries: 3, ...options }; } +${methods.join('\n')} +} +function helper${i}(list) { return list.filter(Boolean).slice(0, ${i % 7}); } +module.exports = { Service${i}, helper${i} }; +`; +} + +function sourceMapFor(i, source) { + return JSON.stringify({ + version: 3, + file: `${i}.js`, + sources: [`../src/${i}.ts`], + sourcesContent: [source], + names: [], + mappings: 'AAAA;' + 'AACA,MAAM;'.repeat(44), + }); +} + +function createModules(n) { + for (const kind of ['none', 'inline', 'external']) { + const dir = path.join(benchmarkDirectory, kind); + fs.mkdirSync(dir, { recursive: true }); + for (let i = 0; i < n; i++) { + const source = moduleSource(i); + let trailer = ''; + if (kind === 'inline') { + const data = Buffer.from(sourceMapFor(i, source)).toString('base64'); + trailer = `//# sourceMappingURL=data:application/json;base64,${data}\n`; + } else if (kind === 'external') { + fs.writeFileSync(path.join(dir, `${i}.js.map`), sourceMapFor(i, source)); + trailer = `//# sourceMappingURL=${i}.js.map\n`; + } + fs.writeFileSync(path.join(dir, `${i}.js`), source + trailer); + } + } +} + +function main({ sourceMap, n }) { + process.setSourceMapsEnabled(true); + const dir = path.join(benchmarkDirectory, sourceMap); + bench.start(); + for (let i = 0; i < n; i++) { + require(path.join(dir, `${i}.js`)); + } + bench.end(n); +} diff --git a/lib/internal/source_map/source_map_cache.js b/lib/internal/source_map/source_map_cache.js index 95b09090c473..e1fa55c5caba 100644 --- a/lib/internal/source_map/source_map_cache.js +++ b/lib/internal/source_map/source_map_cache.js @@ -9,6 +9,7 @@ const { RegExpPrototypeSymbolSplit, SafeMap, StringPrototypeCodePointAt, + StringPrototypeIndexOf, StringPrototypeSplit, StringPrototypeStartsWith, } = primordials; @@ -176,16 +177,14 @@ function maybeCacheSourceMap(filename, content, moduleInstance, isGeneratedSourc // Normalize the sourceURL to a file URL if it is a path. sourceURL = normalizeReferrerURL(sourceURL); - const data = dataFromUrl(filename, sourceMapURL); - // `data` could be null if the source map is invalid. - // In this case, create a cache entry with null data with source url for test coverage. - + // The payload is resolved on first use (see sourceMapData()), except under + // coverage, where it is serialized at exit when no more JS may run. const entry = { __proto__: null, lineLengths: lineLengths(content), - data, - // Save the source map url if it is not a data url. - sourceMapURL: data ? null : sourceMapURL, + data: process.env.NODE_V8_COVERAGE ? dataFromUrl(filename, sourceMapURL) : undefined, + filename, + sourceMapURL, sourceURL, }; @@ -254,20 +253,36 @@ function dataFromUrl(sourceURL, sourceMappingURL) { return sourceMapFromFile(mapURL); } +const kUnicodeLineTerminators = /[\u2028\u2029]/; + // Cache the length of each line in the file that a source map was extracted // from. This allows translation from byte offset V8 coverage reports, // to line/column offset Source Map V3. function lineLengths(content) { + if (RegExpPrototypeExec(kUnicodeLineTerminators, content) !== null) { + return lineLengthsWithUnicodeTerminators(content); + } + // We purposefully keep \r as part of the line-length calculation, in + // cases where there is a \r\n separator, so that this can be taken into + // account in coverage calculations. + const output = []; + let lineStart = 0; + let lineEnd; + while ((lineEnd = StringPrototypeIndexOf(content, '\n', lineStart)) !== -1) { + ArrayPrototypePush(output, lineEnd - lineStart); + lineStart = lineEnd + 1; + } + ArrayPrototypePush(output, content.length - lineStart); + return output; +} + +function lineLengthsWithUnicodeTerminators(content) { const contentLength = content.length; const output = []; let lineLength = 0; for (let i = 0; i < contentLength; i++, lineLength++) { const codePoint = StringPrototypeCodePointAt(content, i); - - // We purposefully keep \r as part of the line-length calculation, in - // cases where there is a \r\n separator, so that this can be taken into - // account in coverage calculations. - // codepoints for \n (new line), \u2028 (line separator) and \u2029 (paragraph separator) + // \n (new line), \u2028 (line separator) and \u2029 (paragraph separator) if (codePoint === 10 || codePoint === 0x2028 || codePoint === 0x2029) { ArrayPrototypePush(output, lineLength); lineLength = -1; // To not count the matched codePoint such as \n character @@ -351,16 +366,31 @@ function sourceMapCacheToObject() { const obj = { __proto__: null }; for (const { 0: k, 1: v } of moduleSourceMapCache) { + const data = v.data ?? null; obj[k] = { __proto__: null, lineLengths: v.lineLengths, - data: v.data, - url: v.sourceMapURL, + data, + // Save the source map url if it is not a data url. + url: data ? null : v.sourceMapURL, }; } return obj; } +/** + * Resolve and parse the payload of a cache entry the first time it is needed; + * `null` marks a source map that could not be loaded. + * @param {object} entry + * @returns {object|null} + */ +function sourceMapData(entry) { + if (entry.data === undefined) { + entry.data = dataFromUrl(entry.filename, entry.sourceMapURL); + } + return entry.data; +} + /** * Find a source map for a given actual source URL or path. * @@ -390,7 +420,7 @@ function findSourceMap(sourceURL) { sourceURL = pathToFileURL(sourceURL).href; } const entry = getModuleSourceMapCache().get(sourceURL) ?? generatedSourceMapCache.get(sourceURL); - if (entry?.data == null) { + if (entry === undefined || sourceMapData(entry) === null) { return undefined; }