summaryrefslogtreecommitdiffstats
path: root/devtools/client/framework/source-map-url-service.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/framework/source-map-url-service.js')
-rw-r--r--devtools/client/framework/source-map-url-service.js476
1 files changed, 476 insertions, 0 deletions
diff --git a/devtools/client/framework/source-map-url-service.js b/devtools/client/framework/source-map-url-service.js
new file mode 100644
index 0000000000..06f6b417fb
--- /dev/null
+++ b/devtools/client/framework/source-map-url-service.js
@@ -0,0 +1,476 @@
+/* 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 Services = require("Services");
+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} toolbox
+ * The toolbox.
+ * @param {SourceMapService} sourceMapService
+ * The devtools-source-map functions
+ */
+class SourceMapURLService {
+ constructor(toolbox, sourceMapService) {
+ this._toolbox = toolbox;
+ this._sourceMapService = sourceMapService;
+
+ 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);
+
+ this._target.on("will-navigate", this._clearAllState);
+
+ Services.prefs.addObserver(SOURCE_MAP_PREF, this._syncPrevValue);
+ }
+
+ get _target() {
+ return this._toolbox.target;
+ }
+
+ destroy() {
+ this._clearAllState();
+ this._target.off("will-navigate", this._clearAllState);
+ Services.prefs.removeObserver(SOURCE_MAP_PREF, this._syncPrevValue);
+ }
+
+ /**
+ * 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 {string} id The actor ID of the source that had the map registered.
+ */
+ async newSourceMapCreated(id) {
+ await this._ensureAllSourcesPopulated();
+
+ const map = this._mapsById.get(id);
+ if (!map) {
+ // State could have been cleared.
+ return;
+ }
+
+ 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._sourceMapService.clearSourceMaps();
+ this._pendingIDSubscriptions.clear();
+ this._pendingURLSubscriptions.clear();
+ this._urlToIDMap.clear();
+
+ const { resourceWatcher } = this._toolbox;
+ try {
+ resourceWatcher.unwatchResources(
+ [resourceWatcher.TYPES.STYLESHEET, resourceWatcher.TYPES.SOURCE],
+ { onAvailable: this._onResourceAvailable }
+ );
+ } catch (e) {
+ // If unwatchResources is called before finishing process of watchResources,
+ // it throws an error during stopping listener.
+ }
+
+ this._sourcesLoading = null;
+ }
+
+ _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, newSubscribers = null) {
+ 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._sourceMapService.getOriginalURLs({
+ id: map.id,
+ url: map.url,
+ sourceMapBaseURL: map.sourceMapBaseURL,
+ sourceMapURL: map.sourceMapURL,
+ });
+ }
+
+ const action = (async () => {
+ let result = null;
+ try {
+ await map.loaded;
+
+ const position = await this._sourceMapService.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) {
+ return null;
+ }
+ if (this._target.isWorkerTarget) {
+ return;
+ }
+
+ if (!this._sourcesLoading) {
+ const { resourceWatcher } = this._toolbox;
+ const { STYLESHEET, SOURCE } = resourceWatcher.TYPES;
+
+ this._sourcesLoading = resourceWatcher.watchResources(
+ [STYLESHEET, SOURCE],
+ {
+ onAvailable: this._onResourceAvailable,
+ }
+ );
+ }
+
+ return this._sourcesLoading;
+ }
+
+ waitForSourcesLoading() {
+ if (this._sourcesLoading) {
+ return this._sourcesLoading;
+ }
+ return Promise.resolve();
+ }
+
+ _onResourceAvailable(resources) {
+ const { resourceWatcher } = this._toolbox;
+ const { STYLESHEET, SOURCE } = resourceWatcher.TYPES;
+ for (const resource of resources) {
+ 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;