diff options
Diffstat (limited to 'devtools/client/shared/source-map-loader/source-map.js')
-rw-r--r-- | devtools/client/shared/source-map-loader/source-map.js | 646 |
1 files changed, 646 insertions, 0 deletions
diff --git a/devtools/client/shared/source-map-loader/source-map.js b/devtools/client/shared/source-map-loader/source-map.js new file mode 100644 index 0000000000..f493271236 --- /dev/null +++ b/devtools/client/shared/source-map-loader/source-map.js @@ -0,0 +1,646 @@ +/* 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 <http://mozilla.org/MPL/2.0/>. */ + +"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, + resolveSourceMapURL, + 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"); + +/** + * Create "original source info" objects being handed over to the main thread + * to describe original sources referenced in a source map + */ +function mapToOriginalSourceInfos(generatedId, urls) { + return urls.map(url => { + return { + id: generatedToOriginalId(generatedId, url), + url, + }; + }); +} + +/** + * Load the source map and retrieved infos about all the original sources + * referenced in that source map. + * + * @param {Object} generatedSource + * Source object for a bundle referencing a source map + * @return {Array<Object>|null} + * List of object with id and url attributes describing the original sources. + */ +async function getOriginalURLs(generatedSource) { + const { resolvedSourceMapURL, baseURL } = + resolveSourceMapURL(generatedSource); + const map = await fetchSourceMap( + generatedSource, + resolvedSourceMapURL, + baseURL + ); + return map ? mapToOriginalSourceInfos(generatedSource.id, map.sources) : null; +} + +/** + * Load the source map for a given bundle and return information + * about the related original sources and the source map itself. + * + * @param {Object} generatedSource + * Source object for the bundle. + * @return {Object} + * - {Array<Object>} sources + * Object with id and url attributes, refering to the related original sources + * referenced in the source map. + * - [String} resolvedSourceMapURL + * Absolute URL for the source map file. + * - {Array<String>} ignoreListUrls + * List of URLs of sources, designated by the source map, to be ignored in the debugger. + * - {String} exception + * In case of error, a string describing the situation. + */ +async function loadSourceMap(generatedSource) { + const { resolvedSourceMapURL, baseURL } = + resolveSourceMapURL(generatedSource); + try { + const map = await fetchSourceMap( + generatedSource, + resolvedSourceMapURL, + baseURL + ); + if (!map.sources.length) { + throw new Error("No sources are declared in this source map."); + } + let ignoreListUrls = []; + if (map.x_google_ignoreList?.length) { + ignoreListUrls = map.x_google_ignoreList.map( + sourceIndex => map.sources[sourceIndex] + ); + } + return { + sources: mapToOriginalSourceInfos(generatedSource.id, map.sources), + resolvedSourceMapURL, + ignoreListUrls, + }; + } catch (e) { + return { + sources: [], + resolvedSourceMapURL, + ignoreListUrls: [], + exception: e.message, + }; + } +} + +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, + }; +} + +/** + * Map the breakable positions (line and columns) from generated to original locations. + * + * @param {Object} breakpointPositions + * List of columns per line refering to the breakable columns per line + * for a given source: + * { + * 1: [2, 6], // On line 1, column 2 and 6 are breakable. + * ... + * } + * @param {string} sourceId + * The ID for the generated source. + */ +async function getOriginalLocations(breakpointPositions, sourceId) { + const map = await getSourceMap(sourceId); + if (!map) { + return null; + } + for (const line in breakpointPositions) { + const breakableColumnsPerLine = breakpointPositions[line]; + for (let i = 0; i < breakableColumnsPerLine.length; i++) { + const column = breakableColumnsPerLine[i]; + const mappedLocation = getOriginalLocationSync(map, { + sourceId, + line: parseInt(line, 10), + column, + }); + if (mappedLocation) { + // As we replace the `column` with the mappedLocation, + // also transfer the generated column so that we can compute both original and generated locations + // in the main thread. + mappedLocation.generatedColumn = column; + breakableColumnsPerLine[i] = mappedLocation; + } + } + } + return breakpointPositions; +} + +/** + * Query the source map for a mapping from bundle location to original location. + * + * @param {SourceMapConsumer} map + * The source map for the bundle source. + * @param {Object} location + * A location within a bundle to map to an original location. + * @param {Object} options + * @param {Boolean} options.looseSearch + * Optional, if true, will do a loose search on first column and next lines + * until a mapping is found. + * @return {location} + * The mapped location in the original source. + */ +function getOriginalLocationSync(map, location, { looseSearch = false } = {}) { + // First check for an exact match + let match = map.originalPositionFor({ + line: location.line, + column: location.column == null ? 0 : location.column, + }); + + // Then check for a loose match by sliding to first column and next lines + if (match.sourceUrl == null && looseSearch) { + let line = location.line; + // if a non-0 column was passed, we want to do the search from the beginning of the line, + // otherwise, we can start looking into next lines + let firstLineChecked = (location.column || 0) !== 0; + + // Avoid looping through the whole file and limit the sliding search to the next 10 lines. + while (match.sourceUrl === null && line < location.line + 10) { + if (firstLineChecked) { + line++; + } else { + firstLineChecked = true; + } + match = map.originalPositionFor({ + line, + column: 0, + bias: SourceMapConsumer.LEAST_UPPER_BOUND, + }); + } + } + + 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, + }; +} + +/** + * Map a bundle location to an original one. + * + * @param {Object} location + * Bundle location + * @param {Object} options + * See getORiginalLocationSync. + * @return {Object} + * Original location + */ +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<string>} 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, + loadSourceMap, + hasOriginalURL, + getOriginalRanges, + getGeneratedRanges, + getGeneratedLocation, + getOriginalLocation, + getOriginalLocations, + getOriginalSourceText, + getGeneratedRangesForOriginal, + getFileGeneratedRange, + setSourceMapForGeneratedSources, + clearSourceMaps, +}; |