Add XHR response modifier

- Add ResponseModifier class which is responsible for handling the
  modification of the responses intercepted by XHRProxy. The
  modifications occur by processing requests via several individual
  response modifiers sequentially, which are applied if the URL matches
  a RegEx condition and, if applicable, passes a condition based on the
  features enabled by the user.

- A sample response modifier called "demo" is included, to show how a
  simple modifier would work.

- Add mainWorldOptionsWatcher with 2 components: a server and a client,
  so main world scripts (clients) can retrieve the extension options via
  their corresponding content scripts (the servers) using an
  OptionsWatcher class instantiated by the server. This is used by the
  ResponseModifier class since it's injected into the main world because
  it's the only way to intercept requests.

Bug: twpowertools:153
Change-Id: I8a9767e1eadd60d3a0f1054669e1e1f5e7a49fbb
diff --git a/src/common/mainWorldOptionsWatcher/Client.js b/src/common/mainWorldOptionsWatcher/Client.js
new file mode 100644
index 0000000..3e5a9f9
--- /dev/null
+++ b/src/common/mainWorldOptionsWatcher/Client.js
@@ -0,0 +1,76 @@
+export const kDefaultTimeout = 10 * 1000;  // 10 seconds
+
+// Main World OptionsWatcher client (used in scripts injected into the Main
+// World (MW) to get the options).
+export default class MWOptionsWatcherClient {
+  constructor(options, CSTarget, MWTarget, timeout) {
+    if (!CSTarget || !MWTarget)
+      throw new Error(
+          `[MWOptionsWatcherClient] CSTarget and MWTarget are compulsory.`);
+
+    this.CSTarget = CSTarget;
+    this.MWTarget = MWTarget;
+    this.timeout = timeout ?? kDefaultTimeout;
+    this.#setUp(options);
+  }
+
+  #setUp(options) {
+    this.#sendRequestWithoutCallback('setUp', {options});
+  }
+
+  async getOption(option) {
+    if (!option) return null;
+    return this.#sendRequest('getOption', {option});
+  }
+
+  async getOptions(options) {
+    if (!options || options?.length === 0) return [];
+    return this.#sendRequest('getOptions', {options});
+  }
+
+  async isEnabled(option) {
+    if (!option) return null;
+    return this.#sendRequest('isEnabled', {option});
+  }
+
+  async areEnabled(options) {
+    if (!options || options?.length === 0) return [];
+    return this.#sendRequest('areEnabled', {options});
+  }
+
+  #sendRequestWithoutCallback(action, request, uuid) {
+    if (!uuid) uuid = self.crypto.randomUUID();
+    const data = {
+      target: this.CSTarget,
+      uuid,
+      action,
+      request,
+    };
+    window.postMessage(data, '*');
+  }
+
+  #sendRequest(action, request) {
+    return new Promise((res, rej) => {
+      const uuid = self.crypto.randomUUID();
+
+      let timeoutId;
+      let listener = e => {
+        if (e.source !== window || e.data?.target !== this.MWTarget ||
+            e.data?.uuid !== uuid)
+          return;
+
+        window.removeEventListener('message', listener);
+        clearTimeout(timeoutId);
+        res(e.data?.response);
+      };
+      window.addEventListener('message', listener);
+
+      timeoutId = setTimeout(() => {
+        window.removeEventListener('message', listener);
+        rej(new Error('Timed out while waiting response.'));
+      }, this.timeout);
+
+      this.#sendRequestWithoutCallback(action, request, uuid);
+    });
+  }
+}
diff --git a/src/common/mainWorldOptionsWatcher/Server.js b/src/common/mainWorldOptionsWatcher/Server.js
new file mode 100644
index 0000000..0d30aac
--- /dev/null
+++ b/src/common/mainWorldOptionsWatcher/Server.js
@@ -0,0 +1,91 @@
+import OptionsWatcher from '../../common/optionsWatcher.js';
+
+// Main World OptionsWatcher server (used in content scripts to be able to serve
+// the options to Main World (MW) scripts).
+export default class MWOptionsWatcherServer {
+  constructor(CSTarget, MWTarget) {
+    if (!CSTarget || !MWTarget)
+      throw new Error(
+          `[MWOptionsWatcherServer] CSTarget and MWTarget are compulsory.`);
+
+    this.optionsWatcher = null;
+    this.CSTarget = CSTarget;
+    this.MWTarget = MWTarget;
+
+    window.addEventListener('message', e => this.handleMessage(e));
+  }
+
+  handleMessage(e) {
+    const uuid = e.data?.uuid;
+    if (e.source !== window || e.data?.target !== this.CSTarget || !uuid)
+      return;
+
+    if (e.data?.action === 'setUp') {
+      this.optionsWatcher = new OptionsWatcher(e.data?.request?.options);
+      return;
+    }
+
+    if (!this.optionsWatcher) {
+      console.warn(`[MWOptionsWatcherServer] Action '${
+          e.data?.action}' called before setting up options watcher.`);
+      return;
+    }
+
+    switch (e.data?.action) {
+      case 'getOption':
+        this.optionsWatcher.getOption(e.data?.request?.option).then(value => {
+          this.respond(uuid, value);
+        });
+        return;
+
+      case 'getOptions':
+        var promises = [];
+        var options = e.data?.request?.options ?? [];
+        for (const option of options) {
+          promises.push(this.optionsWatcher.getOption(option));
+        }
+        Promise.all(promises).then(responses => {
+          const response = {};
+          for (let i = 0; i < responses.length; i++) {
+            response[options[i]] = responses[i];
+          }
+          this.respond(uuid, response);
+        });
+        return;
+
+      case 'isEnabled':
+        this.optionsWatcher.isEnabled(e.data?.request?.option).then(value => {
+          this.respond(uuid, value);
+        });
+        return;
+
+      case 'areEnabled':
+        var promises = [];
+        var options = e.data?.request?.options ?? [];
+        for (const option of options) {
+          promises.push(this.optionsWatcher.isEnabled(option));
+        }
+        Promise.all(promises).then(responses => {
+          const response = {};
+          for (let i = 0; i < responses.length; i++) {
+            response[options[i]] = responses[i];
+          }
+          this.respond(uuid, response);
+        });
+        return;
+
+      default:
+        console.error(`[MWOptionsWatcherServer] Invalid action received (${
+            e.data?.action})`);
+    }
+  }
+
+  respond(uuid, response) {
+    const data = {
+      target: this.MWTarget,
+      uuid,
+      response,
+    };
+    window.postMessage(data, window.origin);
+  }
+}
diff --git a/src/common/protojs.js b/src/common/protojs.js
index 026559b..a2e2d06 100644
--- a/src/common/protojs.js
+++ b/src/common/protojs.js
@@ -11,3 +11,26 @@
   }
   return object;
 }
+
+// The inverse function.
+export function inverseCorrectArrayKeys(input) {
+  if (Array.isArray(input)) {
+    if (input[0] === null || input[0] === undefined) {
+      // Make a copy of the input array so we don't modify the original one.
+      input = Array.from(input);
+      input.shift();
+    }
+    for (let i = 0; i < input.length; ++i) {
+      input[i] = inverseCorrectArrayKeys(input[i]);
+    }
+    return input;
+  }
+
+  if (typeof input !== 'object' || input === null) return input;
+
+  let array = [];
+  Object.entries(input).forEach(entry => {
+    array[entry[0] - 1] = inverseCorrectArrayKeys(entry[1]);
+  });
+  return array;
+}
diff --git a/src/contentScripts/communityConsole/start.js b/src/contentScripts/communityConsole/start.js
index a6e171d..f364626 100644
--- a/src/contentScripts/communityConsole/start.js
+++ b/src/contentScripts/communityConsole/start.js
@@ -1,5 +1,7 @@
 import {injectScript, injectStylesheet} from '../../common/contentScriptsUtils.js';
+import MWOptionsWatcherServer from '../../common/mainWorldOptionsWatcher/Server.js';
 import {getOptions} from '../../common/optionsUtils.js';
+import {kCSTarget, kMWTarget} from '../../xhrInterceptor/responseModifiers/index.js';
 
 import AutoRefresh from './autoRefresh.js';
 import ExtraInfo from './extraInfo.js';
@@ -74,4 +76,6 @@
     injectStylesheet(chrome.runtime.getURL('css/ui_spacing/shared.css'));
     injectStylesheet(chrome.runtime.getURL('css/ui_spacing/console.css'));
   }
+
+  new MWOptionsWatcherServer(kCSTarget, kMWTarget);
 });
diff --git a/src/injections/xhrInterceptor.js b/src/injections/xhrInterceptor.js
index ef2efe3..6dba13e 100644
--- a/src/injections/xhrInterceptor.js
+++ b/src/injections/xhrInterceptor.js
@@ -1,29 +1,74 @@
+import {waitFor} from 'poll-until-promise';
+
 import {correctArrayKeys} from '../common/protojs';
+import ResponseModifier from '../xhrInterceptor/responseModifiers/index.js';
 import * as utils from '../xhrInterceptor/utils.js';
 
-const originalOpen = window.XMLHttpRequest.prototype.open;
-const originalSetRequestHeader =
-    window.XMLHttpRequest.prototype.setRequestHeader;
-const originalSend = window.XMLHttpRequest.prototype.send;
+const kSpecialEvents = ['load', 'loadend'];
+const kErrorEvents = ['error', 'timeout', 'abort'];
 
