tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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;