Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions doc/api/module.md
Original file line number Diff line number Diff line change
Expand Up @@ -1924,6 +1924,10 @@ added:
`path` is the resolved path for the file for which a corresponding source map
should be fetched.

Source maps of code generated by `eval` or `new Function` are held in a cache of
limited size, so the least recently used of them are dropped once enough
generated code has been evaluated.

### `module.setSourceMapsSupport(enabled[, options])`

<!-- YAML
Expand Down
75 changes: 70 additions & 5 deletions lib/internal/source_map/source_map_cache.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

const {
ArrayIsArray,
ArrayPrototypeIndexOf,
ArrayPrototypePush,
JSONParse,
Expand Down Expand Up @@ -36,11 +37,17 @@ const getModuleSourceMapCache = getLazy(() => {
return new SourceMapCacheMap();
});

// The generated source module/script instance is not accessible, so we can use
// a Map without memory concerns. Separate generated source entries with the module
// source entries to avoid overriding the module source entries with arbitrary
// source url magic comments.
// The generated source module/script instance is not accessible, so these entries
// cannot be keyed weakly like the module source entries are. The cache holds the
// least recently used ones within a byte budget instead, or generated sources
// evaluated under a changing source url would retain every payload they ever
// mapped. The budget counts bytes rather than entries because one source map can
// carry hundreds of kilobytes of sourcesContent while the next carries a handful.
// Separate generated source entries with the module source entries to avoid
// overriding the module source entries with arbitrary source url magic comments.
const kGeneratedSourceMapCacheSizeLimit = 32 * 1024 * 1024;
const generatedSourceMapCache = new SafeMap();
let generatedSourceMapCacheSize = 0;
const kLeadingProtocol = /^\w+:\/\//;
const kSourceMappingURLMagicComment = /\/[*/]#\s+sourceMappingURL=(?<sourceMappingURL>[^\s]+)/g;
const kSourceURLMagicComment = /\/[*/]#\s+sourceURL=(?<sourceURL>[^\s]+)/g;
Expand Down Expand Up @@ -189,7 +196,21 @@ function maybeCacheSourceMap(filename, content, moduleInstance, isGeneratedSourc
};

if (isGeneratedSource) {
// Only generated entries are charged against the budget, so the presence of
// a size doubles as the marker for an entry that is accounted for.
entry.size = sourceMapURL.length + entry.lineLengths.length;
if (entry.data != null) {
entry.size += sourceMapPayloadSize(entry.data);
}
deleteGeneratedSourceMap(filename);
generatedSourceMapCache.set(filename, entry);
generatedSourceMapCacheSize += entry.size;
// Keep the newest entry even when it alone exceeds the budget, otherwise a
// single large source map could never be mapped at all.
while (generatedSourceMapCacheSize > kGeneratedSourceMapCacheSizeLimit &&
generatedSourceMapCache.size > 1) {
deleteGeneratedSourceMap(generatedSourceMapCache.keys().next().value);
}
return;
}
// If it is not a generated source, we assume we are in a "cjs/esm"
Expand All @@ -198,6 +219,35 @@ function maybeCacheSourceMap(filename, content, moduleInstance, isGeneratedSourc
getModuleSourceMapCache().set(keys, entry, moduleInstance);
}

/**
* Approximate the bytes a resolved payload keeps alive.
* @param {object} data - deserialized source map JSON object
* @returns {number} size in bytes
*/
function sourceMapPayloadSize(data) {
let size = data.mappings?.length ?? 0;
const sourcesContent = data.sourcesContent;
if (ArrayIsArray(sourcesContent)) {
for (let i = 0; i < sourcesContent.length; i++) {
size += sourcesContent[i]?.length ?? 0;
}
}
return size;
}

/**
* Drop a generated source entry and give its bytes back to the budget.
* @param {string} filename - key of the entry
*/
function deleteGeneratedSourceMap(filename) {
const entry = generatedSourceMapCache.get(filename);
if (entry === undefined) {
return;
}
generatedSourceMapCacheSize -= entry.size;
generatedSourceMapCache.delete(filename);
}

/**
* Caches the source map if it is present in the eval'd source.
* @param {string} content - the eval'd source code
Expand Down Expand Up @@ -387,6 +437,11 @@ function sourceMapCacheToObject() {
function sourceMapData(entry) {
if (entry.data === undefined) {
entry.data = dataFromUrl(entry.filename, entry.sourceMapURL);
if (entry.size !== undefined && entry.data !== null) {
const size = sourceMapPayloadSize(entry.data);
entry.size += size;
generatedSourceMapCacheSize += size;
}
}
return entry.data;
}
Expand Down Expand Up @@ -419,7 +474,16 @@ function findSourceMap(sourceURL) {
// If the sourceURL is an invalid path, this will throw an error.
sourceURL = pathToFileURL(sourceURL).href;
}
const entry = getModuleSourceMapCache().get(sourceURL) ?? generatedSourceMapCache.get(sourceURL);
let entry = getModuleSourceMapCache().get(sourceURL);
if (entry === undefined) {
entry = generatedSourceMapCache.get(sourceURL);
if (entry !== undefined) {
// Move the entry back to the newest end, so that a generated source that
// is still in use is not evicted for being old.
generatedSourceMapCache.delete(sourceURL);
generatedSourceMapCache.set(sourceURL, entry);
}
}
if (entry === undefined || sourceMapData(entry) === null) {
return undefined;
}
Expand Down Expand Up @@ -490,6 +554,7 @@ function getSourceLine(
}

module.exports = {
kGeneratedSourceMapCacheSizeLimit,
findSourceMap,
getSourceLine,
getSourceMapsSupport,
Expand Down
49 changes: 49 additions & 0 deletions test/parallel/test-source-map-generated-cache-limit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Flags: --enable-source-maps --expose-internals
'use strict';

/**
* This test verifies that the cache of source maps of generated sources stays
* within its budget, and that reading an entry keeps it from being evicted.
*/

require('../common');
const assert = require('node:assert');
const { findSourceMap } = require('node:module');
const {
kGeneratedSourceMapCacheSizeLimit,
} = require('internal/source_map/source_map_cache');

const sourcesContent = 'x'.repeat(1024 * 1024);
const payload = Buffer.from(JSON.stringify({
version: 3,
sources: ['a.js'],
sourcesContent: [sourcesContent],
names: [],
mappings: 'AAAA',
})).toString('base64');

function evaluate(id) {
eval(`(() => {})\n` +
`//# sourceURL=file:///generated-${id}.js\n` +
`//# sourceMappingURL=data:application/json;base64,${payload}`);
}

// Enough generated sources to overrun the budget twice over.
const count = Math.ceil(kGeneratedSourceMapCacheSizeLimit / payload.length) * 2;

evaluate(0);
evaluate(1);
assert.ok(findSourceMap('file:///generated-0.js'));

for (let i = 2; i <= count; i++) {
evaluate(i);
// Reading the first entry keeps it alive while the second one ages out.
findSourceMap('file:///generated-0.js');
}

assert.ok(findSourceMap('file:///generated-0.js'));
assert.strictEqual(findSourceMap('file:///generated-1.js'), undefined);

const sourceMap = findSourceMap(`file:///generated-${count}.js`);
assert.ok(sourceMap);
assert.strictEqual(sourceMap.findEntry(0, 0).originalLine, 0);
Loading