-let messageID = 0;
+const kCheckInterceptionOptions = {
+  interval: 50,
+  timeout: 100 * 1000,
+};
 
+function flattenOptions(options) {
+  if (typeof options === 'boolean') return options;
+  if (options) return options['capture'];
+  return undefined;
+}
+
+// Slightly based in https://stackoverflow.com/a/24561614.
 class XHRProxy {
   constructor() {
     this.originalXMLHttpRequest = window.XMLHttpRequest;
-    const originalXMLHttpRequest = this.originalXMLHttpRequest;
+    const classThis = this;
 
     this.messageID = 0;
+    this.responseModifier = new ResponseModifier();
 
     window.XMLHttpRequest = function() {
-      this.xhr = new originalXMLHttpRequest();
-      this.$TWPTID = messageID++;
+      this.xhr = new classThis.originalXMLHttpRequest();
+      this.$TWPTID = classThis.messageID++;
+      this.$responseModified = false;
+      this.$responseIntercepted = false;
+      this.specialHandlers = {
+        load: new Set(),
+        loadend: new Set(),
+      };
+
+      const proxyThis = this;
+      kSpecialEvents.forEach(eventName => {
+        this.xhr.addEventListener(eventName, function() {
+          let p;
+          if (eventName === 'load') {
+            p = classThis.responseModifier.intercept(proxyThis, this.response).then(() => {
+              proxyThis.$responseIntercepted = true;
+            });
+          } else {
+            p = waitFor(() => {
+              if (proxyThis.$responseIntercepted) return Promise.resolve();
+              return Promise.reject();
+            }, kCheckInterceptionOptions);
+          }
+
+          p.then(() => {
+            for (const e of proxyThis.specialHandlers[eventName]) {
+              e[1](arguments);
+            }
+          });
+        });
+      });
+      kErrorEvents.forEach(eventName => {
+        this.xhr.addEventListener(eventName, function() {
+          proxyThis.$responseIntercepted = true;
+        });
+      });
     };
 
     const methods = [
-      'open', 'abort', 'setRequestHeader', 'send', 'addEventListener',
-      'removeEventListener', 'getResponseHeader', 'getAllResponseHeaders',
-      'dispatchEvent', 'overrideMimeType'
+      'open', 'abort', 'setRequestHeader', 'send', 'getResponseHeader',
+      'getAllResponseHeaders', 'dispatchEvent', 'overrideMimeType'
     ];
     methods.forEach(method => {
       window.XMLHttpRequest.prototype[method] = function() {
@@ -87,6 +132,26 @@
       };
     });
 
+    window.XMLHttpRequest.prototype.addEventListener = function() {
+      if (!kSpecialEvents.includes(arguments[0]))
+        return this.xhr.addEventListener.apply(this.xhr, arguments);
+
+      this.specialHandlers[arguments[0]].add(arguments);
+    };
+
+    window.XMLHttpRequest.prototype.removeEventListener = function(
+        type, callback, options) {
+      if (!kSpecialEvents.includes(type))
+        return this.xhr.removeEventListener.apply(this.xhr, arguments);
+
+      const flattenedOptions = flattenOptions(options);
+      for (const e of this.specialHandlers[type]) {
+        if (callback === e[1] && flattenOptions(e[2]) === flattenedOptions) {
+          return this.specialHandlers[type].delete(e);
+        }
+      }
+    };
+
     const scalars = [
       'onabort',
       'onerror',
@@ -96,7 +161,6 @@
       'onprogress',
       'onreadystatechange',
       'readyState',
-      'response',
       'responseText',
       'responseType',
       'responseXML',
@@ -121,6 +185,19 @@
       });
     });
 
+    Object.defineProperty(window.XMLHttpRequest.prototype, 'response', {
+      get: function() {
+        if (!this.$responseIntercepted) return undefined;
+        if (this.$responseModified) return this.$newResponse;
+        return this.xhr.response;
+      },
+    });
+    Object.defineProperty(window.XMLHttpRequest.prototype, 'originalResponse', {
+      get: function() {
+        return this.xhr.response;
+      },
+    });
+
     return this;
   }
 }
