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: