Skip to content

Commit 92d04ee

Browse files
authored
Redo Chrome extension to be based on patching JSON.parse (#468)
For some reason, I thought that the packages LeetCode uses would implement their own JSON parsing. (This is perhaps because I remembered that some packages like Express implement their own JSON stringification.) In any case, it looks like the built-in `JSON.parse` is what they use, because we can get the same functionality as before by patching `JSON.parse`. Some of the other functions I've been playing around with while developing the extension are nevertheless useful, so I'm keeping them as part of the `util` workspace.
1 parent a18b896 commit 92d04ee

28 files changed

+715
-440
lines changed

workspaces/eslint-config/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
"dependencies": {
2424
"@stylistic/eslint-plugin-js": "2.9.0",
2525
"@stylistic/eslint-plugin-ts": "2.9.0",
26-
"@typescript-eslint/eslint-plugin": "8.8.1",
27-
"@typescript-eslint/parser": "8.8.1",
26+
"@typescript-eslint/eslint-plugin": "8.9.0",
27+
"@typescript-eslint/parser": "8.9.0",
2828
"eslint-import-resolver-typescript": "3.6.3",
2929
"eslint-plugin-import": "2.31.0",
3030
"eslint-plugin-import-x": "4.3.1",
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { assignFunctionCosmeticProperties } from "@code-chronicles/util/object-properties/assignFunctionCosmeticProperties";
2+
import { isNonArrayObject } from "@code-chronicles/util/isNonArrayObject";
3+
import { isString } from "@code-chronicles/util/isString";
4+
5+
type Middleware = (packageFactory: Function) => Function;
6+
7+
type Push = typeof Array.prototype.push;
8+
9+
/**
10+
* Patches `webpack` chunk loading so that we can intercept and patch the
11+
* packages used by the page.
12+
*/
13+
export function injectWebpackChunkLoadingMiddleware(
14+
middlewareFn: Middleware,
15+
): void {
16+
const prevSelf = window.self;
17+
18+
// TODO: update comment to not be LeetCode-specific
19+
20+
// LeetCode's `webpack` works by pushing information about chunks onto a
21+
// globally defined array named something like `webpackChunk_N_E`. The array
22+
// is accessed as `self.webpackChunk_N_E`, so we temporarily proxy
23+
// `window.self` so we can detect attempts to define this array, and
24+
// inject some middleware into pushes into this array.
25+
window.self = new Proxy(prevSelf, {
26+
set(target, prop, newValue) {
27+
const res = Reflect.set(target, prop, newValue);
28+
29+
if (!(isString(prop) && prop.startsWith("webpackChunk"))) {
30+
return res;
31+
}
32+
33+
if (typeof newValue?.push !== "function") {
34+
// TODO: console.error something interesting
35+
return res;
36+
}
37+
38+
// Once we've found the magic `webpack` array we remove the proxy,
39+
// parts of the page seem to break without this.
40+
window.self = prevSelf;
41+
42+
// The `webpack` bootstrapping code reassigns the array's `push`
43+
// method. We will intercept this reassignment so we can patch packages
44+
// before they are registered.
45+
let push: Push = newValue.push;
46+
Object.defineProperty(newValue, "push", {
47+
get() {
48+
return push;
49+
},
50+
51+
set<TNewPush extends Push>(newPush: TNewPush) {
52+
const wrappedNewPush = function (this: ThisParameterType<TNewPush>) {
53+
// In practice, `push` gets invoked with one chunk at a time,
54+
// but it's easy to not assume that, so we iterate over the
55+
// arguments.
56+
for (const arg of arguments) {
57+
// A chunk is structured as a tuple, the second element in the
58+
// tuple is an object map of numbers to functions implementing
59+
// each module. We will wrap these functions with our own code.
60+
if (!Array.isArray(arg) || arg.length < 2) {
61+
// TODO: console.error something interesting
62+
continue;
63+
}
64+
65+
const modules = arg[1];
66+
// TODO: Array.isArray type refinement is unsafe
67+
// TODO: make callsites of "isFoo" clearer on intent, for example here we're checking that modules has meaningful entries
68+
if (!isNonArrayObject(modules) && !Array.isArray(modules)) {
69+
// TODO: console.error something interesting
70+
continue;
71+
}
72+
73+
for (const [key, module] of Object.entries(modules)) {
74+
// TODO: make callsites of "isFoo" clearer on intent, for example here we're checking that `module` can be invoked
75+
if (typeof module !== "function") {
76+
// TODO: console.error something interesting
77+
continue;
78+
}
79+
80+
modules[key as any] = middlewareFn(module);
81+
}
82+
}
83+
84+
return newPush.apply(
85+
this,
86+
// Slight lie but `.apply` will work with the `arguments` object.
87+
arguments as unknown as Parameters<TNewPush>,
88+
);
89+
};
90+
91+
push = assignFunctionCosmeticProperties(wrappedNewPush, newPush);
92+
},
93+
});
94+
95+
return res;
96+
},
97+
});
98+
}

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

Lines changed: 0 additions & 72 deletions
This file was deleted.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { isNonArrayObject } from "@code-chronicles/util/isNonArrayObject";
2+
import { isString } from "@code-chronicles/util/isString";
3+
4+
export function isArrayOfDataByDifficulty(
5+
arr: unknown[],
6+
): arr is ({ difficulty: string } & Record<string, unknown>)[] {
7+
return arr.every(
8+
(elem) => isNonArrayObject(elem) && isString(elem.difficulty),
9+
);
10+
}
Lines changed: 13 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,22 @@
1-
import { mapJsonBlobData } from "@code-chronicles/util/mapJsonBlobData";
1+
import { injectJsonParseMiddleware } from "@code-chronicles/util/browser-extensions/injectJsonParseMiddleware";
22

3-
import { injectJsonScriptMiddleware } from "./injectJsonScriptMiddleware.ts";
4-
import { injectXhrBlobResponseMiddleware } from "./injectXhrBlobResponseMiddleware.ts";
5-
import { patchWebpackChunkLoading } from "./patchWebpackChunkLoading.ts";
3+
import { injectWebpackChunkLoadingMiddleware } from "./injectWebpackChunkLoadingMiddleware.ts";
4+
import { patchPageModule } from "./patchPageModule.ts";
65
import { rewriteLeetCodeGraphQLData } from "./rewriteLeetCodeGraphQLData.ts";
76

8-
function main() {
9-
// LeetCode's GraphQL client makes requests through `XMLHttpRequest`, then
10-
// reads the data as `Blob` objects using the `FileReader` API.
11-
//
12-
// So we will inject some middleware to rewrite `XMLHttpRequest` responses
13-
// a bit.
14-
injectXhrBlobResponseMiddleware((xhr, blob) => {
15-
if (xhr.responseURL === "https://leetcode.com/graphql/") {
16-
try {
17-
return mapJsonBlobData(blob, rewriteLeetCodeGraphQLData);
18-
} catch (err) {
19-
console.error(err);
20-
}
21-
}
22-
23-
// No-op for requests that aren't for LeetCode's GraphQL endpoint.
24-
return blob;
25-
});
26-
27-
// LeetCode also reads data stored as JSON within some <script> tags within
28-
// the page, for example the __NEXT_DATA__ used by Next.js. We will inject
29-
// the middleware there as well.
30-
injectJsonScriptMiddleware(rewriteLeetCodeGraphQLData);
7+
function main(): void {
8+
// LeetCode's website gets its data in at least a couple of different ways
9+
// (e.g. a GraphQL client that uses `XMLHttpRequest` and a <script> tag
10+
// holding pre-fetched data) but they all seem to ultimately go through
11+
// the built-in `JSON.parse` for parsing their data! So we can tweak the
12+
// website data by injecting some middleware into the `JSON.parse`
13+
// built-in.
14+
injectJsonParseMiddleware(rewriteLeetCodeGraphQLData);
3115

3216
// Additionally, we will patch some of the actual page code! We will do so
3317
// by trying to intercept `webpack` chunk loading, so that we can patch the
34-
// modules used by the page.
35-
patchWebpackChunkLoading();
18+
// packages used by the page.
19+
injectWebpackChunkLoadingMiddleware(patchPageModule);
3620
}
3721

3822
main();

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type CreateElementFn = (
1515
export function patchJsxFactory(
1616
createElementFn: CreateElementFn,
1717
): CreateElementFn {
18+
// TODO: match the length of createElementFn
1819
return function (_elementType, props) {
1920
try {
2021
// Remove the Difficulty dropdown on `/problemset/`. The dropdown is
@@ -47,6 +48,7 @@ export function patchJsxFactory(
4748

4849
return createElementFn.apply(
4950
this,
51+
// Slight lie but `.apply` will work with the `arguments` object.
5052
arguments as unknown as Parameters<CreateElementFn>,
5153
);
5254
};
Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
11
import { patchJsxFactory } from "./patchJsxFactory.ts";
22

3+
// TODO: weak set?
34
const jsxs = new Set();
45

5-
export function patchPageModule<TThis, TArgs extends unknown[], TRes>(
6-
moduleFn: (this: TThis, ...args: TArgs) => TRes,
7-
): (this: TThis, ...args: TArgs) => TRes {
8-
return function () {
9-
const res = moduleFn.apply(
10-
this,
11-
Array.from(arguments) as Parameters<typeof moduleFn>,
12-
);
6+
export function patchPageModule<T extends Function>(moduleFn: T): T {
7+
return function (this: ThisParameterType<T>) {
8+
const res = moduleFn.apply(this, arguments);
139

10+
// TODO: more defensive programming
1411
const module = arguments[0].exports;
1512

1613
// The core React module is some module with a `useLayoutEffect` property.
@@ -27,5 +24,5 @@ export function patchPageModule<TThis, TArgs extends unknown[], TRes>(
2724
}
2825

2926
return res;
30-
};
27+
} as unknown as T;
3128
}

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

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

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

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,24 @@
1-
import type { JsonArray, JsonObject } from "type-fest";
21
import nullthrows from "nullthrows";
32

43
import { firstOrThrow } from "@code-chronicles/util/firstOrThrow";
54
import { groupBy } from "@code-chronicles/util/groupBy";
65
import { isArrayOfNumbers } from "@code-chronicles/util/isArrayOfNumbers";
7-
import { isNonArrayObject } from "@code-chronicles/util/isNonArrayObject";
8-
import { isString } from "@code-chronicles/util/isString";
96
import { mergeObjects } from "@code-chronicles/util/mergeObjects";
107
import { only } from "@code-chronicles/util/only";
118
import { sum } from "@code-chronicles/util/sum";
129
import { stringToCase, type Case } from "@code-chronicles/util/stringToCase";
1310

11+
import { isArrayOfDataByDifficulty } from "./isArrayOfDataByDifficulty.ts";
1412
import { PREFERRED_STRING_CASE, STRING_CASE_CHECKERS } from "./stringCase.ts";
1513

16-
function isArrayOfDataByDifficulty(
17-
arr: JsonArray,
18-
): arr is ({ difficulty: string } & JsonObject)[] {
19-
return arr.every(
20-
(elem) =>
21-
isNonArrayObject(elem) &&
22-
Object.hasOwn(elem, "difficulty") &&
23-
isString(elem.difficulty),
24-
);
25-
}
26-
2714
/**
2815
* Some of the LeetCode GraphQL data is aggregate statistics about problems
2916
* by difficulty. This function detects instances of this and tries to
3017
* re-aggregate.
3118
*/
3219
export function rewriteLeetCodeAggregateDataForDifficulty(
33-
arr: JsonArray,
34-
): JsonArray {
20+
arr: unknown[],
21+
): unknown[] {
3522
// Do nothing if it's not the kind of data we're looking for.
3623
if (!isArrayOfDataByDifficulty(arr)) {
3724
return arr;

0 commit comments

Comments
 (0)