source-map.js (18952B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ 4 5 "use strict"; 6 7 /** 8 * Source Map Worker 9 * 10 * @module utils/source-map-worker 11 */ 12 13 const { 14 SourceMapConsumer, 15 } = require("resource://devtools/client/shared/vendor/source-map/source-map.js"); 16 17 // Initialize the source-map library right away so that all other code can use it. 18 SourceMapConsumer.initialize({ 19 "lib/mappings.wasm": 20 "resource://devtools/client/shared/vendor/source-map/lib/mappings.wasm", 21 }); 22 23 const { 24 networkRequest, 25 } = require("resource://devtools/client/shared/source-map-loader/utils/network-request.js"); 26 const assert = require("resource://devtools/client/shared/source-map-loader/utils/assert.js"); 27 const { 28 fetchSourceMap, 29 resolveSourceMapURL, 30 hasOriginalURL, 31 clearOriginalURLs, 32 } = require("resource://devtools/client/shared/source-map-loader/utils/fetchSourceMap.js"); 33 const { 34 getSourceMap, 35 getSourceMapWithMetadata, 36 setSourceMap, 37 clearSourceMapForSources, 38 clearSourceMaps: clearSourceMapsRequests, 39 } = require("resource://devtools/client/shared/source-map-loader/utils/sourceMapRequests.js"); 40 const { 41 originalToGeneratedId, 42 generatedToOriginalId, 43 isGeneratedId, 44 isOriginalId, 45 getContentType, 46 } = require("resource://devtools/client/shared/source-map-loader/utils/index.js"); 47 const { 48 clearWasmXScopes, 49 } = require("resource://devtools/client/shared/source-map-loader/wasm-dwarf/wasmXScopes.js"); 50 51 /** 52 * Create "original source info" objects being handed over to the main thread 53 * to describe original sources referenced in a source map 54 */ 55 function mapToOriginalSourceInfos(generatedId, urls) { 56 return urls.map(url => { 57 return { 58 id: generatedToOriginalId(generatedId, url), 59 url, 60 }; 61 }); 62 } 63 64 /** 65 * Load the source map and retrieved infos about all the original sources 66 * referenced in that source map. 67 * 68 * @param {object} generatedSource 69 * Source object for a bundle referencing a source map 70 * @return {Array<object> | null} 71 * List of object with id and url attributes describing the original sources. 72 */ 73 async function getOriginalURLs(generatedSource) { 74 const { resolvedSourceMapURL, baseURL } = 75 resolveSourceMapURL(generatedSource); 76 const map = await fetchSourceMap( 77 generatedSource, 78 resolvedSourceMapURL, 79 baseURL 80 ); 81 return map ? mapToOriginalSourceInfos(generatedSource.id, map.sources) : null; 82 } 83 84 /** 85 * Load the source map for a given bundle and return information 86 * about the related original sources and the source map itself. 87 * 88 * @param {object} generatedSource 89 * Source object for the bundle. 90 * @return {object} 91 * - {Array<Object>} sources 92 * Object with id and url attributes, refering to the related original sources 93 * referenced in the source map. 94 * - [String} resolvedSourceMapURL 95 * Absolute URL for the source map file. 96 * - {Array<String>} ignoreListUrls 97 * List of URLs of sources, designated by the source map, to be ignored in the debugger. 98 * - {String} exception 99 * In case of error, a string describing the situation. 100 */ 101 async function loadSourceMap(generatedSource) { 102 const { resolvedSourceMapURL, baseURL } = 103 resolveSourceMapURL(generatedSource); 104 try { 105 const map = await fetchSourceMap( 106 generatedSource, 107 resolvedSourceMapURL, 108 baseURL 109 ); 110 if (!map.sources.length) { 111 throw new Error("No sources are declared in this source map."); 112 } 113 let ignoreListUrls = []; 114 if (map.x_google_ignoreList?.length) { 115 ignoreListUrls = map.x_google_ignoreList.map( 116 sourceIndex => map.sources[sourceIndex] 117 ); 118 } 119 return { 120 sources: mapToOriginalSourceInfos(generatedSource.id, map.sources), 121 resolvedSourceMapURL, 122 ignoreListUrls, 123 }; 124 } catch (e) { 125 return { 126 sources: [], 127 resolvedSourceMapURL, 128 ignoreListUrls: [], 129 exception: e.message, 130 }; 131 } 132 } 133 134 const COMPUTED_SPANS = new WeakSet(); 135 136 const SOURCE_MAPPINGS = new WeakMap(); 137 async function getOriginalRanges(sourceId) { 138 if (!isOriginalId(sourceId)) { 139 return []; 140 } 141 142 const generatedSourceId = originalToGeneratedId(sourceId); 143 const data = await getSourceMapWithMetadata(generatedSourceId); 144 if (!data) { 145 return []; 146 } 147 const { map } = data; 148 const url = data.urlsById.get(sourceId); 149 150 let mappings = SOURCE_MAPPINGS.get(map); 151 if (!mappings) { 152 mappings = new Map(); 153 SOURCE_MAPPINGS.set(map, mappings); 154 } 155 156 let fileMappings = mappings.get(url); 157 if (!fileMappings) { 158 fileMappings = []; 159 mappings.set(url, fileMappings); 160 161 const originalMappings = fileMappings; 162 map.eachMapping( 163 mapping => { 164 if (mapping.source !== url) { 165 return; 166 } 167 168 const last = originalMappings[originalMappings.length - 1]; 169 170 if (last && last.line === mapping.originalLine) { 171 if (last.columnStart < mapping.originalColumn) { 172 last.columnEnd = mapping.originalColumn; 173 } else { 174 // Skip this duplicate original location, 175 return; 176 } 177 } 178 179 originalMappings.push({ 180 line: mapping.originalLine, 181 columnStart: mapping.originalColumn, 182 columnEnd: Infinity, 183 }); 184 }, 185 null, 186 SourceMapConsumer.ORIGINAL_ORDER 187 ); 188 } 189 190 return fileMappings; 191 } 192 193 /** 194 * Given an original location, find the ranges on the generated file that 195 * are mapped from the original range containing the location. 196 */ 197 async function getGeneratedRanges(location) { 198 if (!isOriginalId(location.sourceId)) { 199 return []; 200 } 201 202 const generatedSourceId = originalToGeneratedId(location.sourceId); 203 const data = await getSourceMapWithMetadata(generatedSourceId); 204 if (!data) { 205 return []; 206 } 207 const { urlsById, map } = data; 208 209 if (!COMPUTED_SPANS.has(map)) { 210 COMPUTED_SPANS.add(map); 211 map.computeColumnSpans(); 212 } 213 214 // We want to use 'allGeneratedPositionsFor' to get the _first_ generated 215 // location, but it hard-codes SourceMapConsumer.LEAST_UPPER_BOUND as the 216 // bias, making it search in the wrong direction for this usecase. 217 // To work around this, we use 'generatedPositionFor' and then look up the 218 // exact original location, making any bias value unnecessary, and then 219 // use that location for the call to 'allGeneratedPositionsFor'. 220 const genPos = map.generatedPositionFor({ 221 source: urlsById.get(location.sourceId), 222 line: location.line, 223 column: location.column == null ? 0 : location.column, 224 bias: SourceMapConsumer.GREATEST_LOWER_BOUND, 225 }); 226 if (genPos.line === null) { 227 return []; 228 } 229 230 const positions = map.allGeneratedPositionsFor( 231 map.originalPositionFor({ 232 line: genPos.line, 233 column: genPos.column, 234 }) 235 ); 236 237 return positions 238 .map(mapping => ({ 239 line: mapping.line, 240 columnStart: mapping.column, 241 columnEnd: mapping.lastColumn, 242 })) 243 .sort((a, b) => { 244 const line = a.line - b.line; 245 return line === 0 ? a.column - b.column : line; 246 }); 247 } 248 249 async function getGeneratedLocation(location) { 250 if (!isOriginalId(location.sourceId)) { 251 return null; 252 } 253 254 const generatedSourceId = originalToGeneratedId(location.sourceId); 255 const data = await getSourceMapWithMetadata(generatedSourceId); 256 if (!data) { 257 return null; 258 } 259 const { urlsById, map } = data; 260 261 const positions = map.allGeneratedPositionsFor({ 262 source: urlsById.get(location.sourceId), 263 line: location.line, 264 column: location.column == null ? 0 : location.column, 265 }); 266 267 // Prior to source-map 0.7, the source-map module returned the earliest 268 // generated location in the file when there were multiple generated 269 // locations. The current comparison fn in 0.7 does not appear to take 270 // generated location into account properly. 271 let match; 272 for (const pos of positions) { 273 if (!match || pos.line < match.line || pos.column < match.column) { 274 match = pos; 275 } 276 } 277 278 if (!match) { 279 match = map.generatedPositionFor({ 280 source: urlsById.get(location.sourceId), 281 line: location.line, 282 column: location.column == null ? 0 : location.column, 283 bias: SourceMapConsumer.LEAST_UPPER_BOUND, 284 }); 285 } 286 287 return { 288 sourceId: generatedSourceId, 289 line: match.line, 290 column: match.column, 291 }; 292 } 293 294 /** 295 * Map the breakable positions (line and columns) from generated to original locations. 296 * 297 * @param {object} breakpointPositions 298 * List of columns per line refering to the breakable columns per line 299 * for a given source: 300 * { 301 * 1: [2, 6], // On line 1, column 2 and 6 are breakable. 302 * ... 303 * } 304 * @param {string} sourceId 305 * The ID for the generated source. 306 */ 307 async function getOriginalLocations(breakpointPositions, sourceId) { 308 const map = await getSourceMap(sourceId); 309 if (!map) { 310 return null; 311 } 312 for (const line in breakpointPositions) { 313 const breakableColumnsPerLine = breakpointPositions[line]; 314 for (let i = 0; i < breakableColumnsPerLine.length; i++) { 315 const column = breakableColumnsPerLine[i]; 316 const mappedLocation = getOriginalLocationSync(map, { 317 sourceId, 318 line: parseInt(line, 10), 319 column, 320 }); 321 if (mappedLocation) { 322 // As we replace the `column` with the mappedLocation, 323 // also transfer the generated column so that we can compute both original and generated locations 324 // in the main thread. 325 mappedLocation.generatedColumn = column; 326 breakableColumnsPerLine[i] = mappedLocation; 327 } 328 } 329 } 330 return breakpointPositions; 331 } 332 333 /** 334 * Query the source map for a mapping from bundle location to original location. 335 * 336 * @param {SourceMapConsumer} map 337 * The source map for the bundle source. 338 * @param {object} location 339 * A location within a bundle to map to an original location. 340 * @param {object} options 341 * @param {boolean} options.looseSearch 342 * Optional, if true, will do a loose search on first column and next lines 343 * until a mapping is found. 344 * @return {location} 345 * The mapped location in the original source. 346 */ 347 function getOriginalLocationSync(map, location, { looseSearch = false } = {}) { 348 // First check for an exact match 349 let match = map.originalPositionFor({ 350 line: location.line, 351 column: location.column == null ? 0 : location.column, 352 }); 353 354 // Then check for a loose match by sliding to first column and next lines 355 if (match.sourceUrl == null && looseSearch) { 356 let line = location.line; 357 // if a non-0 column was passed, we want to do the search from the beginning of the line, 358 // otherwise, we can start looking into next lines 359 let firstLineChecked = (location.column || 0) !== 0; 360 361 // Avoid looping through the whole file and limit the sliding search to the next 10 lines. 362 while (match.sourceUrl === null && line < location.line + 10) { 363 if (firstLineChecked) { 364 line++; 365 } else { 366 firstLineChecked = true; 367 } 368 match = map.originalPositionFor({ 369 line, 370 column: 0, 371 bias: SourceMapConsumer.LEAST_UPPER_BOUND, 372 }); 373 } 374 } 375 376 const { source: sourceUrl, line, column } = match; 377 378 if (sourceUrl == null) { 379 // No url means the location didn't map. 380 return null; 381 } 382 383 return { 384 sourceId: generatedToOriginalId(location.sourceId, sourceUrl), 385 sourceUrl, 386 line, 387 column, 388 }; 389 } 390 391 /** 392 * Map a bundle location to an original one. 393 * 394 * @param {object} location 395 * Bundle location 396 * @param {object} options 397 * See getORiginalLocationSync. 398 * @return {object} 399 * Original location 400 */ 401 async function getOriginalLocation(location, options) { 402 if (!isGeneratedId(location.sourceId)) { 403 return null; 404 } 405 406 const map = await getSourceMap(location.sourceId); 407 if (!map) { 408 return null; 409 } 410 411 return getOriginalLocationSync(map, location, options); 412 } 413 414 async function getOriginalSourceText(originalSourceId) { 415 assert(isOriginalId(originalSourceId), "Source is not an original source"); 416 417 const generatedSourceId = originalToGeneratedId(originalSourceId); 418 const data = await getSourceMapWithMetadata(generatedSourceId); 419 if (!data) { 420 return null; 421 } 422 const { urlsById, map } = data; 423 424 const url = urlsById.get(originalSourceId); 425 let text = map.sourceContentFor(url, true); 426 if (!text) { 427 try { 428 const response = await networkRequest(url, { 429 sourceMapBaseURL: map.sourceMapBaseURL, 430 loadFromCache: false, 431 allowsRedirects: false, 432 }); 433 text = response.content; 434 } catch (err) { 435 // Workers exceptions are processed by worker-utils module and 436 // only metadata attribute is transferred between threads. 437 // Notify the main thread about which url failed loading. 438 err.metadata = { 439 url, 440 }; 441 throw err; 442 } 443 } 444 445 return { 446 text, 447 contentType: getContentType(url || ""), 448 }; 449 } 450 451 /** 452 * Find the set of ranges on the generated file that map from the original 453 * file's locations. 454 * 455 * @param sourceId - The original ID of the file we are processing. 456 * @param url - The original URL of the file we are processing. 457 * @param mergeUnmappedRegions - If unmapped regions are encountered between 458 * two mappings for the given original file, allow the two mappings to be 459 * merged anyway. This is useful if you are more interested in the general 460 * contiguous ranges associated with a file, rather than the specifics of 461 * the ranges provided by the sourcemap. 462 */ 463 const GENERATED_MAPPINGS = new WeakMap(); 464 async function getGeneratedRangesForOriginal( 465 sourceId, 466 mergeUnmappedRegions = false 467 ) { 468 assert(isOriginalId(sourceId), "Source is not an original source"); 469 470 const data = await getSourceMapWithMetadata(originalToGeneratedId(sourceId)); 471 // NOTE: this is only needed for Flow 472 if (!data) { 473 return []; 474 } 475 const { urlsById, map } = data; 476 const url = urlsById.get(sourceId); 477 478 if (!COMPUTED_SPANS.has(map)) { 479 COMPUTED_SPANS.add(map); 480 map.computeColumnSpans(); 481 } 482 483 if (!GENERATED_MAPPINGS.has(map)) { 484 GENERATED_MAPPINGS.set(map, new Map()); 485 } 486 487 const generatedRangesMap = GENERATED_MAPPINGS.get(map); 488 if (!generatedRangesMap) { 489 return []; 490 } 491 492 if (generatedRangesMap.has(sourceId)) { 493 // NOTE we need to coerce the result to an array for Flow 494 return generatedRangesMap.get(sourceId) || []; 495 } 496 497 // Gather groups of mappings on the generated file, with new groups created 498 // if we cross a mapping for a different file. 499 let currentGroup = []; 500 const originalGroups = [currentGroup]; 501 map.eachMapping( 502 mapping => { 503 if (mapping.source === url) { 504 currentGroup.push({ 505 start: { 506 line: mapping.generatedLine, 507 column: mapping.generatedColumn, 508 }, 509 end: { 510 line: mapping.generatedLine, 511 // The lastGeneratedColumn value is an inclusive value so we add 512 // one to it to get the exclusive end position. 513 column: mapping.lastGeneratedColumn + 1, 514 }, 515 }); 516 } else if (typeof mapping.source === "string" && currentGroup.length) { 517 // If there is a URL, but it is for a _different_ file, we create a 518 // new group of mappings so that we can tell 519 currentGroup = []; 520 originalGroups.push(currentGroup); 521 } 522 }, 523 null, 524 SourceMapConsumer.GENERATED_ORDER 525 ); 526 527 const generatedMappingsForOriginal = []; 528 if (mergeUnmappedRegions) { 529 // If we don't care about excluding unmapped regions, then we just need to 530 // create a range that is the fully encompasses each group, ignoring the 531 // empty space between each individual range. 532 for (const group of originalGroups) { 533 if (group.length) { 534 generatedMappingsForOriginal.push({ 535 start: group[0].start, 536 end: group[group.length - 1].end, 537 }); 538 } 539 } 540 } else { 541 let lastEntry; 542 for (const group of originalGroups) { 543 lastEntry = null; 544 for (const { start, end } of group) { 545 const lastEnd = lastEntry 546 ? wrappedMappingPosition(lastEntry.end) 547 : null; 548 549 // If this entry comes immediately after the previous one, extend the 550 // range of the previous entry instead of adding a new one. 551 if ( 552 lastEntry && 553 lastEnd && 554 lastEnd.line === start.line && 555 lastEnd.column === start.column 556 ) { 557 lastEntry.end = end; 558 } else { 559 const newEntry = { start, end }; 560 generatedMappingsForOriginal.push(newEntry); 561 lastEntry = newEntry; 562 } 563 } 564 } 565 } 566 567 generatedRangesMap.set(sourceId, generatedMappingsForOriginal); 568 return generatedMappingsForOriginal; 569 } 570 571 function wrappedMappingPosition(pos) { 572 if (pos.column !== Infinity) { 573 return pos; 574 } 575 576 // If the end of the entry consumes the whole line, treat it as wrapping to 577 // the next line. 578 return { 579 line: pos.line + 1, 580 column: 0, 581 }; 582 } 583 584 async function getFileGeneratedRange(originalSourceId) { 585 assert(isOriginalId(originalSourceId), "Source is not an original source"); 586 587 const data = await getSourceMapWithMetadata( 588 originalToGeneratedId(originalSourceId) 589 ); 590 if (!data) { 591 return null; 592 } 593 const { urlsById, map } = data; 594 595 const start = map.generatedPositionFor({ 596 source: urlsById.get(originalSourceId), 597 line: 1, 598 column: 0, 599 bias: SourceMapConsumer.LEAST_UPPER_BOUND, 600 }); 601 602 const end = map.generatedPositionFor({ 603 source: urlsById.get(originalSourceId), 604 line: Number.MAX_SAFE_INTEGER, 605 column: Number.MAX_SAFE_INTEGER, 606 bias: SourceMapConsumer.GREATEST_LOWER_BOUND, 607 }); 608 609 return { 610 start, 611 end, 612 }; 613 } 614 615 /** 616 * Set the sourceMap for multiple passed source ids. 617 * 618 * @param {Array<string>} generatedSourceIds 619 * @param {object} map: An actual sourcemap (as generated with SourceMapGenerator#toJSON) 620 */ 621 function setSourceMapForGeneratedSources(generatedSourceIds, map) { 622 const sourceMapConsumer = new SourceMapConsumer(map); 623 for (const generatedId of generatedSourceIds) { 624 setSourceMap(generatedId, Promise.resolve(sourceMapConsumer)); 625 } 626 } 627 function clearSourceMapForGeneratedSources(generatedSourceIds) { 628 clearSourceMapForSources(generatedSourceIds); 629 } 630 631 function clearSourceMaps() { 632 clearSourceMapsRequests(); 633 clearWasmXScopes(); 634 clearOriginalURLs(); 635 } 636 637 module.exports = { 638 getOriginalURLs, 639 loadSourceMap, 640 hasOriginalURL, 641 getOriginalRanges, 642 getGeneratedRanges, 643 getGeneratedLocation, 644 getOriginalLocation, 645 getOriginalLocations, 646 getOriginalSourceText, 647 getGeneratedRangesForOriginal, 648 getFileGeneratedRange, 649 setSourceMapForGeneratedSources, 650 clearSourceMapForGeneratedSources, 651 clearSourceMaps, 652 };