source.js (22883B)
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 const { Actor } = require("resource://devtools/shared/protocol.js"); 8 const { sourceSpec } = require("resource://devtools/shared/specs/source.js"); 9 10 const { 11 setBreakpointAtEntryPoints, 12 } = require("resource://devtools/server/actors/breakpoint.js"); 13 const { 14 getSourcemapBaseURL, 15 } = require("resource://devtools/server/actors/utils/source-map-utils.js"); 16 const { 17 getDebuggerSourceURL, 18 } = require("resource://devtools/server/actors/utils/source-url.js"); 19 loader.lazyRequireGetter( 20 this, 21 "ArrayBufferActor", 22 "resource://devtools/server/actors/array-buffer.js", 23 true 24 ); 25 loader.lazyRequireGetter( 26 this, 27 "LongStringActor", 28 "resource://devtools/server/actors/string.js", 29 true 30 ); 31 32 loader.lazyRequireGetter( 33 this, 34 "DevToolsUtils", 35 "resource://devtools/shared/DevToolsUtils.js" 36 ); 37 38 ChromeUtils.defineESModuleGetters( 39 this, 40 { 41 ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs", 42 }, 43 { global: "contextual" } 44 ); 45 46 const windowsDrive = /^([a-zA-Z]:)/; 47 48 function resolveSourceURL(sourceURL, targetActor) { 49 if (sourceURL) { 50 let baseURL; 51 if (targetActor.window) { 52 baseURL = targetActor.window.location?.href; 53 } 54 // For worker, we don't have easy access to location, 55 // so pull extra information directly from the target actor. 56 if (targetActor.workerUrl) { 57 baseURL = targetActor.workerUrl; 58 } 59 const parsedURL = URL.parse(sourceURL, baseURL); 60 if (parsedURL) { 61 return parsedURL.href; 62 } 63 } 64 65 return null; 66 } 67 function getSourceURL(source, targetActor) { 68 // Some eval sources have URLs, but we want to explicitly ignore those because 69 // they are generally useless strings like "eval" or "debugger eval code". 70 let resourceURL = getDebuggerSourceURL(source) || ""; 71 72 // Strip out eventual stack trace stored in Source's url. 73 // (not clear if that still happens) 74 resourceURL = resourceURL.split(" -> ").pop(); 75 76 // Debugger.Source.url attribute may be of the form: 77 // "http://example.com/foo line 10 > inlineScript" 78 // because of the following function `js::FormatIntroducedFilename`: 79 // https://searchfox.org/mozilla-central/rev/253ae246f642fe9619597f44de3b087f94e45a2d/js/src/vm/JSScript.cpp#1816-1846 80 // This isn't so easy to reproduce, but browser_dbg-breakpoints-popup.js's testPausedInTwoPopups covers this 81 resourceURL = resourceURL.replace(/ line \d+ > .*$/, ""); 82 83 // A "//# sourceURL=" pragma should basically be treated as a source file's 84 // full URL, so that is what we want to use as the base if it is present. 85 // If this is not an absolute URL, this will mean the maps in the file 86 // will not have a valid base URL, but that is up to tooling that 87 let result = resolveSourceURL(source.displayURL, targetActor); 88 if (!result) { 89 result = resolveSourceURL(resourceURL, targetActor) || resourceURL; 90 91 // In XPCShell tests, the source URL isn't actually a URL, it's a file path. 92 // That causes issues because "C:/folder/file.js" is parsed as a URL with 93 // "c:" as the URL scheme, which causes the drive letter to be unexpectedly 94 // lower-cased when the parsed URL is re-serialized. To avoid that, we 95 // detect that case and re-uppercase it again. This is a bit gross and 96 // ideally it seems like XPCShell tests should use file:// URLs for files, 97 // but alas they do not. 98 if ( 99 resourceURL && 100 resourceURL.match(windowsDrive) && 101 result.slice(0, 2) == resourceURL.slice(0, 2).toLowerCase() 102 ) { 103 result = resourceURL.slice(0, 2) + result.slice(2); 104 } 105 } 106 107 // Avoid returning empty string and return null if no URL is found 108 return result || null; 109 } 110 111 /** 112 * A SourceActor provides information about the source of a script. Source 113 * actors are 1:1 with Debugger.Source objects. 114 * 115 * @param Debugger.Source source 116 * The source object we are representing. 117 * @param ThreadActor thread 118 * The current thread actor. 119 */ 120 class SourceActor extends Actor { 121 constructor({ source, thread }) { 122 super(thread.conn, sourceSpec); 123 124 this._threadActor = thread; 125 this._url = undefined; 126 this._source = source; 127 this.__isInlineSource = undefined; 128 } 129 130 get _isInlineSource() { 131 const source = this._source; 132 if (this.__isInlineSource === undefined) { 133 // If the source has a usable displayURL, the source is treated as not 134 // inlined because it has its own URL. 135 // Also consider sources loaded from <iframe srcdoc> as independant sources, 136 // because we can't easily fetch the full html content of the srcdoc attribute. 137 this.__isInlineSource = 138 source.introductionType === "inlineScript" && 139 !resolveSourceURL(source.displayURL, this.threadActor.targetActor) && 140 !this.url.startsWith("about:srcdoc"); 141 } 142 return this.__isInlineSource; 143 } 144 145 get threadActor() { 146 return this._threadActor; 147 } 148 get sourcesManager() { 149 return this._threadActor.sourcesManager; 150 } 151 get dbg() { 152 return this.threadActor.dbg; 153 } 154 get breakpointActorMap() { 155 return this.threadActor.breakpointActorMap; 156 } 157 get url() { 158 if (this._url === undefined) { 159 this._url = getSourceURL(this._source, this.threadActor.targetActor); 160 } 161 return this._url; 162 } 163 164 get extensionName() { 165 if (this._extensionName === undefined) { 166 this._extensionName = null; 167 168 // Cu is not available for workers and so we are not able to get a 169 // WebExtensionPolicy object 170 if (!isWorker && ExtensionUtils.isExtensionUrl(this.url)) { 171 try { 172 const extURI = Services.io.newURI(this.url); 173 const policy = WebExtensionPolicy.getByURI(extURI); 174 if (policy) { 175 this._extensionName = policy.name; 176 } 177 } catch (e) { 178 console.warn(`Failed to find extension name for ${this.url} : ${e}`); 179 } 180 } 181 } 182 183 return this._extensionName; 184 } 185 186 get internalSourceId() { 187 return this._source.id; 188 } 189 190 form() { 191 const source = this._source; 192 193 let introductionType = source.introductionType; 194 if ( 195 introductionType === "srcScript" || 196 introductionType === "inlineScript" || 197 introductionType === "injectedScript" 198 ) { 199 // These three used to be one single type, so here we combine them all 200 // so that clients don't see any change in behavior. 201 introductionType = "scriptElement"; 202 } 203 204 // NOTE: Debugger.Source.prototype.startColumn is 1-based. 205 // Convert to 0-based, while keeping the wasm's column (1) as is. 206 // (bug 1863878) 207 const columnBase = source.introductionType === "wasm" ? 0 : 1; 208 209 return { 210 actor: this.actorID, 211 extensionName: this.extensionName, 212 url: this.url, 213 isBlackBoxed: this.sourcesManager.isBlackBoxed(this.url), 214 sourceMapBaseURL: getSourcemapBaseURL( 215 this.url, 216 this.threadActor.targetActor.window 217 ), 218 sourceMapURL: source.sourceMapURL, 219 introductionType, 220 isInlineSource: this._isInlineSource, 221 sourceStartLine: source.startLine, 222 sourceStartColumn: source.startColumn - columnBase, 223 sourceLength: source.text?.length, 224 }; 225 } 226 227 destroy() { 228 const parent = this.getParent(); 229 if (parent && parent.sourceActors) { 230 delete parent.sourceActors[this.actorID]; 231 } 232 super.destroy(); 233 } 234 235 get _isWasm() { 236 return this._source.introductionType === "wasm"; 237 } 238 239 async _getSourceText() { 240 if (this._isWasm) { 241 const wasm = this._source.binary; 242 const buffer = wasm.buffer; 243 DevToolsUtils.assert( 244 wasm.byteOffset === 0 && wasm.byteLength === buffer.byteLength, 245 "Typed array from wasm source binary must cover entire buffer" 246 ); 247 return { 248 content: buffer, 249 contentType: "text/wasm", 250 }; 251 } 252 253 // Use `source.text` if it exists, is not the "no source" string, and 254 // the source isn't one that is inlined into some larger file. 255 // It will be "no source" if the Debugger API wasn't able to load 256 // the source because sources were discarded 257 // (javascript.options.discardSystemSource == true). 258 // 259 // For inline source, we do something special and ignore individual source content. 260 // Instead, each inline source will return the full HTML page content where 261 // the inline source is (i.e. `<script> js source </script>`). 262 // 263 // When using srcdoc attribute on iframes: 264 // <iframe srcdoc="<script> js source </script>"></iframe> 265 // The whole iframe source is going to be considered as an inline source because displayURL is null 266 // and introductionType is inlineScript. But Debugger.Source.text is the only way 267 // to retrieve the source content. 268 if (this._source.text !== "[no source]" && !this._isInlineSource) { 269 return { 270 content: this.actualText(), 271 contentType: "text/javascript", 272 }; 273 } 274 275 return this.sourcesManager.urlContents( 276 this.url, 277 /* partial */ false, 278 /* canUseCache */ this._isInlineSource 279 ); 280 } 281 282 // Get the actual text of this source, padded so that line numbers will match 283 // up with the source itself. 284 actualText() { 285 // If the source doesn't start at line 1, line numbers in the client will 286 // not match up with those in the source. Pad the text with blank lines to 287 // fix this. This can show up for sources associated with inline scripts 288 // in HTML created via document.write() calls: the script's source line 289 // number is relative to the start of the written HTML, but we show the 290 // source's content by itself. 291 const padding = this._source.startLine 292 ? "\n".repeat(this._source.startLine - 1) 293 : ""; 294 return padding + this._source.text; 295 } 296 297 // Return whether the specified fetched contents includes the actual text of 298 // this source in the expected position. 299 contentMatches(fileContents) { 300 const lineBreak = /\r\n?|\n|\u2028|\u2029/; 301 const contentLines = fileContents.content.split(lineBreak); 302 const sourceLines = this._source.text.split(lineBreak); 303 let line = this._source.startLine - 1; 304 for (const sourceLine of sourceLines) { 305 const contentLine = contentLines[line++] || ""; 306 if (!contentLine.includes(sourceLine)) { 307 return false; 308 } 309 } 310 return true; 311 } 312 313 getBreakableLines() { 314 const positions = this._getBreakpointPositions(); 315 const lines = new Set(); 316 for (const position of positions) { 317 if (!lines.has(position.line)) { 318 lines.add(position.line); 319 } 320 } 321 322 return Array.from(lines); 323 } 324 325 // Get all toplevel scripts in the source. Transitive child scripts must be 326 // found by traversing the child script tree. 327 _getTopLevelDebuggeeScripts() { 328 if (this._scripts) { 329 return this._scripts; 330 } 331 332 let scripts = this.dbg.findScripts({ source: this._source }); 333 334 if (!this._isWasm) { 335 // There is no easier way to get the top-level scripts right now, so 336 // we have to build that up the list manually. 337 // Note: It is not valid to simply look for scripts where 338 // `.isFunction == false` because a source may have executed multiple 339 // where some have been GCed and some have not (bug 1627712). 340 const allScripts = new Set(scripts); 341 for (const script of allScripts) { 342 for (const child of script.getChildScripts()) { 343 allScripts.delete(child); 344 } 345 } 346 scripts = [...allScripts]; 347 } 348 349 this._scripts = scripts; 350 return scripts; 351 } 352 353 resetDebuggeeScripts() { 354 this._scripts = null; 355 } 356 357 // Get toplevel scripts which contain all breakpoint positions for the source. 358 // This is different from _scripts if we detected that some scripts have been 359 // GC'ed and reparsed the source contents. 360 _getTopLevelBreakpointPositionScripts() { 361 if (this._breakpointPositionScripts) { 362 return this._breakpointPositionScripts; 363 } 364 365 let scripts = this._getTopLevelDebuggeeScripts(); 366 367 // We need to find all breakpoint positions, even if scripts associated with 368 // this source have been GC'ed. We detect this by looking for a script which 369 // does not have a function: a source will typically have a top level 370 // non-function script. If this top level script still exists, then it keeps 371 // all its child scripts alive and we will find all breakpoint positions by 372 // scanning the existing scripts. If the top level script has been GC'ed 373 // then we won't find its breakpoint positions, and inner functions may have 374 // been GC'ed as well. In this case we reparse the source and generate a new 375 // and complete set of scripts to look for the breakpoint positions. 376 // Note that in some cases like "new Function(stuff)" there might not be a 377 // top level non-function script, but if there is a non-function script then 378 // it must be at the top level and will keep all other scripts in the source 379 // alive. 380 if (!this._isWasm && !scripts.some(script => !script.isFunction)) { 381 let newScript; 382 try { 383 newScript = this._source.reparse(); 384 } catch (e) { 385 // reparse() will throw if the source is not valid JS. This can happen 386 // if this source is the resurrection of a GC'ed source and there are 387 // parse errors in the refetched contents. 388 } 389 if (newScript) { 390 scripts = [newScript]; 391 } 392 } 393 394 this._breakpointPositionScripts = scripts; 395 return scripts; 396 } 397 398 // Get all scripts in this source that might include content in the range 399 // specified by the given query. 400 _findDebuggeeScripts(query, forBreakpointPositions) { 401 const scripts = forBreakpointPositions 402 ? this._getTopLevelBreakpointPositionScripts() 403 : this._getTopLevelDebuggeeScripts(); 404 405 const { 406 start: { line: startLine = 0, column: startColumn = 0 } = {}, 407 end: { line: endLine = Infinity, column: endColumn = Infinity } = {}, 408 } = query || {}; 409 410 const rv = []; 411 addMatchingScripts(scripts); 412 return rv; 413 414 function scriptMatches(script) { 415 // These tests are approximate, as we can't easily get the script's end 416 // column. 417 let lineCount; 418 try { 419 lineCount = script.lineCount; 420 } catch (err) { 421 // Accessing scripts which were optimized out during parsing can throw 422 // an exception. Tolerate these so that we can still get positions for 423 // other scripts in the source. 424 return false; 425 } 426 427 // NOTE: Debugger.Script.prototype.startColumn is 1-based. 428 // Convert to 0-based, while keeping the wasm's column (1) as is. 429 // (bug 1863878) 430 const columnBase = script.format === "wasm" ? 0 : 1; 431 if ( 432 script.startLine > endLine || 433 script.startLine + lineCount <= startLine || 434 (script.startLine == endLine && 435 script.startColumn - columnBase > endColumn) 436 ) { 437 return false; 438 } 439 440 if ( 441 lineCount == 1 && 442 script.startLine == startLine && 443 script.startColumn - columnBase + script.sourceLength <= startColumn 444 ) { 445 return false; 446 } 447 448 return true; 449 } 450 451 function addMatchingScripts(childScripts) { 452 for (const script of childScripts) { 453 if (scriptMatches(script)) { 454 rv.push(script); 455 if (script.format === "js") { 456 addMatchingScripts(script.getChildScripts()); 457 } 458 } 459 } 460 } 461 } 462 463 _getBreakpointPositions(query) { 464 const scripts = this._findDebuggeeScripts( 465 query, 466 /* forBreakpointPositions */ true 467 ); 468 469 const positions = []; 470 for (const script of scripts) { 471 this._addScriptBreakpointPositions(query, script, positions); 472 } 473 474 return ( 475 positions 476 // Sort the items by location. 477 .sort((a, b) => { 478 const lineDiff = a.line - b.line; 479 return lineDiff === 0 ? a.column - b.column : lineDiff; 480 }) 481 ); 482 } 483 484 _addScriptBreakpointPositions(query, script, positions) { 485 const { 486 start: { line: startLine = 0, column: startColumn = 0 } = {}, 487 end: { line: endLine = Infinity, column: endColumn = Infinity } = {}, 488 } = query || {}; 489 490 // NOTE: Debugger.Script.prototype.startColumn is 1-based. 491 // Convert to 0-based, while keeping the wasm's column (1) as is. 492 // (bug 1863878) 493 const columnBase = script.format === "wasm" ? 0 : 1; 494 495 const offsets = script.getPossibleBreakpoints(); 496 for (const { lineNumber, columnNumber } of offsets) { 497 if ( 498 lineNumber < startLine || 499 (lineNumber === startLine && columnNumber - columnBase < startColumn) || 500 lineNumber > endLine || 501 (lineNumber === endLine && columnNumber - columnBase >= endColumn) 502 ) { 503 continue; 504 } 505 506 positions.push({ 507 line: lineNumber, 508 column: columnNumber - columnBase, 509 }); 510 } 511 } 512 513 getBreakpointPositionsCompressed(query) { 514 const items = this._getBreakpointPositions(query); 515 const compressed = {}; 516 for (const { line, column } of items) { 517 if (!compressed[line]) { 518 compressed[line] = []; 519 } 520 compressed[line].push(column); 521 } 522 return compressed; 523 } 524 525 /** 526 * Handler for the "onSource" packet. 527 * 528 * @return Object 529 * The return of this function contains a field `contentType`, and 530 * a field `source`. `source` can either be an ArrayBuffer or 531 * a LongString. 532 */ 533 async source() { 534 try { 535 const { content, contentType } = await this._getSourceText(); 536 if ( 537 typeof content === "object" && 538 content && 539 content.constructor && 540 content.constructor.name === "ArrayBuffer" 541 ) { 542 return { 543 source: new ArrayBufferActor(this.threadActor.conn, content), 544 contentType, 545 }; 546 } 547 548 return { 549 source: new LongStringActor(this.threadActor.conn, content), 550 contentType, 551 }; 552 } catch (error) { 553 throw new Error( 554 "Could not load the source for " + 555 this.url + 556 ".\n" + 557 DevToolsUtils.safeErrorString(error) 558 ); 559 } 560 } 561 562 /** 563 * Handler for the "blackbox" packet. 564 */ 565 blackbox(range) { 566 this.sourcesManager.blackBox(this.url, range); 567 if ( 568 this.threadActor.state == "paused" && 569 this.threadActor.youngestFrame && 570 this.threadActor.youngestFrame.script.url == this.url 571 ) { 572 return true; 573 } 574 return false; 575 } 576 577 /** 578 * Handler for the "unblackbox" packet. 579 */ 580 unblackbox(range) { 581 this.sourcesManager.unblackBox(this.url, range); 582 } 583 584 /** 585 * Handler for the "setPausePoints" packet. 586 * 587 * @param Array pausePoints 588 * A dictionary of pausePoint objects 589 * 590 * type PausePoints = { 591 * line: { 592 * column: { break?: boolean, step?: boolean } 593 * } 594 * } 595 */ 596 setPausePoints(pausePoints) { 597 const uncompressed = {}; 598 const points = { 599 0: {}, 600 1: { break: true }, 601 2: { step: true }, 602 3: { break: true, step: true }, 603 }; 604 605 for (const line in pausePoints) { 606 uncompressed[line] = {}; 607 for (const col in pausePoints[line]) { 608 uncompressed[line][col] = points[pausePoints[line][col]]; 609 } 610 } 611 612 this.pausePoints = uncompressed; 613 } 614 615 /** 616 * Ensure the given BreakpointActor is set as a breakpoint handler on all 617 * scripts that match its location in the generated source. 618 * 619 * @param BreakpointActor actor 620 * The BreakpointActor to be set as a breakpoint handler. 621 * 622 * @returns A Promise that resolves to the given BreakpointActor. 623 */ 624 async applyBreakpoint(actor) { 625 const { line, column } = actor.location; 626 627 // Find all entry points that correspond to the given location. 628 const entryPoints = []; 629 if (column === undefined) { 630 // Find all scripts that match the given source actor and line 631 // number. 632 const query = { start: { line }, end: { line } }; 633 const scripts = this._findDebuggeeScripts(query).filter( 634 script => !actor.hasScript(script) 635 ); 636 637 // NOTE: Debugger.Script.prototype.getPossibleBreakpoints returns 638 // columnNumber in 1-based. 639 // The following code uses columnNumber only for comparing against 640 // other columnNumber, and we don't need to convert to 0-based. 641 642 // This is a line breakpoint, so we add a breakpoint on the first 643 // breakpoint on the line. 644 const lineMatches = []; 645 for (const script of scripts) { 646 const possibleBreakpoints = script.getPossibleBreakpoints({ line }); 647 for (const possibleBreakpoint of possibleBreakpoints) { 648 lineMatches.push({ ...possibleBreakpoint, script }); 649 } 650 } 651 lineMatches.sort((a, b) => a.columnNumber - b.columnNumber); 652 653 if (lineMatches.length) { 654 // A single Debugger.Source may have _multiple_ Debugger.Scripts 655 // at the same position from multiple evaluations of the source, 656 // so we explicitly want to take all of the matches for the matched 657 // column number. 658 const firstColumn = lineMatches[0].columnNumber; 659 const firstColumnMatches = lineMatches.filter( 660 m => m.columnNumber === firstColumn 661 ); 662 663 for (const { script, offset } of firstColumnMatches) { 664 entryPoints.push({ script, offsets: [offset] }); 665 } 666 } 667 } else { 668 // Find all scripts that match the given source actor, line, 669 // and column number. 670 const query = { start: { line, column }, end: { line, column } }; 671 const scripts = this._findDebuggeeScripts(query).filter( 672 script => !actor.hasScript(script) 673 ); 674 675 for (const script of scripts) { 676 // NOTE: getPossibleBreakpoints's minColumn/maxColumn parameters are 677 // 1-based. 678 // Convert to 1-based, while keeping the wasm's column (1) as is. 679 // (bug 1863878) 680 const columnBase = script.format === "wasm" ? 0 : 1; 681 682 // Check to see if the script contains a breakpoint position at 683 // this line and column. 684 const possibleBreakpoint = script 685 .getPossibleBreakpoints({ 686 line, 687 minColumn: column + columnBase, 688 maxColumn: column + columnBase + 1, 689 }) 690 .pop(); 691 692 if (possibleBreakpoint) { 693 const { offset } = possibleBreakpoint; 694 entryPoints.push({ script, offsets: [offset] }); 695 } 696 } 697 } 698 699 setBreakpointAtEntryPoints(actor, entryPoints); 700 } 701 } 702 703 exports.SourceActor = SourceActor;