diff --git a/workspaces/leetcode-api/src/scripts/codegen/graphqlToZod.ts b/workspaces/leetcode-api/src/scripts/codegen/graphqlToZod.ts index c5ae18ff..619884d2 100644 --- a/workspaces/leetcode-api/src/scripts/codegen/graphqlToZod.ts +++ b/workspaces/leetcode-api/src/scripts/codegen/graphqlToZod.ts @@ -113,7 +113,8 @@ function generateZod( ], ]; } - case "ID": { + case "ID": + case "JSONString": { invariant(directives.length === 0, "Directives not supported here."); return [new ZodOutput("z.string()", true), []]; } diff --git a/workspaces/leetcode-zen-mode/src/extension/constants.ts b/workspaces/leetcode-zen-mode/src/extension/constants.ts deleted file mode 100644 index d5f993a1..00000000 --- a/workspaces/leetcode-zen-mode/src/extension/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const GRAPHQL_XHR_RESPONSE = Symbol("graphql-xhr-response"); diff --git a/workspaces/leetcode-zen-mode/src/extension/injectXhrBlobResponseMiddleware.ts b/workspaces/leetcode-zen-mode/src/extension/injectXhrBlobResponseMiddleware.ts new file mode 100644 index 00000000..6130026f --- /dev/null +++ b/workspaces/leetcode-zen-mode/src/extension/injectXhrBlobResponseMiddleware.ts @@ -0,0 +1,72 @@ +import invariant from "invariant"; +import type { Promisable } from "type-fest"; + +import { mapArrayAtIndex } from "@code-chronicles/util/mapArrayAtIndex"; + +import { startRecordingXhrResponseBlobs } from "./startRecordingXhrResponseBlobs.ts"; + +// TODO: maybe support a list of middleware functions? +// TODO: support other ways to read the data besides + +/** + * Injects a function to process and possibly replace `XMLHttpRequest` + * responses (if they are of type `Blob`) when there is an attempt to read + * their data. + * + * The middleware function may be synchronous or asynchronous. If it is + * asynchronous, the read attempt is blocked until the middleware function + * resolves. The middleware can replace the data by returning or resolving to + * a different `Blob` from the one that was passed in. To leave the data + * as-is, resolve either `undefined` or the the original `Blob`. + * + * Note: Currently only attempts to read the data through a `FileReader`'s + * `readAsText` method are detected, but more types of reads may be supported + * in the future. + */ +export function injectXhrBlobResponseMiddleware( + middlewareFn: ( + xhr: XMLHttpRequest, + blob: Blob, + encoding?: string, + ) => Promisable, +): void { + const getXhrForBlob = startRecordingXhrResponseBlobs(); + + const prevDescriptor = Object.getOwnPropertyDescriptor( + FileReader.prototype, + "readAsText", + ); + + invariant( + prevDescriptor && prevDescriptor.value && prevDescriptor.writable, + "`FileReader.prototype.readAsText` property descriptor didn't have the expected form!", + ); + + const prevImplementation: typeof FileReader.prototype.readAsText = + prevDescriptor.value; + + FileReader.prototype.readAsText = function readAsText( + this: FileReader, + originalBlob: Blob, + encoding?: string, + ): void { + const args = [...arguments] as Parameters< + typeof FileReader.prototype.readAsText + >; + const xhr = getXhrForBlob(originalBlob); + + if (!xhr) { + prevImplementation.apply(this, args); + return; + } + + Promise.resolve(middlewareFn(xhr, originalBlob, encoding)).then( + (possiblyUpdatedBlob) => { + prevImplementation.apply( + this, + mapArrayAtIndex(args, 0, () => possiblyUpdatedBlob ?? originalBlob), + ); + }, + ); + }; +} diff --git a/workspaces/leetcode-zen-mode/src/extension/main.ts b/workspaces/leetcode-zen-mode/src/extension/main.ts index 2dc9c8b1..1e715e38 100644 --- a/workspaces/leetcode-zen-mode/src/extension/main.ts +++ b/workspaces/leetcode-zen-mode/src/extension/main.ts @@ -1,16 +1,27 @@ -import { patchFileReader } from "./patchFileReader.ts"; +import { mapJsonBlobData } from "@code-chronicles/util/mapJsonBlobData"; + +import { injectXhrBlobResponseMiddleware } from "./injectXhrBlobResponseMiddleware.ts"; import { patchWebpackChunkLoading } from "./patchWebpackChunkLoading.ts"; -import { patchXhr } from "./patchXhr.ts"; +import { rewriteGraphQLData } from "./rewriteGraphQLData.ts"; function main() { // LeetCode's GraphQL client makes requests through `XMLHttpRequest`, then // reads the data as `Blob` objects using the `FileReader` API. // - // So we will label `Blob` objects from `XMLHttpRequest` responses with a - // special symbol, and then later, when reading from the marked `Blob` - // objects, we will rewrite the data a bit. - patchXhr(); - patchFileReader(); + // So we will inject some middleware to rewrite `XMLHttpRequest` responses + // a bit. + injectXhrBlobResponseMiddleware((xhr, blob) => { + if (xhr.responseURL === "https://leetcode.com/graphql/") { + try { + return mapJsonBlobData(blob, rewriteGraphQLData); + } catch (err) { + console.error(err); + } + } + + // No-op for requests that aren't for LeetCode's GraphQL endpoint. + return blob; + }); // Additionally, we will patch some of the actual page code! We will do so // by trying to intercept `webpack` chunk loading, so that we can patch the diff --git a/workspaces/leetcode-zen-mode/src/extension/patchFileReader.ts b/workspaces/leetcode-zen-mode/src/extension/patchFileReader.ts deleted file mode 100644 index c5c9a45f..00000000 --- a/workspaces/leetcode-zen-mode/src/extension/patchFileReader.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { GRAPHQL_XHR_RESPONSE } from "./constants.ts"; -import { rewriteGraphQLData } from "./rewriteGraphQLData.ts"; - -/** - * Patch the `FileReader` class so that `Blob` objects labeled as LeetCode - * GraphQL responses get rewritten. - */ -export function patchFileReader(): void { - const { readAsText } = FileReader.prototype; - - FileReader.prototype.readAsText = function (blob) { - if (!(blob instanceof Blob) || !Object.hasOwn(blob, GRAPHQL_XHR_RESPONSE)) { - readAsText.apply( - this, - Array.from(arguments) as Parameters, - ); - return; - } - - blob - .text() - .then((text) => - readAsText.call( - this, - new Blob([JSON.stringify(rewriteGraphQLData(JSON.parse(text)))]), - ), - ); - }; -} diff --git a/workspaces/leetcode-zen-mode/src/extension/patchPageModule.ts b/workspaces/leetcode-zen-mode/src/extension/patchPageModule.ts index 52cd9c43..fe1d8289 100644 --- a/workspaces/leetcode-zen-mode/src/extension/patchPageModule.ts +++ b/workspaces/leetcode-zen-mode/src/extension/patchPageModule.ts @@ -21,7 +21,7 @@ export function patchPageModule( // LeetCode also uses a module which exposes `jsx` and `jsxs` methods, // possibly https://web-cell.dev/ - if (Object.hasOwn(module, "jsx")) { + if (Object.hasOwn(module, "jsx") && !jsxs.has(module)) { jsxs.add(module); module.jsx = module.jsxs = patchJsxFactory(module.jsx); } diff --git a/workspaces/leetcode-zen-mode/src/extension/patchXhr.ts b/workspaces/leetcode-zen-mode/src/extension/patchXhr.ts deleted file mode 100644 index daad7983..00000000 --- a/workspaces/leetcode-zen-mode/src/extension/patchXhr.ts +++ /dev/null @@ -1,36 +0,0 @@ -import nullthrows from "nullthrows"; - -import { GRAPHQL_XHR_RESPONSE } from "./constants.ts"; - -/** - * Patch the `XMLHttpRequest` class so that we can label the responses for - * LeetCode's GraphQL requests with a special symbol, that we can later look - * for when the response is parsed. - */ -export function patchXhr(): void { - const xhrResponseDescriptor = Object.getOwnPropertyDescriptor( - XMLHttpRequest.prototype, - "response", - )!; - - Object.defineProperty(XMLHttpRequest.prototype, "response", { - ...xhrResponseDescriptor, - get() { - const res = nullthrows(xhrResponseDescriptor.get).call(this); - - if ( - this.responseURL === "https://leetcode.com/graphql/" && - res instanceof Blob && - res.type === "application/json" - ) { - Object.defineProperty(res, GRAPHQL_XHR_RESPONSE, { - enumerable: false, - configurable: false, - value: undefined, - }); - } - - return res; - }, - }); -} diff --git a/workspaces/leetcode-zen-mode/src/extension/rewriteGraphQLData.ts b/workspaces/leetcode-zen-mode/src/extension/rewriteGraphQLData.ts index 25375e69..da972fb0 100644 --- a/workspaces/leetcode-zen-mode/src/extension/rewriteGraphQLData.ts +++ b/workspaces/leetcode-zen-mode/src/extension/rewriteGraphQLData.ts @@ -1,7 +1,9 @@ +import type { JsonValue } from "type-fest"; + import { isObject } from "@code-chronicles/util/isObject"; import { mapObjectValues } from "@code-chronicles/util/mapObjectValues"; -export function rewriteGraphQLData(value: unknown): unknown { +export function rewriteGraphQLData(value: JsonValue): JsonValue { if (Array.isArray(value)) { return value.map(rewriteGraphQLData); } diff --git a/workspaces/leetcode-zen-mode/src/extension/startRecordingXhrResponseBlobs.ts b/workspaces/leetcode-zen-mode/src/extension/startRecordingXhrResponseBlobs.ts new file mode 100644 index 00000000..0c1a0417 --- /dev/null +++ b/workspaces/leetcode-zen-mode/src/extension/startRecordingXhrResponseBlobs.ts @@ -0,0 +1,45 @@ +import invariant from "invariant"; + +type GetXhrForBlob = (blob: Blob) => XMLHttpRequest | undefined; + +let getXhrForBlob: GetXhrForBlob | undefined = undefined; + +/** + * Patches the `XMLHttpRequest` class so that we can record when the response + * of an `XMLHttpRequest` instance is a `Blob`. Returns a function that can be + * used to look up the `XMLHttpRequest` from which a particular `Blob` + * originated. + */ +export function startRecordingXhrResponseBlobs(): GetXhrForBlob { + // TODO: use a memoize function! + return (getXhrForBlob ??= (() => { + const prevDescriptor = Object.getOwnPropertyDescriptor( + XMLHttpRequest.prototype, + "response", + ); + + invariant( + prevDescriptor && prevDescriptor.get && !prevDescriptor.set, + "`XMLHttpRequest.prototype.response` property descriptor didn't have the expected form!", + ); + const prevDescriptorGet: () => typeof XMLHttpRequest.prototype.response = + prevDescriptor.get; + + const xhrResponses = new WeakMap(); + + Object.defineProperty(XMLHttpRequest.prototype, "response", { + ...prevDescriptor, + get(this: XMLHttpRequest) { + const res = prevDescriptorGet.call(this); + + if (res instanceof Blob) { + xhrResponses.set(res, this); + } + + return res; + }, + }); + + return (blob) => xhrResponses.get(blob); + })()); +} diff --git a/workspaces/util/src/constructJsonBlob.ts b/workspaces/util/src/constructJsonBlob.ts new file mode 100644 index 00000000..b5d24fd2 --- /dev/null +++ b/workspaces/util/src/constructJsonBlob.ts @@ -0,0 +1,7 @@ +import type { JsonValue } from "type-fest"; + +export const JSON_MIME_TYPE = "application/json"; + +export function constructJsonBlob(data: JsonValue): Blob { + return new Blob([JSON.stringify(data)], { type: JSON_MIME_TYPE }); +} diff --git a/workspaces/util/src/mapArrayAtIndex.ts b/workspaces/util/src/mapArrayAtIndex.ts index 6280e7cd..44a3ffbc 100644 --- a/workspaces/util/src/mapArrayAtIndex.ts +++ b/workspaces/util/src/mapArrayAtIndex.ts @@ -1,8 +1,13 @@ -export function mapArrayAtIndex( - arr: readonly T[], - index: number, - mapFn: (oldValue: T) => T, -): T[] { +import type { Writable } from "type-fest"; + +export function mapArrayAtIndex< + TArray extends readonly unknown[], + TIndex extends number, +>( + arr: TArray, + index: TIndex, + mapFn: (oldValue: TArray[TIndex]) => TArray[TIndex], +): Writable { if (index < 0) { throw new RangeError( "Mapping out of bounds index " + @@ -13,5 +18,7 @@ export function mapArrayAtIndex( ); } - return arr.map((value, i) => (i === index ? mapFn(value) : value)); + return arr.map((value, i) => + i === index ? mapFn(value) : value, + ) as unknown as Writable; } diff --git a/workspaces/util/src/mapJsonBlobData.ts b/workspaces/util/src/mapJsonBlobData.ts new file mode 100644 index 00000000..2308eaf1 --- /dev/null +++ b/workspaces/util/src/mapJsonBlobData.ts @@ -0,0 +1,11 @@ +import type { JsonValue } from "type-fest"; + +import { constructJsonBlob } from "@code-chronicles/util/constructJsonBlob"; + +export async function mapJsonBlobData( + blob: Blob, + mapFn: (data: JsonValue) => JsonValue, +): Promise { + const data: JsonValue = JSON.parse(await blob.text()); + return constructJsonBlob(mapFn(data)); +}