Skip to content

Commit ad7c4d0

Browse files
authored
Genericize some of the Chrome extension code as utilities (#465)
I tried to make a fairly generic `injectXhrBlobResponseMiddleware` utility. I think this could come in handy for other kinds of Chrome extension work.
1 parent 944a2f9 commit ad7c4d0

File tree

12 files changed

+172
-82
lines changed

12 files changed

+172
-82
lines changed

workspaces/leetcode-api/src/scripts/codegen/graphqlToZod.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ function generateZod(
113113
],
114114
];
115115
}
116-
case "ID": {
116+
case "ID":
117+
case "JSONString": {
117118
invariant(directives.length === 0, "Directives not supported here.");
118119
return [new ZodOutput("z.string()", true), []];
119120
}

workspaces/leetcode-zen-mode/src/extension/constants.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import invariant from "invariant";
2+
import type { Promisable } from "type-fest";
3+
4+
import { mapArrayAtIndex } from "@code-chronicles/util/mapArrayAtIndex";
5+
6+
import { startRecordingXhrResponseBlobs } from "./startRecordingXhrResponseBlobs.ts";
7+
8+
// TODO: maybe support a list of middleware functions?
9+
// TODO: support other ways to read the data besides
10+
11+
/**
12+
* Injects a function to process and possibly replace `XMLHttpRequest`
13+
* responses (if they are of type `Blob`) when there is an attempt to read
14+
* their data.
15+
*
16+
* The middleware function may be synchronous or asynchronous. If it is
17+
* asynchronous, the read attempt is blocked until the middleware function
18+
* resolves. The middleware can replace the data by returning or resolving to
19+
* a different `Blob` from the one that was passed in. To leave the data
20+
* as-is, resolve either `undefined` or the the original `Blob`.
21+
*
22+
* Note: Currently only attempts to read the data through a `FileReader`'s
23+
* `readAsText` method are detected, but more types of reads may be supported
24+
* in the future.
25+
*/
26+
export function injectXhrBlobResponseMiddleware(
27+
middlewareFn: (
28+
xhr: XMLHttpRequest,
29+
blob: Blob,
30+
encoding?: string,
31+
) => Promisable<Blob | void | undefined>,
32+
): void {
33+
const getXhrForBlob = startRecordingXhrResponseBlobs();
34+
35+
const prevDescriptor = Object.getOwnPropertyDescriptor(
36+
FileReader.prototype,
37+
"readAsText",
38+
);
39+
40+
invariant(
41+
prevDescriptor && prevDescriptor.value && prevDescriptor.writable,
42+
"`FileReader.prototype.readAsText` property descriptor didn't have the expected form!",
43+
);
44+
45+
const prevImplementation: typeof FileReader.prototype.readAsText =
46+
prevDescriptor.value;
47+
48+
FileReader.prototype.readAsText = function readAsText(
49+
this: FileReader,
50+
originalBlob: Blob,
51+
encoding?: string,
52+
): void {
53+
const args = [...arguments] as Parameters<
54+
typeof FileReader.prototype.readAsText
55+
>;
56+
const xhr = getXhrForBlob(originalBlob);
57+
58+
if (!xhr) {
59+
prevImplementation.apply(this, args);
60+
return;
61+
}
62+
63+
Promise.resolve(middlewareFn(xhr, originalBlob, encoding)).then(
64+
(possiblyUpdatedBlob) => {
65+
prevImplementation.apply(
66+
this,
67+
mapArrayAtIndex(args, 0, () => possiblyUpdatedBlob ?? originalBlob),
68+
);
69+
},
70+
);
71+
};
72+
}

workspaces/leetcode-zen-mode/src/extension/main.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
1-
import { patchFileReader } from "./patchFileReader.ts";
1+
import { mapJsonBlobData } from "@code-chronicles/util/mapJsonBlobData";
2+
3+
import { injectXhrBlobResponseMiddleware } from "./injectXhrBlobResponseMiddleware.ts";
24
import { patchWebpackChunkLoading } from "./patchWebpackChunkLoading.ts";
3-
import { patchXhr } from "./patchXhr.ts";
5+
import { rewriteGraphQLData } from "./rewriteGraphQLData.ts";
46

57
function main() {
68
// LeetCode's GraphQL client makes requests through `XMLHttpRequest`, then
79
// reads the data as `Blob` objects using the `FileReader` API.
810
//
9-
// So we will label `Blob` objects from `XMLHttpRequest` responses with a
10-
// special symbol, and then later, when reading from the marked `Blob`
11-
// objects, we will rewrite the data a bit.
12-
patchXhr();
13-
patchFileReader();
11+
// So we will inject some middleware to rewrite `XMLHttpRequest` responses
12+
// a bit.
13+
injectXhrBlobResponseMiddleware((xhr, blob) => {
14+
if (xhr.responseURL === "https://leetcode.com/graphql/") {
15+
try {
16+
return mapJsonBlobData(blob, rewriteGraphQLData);
17+
} catch (err) {
18+
console.error(err);
19+
}
20+
}
21+
22+
// No-op for requests that aren't for LeetCode's GraphQL endpoint.
23+
return blob;
24+
});
1425

1526
// Additionally, we will patch some of the actual page code! We will do so
1627
// by trying to intercept `webpack` chunk loading, so that we can patch the

workspaces/leetcode-zen-mode/src/extension/patchFileReader.ts

Lines changed: 0 additions & 29 deletions
This file was deleted.

workspaces/leetcode-zen-mode/src/extension/patchPageModule.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function patchPageModule<TThis, TArgs extends unknown[], TRes>(
2121

2222
// LeetCode also uses a module which exposes `jsx` and `jsxs` methods,
2323
// possibly https://web-cell.dev/
24-
if (Object.hasOwn(module, "jsx")) {
24+
if (Object.hasOwn(module, "jsx") && !jsxs.has(module)) {
2525
jsxs.add(module);
2626
module.jsx = module.jsxs = patchJsxFactory(module.jsx);
2727
}

workspaces/leetcode-zen-mode/src/extension/patchXhr.ts

Lines changed: 0 additions & 36 deletions
This file was deleted.

workspaces/leetcode-zen-mode/src/extension/rewriteGraphQLData.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import type { JsonValue } from "type-fest";
2+
13
import { isObject } from "@code-chronicles/util/isObject";
24
import { mapObjectValues } from "@code-chronicles/util/mapObjectValues";
35

4-
export function rewriteGraphQLData(value: unknown): unknown {
6+
export function rewriteGraphQLData(value: JsonValue): JsonValue {
57
if (Array.isArray(value)) {
68
return value.map(rewriteGraphQLData);
79
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import invariant from "invariant";
2+
3+
type GetXhrForBlob = (blob: Blob) => XMLHttpRequest | undefined;
4+
5+
let getXhrForBlob: GetXhrForBlob | undefined = undefined;
6+
7+
/**
8+
* Patches the `XMLHttpRequest` class so that we can record when the response
9+
* of an `XMLHttpRequest` instance is a `Blob`. Returns a function that can be
10+
* used to look up the `XMLHttpRequest` from which a particular `Blob`
11+
* originated.
12+
*/
13+
export function startRecordingXhrResponseBlobs(): GetXhrForBlob {
14+
// TODO: use a memoize function!
15+
return (getXhrForBlob ??= (() => {
16+
const prevDescriptor = Object.getOwnPropertyDescriptor(
17+
XMLHttpRequest.prototype,
18+
"response",
19+
);
20+
21+
invariant(
22+
prevDescriptor && prevDescriptor.get && !prevDescriptor.set,
23+
"`XMLHttpRequest.prototype.response` property descriptor didn't have the expected form!",
24+
);
25+
const prevDescriptorGet: () => typeof XMLHttpRequest.prototype.response =
26+
prevDescriptor.get;
27+
28+
const xhrResponses = new WeakMap<Blob, XMLHttpRequest>();
29+
30+
Object.defineProperty(XMLHttpRequest.prototype, "response", {
31+
...prevDescriptor,
32+
get(this: XMLHttpRequest) {
33+
const res = prevDescriptorGet.call(this);
34+
35+
if (res instanceof Blob) {
36+
xhrResponses.set(res, this);
37+
}
38+
39+
return res;
40+
},
41+
});
42+
43+
return (blob) => xhrResponses.get(blob);
44+
})());
45+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { JsonValue } from "type-fest";
2+
3+
export const JSON_MIME_TYPE = "application/json";
4+
5+
export function constructJsonBlob(data: JsonValue): Blob {
6+
return new Blob([JSON.stringify(data)], { type: JSON_MIME_TYPE });
7+
}

0 commit comments

Comments
 (0)