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/common/api.js b/src/common/api.js
index 8e0c19b..b7a1928 100644
--- a/src/common/api.js
+++ b/src/common/api.js
@@ -20,6 +20,9 @@
   16: 'UNAUTHENTICATED',
 };
 
+export const XClientHeader = 'X-Client';
+export const XClientValue = 'twpt';
+
 // Function to wrap calls to the Community Console API with intelligent error
 // handling.
 export function CCApi(
@@ -42,6 +45,9 @@
       .fetch(CC_API_BASE_URL + method + authuserPart, {
         'headers': {
           'content-type': 'text/plain; charset=utf-8',
+          // Used to exclude our requests from being handled by FetchProxy.
+          // FetchProxy will remove this header.
+          [XClientHeader]: XClientValue,
         },
         'body': JSON.stringify(data),
         'method': 'POST',
diff --git a/src/entryPoints/communityConsole/injections/xhrProxy.ts b/src/entryPoints/communityConsole/injections/xhrProxy.ts
new file mode 100644
index 0000000..1ad5d9d
--- /dev/null
+++ b/src/entryPoints/communityConsole/injections/xhrProxy.ts
@@ -0,0 +1,29 @@
+import FetchProxy from '../../../xhrInterceptor/fetchProxy/FetchProxy';
+import InterceptorHandlerAdapter from '../../../xhrInterceptor/interceptors/InterceptorHandler.adapter';
+import interceptors from '../../../xhrInterceptor/interceptors/interceptors';
+import {KILL_SWITCH_LOCALSTORAGE_KEY, KILL_SWITCH_LOCALSTORAGE_VALUE} from '../../../xhrInterceptor/killSwitchHandler.js';
+import MessageIdTracker from '../../../xhrInterceptor/MessageIdTracker';
+import ResponseModifierAdapter from '../../../xhrInterceptor/ResponseModifier.adapter';
+import createMessageRemoveParentRef from '../../../xhrInterceptor/responseModifiers/createMessageRemoveParentRef';
+import flattenThread from '../../../xhrInterceptor/responseModifiers/flattenThread';
+import loadMoreThread from '../../../xhrInterceptor/responseModifiers/loadMoreThread';
+import { Modifier } from '../../../xhrInterceptor/responseModifiers/types';
+import XHRProxy from '../../../xhrInterceptor/XHRProxy';
+
+export const responseModifiers: Modifier[] = [
+  loadMoreThread,
+  flattenThread,
+  createMessageRemoveParentRef,
+];
+
+if (window.localStorage.getItem(KILL_SWITCH_LOCALSTORAGE_KEY) !==
+    KILL_SWITCH_LOCALSTORAGE_VALUE) {
+  const responseModifier = new ResponseModifierAdapter(responseModifiers);
+  const interceptorHandler = new InterceptorHandlerAdapter(interceptors.interceptors);
+  const messageIdTracker = new MessageIdTracker();
+
+  new XHRProxy(responseModifier, messageIdTracker);
+
+  const fetchProxy = new FetchProxy(responseModifier, interceptorHandler, messageIdTracker);
+  fetchProxy.enableInterception();
+}
diff --git a/src/injections/xhrProxy.js b/src/injections/xhrProxy.js
deleted file mode 100644
index 2799bd2..0000000
--- a/src/injections/xhrProxy.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import {KILL_SWITCH_LOCALSTORAGE_KEY, KILL_SWITCH_LOCALSTORAGE_VALUE} from '../xhrInterceptor/killSwitchHandler.js';
-import XHRProxy from '../xhrInterceptor/XHRProxy.js';
-
-if (window.localStorage.getItem(KILL_SWITCH_LOCALSTORAGE_KEY) !==
-    KILL_SWITCH_LOCALSTORAGE_VALUE) {
-  new XHRProxy();
-}
diff --git a/src/presentation/standaloneScripts/mainWorldServers/MWOptionsWatcherServerScript.script.ts b/src/presentation/standaloneScripts/mainWorldServers/MWOptionsWatcherServerScript.script.ts
index c5d5311..484c8e8 100644
--- a/src/presentation/standaloneScripts/mainWorldServers/MWOptionsWatcherServerScript.script.ts
+++ b/src/presentation/standaloneScripts/mainWorldServers/MWOptionsWatcherServerScript.script.ts
@@ -1,6 +1,6 @@
 import Script, { ScriptEnvironment, ScriptPage, ScriptRunPhase } from "../../../common/architecture/scripts/Script"
 import MWOptionsWatcherServer from "../../../common/mainWorldOptionsWatcher/Server"
-import { kCSTarget, kMWTarget } from "../../../xhrInterceptor/ResponseModifier"
+import { kCSTarget, kMWTarget } from "../../../xhrInterceptor/ResponseModifier.adapter"
 
 export default class MWOptionsWatcherServerScript extends Script {
   // The server should be available as soon as possible, since e.g. the
diff --git a/src/xhrInterceptor/MessageIdTracker.ts b/src/xhrInterceptor/MessageIdTracker.ts
new file mode 100644
index 0000000..4540b23
--- /dev/null
+++ b/src/xhrInterceptor/MessageIdTracker.ts
@@ -0,0 +1,7 @@
+export default class MessageIdTracker {
+  private messageId = 0;
+
+  getNewId() {
+    return this.messageId++;
+  }
+}
diff --git a/src/xhrInterceptor/ResponseModifier.ts b/src/xhrInterceptor/ResponseModifier.adapter.ts
similarity index 68%
rename from src/xhrInterceptor/ResponseModifier.ts
rename to src/xhrInterceptor/ResponseModifier.adapter.ts
index c2b3a34..c6ed999 100644
--- a/src/xhrInterceptor/ResponseModifier.ts
+++ b/src/xhrInterceptor/ResponseModifier.adapter.ts
@@ -1,29 +1,24 @@
 import MWOptionsWatcherClient from '../common/mainWorldOptionsWatcher/Client.js';
 import { OptionCodename } from '../common/options/optionsPrototype.js';
-import { ProtobufObject } from '../common/protojs.types.js';
+import {
+  InterceptedResponse,
+  ResponseModifierPort,
+  Result,
+} from './ResponseModifier.port.js';
 
-import createMessageRemoveParentRef from './responseModifiers/createMessageRemoveParentRef';
-import flattenThread from './responseModifiers/flattenThread';
-import loadMoreThread from './responseModifiers/loadMoreThread';
-import { Modifier } from './responseModifiers/types';
-
-export const responseModifiers = [
-  loadMoreThread,
-  flattenThread,
-  createMessageRemoveParentRef,
-] as Modifier[];
+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 ResponseModifier {
+export default class ResponseModifierAdapter implements ResponseModifierPort {
   private optionsWatcher: MWOptionsWatcherClient;
 
-  constructor() {
+  constructor(private responseModifiers: Modifier[]) {
     this.optionsWatcher = new MWOptionsWatcherClient(
-      Array.from(this.watchingFeatures(responseModifiers)),
+      Array.from(this.watchingFeatures(this.responseModifiers)),
       kCSTarget,
       kMWTarget,
     );
@@ -40,7 +35,7 @@
 
   private async getMatchingModifiers(requestUrl: string) {
     // First filter modifiers which match the request URL regex.
-    const urlModifiers = responseModifiers.filter((modifier) =>
+    const urlModifiers = this.responseModifiers.filter((modifier) =>
       requestUrl.match(modifier.urlRegex),
     );
 
@@ -83,25 +78,3 @@
     };
   }
 }
-
-/**
- * Represents an intercepted response.
- */
-export interface InterceptedResponse {
-  /**
-   * URL of the original request.
-   */
-  url: string;
-
-  /**
-   * Object with the response as intercepted without any modification.
-   */
-  originalResponse: ProtobufObject;
-}
-
-export type Result =
-  | { wasModified: false }
-  | {
-      wasModified: true;
-      modifiedResponse: ProtobufObject;
-    };
diff --git a/src/xhrInterceptor/ResponseModifier.port.ts b/src/xhrInterceptor/ResponseModifier.port.ts
new file mode 100644
index 0000000..84b4c92
--- /dev/null
+++ b/src/xhrInterceptor/ResponseModifier.port.ts
@@ -0,0 +1,27 @@
+import { ProtobufObject } from "../common/protojs.types";
+
+export interface ResponseModifierPort {
+  intercept(interception: InterceptedResponse): Promise<Result>;
+}
+
+/**
+ * Represents an intercepted response.
+ */
+export interface InterceptedResponse {
+  /**
+   * URL of the original request.
+   */
+  url: string;
+
+  /**
+   * Object with the response as intercepted without any modification.
+   */
+  originalResponse: ProtobufObject;
+}
+
+export type Result =
+  | { wasModified: false }
+  | {
+      wasModified: true;
+      modifiedResponse: ProtobufObject;
+    };
diff --git a/src/xhrInterceptor/XHRProxy.js b/src/xhrInterceptor/XHRProxy.js
index 14d2aae..1552966 100644
--- a/src/xhrInterceptor/XHRProxy.js
+++ b/src/xhrInterceptor/XHRProxy.js
@@ -1,7 +1,6 @@
 import {waitFor} from 'poll-until-promise';
 
 import {correctArrayKeys} from '../common/protojs';
-import ResponseModifier from '../xhrInterceptor/ResponseModifier';
 import * as utils from '../xhrInterceptor/utils.js';
 
 const kSpecialEvents = ['load', 'loadend'];
@@ -55,13 +54,16 @@
  * requests through our internal interceptors to read/modify requests/responses.
  *
  * Slightly based in https://stackoverflow.com/a/24561614.
+ *
+ * @param responseModifier
+ * @param messageIdTracker
  */
 export default class XHRProxy {
-  constructor() {
+  constructor(responseModifier, messageIdTracker) {
     this.originalXMLHttpRequest = window.XMLHttpRequest;
 
-    this.messageID = 0;
-    this.responseModifier = new ResponseModifier();
+    this.messageIdTracker = messageIdTracker;
+    this.responseModifier = responseModifier;
 
     this.#overrideXHRObject();
   }
@@ -80,7 +82,7 @@
 
     window.XMLHttpRequest = function() {
       this.xhr = new XHRProxyInstance.originalXMLHttpRequest();
-      this.$TWPTID = XHRProxyInstance.messageID++;
+      this.$TWPTID = XHRProxyInstance.messageIdTracker.getNewId();
       this.$responseModified = false;
       this.$responseIntercepted = false;
       this.specialHandlers = {
diff --git a/src/xhrInterceptor/fetchProxy/FetchBody.ts b/src/xhrInterceptor/fetchProxy/FetchBody.ts
new file mode 100644
index 0000000..a178009
--- /dev/null
+++ b/src/xhrInterceptor/fetchProxy/FetchBody.ts
@@ -0,0 +1,12 @@
+export default class FetchBody {
+  constructor(private body: RequestInit['body'] | undefined) {}
+
+  async getJSONRequestBody() {
+    if (!this.body) return undefined;
+
+    // Using Response is a hack, but it works and converts a possibly long code
+    // into a one-liner :D
+    // Source of inspiration: https://stackoverflow.com/a/72718732
+    return await new Response(this.body).json();
+  }
+}
diff --git a/src/xhrInterceptor/fetchProxy/FetchHeaders.test.ts b/src/xhrInterceptor/fetchProxy/FetchHeaders.test.ts
new file mode 100644
index 0000000..c075a2d
--- /dev/null
+++ b/src/xhrInterceptor/fetchProxy/FetchHeaders.test.ts
@@ -0,0 +1,88 @@
+/**
+ * @jest-environment ./src/xhrInterceptor/fetchProxy/__environments__/fetchEnvironment.ts
+ */
+
+import { describe, expect, it } from '@jest/globals';
+import FetchHeaders from './FetchHeaders';
+
+describe('FetchHeaders', () => {
+  const dummyHeaderName = 'X-Foo';
+  const dummyHeaderValue = 'bar';
+  const dummyHeadersPerFormat: { description: string; value: HeadersInit }[] = [
+    {
+      description: 'a Headers instance',
+      value: new Headers({ [dummyHeaderName]: dummyHeaderValue }),
+    },
+    {
+      description: 'an object',
+      value: { [dummyHeaderName]: dummyHeaderValue },
+    },
+    {
+      description: 'an array',
+      value: [[dummyHeaderName, dummyHeaderValue]],
+    },
+  ];
+
+  describe.each(dummyHeadersPerFormat)(
+    'when the headers are presented as $description',
+    ({ value }) => {
+      describe('hasValue', () => {
+        it('should return true when the header with the provided value is present', () => {
+          const sut = new FetchHeaders(value);
+          const result = sut.hasValue(dummyHeaderName, dummyHeaderValue);
+
+          expect(result).toBe(true);
+        });
+
+        it("should return true when a header with the provided value is present even if the provided name doesn't have the same case", () => {
+          const sut = new FetchHeaders(value);
+          const result = sut.hasValue('x-FoO', dummyHeaderValue);
+
+          expect(result).toBe(true);
+        });
+
+        it('should return false when a header with the provided value is not present', () => {
+          const sut = new FetchHeaders(value);
+          const result = sut.hasValue('X-NonExistent', dummyHeaderValue);
+
+          expect(result).toBe(false);
+        });
+      });
+
+      describe('removeHeader', () => {
+        it('should remove the header if it is present', () => {
+          const sut = new FetchHeaders(value);
+          sut.removeHeader(dummyHeaderName);
+
+          if (value instanceof Headers) {
+            expect(value.has(dummyHeaderName)).toBe(false);
+          } else if (Array.isArray(value)) {
+            expect(value).not.toContain(
+              expect.arrayContaining([dummyHeaderName]),
+            );
+          } else {
+            expect(value).not.toHaveProperty(dummyHeaderName);
+          }
+        });
+      });
+    },
+  );
+
+  describe('when the headers are presented as undefined', () => {
+    describe('hasValue', () => {
+      it('should return false', () => {
+        const sut = new FetchHeaders(undefined);
+        const result = sut.hasValue(dummyHeaderName, dummyHeaderValue);
+
+        expect(result).toBe(false);
+      });
+    });
+
+    describe('removeHeader', () => {
+      it('should not throw', () => {
+        const sut = new FetchHeaders(undefined);
+        expect(() => sut.removeHeader(dummyHeaderName)).not.toThrow();
+      });
+    });
+  });
+});
diff --git a/src/xhrInterceptor/fetchProxy/FetchHeaders.ts b/src/xhrInterceptor/fetchProxy/FetchHeaders.ts
new file mode 100644
index 0000000..4968d01
--- /dev/null
+++ b/src/xhrInterceptor/fetchProxy/FetchHeaders.ts
@@ -0,0 +1,47 @@
+export default class FetchHeaders {
+  constructor(private headers: HeadersInit | undefined) {}
+
+  hasValue(name: string, value: string) {
+    if (!this.headers) {
+      return false;
+    } else if (this.headers instanceof Headers) {
+      return this.headers.get(name) == value;
+    } else {
+      const headersArray = Array.isArray(this.headers)
+        ? this.headers
+        : Object.entries(this.headers);
+      return headersArray.some(
+        ([candidateHeaderName, candidateValue]) =>
+          this.isEqualCaseInsensitive(candidateHeaderName, name) &&
+          candidateValue === value,
+      );
+    }
+  }
+
+  removeHeader(name: string) {
+    if (!this.headers) {
+      return;
+    } else if (this.headers instanceof Headers) {
+      this.headers.delete(name);
+    } else if (Array.isArray(this.headers)) {
+      const index = this.headers.findIndex(([candidateHeaderName]) =>
+        this.isEqualCaseInsensitive(candidateHeaderName, name),
+      );
+      if (index !== -1) delete this.headers[index];
+    } else {
+      const headerNames = Object.keys(this.headers);
+      const headerName = headerNames.find((candidateName) =>
+        this.isEqualCaseInsensitive(candidateName, name),
+      );
+      if (headerName) delete this.headers[headerName];
+    }
+  }
+
+  private isEqualCaseInsensitive(a: string, b: string) {
+    return (
+      a.localeCompare(b, undefined, {
+        sensitivity: 'accent',
+      }) == 0
+    );
+  }
+}
diff --git a/src/xhrInterceptor/fetchProxy/FetchInput.test.ts b/src/xhrInterceptor/fetchProxy/FetchInput.test.ts
new file mode 100644
index 0000000..3c20b5b
--- /dev/null
+++ b/src/xhrInterceptor/fetchProxy/FetchInput.test.ts
@@ -0,0 +1,39 @@
+/**
+ * @jest-environment ./src/xhrInterceptor/fetchProxy/__environments__/fetchEnvironment.ts
+ */
+
+import { describe, expect, it } from '@jest/globals';
+import FetchInput from './FetchInput';
+
+describe('FetchInput', () => {
+  describe('getUrl', () => {
+    const urlString = 'https://example.avm99963.com/';
+
+    it('should return input when input is already a string', () => {
+      const dummyInput = urlString;
+
+      const sut = new FetchInput(dummyInput);
+      const result = sut.getUrl();
+
+      expect(result).toBe(urlString);
+    });
+
+    it('should return stringified url when input is a URL instance', () => {
+      const dummyInput = new URL(urlString);
+
+      const sut = new FetchInput(dummyInput);
+      const result = sut.getUrl();
+
+      expect(result).toBe(urlString);
+    });
+
+    it('should return url string when input is a Request instance', () => {
+      const dummyInput = new Request(urlString);
+
+      const sut = new FetchInput(dummyInput);
+      const result = sut.getUrl();
+
+      expect(result).toBe(urlString);
+    });
+  });
+});
diff --git a/src/xhrInterceptor/fetchProxy/FetchInput.ts b/src/xhrInterceptor/fetchProxy/FetchInput.ts
new file mode 100644
index 0000000..75be8b8
--- /dev/null
+++ b/src/xhrInterceptor/fetchProxy/FetchInput.ts
@@ -0,0 +1,16 @@
+export default class FetchInput {
+  constructor(private input: RequestInfo | URL) {}
+
+  /**
+   * Returns the fetch input URL as a string.
+   */
+  getUrl() {
+    if (typeof this.input === 'string') {
+      return this.input;
+    } else if (this.input instanceof URL) {
+      return this.input.toString();
+    } else {
+      return this.input.url;
+    }
+  }
+}
diff --git a/src/xhrInterceptor/fetchProxy/FetchProxy.test.ts b/src/xhrInterceptor/fetchProxy/FetchProxy.test.ts
new file mode 100644
index 0000000..e686535
--- /dev/null
+++ b/src/xhrInterceptor/fetchProxy/FetchProxy.test.ts
@@ -0,0 +1,210 @@
+/**
+ * @jest-environment ./src/xhrInterceptor/fetchProxy/__environments__/fetchEnvironment.ts
+ */
+
+import { beforeEach, describe, expect, it, jest } from '@jest/globals';
+import FetchProxy from './FetchProxy';
+import MessageIdTracker from '../MessageIdTracker';
+import { ResponseModifierPort } from '../ResponseModifier.port';
+import {
+  InterceptorHandlerMock,
+  matchInterceptorsMock,
+  triggerEventMock,
+} from '../interceptors/__mocks__/InterceptorHandler.mock';
+import {
+  Interceptor,
+  InterceptorFilter,
+} from '../interceptors/InterceptorHandler.port';
+
+jest.mock('../interceptors/InterceptorHandler.adapter');
+
+const interceptMock = jest.fn<ResponseModifierPort['intercept']>();
+class MockResponseModifier implements ResponseModifierPort {
+  async intercept(
+    ...args: Parameters<ResponseModifierPort['intercept']>
+  ): ReturnType<ResponseModifierPort['intercept']> {
+    return interceptMock(...args);
+  }
+}
+
+const fetchMock = jest.fn<typeof window.fetch>();
+
+const consoleErrorMock = jest.spyOn(global.console, 'error');
+
+const dummyResponse = new Response('{}', { status: 200 });
+beforeEach(() => {
+  jest.resetAllMocks();
+
+  window.fetch = fetchMock;
+
+  // Sensible defaults which can be overriden in each test.
+  fetchMock.mockResolvedValue(dummyResponse);
+  interceptMock.mockResolvedValue({ wasModified: false });
+  matchInterceptorsMock.mockReturnValue([]);
+  consoleErrorMock.mockImplementation(() => {});
+});
+
+describe('FetchProxy', () => {
+  describe('when calling fetch after enabling interception', () => {
+    const dummyUrl: string = 'https://dummy.avm99963.com/';
+    const dummyInit: RequestInit = {
+      body: '{"1":"request"}',
+    };
+    const dummyInitProtoJs = {
+      body: '["dummy"]',
+      headers: {
+        'Content-Type': 'application/json+protobuf',
+      },
+    };
+
+    let fetchProxy: FetchProxy;
+    beforeEach(() => {
+      fetchProxy = new FetchProxy(
+        new MockResponseModifier(),
+        new InterceptorHandlerMock(),
+        new MessageIdTracker(),
+      );
+      fetchProxy.enableInterception();
+    });
+
+    it('should call originalFetch with the original arguments', async () => {
+      await window.fetch(dummyUrl, dummyInit);
+
+      expect(fetchMock).toBeCalledTimes(1);
+      expect(fetchMock).toBeCalledWith(dummyUrl, dummyInit);
+    });
+
+    it('should remove the X-Client header before passing the request to fetch', async () => {
+      await window.fetch(dummyUrl, {
+        ...dummyInit,
+        headers: {
+          'X-Client': 'twpt',
+        },
+      });
+
+      expect(fetchMock).toBeCalledTimes(1);
+      expect(fetchMock.mock.calls[0][1].headers).toBeDefined();
+      expect(fetchMock.mock.calls[0][1].headers).not.toHaveProperty('X-Client');
+    });
+
+    describe.each(['request', 'response'])(
+      'regarding %s interceptors',
+      (interceptorFilter: InterceptorFilter) => {
+        describe('when no interceptors match', () => {
+          it(`should not send a ${interceptorFilter} interceptor event`, async () => {
+            matchInterceptorsMock.mockReturnValue([]);
+
+            await window.fetch(dummyUrl, dummyInit);
+
+            expect(triggerEventMock).toHaveBeenCalledTimes(0);
+          });
+        });
+
+        describe('when an interceptor matches', () => {
+          const dummyInterceptor: Interceptor = {
+            eventName: 'dummy_event',
+            urlRegex: /.*/,
+            intercepts: interceptorFilter,
+          };
+          const dummyResponse = { 42: 'response' };
+          beforeEach(() => {
+            matchInterceptorsMock.mockImplementation(
+              (filter: InterceptorFilter) =>
+                filter === interceptorFilter ? [dummyInterceptor] : [],
+            );
+            fetchMock.mockResolvedValue(
+              new Response(JSON.stringify(dummyResponse)),
+            );
+          });
+
+          it(`should send a ${interceptorFilter} interceptor event`, async () => {
+            await window.fetch(dummyUrl, dummyInit);
+
+            expect(triggerEventMock).toHaveBeenCalledTimes(1);
+            const expectedBody =
+              interceptorFilter === 'request'
+                ? { 1: 'request' }
+                : dummyResponse;
+            expect(triggerEventMock).toHaveBeenCalledWith(
+              dummyInterceptor.eventName,
+              expectedBody,
+              expect.anything(),
+            );
+          });
+
+          it(`should send a ${interceptorFilter} interceptor event with normalized protobuf when the request is application/json+protobuf`, async () => {
+            fetchMock.mockResolvedValue(new Response('["dummy"]'));
+
+            await window.fetch(dummyUrl, dummyInitProtoJs);
+
+            expect(triggerEventMock).toHaveBeenCalledTimes(1);
+            const eventBody = triggerEventMock.mock.calls[0][1];
+            expect(eventBody[1]).toBe('dummy');
+          });
+
+          it(`should not reject when triggering the event fails`, async () => {
+            triggerEventMock.mockImplementation(() => {
+              throw new Error('dummy error');
+            });
+
+            expect(await window.fetch(dummyUrl, dummyInit)).resolves;
+          });
+
+          // TODO: add test to ensure something is logged when the previous condition happens
+        });
+      },
+    );
+
+    describe('regarding response modifiers', () => {
+      const dummyModifiedResponse = { 99: 'modified' };
+      beforeEach(() => {
+        interceptMock.mockResolvedValue({
+          wasModified: true,
+          modifiedResponse: dummyModifiedResponse,
+        });
+      });
+
+      it('should pass the intercepted response to ResponseModifier', async () => {
+        const dummyResponse = { 1: 'request' };
+        fetchMock.mockResolvedValue(
+          new Response(JSON.stringify(dummyResponse)),
+        );
+
+        await window.fetch(dummyUrl, dummyInit);
+
+        expect(interceptMock).toHaveBeenCalledTimes(1);
+        expect(interceptMock).toHaveBeenCalledWith({
+          url: dummyUrl,
+          originalResponse: dummyResponse,
+        });
+      });
+
+      it('should return the modified response when ResponseModifier modifies it', async () => {
+        const response = await window.fetch(dummyUrl, dummyInit);
+        const result = await response.json();
+
+        expect(result).toEqual(dummyModifiedResponse);
+      });
+
+      it('should not reject when ResponseModifier throws an error', async () => {
+        interceptMock.mockImplementation(() => {
+          throw new Error('dummy error');
+        });
+
+        expect(await window.fetch(dummyUrl, dummyInit)).resolves;
+      });
+    });
+
+    it('should not reject when a body is not passed', async () => {
+      matchInterceptorsMock.mockImplementation((filter: InterceptorFilter) => [
+        {
+          eventName: 'dummy_event',
+          urlRegex: /.*/,
+          intercepts: filter,
+        },
+      ]);
+
+      expect(await window.fetch(dummyUrl)).resolves;
+    });
+  });
+});
diff --git a/src/xhrInterceptor/fetchProxy/FetchProxy.ts b/src/xhrInterceptor/fetchProxy/FetchProxy.ts
new file mode 100644
index 0000000..71031c5
--- /dev/null
+++ b/src/xhrInterceptor/fetchProxy/FetchProxy.ts
@@ -0,0 +1,40 @@
+import { InterceptorHandlerPort } from '../interceptors/InterceptorHandler.port';
+import MessageIdTracker from '../MessageIdTracker';
+import { ResponseModifierPort } from '../ResponseModifier.port';
+import FetchProxyCallHandler from './FetchProxyCallHandler';
+
+/**
+ * Class which lets us override window.fetch to proxy the requests through our
+ * internal interceptors to read/modify requests/responses.
+ */
+export default class FetchProxy {
+  private originalFetch: typeof window.fetch;
+  private isInterceptEnabled = false;
+
+  constructor(
+    private responseModifier: ResponseModifierPort,
+    private interceptorHandler: InterceptorHandlerPort,
+    private messageIdTracker: MessageIdTracker,
+  ) {}
+
+  enableInterception() {
+    if (this.isInterceptEnabled) return;
+
+    this.isInterceptEnabled = true;
+
+    this.originalFetch = window.fetch;
+    this.overrideFetch();
+  }
+
+  private overrideFetch() {
+    window.fetch = async (...args) => {
+      const fetchProxyCallhandler = new FetchProxyCallHandler(
+        this.responseModifier,
+        this.interceptorHandler,
+        this.messageIdTracker,
+        this.originalFetch,
+      );
+      return await fetchProxyCallhandler.proxiedFetch(...args);
+    };
+  }
+}
diff --git a/src/xhrInterceptor/fetchProxy/FetchProxyCallHandler.ts b/src/xhrInterceptor/fetchProxy/FetchProxyCallHandler.ts
new file mode 100644
index 0000000..bba616b
--- /dev/null
+++ b/src/xhrInterceptor/fetchProxy/FetchProxyCallHandler.ts
@@ -0,0 +1,179 @@
+import { XClientHeader, XClientValue } from '../../common/api';
+import {
+  correctArrayKeys,
+  inverseCorrectArrayKeys,
+} from '../../common/protojs';
+import { InterceptorHandlerPort } from '../interceptors/InterceptorHandler.port';
+import MessageIdTracker from '../MessageIdTracker';
+import { ResponseModifierPort } from '../ResponseModifier.port';
+import FetchBody from './FetchBody';
+import FetchHeaders from './FetchHeaders';
+import FetchInput from './FetchInput';
+
+export default class FetchProxyCallHandler {
+  private fetchHeaders: FetchHeaders;
+  private fetchBody: FetchBody;
+  private fetchInput: FetchInput;
+
+  private messageId: number;
+  private url: string;
+  private isArrayProto: boolean;
+
+  constructor(
+    private responseModifier: ResponseModifierPort,
+    private interceptorHandler: InterceptorHandlerPort,
+    private messageIdTracker: MessageIdTracker,
+    private originalFetch: typeof window.fetch,
+  ) {}
+
+  async proxiedFetch(
+    input: RequestInfo | URL,
+    init?: RequestInit,
+  ): Promise<Response> {
+    this.fetchHeaders = new FetchHeaders(init?.headers);
+    this.fetchBody = new FetchBody(init?.body);
+    this.fetchInput = new FetchInput(input);
+
+    const shouldIgnore = this.fetchHeaders.hasValue(
+      XClientHeader,
+      XClientValue,
+    );
+
+    // Remove the header after being read to preserve user privacy.
+    //
+    // If you're a Googler/TW team member reading this, and would like us to
+    // send this header to the server (e.g. for analytics purposes), please
+    // feel free to contact us (the community) at twpowertools-discuss [at]
+    // googlegroups.com!
+    this.fetchHeaders.removeHeader(XClientHeader);
+
+    if (shouldIgnore) {
+      return await this.originalFetch.apply(global, [input, init]);
+    }
+
+    this.messageId = this.messageIdTracker.getNewId();
+    this.url = this.fetchInput.getUrl();
+    this.isArrayProto = this.fetchHeaders.hasValue(
+      'Content-Type',
+      'application/json+protobuf',
+    );
+
+    await this.attemptToSendRequestInterceptorEvent();
+
+    const originalResponse: Response = await this.originalFetch.apply(global, [
+      input,
+      init,
+    ]);
+
+    const response = await this.attemptToModifyResponse(originalResponse);
+
+    await this.attemptToSendResponseInterceptorEvent(response);
+
+    return response;
+  }
+
+  private async attemptToSendRequestInterceptorEvent() {
+    try {
+      await this.sendRequestInterceptorEvent();
+    } catch (e) {
+      console.error(
+        `[FetchProxy] An error ocurred sending a request interceptor event for ${this.url}:`,
+        e,
+      );
+    }
+  }
+
+  private async sendRequestInterceptorEvent() {
+    const interceptors = this.interceptorHandler.matchInterceptors(
+      'request',
+      this.url,
+    );
+    if (interceptors.length === 0) return;
+
+    const rawBody = await this.fetchBody.getJSONRequestBody();
+    if (!rawBody) return;
+
+    const body = this.isArrayProto ? correctArrayKeys(rawBody) : rawBody;
+
+    for (const interceptor of interceptors) {
+      this.interceptorHandler.triggerEvent(
+        interceptor.eventName,
+        body,
+        this.messageId,
+      );
+    }
+  }
+
+  private async attemptToModifyResponse(
+    originalResponse: Response,
+  ): Promise<Response> {
+    try {
+      return await this.modifyResponse(originalResponse);
+    } catch (e) {
+      console.error(
+        `[Fetch Proxy] Couldn\'t modify the response for ${this.url}`,
+        e,
+      );
+      return originalResponse;
+    }
+  }
+
+  private async modifyResponse(originalResponse: Response) {
+    const response = originalResponse.clone();
+    let json = await response.json();
+
+    if (this.isArrayProto) {
+      correctArrayKeys(json);
+    }
+
+    const result = await this.responseModifier.intercept({
+      originalResponse: json,
+      url: this.url,
+    });
+    if (result.wasModified) {
+      json = result.modifiedResponse;
+    }
+
+    if (this.isArrayProto) {
+      inverseCorrectArrayKeys(json);
+    }
+
+    return new Response(JSON.stringify(json), {
+      status: response.status,
+      statusText: response.statusText,
+      headers: response.headers,
+    });
+  }
+
+  private async attemptToSendResponseInterceptorEvent(response: Response) {
+    try {
+      await this.sendResponseInterceptorEvent(response);
+    } catch (e) {
+      console.error(
+        `[FetchProxy] An error ocurred sending a response interceptor event for ${this.url}:`,
+        e,
+      );
+    }
+  }
+
+  private async sendResponseInterceptorEvent(response: Response) {
+    const interceptors = this.interceptorHandler.matchInterceptors(
+      'response',
+      this.url,
+    );
+    if (interceptors.length === 0) return;
+
+    const rawBody = await response.clone().json();
+    if (!rawBody) return;
+
+    const body = this.isArrayProto ? correctArrayKeys(rawBody) : rawBody;
+
+    for (const interceptor of interceptors) {
+      this.interceptorHandler.triggerEvent(
+        interceptor.eventName,
+        body,
+        this.messageId,
+      );
+    }
+  }
+}
diff --git a/src/xhrInterceptor/fetchProxy/__environments__/fetchEnvironment.ts b/src/xhrInterceptor/fetchProxy/__environments__/fetchEnvironment.ts
new file mode 100644
index 0000000..0e70bf7
--- /dev/null
+++ b/src/xhrInterceptor/fetchProxy/__environments__/fetchEnvironment.ts
@@ -0,0 +1,14 @@
+import JSDOMEnvironment from 'jest-environment-jsdom';
+
+// https://github.com/facebook/jest/blob/v29.4.3/website/versioned_docs/version-29.4/Configuration.md#testenvironment-string
+export default class FixJSDOMEnvironment extends JSDOMEnvironment {
+  constructor(...args: ConstructorParameters<typeof JSDOMEnvironment>) {
+    super(...args);
+
+    // TODO: Remove this (and the environment) once https://github.com/jsdom/jsdom/issues/1724 is closed.
+    this.global.fetch = fetch;
+    this.global.Headers = Headers;
+    this.global.Request = Request;
+    this.global.Response = Response;
+  }
+}
diff --git a/src/xhrInterceptor/interceptors.json5 b/src/xhrInterceptor/interceptors.json5
deleted file mode 100644
index 2a0ea6b..0000000
--- a/src/xhrInterceptor/interceptors.json5
+++ /dev/null
@@ -1,34 +0,0 @@
-{
-  interceptors: [
-    {
-      eventName: "ViewForumRequest",
-      urlRegex: "api/ViewForum",
-      intercepts: "request",
-    },
-    {
-      eventName: "ViewForumResponse",
-      urlRegex: "api/ViewForum",
-      intercepts: "response",
-    },
-    {
-      eventName: "CreateMessageRequest",
-      urlRegex: "api/CreateMessage",
-      intercepts: "request",
-    },
-    {
-      eventName: "ViewUnifiedUserResponse",
-      urlRegex: "api/ViewUnifiedUser",
-      intercepts: "response",
-    },
-    {
-      eventName: "ListCannedResponsesResponse",
-      urlRegex: "api/ListCannedResponses",
-      intercepts: "response",
-    },
-    {
-      eventName: "ViewThreadResponse",
-      urlRegex: "api/ViewThread",
-      intercepts: "response",
-    },
-  ],
-}
diff --git a/src/xhrInterceptor/interceptors/InterceptorHandler.adapter.ts b/src/xhrInterceptor/interceptors/InterceptorHandler.adapter.ts
new file mode 100644
index 0000000..197890f
--- /dev/null
+++ b/src/xhrInterceptor/interceptors/InterceptorHandler.adapter.ts
@@ -0,0 +1,27 @@
+import { ProtobufObject } from '../../common/protojs.types';
+import {
+  Interceptor,
+  InterceptorFilter,
+  InterceptorHandlerPort,
+} from './InterceptorHandler.port';
+
+export default class InterceptorHandlerAdapter
+  implements InterceptorHandlerPort
+{
+  constructor(private interceptors: Interceptor[]) {}
+
+  matchInterceptors(filter: InterceptorFilter, url: string): Interceptor[] {
+    return this.interceptors.filter((interceptor) => {
+      return interceptor.intercepts == filter && interceptor.urlRegex.test(url);
+    });
+  }
+  triggerEvent(eventName: string, body: ProtobufObject, id: number): void {
+    const e = new CustomEvent('TWPT_' + eventName, {
+      detail: {
+        body,
+        id,
+      },
+    });
+    window.dispatchEvent(e);
+  }
+}
diff --git a/src/xhrInterceptor/interceptors/InterceptorHandler.port.ts b/src/xhrInterceptor/interceptors/InterceptorHandler.port.ts
new file mode 100644
index 0000000..2bf920a
--- /dev/null
+++ b/src/xhrInterceptor/interceptors/InterceptorHandler.port.ts
@@ -0,0 +1,14 @@
+import { ProtobufObject } from '../../common/protojs.types';
+
+export interface InterceptorHandlerPort {
+  matchInterceptors(filter: InterceptorFilter, url: string): Interceptor[];
+  triggerEvent(eventName: string, body: ProtobufObject, id: number): void;
+}
+
+export interface Interceptor {
+  eventName: string;
+  urlRegex: RegExp;
+  intercepts: InterceptorFilter;
+}
+
+export type InterceptorFilter = 'request' | 'response';
diff --git a/src/xhrInterceptor/interceptors/__mocks__/InterceptorHandler.mock.ts b/src/xhrInterceptor/interceptors/__mocks__/InterceptorHandler.mock.ts
new file mode 100644
index 0000000..ea3501c
--- /dev/null
+++ b/src/xhrInterceptor/interceptors/__mocks__/InterceptorHandler.mock.ts
@@ -0,0 +1,23 @@
+import { jest } from '@jest/globals';
+import { InterceptorHandlerPort } from '../InterceptorHandler.port';
+
+export const matchInterceptorsMock =
+  jest.fn<InterceptorHandlerPort['matchInterceptors']>();
+export const triggerEventMock =
+  jest.fn<InterceptorHandlerPort['triggerEvent']>();
+
+class InterceptorHandlerMock {
+  matchInterceptors(
+    ...args: Parameters<InterceptorHandlerPort['matchInterceptors']>
+  ): ReturnType<InterceptorHandlerPort['matchInterceptors']> {
+    return matchInterceptorsMock(...args);
+  }
+
+  triggerEvent(
+    ...args: Parameters<InterceptorHandlerPort['triggerEvent']>
+  ): ReturnType<InterceptorHandlerPort['triggerEvent']> {
+    return triggerEventMock(...args);
+  }
+}
+
+export { InterceptorHandlerMock };
diff --git a/src/xhrInterceptor/interceptors/interceptors.ts b/src/xhrInterceptor/interceptors/interceptors.ts
new file mode 100644
index 0000000..1454aad
--- /dev/null
+++ b/src/xhrInterceptor/interceptors/interceptors.ts
@@ -0,0 +1,38 @@
+import { Interceptor } from './InterceptorHandler.port';
+
+const interceptors: { interceptors: Interceptor[] } = {
+  interceptors: [
+    {
+      eventName: 'ViewForumRequest',
+      urlRegex: /api\/ViewForum/,
+      intercepts: 'request',
+    },
+    {
+      eventName: 'ViewForumResponse',
+      urlRegex: /api\/ViewForum/,
+      intercepts: 'response',
+    },
+    {
+      eventName: 'CreateMessageRequest',
+      urlRegex: /api\/CreateMessage/,
+      intercepts: 'request',
+    },
+    {
+      eventName: 'ViewUnifiedUserResponse',
+      urlRegex: /api\/ViewUnifiedUser/,
+      intercepts: 'response',
+    },
+    {
+      eventName: 'ListCannedResponsesResponse',
+      urlRegex: /api\/ListCannedResponses/,
+      intercepts: 'response',
+    },
+    {
+      eventName: 'ViewThreadResponse',
+      urlRegex: /api\/ViewThread/,
+      intercepts: 'response',
+    },
+  ],
+};
+
+export default interceptors;
diff --git a/src/xhrInterceptor/utils.js b/src/xhrInterceptor/utils.js
index d48dfd5..4a68c87 100644
--- a/src/xhrInterceptor/utils.js
+++ b/src/xhrInterceptor/utils.js
@@ -1,9 +1,10 @@
 import {correctArrayKeys, inverseCorrectArrayKeys} from '../common/protojs';
 
-import xhrInterceptors from './interceptors.json5';
+import xhrInterceptors from './interceptors/interceptors';
 
-export {xhrInterceptors};
-
+/**
+ * @deprecated Use `InterceptorHandler`.
+ */
 export function matchInterceptors(interceptFilter, url) {
   return xhrInterceptors.interceptors.filter(interceptor => {
     var regex = new RegExp(interceptor.urlRegex);
@@ -90,12 +91,17 @@
 }
 
 export function convertJSONToResponseText(xhr, json) {
-  return convertJSONToResponse({
-    $isArrayProto: xhr.$isArrayProto,
-    responseType: 'text',
-  }, json);
+  return convertJSONToResponse(
+      {
+        $isArrayProto: xhr.$isArrayProto,
+        responseType: 'text',
+      },
+      json);
 }
 
+/**
+ * @deprecated Use `InterceptorHandler`.
+ */
 export function triggerEvent(eventName, body, id) {
   var evt = new CustomEvent('TWPT_' + eventName, {
     detail: {
diff --git a/webpack.config.js b/webpack.config.js
index 326ae89..cf1e816 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -45,7 +45,7 @@
     // Injected JS
     profileIndicatorInject: './src/injections/profileIndicator.js',
     batchLockInject: './src/injections/batchLock.js',
-    xhrInterceptorInject: './src/injections/xhrProxy.js',
+    xhrInterceptorInject: './src/entryPoints/communityConsole/injections/xhrProxy.ts',
     extraInfoInject: './src/injections/extraInfo.js',
     litComponentsInject: './src/injections/litComponentsInject.js',
     updateHandlerLitComponents: