| // Define a universal message passing API. It works cross-origin and across |
| // browsing context groups. |
| const dispatcher_path = "/common/dispatcher/dispatcher.py"; |
| |
| // Finds the nearest ancestor window that has a non srcdoc location. This should |
| // give us a usable location for constructing further URLs. |
| function findLocationFromAncestors(w) { |
| if (w.location.href == 'about:srcdoc') { |
| return findLocationFromAncestors(w.parent); |
| } |
| return w.location; |
| } |
| |
| // Handles differences between workers vs frames (src vs srcdoc). |
| function findLocation() { |
| if (location.href == 'about:srcdoc') { |
| return findLocationFromAncestors(window.parent); |
| } |
| if (location.protocol == 'blob:' || location.protocol == 'data:') { |
| // Allows working around blob and data URLs. |
| if (self.document && self.document.baseURI) { |
| return self.document.baseURI; |
| } |
| } |
| return location; |
| } |
| |
| const dispatcherLocation = findLocation(); |
| const dispatcher_url = new URL(dispatcher_path, dispatcherLocation).href; |
| |
| // Return a promise, limiting the number of concurrent accesses to a shared |
| // resources to |max_concurrent_access|. |
| const concurrencyLimiter = (max_concurrency) => { |
| let pending = 0; |
| let waiting = []; |
| return async (task) => { |
| pending++; |
| if (pending > max_concurrency) |
| await new Promise(resolve => waiting.push(resolve)); |
| let result = await task(); |
| pending--; |
| waiting.shift()?.(); |
| return result; |
| }; |
| } |
| |
| // Wait for a random amount of time in the range [10ms,100ms]. |
| const randomDelay = () => { |
| return new Promise(resolve => setTimeout(resolve, 10 + 90*Math.random())); |
| } |
| |
| // Sending too many requests in parallel causes congestion. Limiting it improves |
| // throughput. |
| // |
| // Note: The following table has been determined on the test: |
| // ../cache-storage.tentative.https.html |
| // using Chrome with a 64 core CPU / 64GB ram, in release mode: |
| // ┌───────────┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬────┐ |
| // │concurrency│ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 10│ 15│ 20│ 30│ 50│ 100│ |
| // ├───────────┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼────┤ |
| // │time (s) │ 54│ 38│ 31│ 29│ 26│ 24│ 22│ 22│ 22│ 22│ 34│ 36 │ |
| // └───────────┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴────┘ |
| const limiter = concurrencyLimiter(6); |
| |
| // While requests to different remote contexts can go in parallel, we need to |
| // ensure that requests to each remote context are done in order. This maps a |
| // uuid to a queue of requests to send. A queue is processed until it is empty |
| // and then is deleted from the map. |
| const sendQueues = new Map(); |
| |
| // Sends a single item (with rate-limiting) and calls the associated resolver |
| // when it is successfully sent. |
| const sendItem = async function (uuid, resolver, message) { |
| await limiter(async () => { |
| // Requests might be dropped. Retry until getting a confirmation it has been |
| // processed. |
| while(1) { |
| try { |
| let response = await fetch(dispatcher_url + `?uuid=${uuid}`, { |
| method: 'POST', |
| body: message |
| }) |
| if (await response.text() == "done") { |
| resolver(); |
| return; |
| } |
| } catch (fetch_error) {} |
| await randomDelay(); |
| }; |
| }); |
| } |
| |
| // While the queue is non-empty, send the next item. This is async and new items |
| // may be added to the queue while others are being sent. |
| const processQueue = async function (uuid, queue) { |
| while (queue.length) { |
| const [resolver, message] = queue.shift(); |
| await sendItem(uuid, resolver, message); |
| } |
| // The queue is empty, delete it. |
| sendQueues.delete(uuid); |
| } |
| |
| const send = async function (uuid, message) { |
| const itemSentPromise = new Promise((resolve) => { |
| const item = [resolve, message]; |
| if (sendQueues.has(uuid)) { |
| // There is already a queue for `uuid`, just add to it and it will be processed. |
| sendQueues.get(uuid).push(item); |
| } else { |
| // There is no queue for `uuid`, create it and start processing. |
| const queue = [item]; |
| sendQueues.set(uuid, queue); |
| processQueue(uuid, queue); |
| } |
| }); |
| // Wait until the item has been successfully sent. |
| await itemSentPromise; |
| } |
| |
| const receive = async function (uuid) { |
| while(1) { |
| let data = "not ready"; |
| try { |
| data = await limiter(async () => { |
| let response = await fetch(dispatcher_url + `?uuid=${uuid}`); |
| return await response.text(); |
| }); |
| } catch (fetch_error) {} |
| |
| if (data == "not ready") { |
| await randomDelay(); |
| continue; |
| } |
| |
| return data; |
| } |
| } |
| |
| // Returns an URL. When called, the server sends toward the `uuid` queue the |
| // request headers. Useful for determining if something was requested with |
| // Cookies. |
| const showRequestHeaders = function(origin, uuid) { |
| return origin + dispatcher_path + `?uuid=${uuid}&show-headers`; |
| } |
| |
| // Same as above, except for the response is cacheable. |
| const cacheableShowRequestHeaders = function(origin, uuid) { |
| return origin + dispatcher_path + `?uuid=${uuid}&cacheable&show-headers`; |
| } |
| |
| // This script requires |
| // - `/common/utils.js` for `token()`. |
| |
| // Returns the URL of a document that can be used as a `RemoteContext`. |
| // |
| // `uuid` should be a UUID uniquely identifying the given remote context. |
| // `options` has the following shape: |
| // |
| // { |
| // host: (optional) Sets the returned URL's `host` property. Useful for |
| // cross-origin executors. |
| // protocol: (optional) Sets the returned URL's `protocol` property. |
| // } |
| function remoteExecutorUrl(uuid, options) { |
| const url = new URL("/common/dispatcher/remote-executor.html", dispatcherLocation); |
| url.searchParams.set("uuid", uuid); |
| |
| if (options?.host) { |
| url.host = options.host; |
| } |
| |
| if (options?.protocol) { |
| url.protocol = options.protocol; |
| } |
| |
| return url; |
| } |
| |
| // Represents a remote executor. For more detailed explanation see `README.md`. |
| class RemoteContext { |
| // `uuid` is a UUID string that identifies the remote context and should |
| // match with the `uuid` parameter of the URL of the remote context. |
| constructor(uuid) { |
| this.context_id = uuid; |
| } |
| |
| // Evaluates the script `expr` on the executor. |
| // - If `expr` is evaluated to a Promise that is resolved with a value: |
| // `execute_script()` returns a Promise resolved with the value. |
| // - If `expr` is evaluated to a non-Promise value: |
| // `execute_script()` returns a Promise resolved with the value. |
| // - If `expr` throws an error or is evaluated to a Promise that is rejected: |
| // `execute_script()` returns a rejected Promise with the error's |
| // `message`. |
| // Note that currently the type of error (e.g. DOMException) is not |
| // preserved, except for `TypeError`. |
| // The values should be able to be serialized by JSON.stringify(). |
| async execute_script(fn, args) { |
| const receiver = token(); |
| await this.send({receiver: receiver, fn: fn.toString(), args: args}); |
| const response = JSON.parse(await receive(receiver)); |
| if (response.status === 'success') { |
| return response.value; |
| } |
| |
| // exception |
| if (response.name === 'TypeError') { |
| throw new TypeError(response.value); |
| } |
| throw new Error(response.value); |
| } |
| |
| async send(msg) { |
| return await send(this.context_id, JSON.stringify(msg)); |
| } |
| }; |
| |
| class Executor { |
| constructor(uuid) { |
| this.uuid = uuid; |
| |
| // If `suspend_callback` is not `null`, the executor should be suspended |
| // when there are no ongoing tasks. |
| this.suspend_callback = null; |
| |
| this.execute(); |
| } |
| |
| // Wait until there are no ongoing tasks nor fetch requests for polling |
| // tasks, and then suspend the executor and call `callback()`. |
| // Navigation from the executor page should be triggered inside `callback()`, |
| // to avoid conflict with in-flight fetch requests. |
| suspend(callback) { |
| this.suspend_callback = callback; |
| } |
| |
| resume() { |
| } |
| |
| async execute() { |
| while(true) { |
| if (this.suspend_callback !== null) { |
| this.suspend_callback(); |
| this.suspend_callback = null; |
| // Wait for `resume()` to be called. |
| await new Promise(resolve => this.resume = resolve); |
| |
| // Workaround for https://crbug.com/1244230. |
| // Without this workaround, the executor is resumed and the fetch |
| // request to poll the next task is initiated synchronously from |
| // pageshow event after the page restored from BFCache, and the fetch |
| // request promise is never resolved (and thus the test results in |
| // timeout) due to https://crbug.com/1244230. The root cause is not yet |
| // known, but setTimeout() with 0ms causes the resume triggered on |
| // another task and seems to resolve the issue. |
| await new Promise(resolve => setTimeout(resolve, 0)); |
| |
| continue; |
| } |
| |
| const task = JSON.parse(await receive(this.uuid)); |
| |
| let response; |
| try { |
| const value = await eval(task.fn).apply(null, task.args); |
| response = JSON.stringify({ |
| status: 'success', |
| value: value |
| }); |
| } catch(e) { |
| response = JSON.stringify({ |
| status: 'exception', |
| name: e.name, |
| value: e.message |
| }); |
| } |
| await send(task.receiver, response); |
| } |
| } |
| } |