diff --git a/workspaces/leetcode-zen-mode/src/extension/injectWebpackChunkLoadingMiddleware.ts b/workspaces/leetcode-zen-mode/src/extension/injectWebpackChunkLoadingMiddleware.ts deleted file mode 100644 index c4ef3a10..00000000 --- a/workspaces/leetcode-zen-mode/src/extension/injectWebpackChunkLoadingMiddleware.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { assignFunctionCosmeticProperties } from "@code-chronicles/util/object-properties/assignFunctionCosmeticProperties"; -import { isNonArrayObject } from "@code-chronicles/util/isNonArrayObject"; -import { isString } from "@code-chronicles/util/isString"; - -type Middleware = (packageFactory: Function) => Function; - -type Push = typeof Array.prototype.push; - -/** - * Patches `webpack` chunk loading so that we can intercept and patch the - * packages used by the page. - */ -export function injectWebpackChunkLoadingMiddleware( - middlewareFn: Middleware, -): void { - const prevSelf = window.self; - - // TODO: update comment to not be LeetCode-specific - - // LeetCode's `webpack` works by pushing information about chunks onto a - // globally defined array named something like `webpackChunk_N_E`. The array - // is accessed as `self.webpackChunk_N_E`, so we temporarily proxy - // `window.self` so we can detect attempts to define this array, and - // inject some middleware into pushes into this array. - window.self = new Proxy(prevSelf, { - set(target, prop, newValue) { - const res = Reflect.set(target, prop, newValue); - - if (!(isString(prop) && prop.startsWith("webpackChunk"))) { - return res; - } - - if (typeof newValue?.push !== "function") { - // TODO: console.error something interesting - return res; - } - - // Once we've found the magic `webpack` array we remove the proxy, - // parts of the page seem to break without this. - window.self = prevSelf; - - // The `webpack` bootstrapping code reassigns the array's `push` - // method. We will intercept this reassignment so we can patch packages - // before they are registered. - let push: Push = newValue.push; - Object.defineProperty(newValue, "push", { - get() { - return push; - }, - - set(newPush: TNewPush) { - const wrappedNewPush = function (this: ThisParameterType) { - // In practice, `push` gets invoked with one chunk at a time, - // but it's easy to not assume that, so we iterate over the - // arguments. - for (const arg of arguments) { - // A chunk is structured as a tuple, the second element in the - // tuple is an object map of numbers to functions implementing - // each module. We will wrap these functions with our own code. - if (!Array.isArray(arg) || arg.length < 2) { - // TODO: console.error something interesting - continue; - } - - const modules = arg[1]; - // TODO: Array.isArray type refinement is unsafe - // TODO: make callsites of "isFoo" clearer on intent, for example here we're checking that modules has meaningful entries - if (!isNonArrayObject(modules) && !Array.isArray(modules)) { - // TODO: console.error something interesting - continue; - } - - for (const [key, module] of Object.entries(modules)) { - // TODO: make callsites of "isFoo" clearer on intent, for example here we're checking that `module` can be invoked - if (typeof module !== "function") { - // TODO: console.error something interesting - continue; - } - - modules[key as any] = middlewareFn(module); - } - } - - return newPush.apply( - this, - // Slight lie but `.apply` will work with the `arguments` object. - arguments as unknown as Parameters, - ); - }; - - push = assignFunctionCosmeticProperties(wrappedNewPush, newPush); - }, - }); - - return res; - }, - }); -} diff --git a/workspaces/leetcode-zen-mode/src/extension/main.ts b/workspaces/leetcode-zen-mode/src/extension/main.ts index 62ad28a8..d3b262d0 100644 --- a/workspaces/leetcode-zen-mode/src/extension/main.ts +++ b/workspaces/leetcode-zen-mode/src/extension/main.ts @@ -1,7 +1,7 @@ import { injectJsonParseMiddleware } from "@code-chronicles/util/browser-extensions/injectJsonParseMiddleware"; +import { injectWebpackChunkLoadingMiddleware } from "@code-chronicles/util/browser-extensions/injectWebpackChunkLoadingMiddleware"; -import { injectWebpackChunkLoadingMiddleware } from "./injectWebpackChunkLoadingMiddleware.ts"; -import { patchPageModule } from "./patchPageModule.ts"; +import { patchLeetCodeModule } from "./patchLeetCodeModule.ts"; import { rewriteLeetCodeGraphQLData } from "./rewriteLeetCodeGraphQLData.ts"; function main(): void { @@ -15,8 +15,8 @@ function main(): void { // 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 - // packages used by the page. - injectWebpackChunkLoadingMiddleware(patchPageModule); + // modules used by the page. + injectWebpackChunkLoadingMiddleware(patchLeetCodeModule); } main(); diff --git a/workspaces/leetcode-zen-mode/src/extension/patchJsxFactory.ts b/workspaces/leetcode-zen-mode/src/extension/patchJsxFactory.ts index 7e041c44..c8f9f2a2 100644 --- a/workspaces/leetcode-zen-mode/src/extension/patchJsxFactory.ts +++ b/workspaces/leetcode-zen-mode/src/extension/patchJsxFactory.ts @@ -1,9 +1,7 @@ import { isNonArrayObject } from "@code-chronicles/util/isNonArrayObject"; import { isString } from "@code-chronicles/util/isString"; - -function Null() { - return null; -} +import { assignFunctionCosmeticProperties } from "@code-chronicles/util/object-properties/assignFunctionCosmeticProperties"; +import { NullReactElement } from "@code-chronicles/util/browser-extensions/NullReactElement"; type CreateElementFn = ( this: unknown, @@ -15,8 +13,7 @@ type CreateElementFn = ( export function patchJsxFactory( createElementFn: CreateElementFn, ): CreateElementFn { - // TODO: match the length of createElementFn - return function (_elementType, props) { + return assignFunctionCosmeticProperties(function (_elementType, props) { try { // Remove the Difficulty dropdown on `/problemset/`. The dropdown is // implemented as a React element with an `items` prop which is an @@ -29,7 +26,7 @@ export function patchJsxFactory( isString(it.value) && /^easy$/i.test(it.value), ) ) { - return createElementFn.apply(this, [Null, {}]); + return createElementFn.apply(this, [NullReactElement, {}]); } // Remove the non-Easy sections of the problems solved panel on user @@ -40,7 +37,7 @@ export function patchJsxFactory( isString(props.category) && /^(?:medium|hard)$/i.test(props.category) ) { - return createElementFn.apply(this, [Null, {}]); + return createElementFn.apply(this, [NullReactElement, {}]); } } catch (err) { console.error(err); @@ -51,5 +48,5 @@ export function patchJsxFactory( // Slight lie but `.apply` will work with the `arguments` object. arguments as unknown as Parameters, ); - }; + }, createElementFn); } diff --git a/workspaces/leetcode-zen-mode/src/extension/patchLeetCodeModule.ts b/workspaces/leetcode-zen-mode/src/extension/patchLeetCodeModule.ts new file mode 100644 index 00000000..d7128a84 --- /dev/null +++ b/workspaces/leetcode-zen-mode/src/extension/patchLeetCodeModule.ts @@ -0,0 +1,32 @@ +import { isModuleTheReactPackage } from "@code-chronicles/util/browser-extensions/isModuleTheReactPackage"; +import { isModuleWebCellDomRenderer } from "@code-chronicles/util/browser-extensions/isModuleWebCellDomRenderer"; + +import { patchJsxFactory } from "./patchJsxFactory.ts"; + +const jsxs = new WeakSet(); + +export function patchLeetCodeModule(moduleExports: unknown): unknown { + // LeetCode uses React for its website. + if (isModuleTheReactPackage(moduleExports) && !jsxs.has(moduleExports)) { + jsxs.add(moduleExports); + moduleExports.createElement = patchJsxFactory( + // TODO: remove the any + moduleExports.createElement as any, + ); + } + + // LeetCode also uses a package which exposes `jsx` and `jsxs` methods, + // seemingly https://github.com/EasyWebApp/DOM-Renderer + if (isModuleWebCellDomRenderer(moduleExports) && !jsxs.has(moduleExports)) { + jsxs.add(moduleExports); + moduleExports.jsx = + moduleExports.jsxs = + moduleExports.jsxDEV = + patchJsxFactory( + // TODO: remove the any + moduleExports.jsx as any, + ); + } + + return moduleExports; +} diff --git a/workspaces/leetcode-zen-mode/src/extension/patchPageModule.ts b/workspaces/leetcode-zen-mode/src/extension/patchPageModule.ts deleted file mode 100644 index eb6e2e44..00000000 --- a/workspaces/leetcode-zen-mode/src/extension/patchPageModule.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { patchJsxFactory } from "./patchJsxFactory.ts"; - -// TODO: weak set? -const jsxs = new Set(); - -export function patchPageModule(moduleFn: T): T { - return function (this: ThisParameterType) { - const res = moduleFn.apply(this, arguments); - - // TODO: more defensive programming - const module = arguments[0].exports; - - // The core React module is some module with a `useLayoutEffect` property. - if (Object.hasOwn(module, "useLayoutEffect") && !jsxs.has(module)) { - jsxs.add(module); - module.createElement = patchJsxFactory(module.createElement); - } - - // LeetCode also uses a module which exposes `jsx` and `jsxs` methods, - // possibly https://web-cell.dev/ - if (Object.hasOwn(module, "jsx") && !jsxs.has(module)) { - jsxs.add(module); - module.jsx = module.jsxs = patchJsxFactory(module.jsx); - } - - return res; - } as unknown as T; -} diff --git a/workspaces/leetcode-zen-mode/src/extension/rewriteLeetCodeGraphQLData.ts b/workspaces/leetcode-zen-mode/src/extension/rewriteLeetCodeGraphQLData.ts index 90bb7cff..b593ab98 100644 --- a/workspaces/leetcode-zen-mode/src/extension/rewriteLeetCodeGraphQLData.ts +++ b/workspaces/leetcode-zen-mode/src/extension/rewriteLeetCodeGraphQLData.ts @@ -4,7 +4,6 @@ import { mapObjectValues } from "@code-chronicles/util/mapObjectValues"; import { stringToCase } from "@code-chronicles/util/stringToCase"; import { rewriteLeetCodeAggregateDataForDifficulty } from "./rewriteLeetCodeAggregateDataForDifficulty.ts"; - import { PREFERRED_STRING_CASE, STRING_CASE_CHECKERS } from "./stringCase.ts"; export function rewriteLeetCodeGraphQLData(value: unknown): unknown { diff --git a/workspaces/util/src/browser-extensions/NullReactElement.ts b/workspaces/util/src/browser-extensions/NullReactElement.ts new file mode 100644 index 00000000..ce0beb46 --- /dev/null +++ b/workspaces/util/src/browser-extensions/NullReactElement.ts @@ -0,0 +1,3 @@ +export function NullReactElement() { + return null; +} diff --git a/workspaces/util/src/browser-extensions/injectWebpackChunkLoadingMiddleware.ts b/workspaces/util/src/browser-extensions/injectWebpackChunkLoadingMiddleware.ts new file mode 100644 index 00000000..3c762979 --- /dev/null +++ b/workspaces/util/src/browser-extensions/injectWebpackChunkLoadingMiddleware.ts @@ -0,0 +1,125 @@ +import { assignFunctionCosmeticProperties } from "@code-chronicles/util/object-properties/assignFunctionCosmeticProperties"; +import { coalesceResults } from "@code-chronicles/util/coalesceResults"; +import { isString } from "@code-chronicles/util/isString"; +import { isNonArrayObject } from "@code-chronicles/util/isNonArrayObject"; + +type Middleware = (moduleFn: unknown) => unknown; + +type Push = typeof Array.prototype.push; + +/** + * Injects middleware that runs when the current page is loading code chunks + * built using `webpack`, allowing the patching of modules that make up the + * page. + * + * Works when `webpack` was run using the "array-push" `output.chunkFormat`: + * https://webpack.js.org/configuration/output/#outputchunkformat + */ +export function injectWebpackChunkLoadingMiddleware( + middlewareFn: Middleware, +): void { + // `webpack`'s "array-push" chunk format works by pushing information about + // chunks onto a globally defined array named something like + // `webpackChunk_N_E`. The array is accessed as `self.webpackChunk_N_E`, so + // we will temporarily proxy `globalThis.self` so we can detect attempts to + // define this array, and inject some middleware into pushes into the array. + const prevSelf = globalThis.self; + + globalThis.self = new Proxy(prevSelf, { + set(target, prop, newValue) { + const res = Reflect.set(target, prop, newValue); + + if (!(isString(prop) && prop.startsWith("webpackChunk"))) { + return res; + } + + if (typeof newValue?.push !== "function") { + // TODO: console.error something interesting + return res; + } + + // Once we've found the magic `webpack` array we remove the proxy, + // parts of the page seem to break without this. + globalThis.self = prevSelf; + + // The `webpack` bootstrapping code reassigns the array's `push` + // method. We will intercept this reassignment so we can patch modules + // before they are registered. + let push: Push = newValue.push; + Object.defineProperty(newValue, "push", { + get() { + return push; + }, + + set(newPush: TNewPush) { + // This should never happen, but we'll defend. + if (typeof newPush !== "function") { + // TODO: console.error something interesting + push = newPush; + return; + } + + const wrappedNewPush = function (this: ThisParameterType) { + // In practice, `push` gets invoked with one chunk at a time, + // but it's easy to not assume that, so we iterate over the + // arguments. + for (const arg of arguments) { + // TODO: Array.isArray type refinement is unsafe, create a safer utility + + // A chunk is structured as a tuple, the second element in the + // tuple is an object map of numbers to functions implementing + // each module. We will wrap these functions with our own code. + const modules = arg?.[1]; + + for (const [key, moduleFn] of coalesceResults( + () => Object.entries(modules), + () => [], + )) { + if (typeof moduleFn !== "function") { + // TODO: console.error something interesting + continue; + } + + // `webpack` invokes module functions with a module object as + // the first argument, and then the "exports" property of this + // object is what was exported by the module (e.g. assigned to + // `module.exports`). + modules[key] = assignFunctionCosmeticProperties(function ( + this: unknown, + ) { + const res = moduleFn.apply(this, arguments); + + const module = arguments[0]; + if ( + isNonArrayObject(module) && + Object.hasOwn(module, "exports") + ) { + // eslint-disable-next-line import-x/no-commonjs -- It's not our code that's CommonJS. + module.exports = middlewareFn(module.exports); + } else { + console.error( + "Surprising `webpack` module format: ", + module, + ); + } + + return res; + }, moduleFn); + } + } + + return newPush.apply( + this, + // Slight lie but `.apply` will work with the `arguments` object. + arguments as unknown as Parameters, + ); + }; + + push = assignFunctionCosmeticProperties(wrappedNewPush, newPush); + }, + }); + + return res; + }, + }); +} diff --git a/workspaces/util/src/browser-extensions/isModuleTheReactPackage.ts b/workspaces/util/src/browser-extensions/isModuleTheReactPackage.ts new file mode 100644 index 00000000..d5459f38 --- /dev/null +++ b/workspaces/util/src/browser-extensions/isModuleTheReactPackage.ts @@ -0,0 +1,15 @@ +import { isNonArrayObject } from "@code-chronicles/util/isNonArrayObject"; + +// The core React package is some module with `createElement` and `useLayoutEffect` properties. +type React = { + createElement: Function; + useLayoutEffect: Function; +}; + +export function isModuleTheReactPackage(module: unknown): module is React { + return ( + isNonArrayObject(module) && + typeof module.createElement === "function" && + typeof module.useLayoutEffect === "function" + ); +} diff --git a/workspaces/util/src/browser-extensions/isModuleWebCellDomRenderer.ts b/workspaces/util/src/browser-extensions/isModuleWebCellDomRenderer.ts new file mode 100644 index 00000000..ca16d386 --- /dev/null +++ b/workspaces/util/src/browser-extensions/isModuleWebCellDomRenderer.ts @@ -0,0 +1,18 @@ +import { isNonArrayObject } from "@code-chronicles/util/isNonArrayObject"; + +// Per https://github.com/EasyWebApp/DOM-Renderer/blob/main/source/jsx-runtime.ts +type JsxRuntime = { + jsx: Function; + jsxs: Function; + jsxDEV?: Function; +}; + +export function isModuleWebCellDomRenderer( + moduleExports: unknown, +): moduleExports is JsxRuntime { + return ( + isNonArrayObject(moduleExports) && + typeof moduleExports.jsx === "function" && + moduleExports.jsx === moduleExports.jsxs + ); +} diff --git a/workspaces/util/src/coalesceResults.ts b/workspaces/util/src/coalesceResults.ts new file mode 100644 index 00000000..609c1dc7 --- /dev/null +++ b/workspaces/util/src/coalesceResults.ts @@ -0,0 +1,22 @@ +import { getResult } from "@code-chronicles/util/getResult"; + +export function coalesceResults( + // This function is only worth using if it's invoked with at least two arguments. + ...functions: readonly [() => T, () => T, ...(() => T)[]] +): T { + const errors = []; + + for (const fn of functions) { + const result = getResult(fn); + if (result.isSuccess) { + return result.value; + } + + errors.push(result.error); + } + + throw new AggregateError( + errors, + "None of the given functions returned without throwing!", + ); +} diff --git a/workspaces/util/src/once.ts b/workspaces/util/src/once.ts index 9bf2364b..4205fc19 100644 --- a/workspaces/util/src/once.ts +++ b/workspaces/util/src/once.ts @@ -4,6 +4,7 @@ import { getResult, type Result } from "@code-chronicles/util/getResult"; export function once(fn: () => T): () => T { let result: Result | undefined; + // TODO: maybe add `once` around the name? return assignFunctionCosmeticProperties(function () { result ??= getResult(fn); diff --git a/workspaces/util/src/resultify.ts b/workspaces/util/src/resultify.ts index f1ff74f7..7978e2d4 100644 --- a/workspaces/util/src/resultify.ts +++ b/workspaces/util/src/resultify.ts @@ -11,9 +11,16 @@ export function resultify< TRes, TFn extends (this: TThis, ...args: TArgs) => TRes, >(fn: TFn): (this: TThis, ...args: TArgs) => Result { + // TODO: maybe add `resultify` around the name? return assignFunctionCosmeticProperties(function (this: TThis) { try { - return new SuccessResult(fn.apply(this, arguments as unknown as TArgs)); + return new SuccessResult( + fn.apply( + this, + // Slight lie but `.apply` will work with the `arguments` object. + arguments as unknown as TArgs, + ), + ); } catch (err) { return new ErrorResult(err); }