/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at . */ "use strict"; /** * Source Map Worker * @module utils/source-map-worker */ const { SourceMapConsumer, } = require("resource://devtools/client/shared/vendor/source-map/source-map.js"); // Initialize the source-map library right away so that all other code can use it. SourceMapConsumer.initialize({ "lib/mappings.wasm": "resource://devtools/client/shared/vendor/source-map/lib/mappings.wasm", }); const { networkRequest, } = require("resource://devtools/client/shared/source-map-loader/utils/network-request.js"); const assert = require("resource://devtools/client/shared/source-map-loader/utils/assert.js"); const { fetchSourceMap, hasOriginalURL, clearOriginalURLs, } = require("resource://devtools/client/shared/source-map-loader/utils/fetchSourceMap.js"); const { getSourceMap, getSourceMapWithMetadata, setSourceMap, clearSourceMaps: clearSourceMapsRequests, } = require("resource://devtools/client/shared/source-map-loader/utils/sourceMapRequests.js"); const { originalToGeneratedId, generatedToOriginalId, isGeneratedId, isOriginalId, getContentType, } = require("resource://devtools/client/shared/source-map-loader/utils/index.js"); const { clearWasmXScopes, } = require("resource://devtools/client/shared/source-map-loader/wasm-dwarf/wasmXScopes.js"); async function getOriginalURLs(generatedSource) { await fetchSourceMap(generatedSource); const data = await getSourceMapWithMetadata(generatedSource.id); return data ? data.sources : null; } async function getSourceMapIgnoreList(generatedSourceId) { const data = await getSourceMapWithMetadata(generatedSourceId); return data ? data.ignoreListUrls : []; } const COMPUTED_SPANS = new WeakSet(); const SOURCE_MAPPINGS = new WeakMap(); async function getOriginalRanges(sourceId) { if (!isOriginalId(sourceId)) { return []; } const generatedSourceId = originalToGeneratedId(sourceId); const data = await getSourceMapWithMetadata(generatedSourceId); if (!data) { return []; } const { map } = data; const url = data.urlsById.get(sourceId); let mappings = SOURCE_MAPPINGS.get(map); if (!mappings) { mappings = new Map(); SOURCE_MAPPINGS.set(map, mappings); } let fileMappings = mappings.get(url); if (!fileMappings) { fileMappings = []; mappings.set(url, fileMappings); const originalMappings = fileMappings; map.eachMapping( mapping => { if (mapping.source !== url) { return; } const last = originalMappings[originalMappings.length - 1]; if (last && last.line === mapping.originalLine) { if (last.columnStart < mapping.originalColumn) { last.columnEnd = mapping.originalColumn; } else { // Skip this duplicate original location, return; } } originalMappings.push({ line: mapping.originalLine, columnStart: mapping.originalColumn, columnEnd: Infinity, }); }, null, SourceMapConsumer.ORIGINAL_ORDER ); } return fileMappings; } /** * Given an original location, find the ranges on the generated file that * are mapped from the original range containing the location. */ async function getGeneratedRanges(location) { if (!isOriginalId(location.sourceId)) { return []; } const generatedSourceId = originalToGeneratedId(location.sourceId); const data = await getSourceMapWithMetadata(generatedSourceId); if (!data) { return []; } const { urlsById, map } = data; if (!COMPUTED_SPANS.has(map)) { COMPUTED_SPANS.add(map); map.computeColumnSpans(); } // We want to use 'allGeneratedPositionsFor' to get the _first_ generated // location, but it hard-codes SourceMapConsumer.LEAST_UPPER_BOUND as the // bias, making it search in the wrong direction for this usecase. // To work around this, we use 'generatedPositionFor' and then look up the // exact original location, making any bias value unnecessary, and then // use that location for the call to 'allGeneratedPositionsFor'. const genPos = map.generatedPositionFor({ source: urlsById.get(location.sourceId), line: location.line, column: location.column == null ? 0 : location.column, bias: SourceMapConsumer.GREATEST_LOWER_BOUND, }); if (genPos.line === null) { return []; } const positions = map.allGeneratedPositionsFor( map.originalPositionFor({ line: genPos.line, column: genPos.column, }) ); return positions .map(mapping => ({ line: mapping.line, columnStart: mapping.column, columnEnd: mapping.lastColumn, })) .sort((a, b) => { const line = a.line - b.line; return line === 0 ? a.column - b.column : line; }); } async function getGeneratedLocation(location) { if (!isOriginalId(location.sourceId)) { return null; } const generatedSourceId = originalToGeneratedId(location.sourceId); const data = await getSourceMapWithMetadata(generatedSourceId); if (!data) { return null; } const { urlsById, map } = data; const positions = map.allGeneratedPositionsFor({ source: urlsById.get(location.sourceId), line: location.line, column: location.column == null ? 0 : location.column, }); // Prior to source-map 0.7, the source-map module returned the earliest // generated location in the file when there were multiple generated // locations. The current comparison fn in 0.7 does not appear to take // generated location into account properly. let match; for (const pos of positions) { if (!match || pos.line < match.line || pos.column < match.column) { match = pos; } } if (!match) { match = map.generatedPositionFor({ source: urlsById.get(location.sourceId), line: location.line, column: location.column == null ? 0 : location.column, bias: SourceMapConsumer.LEAST_UPPER_BOUND, }); } return { sourceId: generatedSourceId, line: match.line, column: match.column, }; } async function getOriginalLocations(locations, options = {}) { const maps = {}; const results = []; for (const location of locations) { let map = maps[location.sourceId]; if (map === undefined) { map = await getSourceMap(location.sourceId); maps[location.sourceId] = map || null; } results.push(map ? getOriginalLocationSync(map, location, options) : null); } return results; } function getOriginalLocationSync(map, location, { search } = {}) { // First check for an exact match let match = map.originalPositionFor({ line: location.line, column: location.column == null ? 0 : location.column, }); // If there is not an exact match, look for a match with a bias at the // current location and then on subsequent lines if (search) { let line = location.line; let column = location.column == null ? 0 : location.column; while (match.source === null) { match = map.originalPositionFor({ line, column, bias: SourceMapConsumer[search], }); line += search == "LEAST_UPPER_BOUND" ? 1 : -1; column = search == "LEAST_UPPER_BOUND" ? 0 : Infinity; } } const { source: sourceUrl, line, column } = match; if (sourceUrl == null) { // No url means the location didn't map. return null; } return { sourceId: generatedToOriginalId(location.sourceId, sourceUrl), sourceUrl, line, column, }; } async function getOriginalLocation(location, options = {}) { if (!isGeneratedId(location.sourceId)) { return null; } const map = await getSourceMap(location.sourceId); if (!map) { return null; } return getOriginalLocationSync(map, location, options); } async function getOriginalSourceText(originalSourceId) { assert(isOriginalId(originalSourceId), "Source is not an original source"); const generatedSourceId = originalToGeneratedId(originalSourceId); const data = await getSourceMapWithMetadata(generatedSourceId); if (!data) { return null; } const { urlsById, map } = data; const url = urlsById.get(originalSourceId); let text = map.sourceContentFor(url, true); if (!text) { try { const response = await networkRequest(url, { sourceMapBaseURL: map.sourceMapBaseURL, loadFromCache: false, allowsRedirects: false, }); text = response.content; } catch (err) { // Workers exceptions are processed by worker-utils module and // only metadata attribute is transferred between threads. // Notify the main thread about which url failed loading. err.metadata = { url, }; throw err; } } return { text, contentType: getContentType(url || ""), }; } /** * Find the set of ranges on the generated file that map from the original * file's locations. * * @param sourceId - The original ID of the file we are processing. * @param url - The original URL of the file we are processing. * @param mergeUnmappedRegions - If unmapped regions are encountered between * two mappings for the given original file, allow the two mappings to be * merged anyway. This is useful if you are more interested in the general * contiguous ranges associated with a file, rather than the specifics of * the ranges provided by the sourcemap. */ const GENERATED_MAPPINGS = new WeakMap(); async function getGeneratedRangesForOriginal( sourceId, mergeUnmappedRegions = false ) { assert(isOriginalId(sourceId), "Source is not an original source"); const data = await getSourceMapWithMetadata(originalToGeneratedId(sourceId)); // NOTE: this is only needed for Flow if (!data) { return []; } const { urlsById, map } = data; const url = urlsById.get(sourceId); if (!COMPUTED_SPANS.has(map)) { COMPUTED_SPANS.add(map); map.computeColumnSpans(); } if (!GENERATED_MAPPINGS.has(map)) { GENERATED_MAPPINGS.set(map, new Map()); } const generatedRangesMap = GENERATED_MAPPINGS.get(map); if (!generatedRangesMap) { return []; } if (generatedRangesMap.has(sourceId)) { // NOTE we need to coerce the result to an array for Flow return generatedRangesMap.get(sourceId) || []; } // Gather groups of mappings on the generated file, with new groups created // if we cross a mapping for a different file. let currentGroup = []; const originalGroups = [currentGroup]; map.eachMapping( mapping => { if (mapping.source === url) { currentGroup.push({ start: { line: mapping.generatedLine, column: mapping.generatedColumn, }, end: { line: mapping.generatedLine, // The lastGeneratedColumn value is an inclusive value so we add // one to it to get the exclusive end position. column: mapping.lastGeneratedColumn + 1, }, }); } else if (typeof mapping.source === "string" && currentGroup.length) { // If there is a URL, but it is for a _different_ file, we create a // new group of mappings so that we can tell currentGroup = []; originalGroups.push(currentGroup); } }, null, SourceMapConsumer.GENERATED_ORDER ); const generatedMappingsForOriginal = []; if (mergeUnmappedRegions) { // If we don't care about excluding unmapped regions, then we just need to // create a range that is the fully encompasses each group, ignoring the // empty space between each individual range. for (const group of originalGroups) { if (group.length) { generatedMappingsForOriginal.push({ start: group[0].start, end: group[group.length - 1].end, }); } } } else { let lastEntry; for (const group of originalGroups) { lastEntry = null; for (const { start, end } of group) { const lastEnd = lastEntry ? wrappedMappingPosition(lastEntry.end) : null; // If this entry comes immediately after the previous one, extend the // range of the previous entry instead of adding a new one. if ( lastEntry && lastEnd && lastEnd.line === start.line && lastEnd.column === start.column ) { lastEntry.end = end; } else { const newEntry = { start, end }; generatedMappingsForOriginal.push(newEntry); lastEntry = newEntry; } } } } generatedRangesMap.set(sourceId, generatedMappingsForOriginal); return generatedMappingsForOriginal; } function wrappedMappingPosition(pos) { if (pos.column !== Infinity) { return pos; } // If the end of the entry consumes the whole line, treat it as wrapping to // the next line. return { line: pos.line + 1, column: 0, }; } async function getFileGeneratedRange(originalSourceId) { assert(isOriginalId(originalSourceId), "Source is not an original source"); const data = await getSourceMapWithMetadata( originalToGeneratedId(originalSourceId) ); if (!data) { return null; } const { urlsById, map } = data; const start = map.generatedPositionFor({ source: urlsById.get(originalSourceId), line: 1, column: 0, bias: SourceMapConsumer.LEAST_UPPER_BOUND, }); const end = map.generatedPositionFor({ source: urlsById.get(originalSourceId), line: Number.MAX_SAFE_INTEGER, column: Number.MAX_SAFE_INTEGER, bias: SourceMapConsumer.GREATEST_LOWER_BOUND, }); return { start, end, }; } /** * Set the sourceMap for multiple passed source ids. * * @param {Array} generatedSourceIds * @param {Object} map: An actual sourcemap (as generated with SourceMapGenerator#toJSON) */ function setSourceMapForGeneratedSources(generatedSourceIds, map) { const sourceMapConsumer = new SourceMapConsumer(map); for (const generatedId of generatedSourceIds) { setSourceMap(generatedId, Promise.resolve(sourceMapConsumer)); } } function clearSourceMaps() { clearSourceMapsRequests(); clearWasmXScopes(); clearOriginalURLs(); } module.exports = { getOriginalURLs, hasOriginalURL, getOriginalRanges, getGeneratedRanges, getGeneratedLocation, getOriginalLocation, getOriginalLocations, getOriginalSourceText, getGeneratedRangesForOriginal, getFileGeneratedRange, getSourceMapIgnoreList, setSourceMapForGeneratedSources, clearSourceMaps, };