/* 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"; const SOURCE_MAP_PREF = "devtools.source-map.client-service.enabled"; /** * A simple service to track source actors and keep a mapping between * original URLs and objects holding the source or style actor's ID * (which is used as a cookie by the devtools-source-map service) and * the source map URL. * * @param {object} commands * The commands object with all interfaces defined from devtools/shared/commands/ * @param {SourceMapLoader} sourceMapLoader * The source-map-loader implemented in devtools/client/shared/source-map-loader/ */ class SourceMapURLService { constructor(commands, sourceMapLoader) { this._commands = commands; this._sourceMapLoader = sourceMapLoader; this._prefValue = Services.prefs.getBoolPref(SOURCE_MAP_PREF); this._pendingIDSubscriptions = new Map(); this._pendingURLSubscriptions = new Map(); this._urlToIDMap = new Map(); this._mapsById = new Map(); this._sourcesLoading = null; this._onResourceAvailable = this._onResourceAvailable.bind(this); this._runningCallback = false; this._syncPrevValue = this._syncPrevValue.bind(this); this._clearAllState = this._clearAllState.bind(this); Services.prefs.addObserver(SOURCE_MAP_PREF, this._syncPrevValue); // If a tool has changed or introduced a source map // (e.g, by pretty-printing a source), tell the // source map URL service about the change, so that // subscribers to that service can be updated as // well. this._sourceMapLoader.on( "source-map-created", this.newSourceMapCreated.bind(this) ); } destroy() { Services.prefs.removeObserver(SOURCE_MAP_PREF, this._syncPrevValue); this._clearAllState(); const { resourceCommand } = this._commands; try { resourceCommand.unwatchResources( [ resourceCommand.TYPES.STYLESHEET, resourceCommand.TYPES.SOURCE, resourceCommand.TYPES.DOCUMENT_EVENT, ], { onAvailable: this._onResourceAvailable } ); } catch (e) { // If unwatchResources is called before finishing process of watchResources, // it throws an error during stopping listener. } this._sourcesLoading = null; this._pendingIDSubscriptions = null; this._pendingURLSubscriptions = null; this._urlToIDMap = null; this._mapsById = null; } /** * Subscribe to notifications about the original location of a given * generated location, as it may not be known at this time, may become * available at some unknown time in the future, or may change from one * location to another. * * @param {string} id The actor ID of the source. * @param {number} line The line number in the source. * @param {number} column The column number in the source. * @param {Function} callback A callback that may eventually be passed an * an object with url/line/column properties specifying a location in * the original file, or null if no particular original location could * be found. The callback will run synchronously if the location is * already know to the URL service. * * @return {Function} A function to call to remove this subscription. The * "callback" argument is guaranteed to never run once unsubscribed. */ subscribeByID(id, line, column, callback) { this._ensureAllSourcesPopulated(); let pending = this._pendingIDSubscriptions.get(id); if (!pending) { pending = new Set(); this._pendingIDSubscriptions.set(id, pending); } const entry = { line, column, callback, unsubscribed: false, owner: pending, }; pending.add(entry); const map = this._mapsById.get(id); if (map) { this._flushPendingIDSubscriptionsToMapQueries(map); } return () => { entry.unsubscribed = true; entry.owner.delete(entry); }; } /** * Subscribe to notifications about the original location of a given * generated location, as it may not be known at this time, may become * available at some unknown time in the future, or may change from one * location to another. * * @param {string} id The actor ID of the source. * @param {number} line The line number in the source. * @param {number} column The column number in the source. * @param {Function} callback A callback that may eventually be passed an * an object with url/line/column properties specifying a location in * the original file, or null if no particular original location could * be found. The callback will run synchronously if the location is * already know to the URL service. * * @return {Function} A function to call to remove this subscription. The * "callback" argument is guaranteed to never run once unsubscribed. */ subscribeByURL(url, line, column, callback) { this._ensureAllSourcesPopulated(); let pending = this._pendingURLSubscriptions.get(url); if (!pending) { pending = new Set(); this._pendingURLSubscriptions.set(url, pending); } const entry = { line, column, callback, unsubscribed: false, owner: pending, }; pending.add(entry); const id = this._urlToIDMap.get(url); if (id) { this._convertPendingURLSubscriptionsToID(url, id); const map = this._mapsById.get(id); if (map) { this._flushPendingIDSubscriptionsToMapQueries(map); } } return () => { entry.unsubscribed = true; entry.owner.delete(entry); }; } /** * Subscribe generically based on either an ID or a URL. * * In an ideal world we'd always know which of these to use, but there are * still cases where end up with a mixture of both, so this is provided as * a helper. If you can specifically use one of these, please do that * instead however. */ subscribeByLocation({ id, url, line, column }, callback) { if (id) { return this.subscribeByID(id, line, column, callback); } return this.subscribeByURL(url, line, column, callback); } /** * Tell the URL service than some external entity has registered a sourcemap * in the worker for one of the source files. * * @param {Array} ids The actor ids of the sources that had the map registered. */ async newSourceMapCreated(ids) { await this._ensureAllSourcesPopulated(); for (const id of ids) { const map = this._mapsById.get(id); if (!map) { // State could have been cleared. continue; } map.loaded = Promise.resolve(); for (const query of map.queries.values()) { query.action = null; query.result = null; if (this._prefValue) { this._dispatchQuery(query); } } } } _syncPrevValue() { this._prefValue = Services.prefs.getBoolPref(SOURCE_MAP_PREF); for (const map of this._mapsById.values()) { for (const query of map.queries.values()) { this._ensureSubscribersSynchronized(query); } } } _clearAllState() { this._sourceMapLoader.clearSourceMaps(); this._pendingIDSubscriptions.clear(); this._pendingURLSubscriptions.clear(); this._urlToIDMap.clear(); this._mapsById.clear(); } _onNewJavascript(source) { const { url, actor: id, sourceMapBaseURL, sourceMapURL } = source; this._onNewSource(id, url, sourceMapURL, sourceMapBaseURL); } _onNewStyleSheet(sheet) { const { href, nodeHref, sourceMapBaseURL, sourceMapURL, resourceId: id, } = sheet; const url = href || nodeHref; this._onNewSource(id, url, sourceMapURL, sourceMapBaseURL); } _onNewSource(id, url, sourceMapURL, sourceMapBaseURL) { this._urlToIDMap.set(url, id); this._convertPendingURLSubscriptionsToID(url, id); let map = this._mapsById.get(id); if (!map) { map = { id, url, sourceMapURL, sourceMapBaseURL, loaded: null, queries: new Map(), }; this._mapsById.set(id, map); } else if ( map.id !== id && map.url !== url && map.sourceMapURL !== sourceMapURL && map.sourceMapBaseURL !== sourceMapBaseURL ) { console.warn( `Attempted to load populate sourcemap for source ${id} multiple times` ); } this._flushPendingIDSubscriptionsToMapQueries(map); } _buildQuery(map, line, column) { const key = `${line}:${column}`; let query = map.queries.get(key); if (!query) { query = { map, line, column, subscribers: new Set(), action: null, result: null, mostRecentEmitted: null, }; map.queries.set(key, query); } return query; } _dispatchQuery(query) { if (!this._prefValue) { throw new Error("This function should only be called if the pref is on."); } if (!query.action) { const { map } = query; // Call getOriginalURLs to make sure the source map has been // fetched. We don't actually need the result of this though. if (!map.loaded) { map.loaded = this._sourceMapLoader.getOriginalURLs({ id: map.id, url: map.url, sourceMapBaseURL: map.sourceMapBaseURL, sourceMapURL: map.sourceMapURL, }); } const action = (async () => { let result = null; try { await map.loaded; } catch (e) { // SourceMapLoader.getOriginalURLs may throw, but it will handle // the exception and notify the user via a console message. // So ignore the exception here, which is meant to be used by the Debugger. } try { const position = await this._sourceMapLoader.getOriginalLocation({ sourceId: map.id, line: query.line, column: query.column, }); if (position && position.sourceId !== map.id) { result = { url: position.sourceUrl, line: position.line, column: position.column, }; } } finally { // If this action was dispatched and then the file was pretty-printed // we want to ignore the result since the query has restarted. if (action === query.action) { // It is important that we consistently set the query result and // trigger the subscribers here in order to maintain the invariant // that if 'result' is truthy, then the subscribers will have run. const position = result; query.result = { position }; this._ensureSubscribersSynchronized(query); } } })(); query.action = action; } this._ensureSubscribersSynchronized(query); } _ensureSubscribersSynchronized(query) { // Synchronize the subscribers with the pref-disabled state if they need it. if (!this._prefValue) { if (query.mostRecentEmitted) { query.mostRecentEmitted = null; this._dispatchSubscribers(null, query.subscribers); } return; } // Synchronize the subscribers with the newest computed result if they // need it. const { result } = query; if (result && query.mostRecentEmitted !== result.position) { query.mostRecentEmitted = result.position; this._dispatchSubscribers(result.position, query.subscribers); } } _dispatchSubscribers(position, subscribers) { // We copy the subscribers before iterating because something could be // removed while we're calling the callbacks, which is also why we check // the 'unsubscribed' flag. for (const subscriber of Array.from(subscribers)) { if (subscriber.unsubscribed) { continue; } if (this._runningCallback) { console.error( "The source map url service does not support reentrant subscribers." ); continue; } try { this._runningCallback = true; const { callback } = subscriber; callback(position ? { ...position } : null); } catch (err) { console.error("Error in source map url service subscriber", err); } finally { this._runningCallback = false; } } } _flushPendingIDSubscriptionsToMapQueries(map) { const subscriptions = this._pendingIDSubscriptions.get(map.id); if (!subscriptions || subscriptions.size === 0) { return; } this._pendingIDSubscriptions.delete(map.id); for (const entry of subscriptions) { const query = this._buildQuery(map, entry.line, entry.column); const { subscribers } = query; entry.owner = subscribers; subscribers.add(entry); if (query.mostRecentEmitted) { // Maintain the invariant that if a query has emitted a value, then // _all_ subscribers will have received that value. this._dispatchSubscribers(query.mostRecentEmitted, [entry]); } if (this._prefValue) { this._dispatchQuery(query); } } } _ensureAllSourcesPopulated() { if (!this._prefValue || this._commands.descriptorFront.isWorkerDescriptor) { return null; } if (!this._sourcesLoading) { const { resourceCommand } = this._commands; const { STYLESHEET, SOURCE, DOCUMENT_EVENT } = resourceCommand.TYPES; const onResources = resourceCommand.watchResources( [STYLESHEET, SOURCE, DOCUMENT_EVENT], { onAvailable: this._onResourceAvailable, } ); this._sourcesLoading = onResources; } return this._sourcesLoading; } waitForSourcesLoading() { if (this._sourcesLoading) { return this._sourcesLoading; } return Promise.resolve(); } _onResourceAvailable(resources) { const { resourceCommand } = this._commands; const { STYLESHEET, SOURCE, DOCUMENT_EVENT } = resourceCommand.TYPES; for (const resource of resources) { // Only consider top level document, and ignore remote iframes top document if ( resource.resourceType == DOCUMENT_EVENT && resource.name == "will-navigate" && resource.targetFront.isTopLevel ) { this._clearAllState(); } else if (resource.resourceType == STYLESHEET) { this._onNewStyleSheet(resource); } else if (resource.resourceType == SOURCE) { this._onNewJavascript(resource); } } } _convertPendingURLSubscriptionsToID(url, id) { const urlSubscriptions = this._pendingURLSubscriptions.get(url); if (!urlSubscriptions) { return; } this._pendingURLSubscriptions.delete(url); let pending = this._pendingIDSubscriptions.get(id); if (!pending) { pending = new Set(); this._pendingIDSubscriptions.set(id, pending); } for (const entry of urlSubscriptions) { entry.owner = pending; pending.add(entry); } } } exports.SourceMapURLService = SourceMapURLService;