diff --git a/src/xhrInterceptor/responseModifiers/demo.js b/src/xhrInterceptor/responseModifiers/demo.js
new file mode 100644
index 0000000..5839207
--- /dev/null
+++ b/src/xhrInterceptor/responseModifiers/demo.js
@@ -0,0 +1,18 @@
+export default {
+  urlRegex: /api\/ViewForum/i,
+  featureGated: true,
+  features: ['demo1', 'demo2'],
+  isEnabled(features) {
+    return features['demo1'] || features['demo2'];
+  },
+  interceptor(_request, _response) {
+    return Promise.resolve({
+      1: {
+        2: [],
+        4: 0,
+        6: {},
+        7: {},
+      },
+    });
+  },
+};
diff --git a/src/xhrInterceptor/responseModifiers/index.js b/src/xhrInterceptor/responseModifiers/index.js
new file mode 100644
index 0000000..6a4573a
--- /dev/null
+++ b/src/xhrInterceptor/responseModifiers/index.js
@@ -0,0 +1,75 @@
+import MWOptionsWatcherClient from '../../common/mainWorldOptionsWatcher/Client.js';
+import {convertJSONToResponse, getResponseJSON} from '../utils.js';
+
+import demo from './demo.js';
+
+export const responseModifiers = [
+  demo,
+];
+
+// Content script target
+export const kCSTarget = 'TWPT-XHRInterceptorOptionsWatcher-CS';
+// Main world (AKA regular web page) target
+export const kMWTarget = 'TWPT-XHRInterceptorOptionsWatcher-MW';
+
+export default class ResponseModifier {
+  constructor() {
+    this.optionsWatcher = new MWOptionsWatcherClient(
+        Array.from(this.watchingFeatures()), kCSTarget, kMWTarget);
+  }
+
+  watchingFeatures(modifiers) {
+    if (!modifiers) modifiers = responseModifiers;
+
+    const union = new Set();
+    for (const m of modifiers) {
+      if (!m.featureGated) continue;
+      for (const feature of m.features) union.add(feature);
+    }
+    return union;
+  }
+
+  async #getMatchingModifiers(request) {
+    // First filter modifiers which match the request URL regex.
+    const urlModifiers = responseModifiers.filter(
+        modifier => request.$TWPTRequestURL.match(modifier.urlRegex));
+
+    // Now filter modifiers which require a certain feature to be enabled
+    // (feature-gated modifiers).
+    const featuresAreEnabled = await this.optionsWatcher.areEnabled(
+        Array.from(this.watchingFeatures(urlModifiers)));
+
+    // #!if !production
+    if (Object.keys(featuresAreEnabled).length > 0) {
+      console.info(
+          '[XHR Interceptor - Response Modifier] Requested features',
+          featuresAreEnabled, 'for request', request.$TWPTRequestURL);
+    }
+    // #!endif
+
+    return urlModifiers.filter(modifier => {
+      return !modifier.featureGated || modifier.isEnabled(featuresAreEnabled);
+    });
+  }
+
+  async intercept(request, response) {
+    const matchingModifiers = await this.#getMatchingModifiers(request);
+
+    // If we didn't find any matching modifiers, return the response right away.
+    if (matchingModifiers.length === 0) return response;
+
+    // Otherwise, apply the modifiers sequentially and set the new response.
+    let json = getResponseJSON({
+      responseType: request.xhr.responseType,
+      response: request.xhr.response,
+      $TWPTRequestURL: request.$TWPTRequestURL,
+      $isArrayProto: request.$isArrayProto,
+    });
+    for (const modifier of matchingModifiers) {
+      json = await modifier.interceptor(request, json);
+    }
+    response = convertJSONToResponse(request, json);
+    request.$newResponse = response;
+    request.$responseModified = true;
+  }
+}
diff --git a/src/xhrInterceptor/utils.js b/src/xhrInterceptor/utils.js
index 909549b..849c543 100644
--- a/src/xhrInterceptor/utils.js
+++ b/src/xhrInterceptor/utils.js
@@ -1,4 +1,4 @@
-import {correctArrayKeys} from '../common/protojs';
+import {correctArrayKeys, inverseCorrectArrayKeys} from '../common/protojs';
 
 import xhrInterceptors from './interceptors.json5';
 
@@ -52,7 +52,7 @@
     let rawResponse = dec.decode(byteArray);
     response = JSON.parse(rawResponse);
   } else if (xhr.responseType === 'text' || xhr.responseType === '') {
-    response = JSON.parse(xhr.responseText);
+    response = JSON.parse(xhr.response);
   } else if (xhr.responseType === 'json') {
     response = xhr.response;
   } else {
@@ -66,6 +66,29 @@
   return response;
 }
 
+export function convertJSONToResponse(xhr, json) {
+  if (xhr.$isArrayProto) json = inverseCorrectArrayKeys(json);
+
+  switch (xhr.responseType) {
+    case 'json':
+      return json;
+
+    case 'text':
+    case '':
+      return JSON.stringify(json);
+
+    case 'arraybuffer':
+      const encoder = new TextEncoder();
+      return encoder.encode(JSON.stringify(json)).buffer;
+
+    default:
+      console.error(
+          'Unexpected responseType ' + xhr.responseType + '. Request url: ',
+          xhr.$TWPTRequestURL || xhr.responseURL);
+      return undefined;
+  }
+}
+
 export function triggerEvent(eventName, body, id) {
   var evt = new CustomEvent('TWPT_' + eventName, {
     detail: {