Skip to content

Commit

Permalink
fix: add FetchProxy
Browse files Browse the repository at this point in the history
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
  • Loading branch information
avm99963 committed Dec 6, 2024
1 parent 47c4c81 commit 9c418ab
Show file tree
Hide file tree
Showing 24 changed files with 848 additions and 92 deletions.
6 changes: 6 additions & 0 deletions src/common/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const apiErrors = {
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(
Expand All @@ -42,6 +45,9 @@ export function CCApi(
.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',
Expand Down
29 changes: 29 additions & 0 deletions src/entryPoints/communityConsole/injections/xhrProxy.ts
Original file line number Diff line number Diff line change
@@ -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();
}
7 changes: 0 additions & 7 deletions src/injections/xhrProxy.js

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/xhrInterceptor/MessageIdTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default class MessageIdTracker {
private messageId = 0;

getNewId() {
return this.messageId++;
}
}
Original file line number Diff line number Diff line change
@@ -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,
);
Expand All @@ -40,7 +35,7 @@ export default class ResponseModifier {

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),
);

Expand Down Expand Up @@ -83,25 +78,3 @@ export default class ResponseModifier {
};
}
}

/**
* 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;
};
27 changes: 27 additions & 0 deletions src/xhrInterceptor/ResponseModifier.port.ts
Original file line number Diff line number Diff line change
@@ -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;
};
12 changes: 7 additions & 5 deletions src/xhrInterceptor/XHRProxy.js
Original file line number Diff line number Diff line change
@@ -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'];
Expand Down Expand Up @@ -55,13 +54,16 @@ function flattenOptions(options) {
* 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();
}
Expand All @@ -80,7 +82,7 @@ export default class XHRProxy {

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 = {
Expand Down
12 changes: 12 additions & 0 deletions src/xhrInterceptor/fetchProxy/FetchBody.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
88 changes: 88 additions & 0 deletions src/xhrInterceptor/fetchProxy/FetchHeaders.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
});
47 changes: 47 additions & 0 deletions src/xhrInterceptor/fetchProxy/FetchHeaders.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
}
Loading

0 comments on commit 9c418ab

Please sign in to comment.