source-map-url-service.js (15313B)
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 "use strict"; 5 6 const SOURCE_MAP_PREF = "devtools.source-map.client-service.enabled"; 7 8 /** 9 * A simple service to track source actors and keep a mapping between 10 * original URLs and objects holding the source or style actor's ID 11 * (which is used as a cookie by the devtools-source-map service) and 12 * the source map URL. 13 * 14 * @param {object} commands 15 * The commands object with all interfaces defined from devtools/shared/commands/ 16 * @param {SourceMapLoader} sourceMapLoader 17 * The source-map-loader implemented in devtools/client/shared/source-map-loader/ 18 */ 19 class SourceMapURLService { 20 constructor(commands, sourceMapLoader) { 21 this._commands = commands; 22 this._sourceMapLoader = sourceMapLoader; 23 24 this._prefValue = Services.prefs.getBoolPref(SOURCE_MAP_PREF); 25 this._pendingIDSubscriptions = new Map(); 26 this._pendingURLSubscriptions = new Map(); 27 this._urlToIDMap = new Map(); 28 this._mapsById = new Map(); 29 this._sourcesLoading = null; 30 this._onResourceAvailable = this._onResourceAvailable.bind(this); 31 this._runningCallback = false; 32 33 this._syncPrevValue = this._syncPrevValue.bind(this); 34 this._clearAllState = this._clearAllState.bind(this); 35 36 Services.prefs.addObserver(SOURCE_MAP_PREF, this._syncPrevValue); 37 38 // If a tool has changed or introduced a source map 39 // (e.g, by pretty-printing a source), tell the 40 // source map URL service about the change, so that 41 // subscribers to that service can be updated as 42 // well. 43 this._sourceMapLoader.on( 44 "source-map-created", 45 this.newSourceMapCreated.bind(this) 46 ); 47 } 48 49 destroy() { 50 Services.prefs.removeObserver(SOURCE_MAP_PREF, this._syncPrevValue); 51 52 this._clearAllState(); 53 54 const { resourceCommand } = this._commands; 55 try { 56 resourceCommand.unwatchResources( 57 [ 58 resourceCommand.TYPES.STYLESHEET, 59 resourceCommand.TYPES.SOURCE, 60 resourceCommand.TYPES.DOCUMENT_EVENT, 61 ], 62 { onAvailable: this._onResourceAvailable } 63 ); 64 } catch (e) { 65 // If unwatchResources is called before finishing process of watchResources, 66 // it throws an error during stopping listener. 67 } 68 69 this._sourcesLoading = null; 70 this._pendingIDSubscriptions = null; 71 this._pendingURLSubscriptions = null; 72 this._urlToIDMap = null; 73 this._mapsById = null; 74 } 75 76 /** 77 * Subscribe to notifications about the original location of a given 78 * generated location, as it may not be known at this time, may become 79 * available at some unknown time in the future, or may change from one 80 * location to another. 81 * 82 * @param {string} id The actor ID of the source. 83 * @param {number} line The line number in the source. 84 * @param {number} column The column number in the source. 85 * @param {Function} callback A callback that may eventually be passed an 86 * an object with url/line/column properties specifying a location in 87 * the original file, or null if no particular original location could 88 * be found. The callback will run synchronously if the location is 89 * already know to the URL service. 90 * 91 * @return {Function} A function to call to remove this subscription. The 92 * "callback" argument is guaranteed to never run once unsubscribed. 93 */ 94 subscribeByID(id, line, column, callback) { 95 this._ensureAllSourcesPopulated(); 96 97 let pending = this._pendingIDSubscriptions.get(id); 98 if (!pending) { 99 pending = new Set(); 100 this._pendingIDSubscriptions.set(id, pending); 101 } 102 const entry = { 103 line, 104 column, 105 callback, 106 unsubscribed: false, 107 owner: pending, 108 }; 109 pending.add(entry); 110 111 const map = this._mapsById.get(id); 112 if (map) { 113 this._flushPendingIDSubscriptionsToMapQueries(map); 114 } 115 116 return () => { 117 entry.unsubscribed = true; 118 entry.owner.delete(entry); 119 }; 120 } 121 122 /** 123 * Subscribe to notifications about the original location of a given 124 * generated location, as it may not be known at this time, may become 125 * available at some unknown time in the future, or may change from one 126 * location to another. 127 * 128 * @param {string} id The actor ID of the source. 129 * @param {number} line The line number in the source. 130 * @param {number} column The column number in the source. 131 * @param {Function} callback A callback that may eventually be passed an 132 * an object with url/line/column properties specifying a location in 133 * the original file, or null if no particular original location could 134 * be found. The callback will run synchronously if the location is 135 * already know to the URL service. 136 * 137 * @return {Function} A function to call to remove this subscription. The 138 * "callback" argument is guaranteed to never run once unsubscribed. 139 */ 140 subscribeByURL(url, line, column, callback) { 141 this._ensureAllSourcesPopulated(); 142 143 let pending = this._pendingURLSubscriptions.get(url); 144 if (!pending) { 145 pending = new Set(); 146 this._pendingURLSubscriptions.set(url, pending); 147 } 148 const entry = { 149 line, 150 column, 151 callback, 152 unsubscribed: false, 153 owner: pending, 154 }; 155 pending.add(entry); 156 157 const id = this._urlToIDMap.get(url); 158 if (id) { 159 this._convertPendingURLSubscriptionsToID(url, id); 160 const map = this._mapsById.get(id); 161 if (map) { 162 this._flushPendingIDSubscriptionsToMapQueries(map); 163 } 164 } 165 166 return () => { 167 entry.unsubscribed = true; 168 entry.owner.delete(entry); 169 }; 170 } 171 172 /** 173 * Subscribe generically based on either an ID or a URL. 174 * 175 * In an ideal world we'd always know which of these to use, but there are 176 * still cases where end up with a mixture of both, so this is provided as 177 * a helper. If you can specifically use one of these, please do that 178 * instead however. 179 */ 180 subscribeByLocation({ id, url, line, column }, callback) { 181 if (id) { 182 return this.subscribeByID(id, line, column, callback); 183 } 184 185 return this.subscribeByURL(url, line, column, callback); 186 } 187 188 /** 189 * Tell the URL service than some external entity has registered a sourcemap 190 * in the worker for one of the source files. 191 * 192 * @param {Array<string>} ids The actor ids of the sources that had the map registered. 193 */ 194 async newSourceMapCreated(ids) { 195 await this._ensureAllSourcesPopulated(); 196 197 for (const id of ids) { 198 const map = this._mapsById.get(id); 199 if (!map) { 200 // State could have been cleared. 201 continue; 202 } 203 204 map.loaded = Promise.resolve(); 205 for (const query of map.queries.values()) { 206 query.action = null; 207 query.result = null; 208 if (this._prefValue) { 209 this._dispatchQuery(query); 210 } 211 } 212 } 213 } 214 215 _syncPrevValue() { 216 this._prefValue = Services.prefs.getBoolPref(SOURCE_MAP_PREF); 217 218 for (const map of this._mapsById.values()) { 219 for (const query of map.queries.values()) { 220 this._ensureSubscribersSynchronized(query); 221 } 222 } 223 } 224 225 _clearAllState() { 226 this._sourceMapLoader.clearSourceMaps(); 227 this._pendingIDSubscriptions.clear(); 228 this._pendingURLSubscriptions.clear(); 229 this._urlToIDMap.clear(); 230 this._mapsById.clear(); 231 } 232 233 _onNewJavascript(source) { 234 const { url, actor: id, sourceMapBaseURL, sourceMapURL } = source; 235 236 this._onNewSource(id, url, sourceMapURL, sourceMapBaseURL); 237 } 238 239 _onNewStyleSheet(sheet) { 240 const { 241 href, 242 nodeHref, 243 sourceMapBaseURL, 244 sourceMapURL, 245 resourceId: id, 246 } = sheet; 247 const url = href || nodeHref; 248 249 this._onNewSource(id, url, sourceMapURL, sourceMapBaseURL); 250 } 251 252 _onNewSource(id, url, sourceMapURL, sourceMapBaseURL) { 253 this._urlToIDMap.set(url, id); 254 this._convertPendingURLSubscriptionsToID(url, id); 255 256 let map = this._mapsById.get(id); 257 if (!map) { 258 map = { 259 id, 260 url, 261 sourceMapURL, 262 sourceMapBaseURL, 263 loaded: null, 264 queries: new Map(), 265 }; 266 this._mapsById.set(id, map); 267 } else if ( 268 map.id !== id && 269 map.url !== url && 270 map.sourceMapURL !== sourceMapURL && 271 map.sourceMapBaseURL !== sourceMapBaseURL 272 ) { 273 console.warn( 274 `Attempted to load populate sourcemap for source ${id} multiple times` 275 ); 276 } 277 278 this._flushPendingIDSubscriptionsToMapQueries(map); 279 } 280 281 _buildQuery(map, line, column) { 282 const key = `${line}:${column}`; 283 let query = map.queries.get(key); 284 if (!query) { 285 query = { 286 map, 287 line, 288 column, 289 subscribers: new Set(), 290 action: null, 291 result: null, 292 mostRecentEmitted: null, 293 }; 294 map.queries.set(key, query); 295 } 296 return query; 297 } 298 299 _dispatchQuery(query) { 300 if (!this._prefValue) { 301 throw new Error("This function should only be called if the pref is on."); 302 } 303 304 if (!query.action) { 305 const { map } = query; 306 307 // Call getOriginalURLs to make sure the source map has been 308 // fetched. We don't actually need the result of this though. 309 if (!map.loaded) { 310 map.loaded = this._sourceMapLoader.getOriginalURLs({ 311 id: map.id, 312 url: map.url, 313 sourceMapBaseURL: map.sourceMapBaseURL, 314 sourceMapURL: map.sourceMapURL, 315 }); 316 } 317 318 const action = (async () => { 319 let result = null; 320 try { 321 await map.loaded; 322 } catch (e) { 323 // SourceMapLoader.getOriginalURLs may throw, but it will handle 324 // the exception and notify the user via a console message. 325 // So ignore the exception here, which is meant to be used by the Debugger. 326 } 327 328 try { 329 const position = await this._sourceMapLoader.getOriginalLocation({ 330 sourceId: map.id, 331 line: query.line, 332 column: query.column, 333 }); 334 if (position && position.sourceId !== map.id) { 335 result = { 336 url: position.sourceUrl, 337 line: position.line, 338 column: position.column, 339 }; 340 } 341 } finally { 342 // If this action was dispatched and then the file was pretty-printed 343 // we want to ignore the result since the query has restarted. 344 if (action === query.action) { 345 // It is important that we consistently set the query result and 346 // trigger the subscribers here in order to maintain the invariant 347 // that if 'result' is truthy, then the subscribers will have run. 348 const position = result; 349 query.result = { position }; 350 this._ensureSubscribersSynchronized(query); 351 } 352 } 353 })(); 354 query.action = action; 355 } 356 357 this._ensureSubscribersSynchronized(query); 358 } 359 360 _ensureSubscribersSynchronized(query) { 361 // Synchronize the subscribers with the pref-disabled state if they need it. 362 if (!this._prefValue) { 363 if (query.mostRecentEmitted) { 364 query.mostRecentEmitted = null; 365 this._dispatchSubscribers(null, query.subscribers); 366 } 367 return; 368 } 369 370 // Synchronize the subscribers with the newest computed result if they 371 // need it. 372 const { result } = query; 373 if (result && query.mostRecentEmitted !== result.position) { 374 query.mostRecentEmitted = result.position; 375 this._dispatchSubscribers(result.position, query.subscribers); 376 } 377 } 378 379 _dispatchSubscribers(position, subscribers) { 380 // We copy the subscribers before iterating because something could be 381 // removed while we're calling the callbacks, which is also why we check 382 // the 'unsubscribed' flag. 383 for (const subscriber of Array.from(subscribers)) { 384 if (subscriber.unsubscribed) { 385 continue; 386 } 387 388 if (this._runningCallback) { 389 console.error( 390 "The source map url service does not support reentrant subscribers." 391 ); 392 continue; 393 } 394 395 try { 396 this._runningCallback = true; 397 398 const { callback } = subscriber; 399 callback(position ? { ...position } : null); 400 } catch (err) { 401 console.error("Error in source map url service subscriber", err); 402 } finally { 403 this._runningCallback = false; 404 } 405 } 406 } 407 408 _flushPendingIDSubscriptionsToMapQueries(map) { 409 const subscriptions = this._pendingIDSubscriptions.get(map.id); 410 if (!subscriptions || subscriptions.size === 0) { 411 return; 412 } 413 this._pendingIDSubscriptions.delete(map.id); 414 415 for (const entry of subscriptions) { 416 const query = this._buildQuery(map, entry.line, entry.column); 417 418 const { subscribers } = query; 419 420 entry.owner = subscribers; 421 subscribers.add(entry); 422 423 if (query.mostRecentEmitted) { 424 // Maintain the invariant that if a query has emitted a value, then 425 // _all_ subscribers will have received that value. 426 this._dispatchSubscribers(query.mostRecentEmitted, [entry]); 427 } 428 429 if (this._prefValue) { 430 this._dispatchQuery(query); 431 } 432 } 433 } 434 435 _ensureAllSourcesPopulated() { 436 if (!this._prefValue || this._commands.descriptorFront.isWorkerDescriptor) { 437 return null; 438 } 439 440 if (!this._sourcesLoading) { 441 const { resourceCommand } = this._commands; 442 const { STYLESHEET, SOURCE, DOCUMENT_EVENT } = resourceCommand.TYPES; 443 444 const onResources = resourceCommand.watchResources( 445 [STYLESHEET, SOURCE, DOCUMENT_EVENT], 446 { 447 onAvailable: this._onResourceAvailable, 448 } 449 ); 450 this._sourcesLoading = onResources; 451 } 452 453 return this._sourcesLoading; 454 } 455 456 waitForSourcesLoading() { 457 if (this._sourcesLoading) { 458 return this._sourcesLoading; 459 } 460 return Promise.resolve(); 461 } 462 463 _onResourceAvailable(resources) { 464 const { resourceCommand } = this._commands; 465 const { STYLESHEET, SOURCE, DOCUMENT_EVENT } = resourceCommand.TYPES; 466 for (const resource of resources) { 467 // Only consider top level document, and ignore remote iframes top document 468 if ( 469 resource.resourceType == DOCUMENT_EVENT && 470 resource.name == "will-navigate" && 471 resource.targetFront.isTopLevel 472 ) { 473 this._clearAllState(); 474 } else if (resource.resourceType == STYLESHEET) { 475 this._onNewStyleSheet(resource); 476 } else if (resource.resourceType == SOURCE) { 477 this._onNewJavascript(resource); 478 } 479 } 480 } 481 482 _convertPendingURLSubscriptionsToID(url, id) { 483 const urlSubscriptions = this._pendingURLSubscriptions.get(url); 484 if (!urlSubscriptions) { 485 return; 486 } 487 this._pendingURLSubscriptions.delete(url); 488 489 let pending = this._pendingIDSubscriptions.get(id); 490 if (!pending) { 491 pending = new Set(); 492 this._pendingIDSubscriptions.set(id, pending); 493 } 494 for (const entry of urlSubscriptions) { 495 entry.owner = pending; 496 pending.add(entry); 497 } 498 } 499 } 500 501 exports.SourceMapURLService = SourceMapURLService;