'use strict'; const { ArrayPrototypePush, JSONParse, ObjectFreeze, RegExpPrototypeExec, SafeMap, StringPrototypeCodePointAt, StringPrototypeSplit, } = primordials; // See https://tc39.es/ecma426/ for SourceMap V3 specification. const { Buffer } = require('buffer'); let debug = require('internal/util/debuglog').debuglog('source_map', (fn) => { debug = fn; }); const { validateBoolean, validateObject } = require('internal/validators'); const { setSourceMapsEnabled: setSourceMapsNative, } = internalBinding('errors'); const { defaultPrepareStackTrace, setInternalPrepareStackTrace, } = require('internal/errors'); const { getLazy, isUnderNodeModules, kEmptyObject } = require('internal/util'); const getModuleSourceMapCache = getLazy(() => { const { SourceMapCacheMap } = require('internal/source_map/source_map_cache_map'); 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. const generatedSourceMapCache = new SafeMap(); const kLeadingProtocol = /^\w+:\/\//; const kSourceMappingURLMagicComment = /\/[*/]#\s+sourceMappingURL=(?[^\s]+)/g; const kSourceURLMagicComment = /\/[*/]#\s+sourceURL=(?[^\s]+)/g; const { isAbsolute } = require('path'); const { fileURLToPath, pathToFileURL, URL, URLParse } = require('internal/url'); let SourceMap; // This is configured with --enable-source-maps during pre-execution. let sourceMapsSupport = ObjectFreeze({ __proto__: null, enabled: false, nodeModules: false, generatedCode: false, }); function getSourceMapsSupport() { // Return a read-only object. return sourceMapsSupport; } /** * Enables or disables source maps programmatically. * @param {boolean} enabled * @param {object} options * @param {boolean} [options.nodeModules] * @param {boolean} [options.generatedCode] */ function setSourceMapsSupport(enabled, options = kEmptyObject) { validateBoolean(enabled, 'enabled'); validateObject(options, 'options'); const { nodeModules = false, generatedCode = false } = options; validateBoolean(nodeModules, 'options.nodeModules'); validateBoolean(generatedCode, 'options.generatedCode'); setSourceMapsNative(enabled); if (enabled) { const { prepareStackTraceWithSourceMaps, } = require('internal/source_map/prepare_stack_trace'); setInternalPrepareStackTrace(prepareStackTraceWithSourceMaps); } else { setInternalPrepareStackTrace(defaultPrepareStackTrace); } sourceMapsSupport = ObjectFreeze({ __proto__: null, enabled, nodeModules: nodeModules, generatedCode: generatedCode, }); } /** * Extracts the source url from the content if present. For example * //# sourceURL=file:///path/to/file * * Read more at: https://tc39.es/source-map-spec/#linking-evald-code-to-named-generated-code * @param {string} content - source content * @returns {string | null} source url or null if not present */ function extractSourceURLMagicComment(content) { let match; let matchSourceURL; // A while loop is used here to get the last occurrence of sourceURL. // This is needed so that we don't match sourceURL in string literals. while ((match = RegExpPrototypeExec(kSourceURLMagicComment, content))) { matchSourceURL = match; } if (matchSourceURL == null) { return null; } let sourceURL = matchSourceURL.groups.sourceURL; if (sourceURL != null && RegExpPrototypeExec(kLeadingProtocol, sourceURL) === null) { sourceURL = pathToFileURL(sourceURL).href; } return sourceURL; } /** * Extracts the source map url from the content if present. For example * //# sourceMappingURL=file:///path/to/file * * Read more at: https://tc39.es/source-map-spec/#linking-generated-code * @param {string} content - source content * @returns {string | null} source map url or null if not present */ function extractSourceMapURLMagicComment(content) { let match; let lastMatch; // A while loop is used here to get the last occurrence of sourceMappingURL. // This is needed so that we don't match sourceMappingURL in string literals. while ((match = RegExpPrototypeExec(kSourceMappingURLMagicComment, content))) { lastMatch = match; } if (lastMatch == null) { return null; } return lastMatch.groups.sourceMappingURL; } /** * Caches the source map, with the given filename, moduleInstance, sourceURL and sourceMapURL. * This function does not automatically extract the source map from the content. The caller should either * extract the source map from the content via V8 API or use {@link extractSourceURLMagicComment} explicitly. * @param {string} filename - the actual filename * @param {string} content - the actual source content * @param {import('internal/modules/cjs/loader').Module | ModuleWrap} moduleInstance - a module instance that * associated with the source, once this is reclaimed, the source map entry will be removed from the cache * @param {boolean} isGeneratedSource - if the source was generated and evaluated with the global eval * @param {string | undefined} sourceURL - the source url * @param {string | undefined} sourceMapURL - the source map url */ function maybeCacheSourceMap(filename, content, moduleInstance, isGeneratedSource, sourceURL, sourceMapURL) { const support = getSourceMapsSupport(); if (!(process.env.NODE_V8_COVERAGE || support.enabled)) return; const { normalizeReferrerURL } = require('internal/modules/helpers'); filename = normalizeReferrerURL(filename); if (filename === undefined) { // This is most likely an invalid filename in sourceURL of [eval]-wrapper. return; } if (!support.nodeModules && isUnderNodeModules(filename)) { // Skip file under node_modules if not enabled. return; } // Bail out when there is no source map url. if (typeof sourceMapURL !== 'string') { return; } // 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. const entry = { __proto__: null, lineLengths: lineLengths(content), data, // Save the source map url if it is not a data url. sourceMapURL: data ? null : sourceMapURL, sourceURL, }; if (isGeneratedSource) { generatedSourceMapCache.set(filename, entry); return; } // If it is not a generated source, we assume we are in a "cjs/esm" // context. const keys = sourceURL ? [filename, sourceURL] : [filename]; getModuleSourceMapCache().set(keys, entry, moduleInstance); } /** * Caches the source map if it is present in the eval'd source. * @param {string} content - the eval'd source code */ function maybeCacheGeneratedSourceMap(content) { const support = getSourceMapsSupport(); if (!(process.env.NODE_V8_COVERAGE || support.enabled || support.generated)) return; const sourceURL = extractSourceURLMagicComment(content); if (sourceURL === null) { return; } const sourceMapURL = extractSourceMapURLMagicComment(content); if (sourceMapURL === null) { return; } try { // Use the sourceURL as the filename, and do not create a duplicate entry. maybeCacheSourceMap(sourceURL, content, null, true, undefined /** no duplicated sourceURL */, sourceMapURL); } catch (err) { // This can happen if the filename is not a valid URL. // If we fail to cache the source map, we should not fail the whole process. debug(err); } } /** * Resolves source map payload data from the source url and source map url. * If the source map url is a data url, the data is returned. * Otherwise the source map url is resolved to a file path and the file is read. * @param {string} sourceURL - url of the source file * @param {string} sourceMappingURL - url of the source map * @returns {object} deserialized source map JSON object */ function dataFromUrl(sourceURL, sourceMappingURL) { const url = URLParse(sourceMappingURL); if (url != null) { switch (url.protocol) { case 'data:': return sourceMapFromDataUrl(sourceURL, url.pathname); default: debug(`unknown protocol ${url.protocol}`); return null; } } const mapURL = new URL(sourceMappingURL, sourceURL).href; return sourceMapFromFile(mapURL); } // 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) { 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) if (codePoint === 10 || codePoint === 0x2028 || codePoint === 0x2029) { ArrayPrototypePush(output, lineLength); lineLength = -1; // To not count the matched codePoint such as \n character } } ArrayPrototypePush(output, lineLength); return output; } /** * Read source map from file. * @param {string} mapURL - file url of the source map * @returns {object} deserialized source map JSON object */ function sourceMapFromFile(mapURL) { try { const fs = require('fs'); const content = fs.readFileSync(fileURLToPath(mapURL), 'utf8'); const data = JSONParse(content); return sourcesToAbsolute(mapURL, data); } catch (err) { debug(err); return null; } } // data:[][;base64], see: // https://tools.ietf.org/html/rfc2397#section-2 function sourceMapFromDataUrl(sourceURL, url) { const { 0: format, 1: data } = StringPrototypeSplit(url, ',', 2); const splitFormat = StringPrototypeSplit(format, ';'); const contentType = splitFormat[0]; const base64 = splitFormat[splitFormat.length - 1] === 'base64'; if (contentType === 'application/json') { const decodedData = base64 ? Buffer.from(data, 'base64').toString('utf8') : data; try { const parsedData = JSONParse(decodedData); return sourcesToAbsolute(sourceURL, parsedData); } catch (err) { // TODO(legendecas): warn about invalid source map JSON string. // But it could be verbose. debug(err); return null; } } else { debug(`unknown content-type ${contentType}`); return null; } } // If the sources are not absolute URLs after prepending of the "sourceRoot", // the sources are resolved relative to the SourceMap (like resolving script // src in a html document). // If the sources are absolute paths, the sources are converted to absolute file URLs. function sourcesToAbsolute(baseURL, data) { data.sources = data.sources.map((source) => { source = (data.sourceRoot || '') + source; if (isAbsolute(source)) { return pathToFileURL(source).href; } return new URL(source, baseURL).href; }); // The sources array is now resolved to absolute URLs, sourceRoot should // be updated to noop. data.sourceRoot = ''; return data; } // WARNING: The `sourceMapCacheToObject` runs during shutdown. In particular, // it also runs when Workers are terminated, making it important that it does // not call out to any user-provided code, including built-in prototypes that // might have been tampered with. // Get serialized representation of source-map cache, this is used // to persist a cache of source-maps to disk when NODE_V8_COVERAGE is enabled. function sourceMapCacheToObject() { const moduleSourceMapCache = getModuleSourceMapCache(); if (moduleSourceMapCache.size === 0) { return undefined; } const obj = { __proto__: null }; for (const { 0: k, 1: v } of moduleSourceMapCache) { obj[k] = { __proto__: null, lineLengths: v.lineLengths, data: v.data, url: v.sourceMapURL, }; } return obj; } /** * Find a source map for a given actual source URL or path. * * This function may be invoked from user code or test runner, this must not throw * any exceptions. * @param {string} sourceURL - actual source URL or path * @returns {import('internal/source_map/source_map').SourceMap | undefined} a source map or undefined if not found */ function findSourceMap(sourceURL) { if (typeof sourceURL !== 'string') { return undefined; } // No source maps for builtin modules. if (sourceURL.startsWith('node:')) { return undefined; } if (!getSourceMapsSupport().nodeModules && isUnderNodeModules(sourceURL)) { return undefined; } SourceMap ??= require('internal/source_map/source_map').SourceMap; try { if (RegExpPrototypeExec(kLeadingProtocol, sourceURL) === null) { // If the sourceURL is an invalid path, this will throw an error. sourceURL = pathToFileURL(sourceURL).href; } const entry = getModuleSourceMapCache().get(sourceURL) ?? generatedSourceMapCache.get(sourceURL); if (entry?.data == null) { return undefined; } let sourceMap = entry.sourceMap; if (sourceMap === undefined) { sourceMap = new SourceMap(entry.data, { lineLengths: entry.lineLengths }); entry.sourceMap = sourceMap; } return sourceMap; } catch (err) { debug(err); return undefined; } } module.exports = { findSourceMap, getSourceMapsSupport, setSourceMapsSupport, maybeCacheSourceMap, maybeCacheGeneratedSourceMap, sourceMapCacheToObject, };