tor-browser

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

utils.js (18944B)


      1 const STORE_URL = '/speculation-rules/prerender/resources/key-value-store.py';
      2 
      3 // Starts prerendering for `url`.
      4 //
      5 // `rule_extras` provides additional parameters for the speculation rule used
      6 // to trigger prerendering.
      7 function startPrerendering(url, rule_extras = {}) {
      8  // Adds <script type="speculationrules"> and specifies a prerender candidate
      9  // for the given URL.
     10  // TODO(https://crbug.com/1174978): <script type="speculationrules"> may not
     11  // start prerendering for some reason (e.g., resource limit). Implement a
     12  // WebDriver API to force prerendering.
     13  const script = document.createElement('script');
     14  script.type = 'speculationrules';
     15  script.text = JSON.stringify(
     16      {prerender: [{source: 'list', urls: [url], ...rule_extras}]});
     17  document.head.appendChild(script);
     18  return script;
     19 }
     20 
     21 class PrerenderChannel extends EventTarget {
     22  #ids = new Set();
     23  #url;
     24  #active = true;
     25 
     26  constructor(name, uid = new URLSearchParams(location.search).get('uid')) {
     27    super();
     28    this.#url = `/speculation-rules/prerender/resources/deprecated-broadcast-channel.py?name=${name}&uid=${uid}`;
     29    (async() => {
     30      while (this.#active) {
     31        // Add the "keepalive" option to avoid fetch() results in unhandled
     32        // rejection with fetch abortion due to window.close().
     33        // TODO(crbug.com/1356128): After this migration, "keepalive" will not
     34        // be able to extend the lifetime of a Document, such that it cannot be
     35        // used here to guarantee the promise resolution.
     36        const messages = await (await fetch(this.#url, {keepalive: true})).json();
     37        for (const {data, id} of messages) {
     38          if (!this.#ids.has(id))
     39            this.dispatchEvent(new MessageEvent('message', {data}));
     40          this.#ids.add(id);
     41        }
     42      }
     43    })();
     44  }
     45 
     46  close() {
     47    this.#active = false;
     48  }
     49 
     50  set onmessage(m) {
     51    this.addEventListener('message', m)
     52  }
     53 
     54  async postMessage(data) {
     55    const id = new Date().valueOf();
     56    this.#ids.add(id);
     57    // Add the "keepalive" option to prevent messages from being lost due to
     58    // window.close().
     59    await fetch(this.#url, {method: 'POST', body: JSON.stringify({data, id}), keepalive: true});
     60  }
     61 }
     62 
     63 // Reads the value specified by `key` from the key-value store on the server.
     64 async function readValueFromServer(key) {
     65  const serverUrl = `${STORE_URL}?key=${key}`;
     66  const response = await fetch(serverUrl);
     67  if (!response.ok)
     68    throw new Error('An error happened in the server');
     69  const value = await response.text();
     70 
     71  // The value is not stored in the server.
     72  if (value === "")
     73    return { status: false };
     74 
     75  return { status: true, value: value };
     76 }
     77 
     78 // Convenience wrapper around the above getter that will wait until a value is
     79 // available on the server.
     80 async function nextValueFromServer(key) {
     81  let retry = 0;
     82  while (true) {
     83    // Fetches the test result from the server.
     84    let success = true;
     85    const { status, value } = await readValueFromServer(key).catch(e => {
     86      if (retry++ >= 5) {
     87        throw new Error('readValueFromServer failed');
     88      }
     89      success = false;
     90    });
     91    if (!success || !status) {
     92      // The test result has not been stored yet. Retry after a while.
     93      await new Promise(resolve => setTimeout(resolve, 100));
     94      continue;
     95    }
     96 
     97    return value;
     98  }
     99 }
    100 
    101 // Writes `value` for `key` in the key-value store on the server.
    102 async function writeValueToServer(key, value) {
    103  const serverUrl = `${STORE_URL}?key=${key}&value=${value}`;
    104  await fetch(serverUrl);
    105 }
    106 
    107 // Loads the initiator page, and navigates to the prerendered page after it
    108 // receives the 'readyToActivate' message.
    109 //
    110 // `rule_extras` provides additional parameters for the speculation rule used
    111 // to trigger prerendering.
    112 function loadInitiatorPage(rule_extras = {}) {
    113  // Used to communicate with the prerendering page.
    114  const prerenderChannel = new PrerenderChannel('prerender-channel');
    115  window.addEventListener('pagehide', () => {
    116    prerenderChannel.close();
    117  });
    118 
    119  // We need to wait for the 'readyToActivate' message before navigation
    120  // since the prerendering implementation in Chromium can only activate if the
    121  // response for the prerendering navigation has already been received and the
    122  // prerendering document was created.
    123  const readyToActivate = new Promise((resolve, reject) => {
    124    prerenderChannel.addEventListener('message', e => {
    125      if (e.data != 'readyToActivate')
    126        reject(`The initiator page receives an unsupported message: ${e.data}`);
    127      resolve(e.data);
    128    });
    129  });
    130 
    131  const url = new URL(document.URL);
    132  url.searchParams.append('prerendering', '');
    133  // Prerender a page that notifies the initiator page of the page's ready to be
    134  // activated via the 'readyToActivate'.
    135  startPrerendering(url.toString(), rule_extras);
    136 
    137  // Navigate to the prerendered page after being informed.
    138  readyToActivate.then(() => {
    139    if (rule_extras['target_hint'] === '_blank') {
    140      window.open(url.toString(), '_blank', 'noopener');
    141    } else {
    142      window.location = url.toString();
    143    }
    144  }).catch(e => {
    145    const testChannel = new PrerenderChannel('test-channel');
    146    testChannel.postMessage(
    147        `Failed to navigate the prerendered page: ${e.toString()}`);
    148    testChannel.close();
    149    window.close();
    150  });
    151 }
    152 
    153 // Returns messages received from the given PrerenderChannel
    154 // so that callers do not need to add their own event listeners.
    155 // nextMessage() returns a promise which resolves with the next message.
    156 //
    157 // Usage:
    158 //   const channel = new PrerenderChannel('channel-name');
    159 //   const messageQueue = new BroadcastMessageQueue(channel);
    160 //   const message1 = await messageQueue.nextMessage();
    161 //   const message2 = await messageQueue.nextMessage();
    162 //   message1 and message2 are the messages received.
    163 class BroadcastMessageQueue {
    164  constructor(c) {
    165    this.messages = [];
    166    this.resolveFunctions = [];
    167    this.channel = c;
    168    this.channel.addEventListener('message', e => {
    169      if (this.resolveFunctions.length > 0) {
    170        const fn = this.resolveFunctions.shift();
    171        fn(e.data);
    172      } else {
    173        this.messages.push(e.data);
    174      }
    175    });
    176  }
    177 
    178  // Returns a promise that resolves with the next message from this queue.
    179  nextMessage() {
    180    return new Promise(resolve => {
    181      if (this.messages.length > 0)
    182        resolve(this.messages.shift())
    183      else
    184        this.resolveFunctions.push(resolve);
    185    });
    186  }
    187 }
    188 
    189 // Returns <iframe> element upon load.
    190 function createFrame(url) {
    191  return new Promise(resolve => {
    192      const frame = document.createElement('iframe');
    193      frame.src = url;
    194      frame.onload = () => resolve(frame);
    195      document.body.appendChild(frame);
    196    });
    197 }
    198 
    199 /**
    200 * Creates a prerendered page.
    201 * @param {Object} params - Additional query params for navigations.
    202 * @param {URLSearchParams} [params.initiator] - For the page that triggers
    203 *     prerendering.
    204 * @param {URLSearchParams} [params.prerendering] - For prerendering navigation.
    205 * @param {URLSearchParams} [params.activating] - For activating navigation.
    206 * @param {Object} opt - Controls creation of prerendered pages.
    207 * @param {boolean} [opt.prefetch] - When this is true, prefetch is also
    208 *     triggered before prerendering.
    209 * @param {Object} rule_extras - Additional params for the speculation rule used
    210 *     to trigger prerendering.
    211 */
    212 async function create_prerendered_page(t, params = {}, opt = {}, rule_extras = {}) {
    213  const baseUrl = '/speculation-rules/prerender/resources/exec.py';
    214  const init_uuid = token();
    215  const prerender_uuid = token();
    216  const discard_uuid = token();
    217  const init_remote = new RemoteContext(init_uuid);
    218  const prerender_remote = new RemoteContext(prerender_uuid);
    219  const discard_remote = new RemoteContext(discard_uuid);
    220 
    221  const init_params = new URLSearchParams();
    222  init_params.set('uuid', init_uuid);
    223  if ('initiator' in params) {
    224    for (const [key, value] of params.initiator.entries()) {
    225      init_params.set(key, value);
    226    }
    227  }
    228  window.open(`${baseUrl}?${init_params.toString()}&init`, '_blank', 'noopener');
    229 
    230  // Construct a URL for prerendering.
    231  const prerendering_params = new URLSearchParams();
    232  prerendering_params.set('uuid', prerender_uuid);
    233  prerendering_params.set('discard_uuid', discard_uuid);
    234  if ('prerendering' in params) {
    235    for (const [key, value] of params.prerendering.entries()) {
    236      prerendering_params.set(key, value);
    237    }
    238  }
    239  const prerendering_url = `${baseUrl}?${prerendering_params.toString()}`;
    240 
    241  // Construct a URL for activation. If `params.activating` is provided, the
    242  // URL is constructed with the params. Otherwise, the URL is the same as
    243  // `prerendering_url`.
    244  const activating_url = (() => {
    245    if ('activating' in params) {
    246      const activating_params = new URLSearchParams();
    247      activating_params.set('uuid', prerender_uuid);
    248      activating_params.set('discard_uuid', discard_uuid);
    249      for (const [key, value] of params.activating.entries()) {
    250        activating_params.set(key, value);
    251      }
    252      return `${baseUrl}?${activating_params.toString()}`;
    253    } else {
    254      return prerendering_url;
    255    }
    256  })();
    257 
    258  if (opt.prefetch) {
    259    await init_remote.execute_script((prerendering_url, rule_extras) => {
    260        const a = document.createElement('a');
    261        a.href = prerendering_url;
    262        a.innerText = 'Activate (prefetch)';
    263        document.body.appendChild(a);
    264        const rules = document.createElement('script');
    265        rules.type = "speculationrules";
    266        rules.text = JSON.stringify(
    267            {prefetch: [{source: 'list', urls: [prerendering_url], ...rule_extras}]});
    268        document.head.appendChild(rules);
    269    }, [prerendering_url, rule_extras]);
    270 
    271    // Wait for the completion of the prefetch.
    272    await new Promise(resolve => t.step_timeout(resolve, 3000));
    273  }
    274 
    275  await init_remote.execute_script((prerendering_url, rule_extras) => {
    276      const a = document.createElement('a');
    277      a.href = prerendering_url;
    278      a.innerText = 'Activate';
    279      document.body.appendChild(a);
    280      const rules = document.createElement('script');
    281      rules.type = "speculationrules";
    282      rules.text = JSON.stringify({prerender: [{source: 'list', urls: [prerendering_url], ...rule_extras}]});
    283      document.head.appendChild(rules);
    284  }, [prerendering_url, rule_extras]);
    285 
    286  await Promise.any([
    287    prerender_remote.execute_script(() => {
    288        window.import_script_to_prerendered_page = src => {
    289            const script = document.createElement('script');
    290            script.src = src;
    291            document.head.appendChild(script);
    292            return new Promise(resolve => script.addEventListener('load', resolve));
    293        }
    294    }), new Promise(r => t.step_timeout(r, 3000))
    295    ]);
    296 
    297  t.add_cleanup(() => {
    298    init_remote.execute_script(() => window.close());
    299    discard_remote.execute_script(() => window.close());
    300    prerender_remote.execute_script(() => window.close());
    301  });
    302 
    303  async function tryToActivate() {
    304    const prerendering = prerender_remote.execute_script(() => new Promise(resolve => {
    305        if (!document.prerendering)
    306            resolve('activated');
    307        else document.addEventListener('prerenderingchange', () => resolve('activated'));
    308    }));
    309 
    310    const discarded = discard_remote.execute_script(() => Promise.resolve('discarded'));
    311 
    312    init_remote.execute_script((activating_url, target_hint) => {
    313      if (target_hint === '_blank') {
    314        window.open(activating_url, '_blank', 'noopener');
    315      } else {
    316        window.location = activating_url;
    317      }
    318    }, [activating_url, rule_extras['target_hint']]);
    319    return Promise.any([prerendering, discarded]);
    320  }
    321 
    322  async function activate() {
    323    const prerendering = await tryToActivate();
    324    if (prerendering !== 'activated')
    325      throw new Error('Should not be prerendering at this point')
    326  }
    327 
    328  // Get the number of network requests for exec.py. This doesn't care about
    329  // differences in search params.
    330  async function getNetworkRequestCount() {
    331    return await (await fetch(prerendering_url + '&get-fetch-count')).text();
    332  }
    333 
    334  return {
    335    exec: (fn, args) => prerender_remote.execute_script(fn, args),
    336    activate,
    337    tryToActivate,
    338    getNetworkRequestCount,
    339    prerenderingURL: (new URL(prerendering_url, document.baseURI)).href,
    340    activatingURL: (new URL(activating_url, document.baseURI)).href
    341  };
    342 }
    343 
    344 
    345 function test_prerender_restricted(fn, expected, label) {
    346  promise_test(async t => {
    347    const {exec} = await create_prerendered_page(t);
    348    let result = null;
    349    try {
    350      await exec(fn);
    351      result = "OK";
    352    } catch (e) {
    353      result = e.name;
    354    }
    355 
    356    assert_equals(result, expected);
    357  }, label);
    358 }
    359 
    360 function test_prerender_defer(fn, label) {
    361  promise_test(async t => {
    362    const {exec, activate} = await create_prerendered_page(t);
    363    let activated = false;
    364    const deferred = exec(fn);
    365 
    366    const post = new Promise(resolve =>
    367      deferred.then(result => {
    368        assert_true(activated, "Deferred operation should occur only after activation");
    369        resolve(result);
    370      }));
    371 
    372    await activate();
    373    activated = true;
    374    await post;
    375  }, label);
    376 }
    377 
    378 // If you want access to these, be sure to include
    379 // /html/browsers/browsing-the-web/remote-context-helper/resources/remote-context-helper.js
    380 // and /speculation-rules/resources/utils.js. So as to avoid requiring everyone
    381 // to do that, we only conditionally define this infrastructure.
    382 if (globalThis.PreloadingRemoteContextHelper) {
    383  class PrerenderingRemoteContextWrapper extends PreloadingRemoteContextHelper.RemoteContextWrapper {
    384    /**
    385    * Activates a prerendered page represented by `destinationRC` by navigating
    386    * the page currently displayed in this `PrerenderingRemoteContextWrapper` to
    387    * it. If the navigation does not result in a prerender activation, the
    388    * returned promise will be rejected with a testharness.js AssertionError.
    389    *
    390    * @param {PrerenderingRemoteContextWrapper} destinationRC - The
    391    *     `PrerenderingRemoteContextWrapper` pointing to the prerendered
    392    *     content. This is monitored to ensure the navigation results in a
    393    *     prerendering activation.
    394    * @param {(string) => Promise<undefined>} [navigateFn] - An optional
    395    *     function to customize the navigation. It will be passed the URL of the
    396    *     prerendered content, and will run as a script in this  (see
    397    *     `RemoteContextWrapper.prototype.executeScript`). If not given,
    398    *     navigation will be done via the `location.href` setter (see
    399    *     `RemoteContextWrapper.prototype.navigateTo`).
    400    * @returns {Promise<undefined>}
    401    */
    402    async navigateExpectingPrerenderingActivation(destinationRC, navigateFn) {
    403      // Store a promise that will fulfill when the `prerenderingchange` event
    404      // fires.
    405      await destinationRC.executeScript(() => {
    406        window.activatedPromise = new Promise(resolve => {
    407          document.addEventListener("prerenderingchange", () => resolve("activated"), { once: true });
    408        });
    409      });
    410 
    411      if (navigateFn === undefined) {
    412        await this.navigateTo(destinationRC.url);
    413      } else {
    414        await this.navigate(navigateFn, [destinationRC.url]);
    415      }
    416 
    417      // Wait until that event fires. If the activation fails and a normal
    418      // navigation happens instead, then `destinationRC` will start pointing to
    419      // that other page, where `window.activatedPromise` is undefined. In that
    420      // case this assert will fail since `undefined !== "activated"`.
    421      assert_equals(
    422        await destinationRC.executeScript(() => window.activatedPromise),
    423        "activated",
    424        "The prerendered page must be activated; instead a normal navigation happened."
    425      );
    426    }
    427 
    428    /**
    429    * Navigates to the URL identified by `destinationRC`, but expects that the
    430    * navigation does not cause a prerendering activation. (E.g., because the
    431    * prerender was canceled by something in the test code.) If the navigation
    432    * results in a prerendering activation, the returned promise will be
    433    * rejected with a testharness.js AssertionError.
    434    * @param {RemoteContextWrapper} destinationRC - The `RemoteContextWrapper`
    435    *     pointing to the destination URL. Usually this is obtained by
    436    *     prerendering (e.g., via `addPrerender()`), even though we are testing
    437    *     that the prerendering does not activate.
    438    * @param {(string) => Promise<undefined>} [navigateFn] - An optional
    439    *     function to customize the navigation. It will be passed the URL of the
    440    *     prerendered content, and will run as a script in this  (see
    441    *     `RemoteContextWrapper.prototype.executeScript`). If not given,
    442    *     navigation will be done via the `location.href` setter (see
    443    *     `RemoteContextWrapper.prototype.navigateTo`).
    444    * @returns {Promise<undefined>}
    445    */
    446    async navigateExpectingNoPrerenderingActivation(destinationRC, navigateFn) {
    447      if (navigateFn === undefined) {
    448        await this.navigateTo(destinationRC.url);
    449      } else {
    450        await this.navigate(navigateFn, [destinationRC.url]);
    451      }
    452 
    453      assert_equals(
    454        await destinationRC.executeScript(() => {
    455          return performance.getEntriesByType("navigation")[0].activationStart;
    456        }),
    457        0,
    458        "The prerendered page must not be activated."
    459      );
    460    }
    461 
    462    /**
    463    * Starts prerendering a page with this `PreloadingRemoteContextWrapper` as the
    464    * referrer, using `<script type="speculationrules">`.
    465    *
    466    * @param {object} [extrasInSpeculationRule] - Additional properties to add
    467    *     to the speculation rule JSON.
    468    * @param {RemoteContextConfig|object} [extraConfig] - Additional remote
    469    *     context configuration for the preloaded context.
    470    * @returns {Promise<PreloadingRemoteContextWrapper>}
    471    */
    472    addPrerender(options) {
    473      return this.addPreload("prerender", options);
    474    }
    475  }
    476 
    477  globalThis.PrerenderingRemoteContextHelper = class extends PreloadingRemoteContextHelper {
    478    static RemoteContextWrapper = PrerenderingRemoteContextWrapper;
    479  };
    480 }
    481 
    482 // Used by the opened window, to tell the main test runner to terminate a
    483 // failed test.
    484 function failTest(reason, uid) {
    485  const bc = new PrerenderChannel('test-channel', uid);
    486  bc.postMessage({result: 'FAILED', reason});
    487  bc.close();
    488 }
    489 
    490 // Retrieves a target hint from URLSearchParams of the current window and
    491 // returns it. Throw an Error if it doesn't have the valid target hint param.
    492 function getTargetHint() {
    493  const params = new URLSearchParams(window.location.search);
    494  const target_hint = params.get('target_hint');
    495  if (target_hint === null)
    496    throw new Error('window.location does not have a target hint param');
    497  if (target_hint !== '_self' && target_hint !== '_blank')
    498    throw new Error('window.location does not have a valid target hint param');
    499  return target_hint;
    500 }