fix: add FetchProxy

This will let us intercept fetch requests (until now we're only proxying
XMLHttpRequest), in order to fix the issues we're experiencing with some
features.

Bug: twpowertools:229
Change-Id: I277473c05479ca39bb6183a51855382124890bde
diff --git a/src/xhrInterceptor/ResponseModifier.adapter.ts b/src/xhrInterceptor/ResponseModifier.adapter.ts
new file mode 100644
index 0000000..c6ed999
--- /dev/null
+++ b/src/xhrInterceptor/ResponseModifier.adapter.ts
@@ -0,0 +1,80 @@
+import MWOptionsWatcherClient from '../common/mainWorldOptionsWatcher/Client.js';
+import { OptionCodename } from '../common/options/optionsPrototype.js';
+import {
+  InterceptedResponse,
+  ResponseModifierPort,
+  Result,
+} from './ResponseModifier.port.js';
+
+import { Modifier } from './responseModifiers/types.js';
+
+// 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 ResponseModifierAdapter implements ResponseModifierPort {
+  private optionsWatcher: MWOptionsWatcherClient;
+
+  constructor(private responseModifiers: Modifier[]) {
+    this.optionsWatcher = new MWOptionsWatcherClient(
+      Array.from(this.watchingFeatures(this.responseModifiers)),
+      kCSTarget,
+      kMWTarget,
+    );
+  }
+
+  private watchingFeatures(modifiers: Modifier[]): Set<OptionCodename> {
+    const union = new Set<OptionCodename>();
+    for (const m of modifiers) {
+      if (!m.featureGated) continue;
+      for (const feature of m.features) union.add(feature);
+    }
+    return union;
+  }
+
+  private async getMatchingModifiers(requestUrl: string) {
+    // First filter modifiers which match the request URL regex.
+    const urlModifiers = this.responseModifiers.filter((modifier) =>
+      requestUrl.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.debug(
+        '[XHR Interceptor - Response Modifier] Requested features',
+        featuresAreEnabled,
+        'for request',
+        requestUrl,
+      );
+    }
+    // #!endif
+
+    return urlModifiers.filter((modifier) => {
+      return !modifier.featureGated || modifier.isEnabled(featuresAreEnabled);
+    });
+  }
+
+  async intercept(interception: InterceptedResponse): Promise<Result> {
+    const matchingModifiers = await this.getMatchingModifiers(interception.url);
+
+    // If we didn't find any matching modifiers, return the response right away.
+    if (matchingModifiers.length === 0) return { wasModified: false };
+
+    // Otherwise, apply the modifiers sequentially and set the new response.
+    let json = interception.originalResponse;
+    for (const modifier of matchingModifiers) {
+      json = await modifier.interceptor(json, interception.url);
+    }
+    return {
+      wasModified: true,
+      modifiedResponse: json,
+    };
+  }
+}