Backed out changeset 3fc4edd663d9 (bug 1701790) Backed out changeset 2d3ea7c0d695 (bug 1701790) Backed out changeset 6cb7502fc1a2 (bug 1701790) Backed out changeset 8f4205a1a369 (bug 1701790) Backed out changeset bd3c9b1482c7 (bug 1701790)
477 lines
14 KiB
JavaScript
477 lines
14 KiB
JavaScript
/* 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;
|