sources-manager.js (13164B)
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 DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); 8 const { assert, fetch } = DevToolsUtils; 9 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 10 const { 11 SourceLocation, 12 } = require("resource://devtools/server/actors/common.js"); 13 14 loader.lazyRequireGetter( 15 this, 16 "SourceActor", 17 "resource://devtools/server/actors/source.js", 18 true 19 ); 20 21 const lazy = {}; 22 ChromeUtils.defineESModuleGetters( 23 lazy, 24 { 25 HTMLSourcesCache: 26 "resource://devtools/server/actors/utils/HTMLSourcesCache.sys.mjs", 27 }, 28 { global: "contextual" } 29 ); 30 31 /** 32 * Matches strings of the form "foo.min.js" or "foo-min.js", etc. If the regular 33 * expression matches, we can be fairly sure that the source is minified, and 34 * treat it as such. 35 */ 36 const MINIFIED_SOURCE_REGEXP = /\bmin\.js$/; 37 38 /** 39 * Manages the sources for a thread. Handles URL contents, locations in 40 * the sources, etc for ThreadActors. 41 */ 42 class SourcesManager extends EventEmitter { 43 constructor(threadActor) { 44 super(); 45 this._thread = threadActor; 46 47 this.blackBoxedSources = new Map(); 48 49 // Debugger.Source -> SourceActor 50 this._sourceActors = new Map(); 51 52 // Debugger.Source.id -> Debugger.Source 53 // 54 // The IDs associated with ScriptSources and available via DebuggerSource.id 55 // are internal to this process and should not be exposed to the client. This 56 // map associates these IDs with the corresponding source, provided the source 57 // has not been GC'ed and the actor has been created. This is lazily populated 58 // the first time it is needed. 59 this._sourcesByInternalSourceId = null; 60 } 61 62 destroy() {} 63 64 /** 65 * Clear existing sources so they are recreated on the next access. 66 */ 67 reset() { 68 this._sourceActors = new Map(); 69 this._sourcesByInternalSourceId = null; 70 } 71 72 /** 73 * Create a source actor representing this source. 74 * 75 * @param Debugger.Source source 76 * The source to make an actor for. 77 * @returns a SourceActor representing the source. 78 */ 79 createSourceActor(source) { 80 assert(source, "SourcesManager.prototype.source needs a source"); 81 82 if (this._sourceActors.has(source)) { 83 return this._sourceActors.get(source); 84 } 85 86 const actor = new SourceActor({ 87 thread: this._thread, 88 source, 89 }); 90 91 this._thread.threadLifetimePool.manage(actor); 92 93 this._sourceActors.set(source, actor); 94 // source.id can be 0 for WASM sources 95 if (this._sourcesByInternalSourceId && Number.isInteger(source.id)) { 96 this._sourcesByInternalSourceId.set(source.id, source); 97 } 98 99 this.emit("newSource", actor); 100 return actor; 101 } 102 103 _getSourceActor(source) { 104 if (this._sourceActors.has(source)) { 105 return this._sourceActors.get(source); 106 } 107 108 return null; 109 } 110 111 hasSourceActor(source) { 112 return !!this._getSourceActor(source); 113 } 114 115 getSourceActor(source) { 116 const sourceActor = this._getSourceActor(source); 117 118 if (!sourceActor) { 119 throw new Error( 120 "getSource: could not find source actor for " + (source.url || "source") 121 ); 122 } 123 124 return sourceActor; 125 } 126 127 getOrCreateSourceActor(source) { 128 // Tolerate the source coming from a different Debugger than the one 129 // associated with the thread. 130 try { 131 source = this._thread.dbg.adoptSource(source); 132 } catch (e) { 133 // We can't create actors for sources in the same compartment as the 134 // thread's Debugger. 135 if (/is in the same compartment as this debugger/.test(e)) { 136 return null; 137 } 138 throw e; 139 } 140 141 if (this.hasSourceActor(source)) { 142 return this.getSourceActor(source); 143 } 144 return this.createSourceActor(source); 145 } 146 147 getSourceActorByInternalSourceId(id) { 148 if (!this._sourcesByInternalSourceId) { 149 this._sourcesByInternalSourceId = new Map(); 150 for (const source of this._thread.dbg.findSources()) { 151 // source.id can be 0 for WASM sources 152 if (Number.isInteger(source.id)) { 153 this._sourcesByInternalSourceId.set(source.id, source); 154 } 155 } 156 } 157 const source = this._sourcesByInternalSourceId.get(id); 158 if (source) { 159 return this.getOrCreateSourceActor(source); 160 } 161 return null; 162 } 163 164 getSourceActorsByURL(url) { 165 const rv = []; 166 if (url) { 167 for (const [, actor] of this._sourceActors) { 168 if (actor.url === url) { 169 rv.push(actor); 170 } 171 } 172 } 173 return rv; 174 } 175 176 getSourceActorById(actorId) { 177 for (const [, actor] of this._sourceActors) { 178 if (actor.actorID == actorId) { 179 return actor; 180 } 181 } 182 return null; 183 } 184 185 /** 186 * Returns true if the URL likely points to a minified resource, false 187 * otherwise. 188 * 189 * @param String uri 190 * The url to test. 191 * @returns Boolean 192 */ 193 _isMinifiedURL(uri) { 194 if (!uri) { 195 return false; 196 } 197 198 const url = URL.parse(uri); 199 if (url) { 200 const pathname = url.pathname; 201 return MINIFIED_SOURCE_REGEXP.test( 202 pathname.slice(pathname.lastIndexOf("/") + 1) 203 ); 204 } 205 // Not a valid URL so don't try to parse out the filename, just test the 206 // whole thing with the minified source regexp. 207 return MINIFIED_SOURCE_REGEXP.test(uri); 208 } 209 210 /** 211 * Return the non-source-mapped location of an offset in a script. 212 * 213 * @param Debugger.Script script 214 * The script associated with the offset. 215 * @param Number offset 216 * Offset within the script of the location. 217 * @returns Object 218 * Returns an object of the form { source, line, column } 219 */ 220 getScriptOffsetLocation(script, offset) { 221 const { lineNumber, columnNumber } = script.getOffsetMetadata(offset); 222 // NOTE: Debugger.Source.prototype.startColumn is 1-based. 223 // Convert to 0-based, while keeping the wasm's column (1) as is. 224 // (bug 1863878) 225 const columnBase = script.format === "wasm" ? 0 : 1; 226 return new SourceLocation( 227 this.createSourceActor(script.source), 228 lineNumber, 229 columnNumber - columnBase 230 ); 231 } 232 233 /** 234 * Return the non-source-mapped location of the given Debugger.Frame. If the 235 * frame does not have a script, the location's properties are all null. 236 * 237 * @param Debugger.Frame frame 238 * The frame whose location we are getting. 239 * @returns Object 240 * Returns an object of the form { source, line, column } 241 */ 242 getFrameLocation(frame) { 243 if (!frame || !frame.script) { 244 return new SourceLocation(); 245 } 246 return this.getScriptOffsetLocation(frame.script, frame.offset); 247 } 248 249 /** 250 * Returns true if URL for the given source is black boxed. 251 * 252 * * @param url String 253 * The URL of the source which we are checking whether it is black 254 * boxed or not. 255 */ 256 isBlackBoxed(url, line, column) { 257 if (this.blackBoxedSources.size == 0) { 258 return false; 259 } 260 if (!this.blackBoxedSources.has(url)) { 261 return false; 262 } 263 264 const ranges = this.blackBoxedSources.get(url); 265 266 // If we have an entry in the map, but it is falsy, the source is fully blackboxed. 267 if (!ranges) { 268 return true; 269 } 270 271 const range = ranges.find(r => isLocationInRange({ line, column }, r)); 272 return !!range; 273 } 274 275 isFrameBlackBoxed(frame) { 276 if (this.blackBoxedSources.size == 0) { 277 return false; 278 } 279 const { url, line, column } = this.getFrameLocation(frame); 280 return this.isBlackBoxed(url, line, column); 281 } 282 283 clearAllBlackBoxing() { 284 this.blackBoxedSources.clear(); 285 } 286 287 /** 288 * Add the given source URL to the set of sources that are black boxed. 289 * 290 * @param url String 291 * The URL of the source which we are black boxing. 292 */ 293 blackBox(url, range) { 294 if (!range) { 295 // blackbox the whole source 296 return this.blackBoxedSources.set(url, null); 297 } 298 299 const ranges = this.blackBoxedSources.get(url) || []; 300 // ranges are sorted in ascening order 301 const index = ranges.findIndex( 302 r => r.end.line <= range.start.line && r.end.column <= range.start.column 303 ); 304 305 ranges.splice(index + 1, 0, range); 306 this.blackBoxedSources.set(url, ranges); 307 return true; 308 } 309 310 /** 311 * Remove the given source URL to the set of sources that are black boxed. 312 * 313 * @param url String 314 * The URL of the source which we are no longer black boxing. 315 */ 316 unblackBox(url, range) { 317 if (!range) { 318 return this.blackBoxedSources.delete(url); 319 } 320 321 const ranges = this.blackBoxedSources.get(url); 322 const index = ranges.findIndex( 323 r => 324 r.start.line === range.start.line && 325 r.start.column === range.start.column && 326 r.end.line === range.end.line && 327 r.end.column === range.end.column 328 ); 329 330 if (index !== -1) { 331 ranges.splice(index, 1); 332 } 333 334 if (ranges.length === 0) { 335 return this.blackBoxedSources.delete(url); 336 } 337 338 return this.blackBoxedSources.set(url, ranges); 339 } 340 341 /** 342 * List all currently registered source actors. 343 * 344 * @return Iterator<SourceActor> 345 */ 346 iter() { 347 return this._sourceActors.values(); 348 } 349 350 /** 351 * Get the contents of a URL, fetching it if necessary. If partial is set and 352 * any content for the URL has been received, that partial content is returned 353 * synchronously. 354 */ 355 urlContents(url, partial, canUseCache) { 356 const { browsingContextID } = this._thread.targetActor; 357 const content = !isWorker 358 ? lazy.HTMLSourcesCache.get(browsingContextID, url, partial) 359 : null; 360 if (content) { 361 return content; 362 } 363 return this._fetchURLContents(url, partial, canUseCache); 364 } 365 366 async _fetchURLContents(url, partial, canUseCache) { 367 // Only try the cache if it is currently enabled for the document. 368 // Without this check, the cache may return stale data that doesn't match 369 // the document shown in the browser. 370 let loadFromCache = canUseCache; 371 if (canUseCache && this._thread.targetActor.browsingContext) { 372 loadFromCache = !( 373 this._thread.targetActor.browsingContext.defaultLoadFlags === 374 Ci.nsIRequest.LOAD_BYPASS_CACHE 375 ); 376 } 377 378 // Fetch the sources with the same principal as the original document 379 const win = this._thread.targetActor.window; 380 let principal, cacheKey; 381 // On xpcshell, we don't have a window but a Sandbox 382 if (!isWorker && win instanceof Ci.nsIDOMWindow && win.docShell) { 383 const docShell = win.docShell; 384 const channel = docShell.currentDocumentChannel; 385 principal = channel.loadInfo.loadingPrincipal; 386 387 // Retrieve the cacheKey in order to load POST requests from cache 388 // Note that chrome:// URLs don't support this interface. 389 if ( 390 loadFromCache && 391 docShell.currentDocumentChannel instanceof Ci.nsICacheInfoChannel 392 ) { 393 cacheKey = docShell.currentDocumentChannel.cacheKey; 394 } 395 } 396 397 let result; 398 try { 399 result = await fetch(url, { 400 principal, 401 cacheKey, 402 loadFromCache, 403 }); 404 } catch (error) { 405 this._reportLoadSourceError(error); 406 throw error; 407 } 408 409 // When we fetch the contents, there is a risk that the contents we get 410 // do not match up with the actual text of the sources these contents will 411 // be associated with. We want to always show contents that include that 412 // actual text (otherwise it will be very confusing or unusable for users), 413 // so replace the contents with the actual text if there is a mismatch. 414 const actors = [...this._sourceActors.values()].filter( 415 // Bug 1907977: some source may not have a valid source text content exposed by spidermonkey 416 // and have their text be "[no source]", so avoid falling back to them and consider 417 // the request fallback. 418 actor => actor.url == url && actor.actualText() != "[no source]" 419 ); 420 if (!actors.every(actor => actor.contentMatches(result))) { 421 if (actors.length > 1) { 422 // When there are multiple actors we won't be able to show the source 423 // for all of them. Ask the user to reload so that we don't have to do 424 // any fetching. 425 result.content = "Error: Incorrect contents fetched, please reload."; 426 } else { 427 result.content = actors[0].actualText(); 428 } 429 } 430 431 return result; 432 } 433 434 _reportLoadSourceError(error) { 435 try { 436 DevToolsUtils.reportException("SourceActor", error); 437 438 const lines = JSON.stringify(this.form(), null, 4).split(/\n/g); 439 lines.forEach(line => console.error("\t", line)); 440 } catch (e) { 441 // ignore 442 } 443 } 444 } 445 446 function isLocationInRange({ line, column }, range) { 447 return ( 448 (range.start.line <= line || 449 (range.start.line == line && range.start.column <= column)) && 450 (range.end.line >= line || 451 (range.end.line == line && range.end.column >= column)) 452 ); 453 } 454 455 exports.SourcesManager = SourcesManager;