From 6162dfd08b2d5a70f2c32664fdb42c42e781f06b Mon Sep 17 00:00:00 2001 From: ryan-0324 <77452312+ryan-0324@users.noreply.github.com> Date: Sat, 4 May 2024 23:33:53 -0400 Subject: [PATCH] refactor+chore: metro & misc. (#29) * Refactored `lib/metro` and added types + cleaned up `tsconfig.json` + minor changes to `build.mjs` and `package.json` * Made requested changes + added eslint rule to restrict top-level await * Exclude `transform-async-to-generator` from `env` (async/await is es7) --- .eslintrc.json | 6 +- build.mjs | 25 ++-- package.json | 18 +-- src/lib/metro/filters.ts | 178 +++++++++++++++------------- src/lib/metro/index.ts | 5 +- src/lib/metro/polyfills/redesign.ts | 18 ++- src/lib/metro/types.ts | 94 ++++++++++++++- src/lib/metro/utils.ts | 11 ++ src/lib/utils/types.ts | 2 + tsconfig.json | 48 ++++---- 10 files changed, 264 insertions(+), 141 deletions(-) create mode 100644 src/lib/metro/utils.ts diff --git a/.eslintrc.json b/.eslintrc.json index f2dd2e1..ac03347 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -9,6 +9,10 @@ "import-alias" ], "rules": { + "no-restricted-syntax": [ "error", { + "selector": "AwaitExpression:not(:function *)", + "message": "Hermes does not support top-level await, and SWC cannot transform it." + } ], "quotes": [ "error", "double", { "avoidEscape": true } ], "jsx-quotes": [ "error", "prefer-double" ], "no-mixed-spaces-and-tabs": "error", @@ -72,4 +76,4 @@ } ] } -} \ No newline at end of file +} diff --git a/build.mjs b/build.mjs index 12bff34..b05af40 100644 --- a/build.mjs +++ b/build.mjs @@ -6,6 +6,7 @@ import { readFile } from "fs/promises"; import { createServer } from "http"; import { argv } from "process"; +// @ts-ignore const isFlag = (s, l) => argv.slice(2).some(c => c === `-${s}` || c === `--${l}`); const isRelease = isFlag("r", "release"); @@ -16,7 +17,7 @@ const commitHash = execSync("git rev-parse --short HEAD").toString().trim(); try { const ctx = await esbuild.context({ - entryPoints: ["src/entry.js"], + entryPoints: ["./src/entry.js"], bundle: true, minify: isRelease, format: "iife", @@ -33,13 +34,6 @@ try { }, loader: { ".png": "dataurl" }, legalComments: "none", - alias: { - "@lib/*": "src/lib/*", - "@core/*": "src/core/*", - "@metro/*": "src/lib/metro/*", - "@ui/*": "src/lib/ui/*", - "@types": "src/utils/types.ts", - }, plugins: [ { name: "runtimeGlobalAlias", @@ -55,6 +49,7 @@ try { namespace: "glob-" + key, path: args.path })); build.onLoad({ filter, namespace: "glob-" + key }, () => ({ + // @ts-ignore contents: `Object.defineProperty(module, 'exports', { get: () => ${globalMap[key]} })`, resolveDir: "src", })); @@ -99,16 +94,22 @@ try { } }, }, + // https://github.com/facebook/hermes/blob/3815fec63d1a6667ca3195160d6e12fee6a0d8d5/doc/Features.md + // https://github.com/facebook/hermes/issues/696#issuecomment-1396235791 env: { - targets: "defaults", + targets: "fully supports es6", include: [ - "transform-classes", "transform-arrow-functions", "transform-block-scoping", - "transform-class-properties" + "transform-classes" ], exclude: [ - "transform-parameters" + "transform-async-to-generator", + "transform-exponentiation-operator", + "transform-named-capturing-groups-regex", + "transform-nullish-coalescing-operator", + "transform-object-rest-spread", + "transform-optional-chaining" ] }, }); diff --git a/package.json b/package.json index f009de4..b2d7207 100644 --- a/package.json +++ b/package.json @@ -21,16 +21,17 @@ "license": "BSD-3-Clause", "devDependencies": { "@react-native-clipboard/clipboard": "1.10.0", - "@swc/core": "^1.4.6", - "@types/chroma-js": "^2.4.4", - "@types/lodash": "^4.14.202", + "@swc/core": "^1.4.17", + "@swc/helpers": "^0.5.11", + "@types/chroma-js": "~2.4.0", + "@types/lodash": "~4.17.0", "@types/node": "^20.11.25", - "@types/react": "18.2.60", - "@types/react-native": "0.72.3", + "@types/react": "~18.2.0", + "@types/react-native": "~0.72.0", "@typescript-eslint/eslint-plugin": "^7.1.1", "@typescript-eslint/parser": "^7.1.1", "@typescript-eslint/typescript-estree": "^7.1.1", - "esbuild": "^0.17.19", + "esbuild": "^0.20.2", "eslint": "^8.57.0", "eslint-plugin-import-alias": "^1.2.0", "eslint-plugin-react": "^7.34.0", @@ -39,11 +40,10 @@ "intl-messageformat": "^10.5.11", "moment": "2.22.2", "type-fest": "^4.12.0", - "typescript": "^5.4.2", + "typescript": "^5.4.5", "typescript-transform-paths": "^3.4.7" }, "dependencies": { - "@swc/helpers": "0.5.0", "fuzzysort": "^2.0.4", "spitroast": "^1.4.4" }, @@ -55,4 +55,4 @@ ] } } -} \ No newline at end of file +} diff --git a/src/lib/metro/filters.ts b/src/lib/metro/filters.ts index e6af7bb..b45b359 100644 --- a/src/lib/metro/filters.ts +++ b/src/lib/metro/filters.ts @@ -1,81 +1,67 @@ import { instead } from "@lib/api/patcher"; +import type { Metro } from "@metro/types"; +import { metroRequire, modules } from "@metro/utils"; -export type MetroModules = { [id: string]: any; }; -export type PropIntellisense

= Record & Record; -export type PropsFinder = (...props: T[]) => PropIntellisense; -export type PropsFinderAll = (...props: T[]) => PropIntellisense[]; +export type PropIntellisense

= Record & Record; +export type PropsFinder = (...props: T[]) => PropIntellisense; +export type PropsFinderAll = (...props: T[]) => PropIntellisense[]; -interface MetroRequire { - (moduleId: string): any; - importDefault(moduleId: string): any; - importAll(moduleId: string): any; +/** Makes the module associated with the specified ID non-enumberable. */ +function blacklistModule(id: Metro.ModuleID) { + Object.defineProperty(modules, id, { enumerable: false }); } -// Metro global vars -declare var __r: MetroRequire; -declare var modules: MetroModules; - -// Function to blacklist a module, preventing it from being searched again -const blacklist = (id: string) => Object.defineProperty(window.modules, id, { - value: window.modules[id], - enumerable: false, - configurable: true, - writable: true -}); - const functionToString = Function.prototype.toString; -for (const id in window.modules) { - const moduleDefinition = window.modules[id]; +for (const id in modules) { + const metroModule = modules[id]; - if (moduleDefinition.factory) { - instead("factory", moduleDefinition, (args, orig) => { + if (metroModule!.factory) { + instead("factory", metroModule, ((args: Parameters, origFunc: Metro.FactoryFn) => { const { 1: metroRequire, 4: moduleObject } = args; - args[2 /* metroImportDefault */] = (id: string) => { - const exp = metroRequire(id); - return exp && exp.__esModule ? exp.default : exp; + args[2 /* metroImportDefault */] = id => { + const exps = metroRequire(id); + return exps && exps.__esModule ? exps.default : exps; }; - args[3 /* metroImportAll */] = (id: string) => { - const exp = metroRequire(id); - if (exp && exp.__esModule) return exp; + args[3 /* metroImportAll */] = id => { + const exps = metroRequire(id); + if (exps && exps.__esModule) return exps; const importAll: Record = {}; - if (exp) Object.assign(importAll, exp); - return importAll.default = exp, importAll; + if (exps) Object.assign(importAll, exps); + importAll.default = exps; + return importAll; }; - orig(...args); + origFunc(...args); if (moduleObject.exports) onModuleRequire(moduleObject.exports); - }); + }) as any); // If only spitroast had better types } } // Blacklist any "bad-actor" modules, e.g. the dreaded null proxy, the window itself, or undefined modules -for (const id in window.modules) { - const module = requireModule(id); +for (const id in modules) { + const moduleExports = requireModule(id); - if (!module || module === window || module["Revenge EOL when?"] === null) { - blacklist(id); - continue; - } + if (!moduleExports || moduleExports === window || moduleExports["Revenge EOL when?"] === null) + blacklistModule(id); } let patchedInspectSource = false; -function onModuleRequire(exports: any) { +function onModuleRequire(moduleExports: any) { // Temporary - exports.initSentry &&= () => { }; - if (exports.default?.track && exports.default.trackMaker) { - exports.default.track = () => Promise.resolve(); - } + moduleExports.initSentry &&= () => undefined; + if (moduleExports.default?.track && moduleExports.default.trackMaker) + moduleExports.default.track = () => Promise.resolve(); // There are modules registering the same native component - if (exports?.default?.name === "requireNativeComponent") { - instead("default", exports, (args, orig) => { + if (moduleExports?.default?.name === "requireNativeComponent") { + instead("default", moduleExports, (args, origFunc) => { try { - return orig(...args); + return origFunc(...args); } catch { return args[0]; } @@ -83,9 +69,9 @@ function onModuleRequire(exports: any) { } // Hook DeveloperExperimentStore - if (exports?.default?.constructor?.displayName === "DeveloperExperimentStore") { - exports.default = new Proxy(exports.default, { - get: (target, property, receiver) => { + if (moduleExports?.default?.constructor?.displayName === "DeveloperExperimentStore") { + moduleExports.default = new Proxy(moduleExports.default, { + get(target, property, receiver) { if (property === "isDeveloper") { // Hopefully won't explode accessing it here :3 const { settings } = require("@lib/settings"); @@ -99,66 +85,90 @@ function onModuleRequire(exports: any) { // Funny infinity recursion caused by a race condition if (!patchedInspectSource && window["__core-js_shared__"]) { - const inspect = (f: Function) => typeof f === "function" && functionToString.apply(f, []); + const inspect = (f: unknown) => typeof f === "function" && functionToString.apply(f, []); window["__core-js_shared__"].inspectSource = inspect; patchedInspectSource = true; } } -function requireModule(id: string) { - if (modules[id].isInitialized) return __r(id); +const noopHandler = () => undefined; + +function requireModule(id: Metro.ModuleID) { + if (modules[id]!.isInitialized) return metroRequire(id); // Disable Internal Metro error reporting logic - const originalHandler = window.ErrorUtils.getGlobalHandler(); - window.ErrorUtils.setGlobalHandler(null); + const originalHandler = ErrorUtils.getGlobalHandler(); + ErrorUtils.setGlobalHandler(noopHandler); + let moduleExports; try { - var exports = __r(id); // metroRequire(id); + moduleExports = metroRequire(id); } catch { - var exports = undefined; + moduleExports = undefined; } // Done initializing! Now, revert our hacks - window.ErrorUtils.setGlobalHandler(originalHandler); + ErrorUtils.setGlobalHandler(originalHandler); - return exports; + return moduleExports; } -function* getModules() { - yield require("./polyfills/redesign"); +function* getModuleExports() { + yield require("@metro/polyfills/redesign"); for (const id in modules) { yield requireModule(id); } } -// Function to filter through modules -const filterModules = (modules: MetroModules, single = false) => (filter: (m: any) => boolean) => { - const found = []; +type ExportsFilter = (moduleExports: any) => unknown; - for (const exports of getModules()) { - if (exports.default && exports.__esModule && filter(exports.default)) { - if (single) return exports.default; - found.push(exports.default); - } +function testExports(moduleExports: any, filter: ExportsFilter) { + if (moduleExports.default && moduleExports.__esModule && filter(moduleExports.default)) + return moduleExports.default; + if (filter(moduleExports)) + return moduleExports; +} - if (filter(exports)) { - if (single) return exports; - else found.push(exports); - } +/** + * Returns the exports of the first module where filter returns truthy, and undefined otherwise. + * @param filter find calls filter once for each enumerable module's exports until it finds one where filter returns a thruthy value. + */ +export function find(filter: ExportsFilter) { + for (const moduleExports of getModuleExports()) { + const testedExports = testExports(moduleExports, filter); + if (testedExports !== undefined) + return testedExports; } +} - if (!single) return found; -}; +/** + * Returns the exports of all modules where filter returns truthy. + * @param filter findAll calls filter once for each enumerable module's exports, adding the exports to the returned array when filter returns a thruthy value. + */ +export function findAll(filter: ExportsFilter) { + const foundExports: any[] = []; + for (const moduleExports of getModuleExports()) { + const testedExports = testExports(moduleExports, filter); + if (testedExports !== undefined) + foundExports.push(testedExports); + } + return foundExports; +} -export const find = filterModules(modules, true); -export const findAll = filterModules(modules); - -const propsFilter = (props: (string | symbol)[]) => (m: any) => props.every(p => m[p] !== undefined); -const nameFilter = (name: string, defaultExp: boolean) => (defaultExp ? (m: any) => m?.name === name : (m: any) => m?.default?.name === name); -const dNameFilter = (displayName: string, defaultExp: boolean) => (defaultExp ? (m: any) => m?.displayName === displayName : (m: any) => m?.default?.displayName === displayName); -const tNameFilter = (typeName: string, defaultExp: boolean) => (defaultExp ? (m: any) => m?.type?.name === typeName : (m: any) => m?.default?.type?.name === typeName); -const storeFilter = (name: string) => (m: any) => m.getName && m.getName.length === 0 && m.getName() === name; +const propsFilter = (props: PropertyKey[]) => + (exps: any) => props.every(p => exps[p] !== undefined); +const nameFilter = (name: string, defaultExp: boolean) => defaultExp + ? (exps: any) => exps?.name === name + : (exps: any) => exps?.default?.name === name; +const dNameFilter = (displayName: string, defaultExp: boolean) => defaultExp + ? (exps: any) => exps?.displayName === displayName + : (exps: any) => exps?.default?.displayName === displayName; +const tNameFilter = (typeName: string, defaultExp: boolean) => defaultExp + ? (exps: any) => exps?.type?.name === typeName + : (exps: any) => exps?.default?.type?.name === typeName; +const storeFilter = (name: string) => + (exps: any) => exps.getName && exps.getName.length === 0 && exps.getName() === name; export const findByProps: PropsFinder = (...props) => find(propsFilter(props)); export const findByPropsAll: PropsFinderAll = (...props) => findAll(propsFilter(props)); diff --git a/src/lib/metro/index.ts b/src/lib/metro/index.ts index bc2ad68..70391bc 100644 --- a/src/lib/metro/index.ts +++ b/src/lib/metro/index.ts @@ -1,4 +1,4 @@ -export * as common from "./common"; +export * from "./utils"; export { find, findAll, @@ -10,6 +10,7 @@ export { findByPropsAll, findByStoreName, findByTypeName, - findByTypeNameAll, + findByTypeNameAll } from "./filters"; export * as filters from "./filters"; +export * as common from "./common"; diff --git a/src/lib/metro/polyfills/redesign.ts b/src/lib/metro/polyfills/redesign.ts index 0d54786..fa07b6e 100644 --- a/src/lib/metro/polyfills/redesign.ts +++ b/src/lib/metro/polyfills/redesign.ts @@ -1,4 +1,4 @@ -import "@metro/index"; +import { metroRequire, modules } from "@metro"; import { LiteralUnion } from "type-fest"; @@ -103,25 +103,23 @@ type Keys = LiteralUnion ? U : string, const redesignPropSource = {} as Record; const redesignModule = {} as Record; -for (const id in window.modules) { - const exports = window.__r(id); - polyfillRedesignModule(exports); +for (const id in modules) { + const moduleExports = metroRequire(id); + polyfillRedesignModule(moduleExports); } -function polyfillRedesignModule(exports: any) { +function polyfillRedesignModule(moduleExports: any) { const propMap = new Map(); for (const prop of redesignProps) { - if (exports?.[prop]) { + if (moduleExports?.[prop]) propMap.set(prop, null); - } - else if (exports?.default?.[prop]) { + else if (moduleExports?.default?.[prop]) propMap.set(prop, "default"); - } } for (const [prop, defaultKey] of propMap) { - const exportsForProp = defaultKey ? exports[defaultKey] : exports; + const exportsForProp = defaultKey ? moduleExports[defaultKey] : moduleExports; if (redesignModule[prop]) { if (Object.keys(exportsForProp).length < Object.keys(redesignPropSource[prop]).length) { diff --git a/src/lib/metro/types.ts b/src/lib/metro/types.ts index adf1c65..3b2d6b6 100644 --- a/src/lib/metro/types.ts +++ b/src/lib/metro/types.ts @@ -1,3 +1,96 @@ +import type { Nullish } from "@types"; + +/** @see {@link https://github.com/facebook/metro/blob/c2d7539dfc10aacb2f99fcc2f268a3b53e867a90/packages/metro-runtime/src/polyfills/require.js} */ +export namespace Metro { + export interface DependencyMap { + readonly [indexer: number]: ModuleID; + readonly paths?: Readonly> | undefined; + } + + export type InverseDependencyMap = Record; + + export type FactoryFn = ( + global: object, + require: RequireFn, + metroImportDefault: RequireFn, + metroImportAll: RequireFn, + moduleObject: { + exports: any; + }, + exports: any, + dependencyMap: DependencyMap | Nullish, + ) => void; + + export interface HotModuleReloadingData { + _acceptCallback: (() => void) | Nullish; + _disposeCallback: (() => void) | Nullish; + _didAccept: boolean; + accept: (callback?: (() => void) | undefined) => void; + dispose: (callback?: (() => void) | undefined) => void; + } + + export type ModuleID = string | number; + + export interface Module { + id?: ModuleID | undefined; + exports: any; + hot?: HotModuleReloadingData | undefined; + } + + export interface ModuleDefinition { + dependencyMap: DependencyMap | Nullish; + error?: any; + factory: FactoryFn | undefined; + hasError: boolean; + hot?: HotModuleReloadingData | undefined; + importedAll: any; + importedDefault: any; + isInitialized: boolean; + path?: string | undefined; + publicModule: Module; + verboseName?: string | undefined; + } + + export type ModuleList = Record; + + export type RequireFn = (id: ModuleID) => any; + + export type DefineFn = ( + factory: FactoryFn, + moduleId: ModuleID, + dependencyMap?: DependencyMap | undefined, + verboseName?: string | undefined, + inverseDependencies?: InverseDependencyMap | undefined + ) => void; + + export type ModuleDefiner = (moduleId: ModuleID) => void; + + export type ClearFn = () => ModuleList; + + export type RegisterSegmentFn = ( + segmentId: number, + moduleDefiner: ModuleDefiner, + moduleIds: Readonly | Nullish + ) => void; + + export interface Require extends RequireFn { + importDefault: RequireFn; + importAll: RequireFn; + /** @throws {Error} */ + context: () => never; + /** @throws {Error} */ + resolveWeak: () => never; + unpackModuleId: (moduleId: ModuleID) => { + localId: number; + segmentId: number; + }; + packModuleId: (value: { + localId: number; + segmentId: number; + }) => ModuleID; + } +} + export interface Dispatcher { _actionHandlers: unknown; _interceptors?: ((payload: any) => void | boolean)[]; @@ -16,4 +109,3 @@ export interface Dispatcher { unsubscribe(actionType: string, callback: (payload: any) => void): void; wait(cb: () => void): void; } - diff --git a/src/lib/metro/utils.ts b/src/lib/metro/utils.ts new file mode 100644 index 0000000..e6cd4f7 --- /dev/null +++ b/src/lib/metro/utils.ts @@ -0,0 +1,11 @@ +import type { Metro } from "@metro/types"; + +export const modules: Metro.ModuleList = window.modules; + +export const metroRequire: Metro.Require = window.__r; + +export const define: Metro.DefineFn = window.__d; + +export const clear: Metro.ClearFn = window.__c; + +export const registerSegment: Metro.RegisterSegmentFn = window.__registerSegment; diff --git a/src/lib/utils/types.ts b/src/lib/utils/types.ts index cc257cd..a597341 100644 --- a/src/lib/utils/types.ts +++ b/src/lib/utils/types.ts @@ -1,5 +1,7 @@ import { LiteralUnion } from "type-fest"; +export type Nullish = null | undefined; + export type OptionalKeys> = LiteralUnion; export interface Author { diff --git a/tsconfig.json b/tsconfig.json index d36f21a..bd56899 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,31 +1,35 @@ { - "include": ["src"], - "exclude": ["node_modules"], + "exclude": ["dist"], + "compilerOptions": { - "baseUrl": ".", - "sourceMap": true, - "module": "ESNext", - "target": "ESNext", - "declaration": true, - "emitDeclarationOnly": true, - "moduleResolution": "node", - "esModuleInterop": true, - "jsx": "react", "strict": true, - "stripInternal": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "skipLibCheck": true, + "paths": { - "@lib/*": ["src/lib/*"], - "@core/*": ["src/core/*"], - "@metro/*": ["src/lib/metro/*"], - "@ui/*": ["src/lib/ui/*"], - "@types": ["src/lib/utils/types.ts"], - "bunny-build": ["src/modules.d.ts"] + "@core/*": ["./src/core/*"], + "@lib": ["./src/lib"], + "@lib/*": ["./src/lib/*"], + "@metro": ["./src/lib/metro"], + "@metro/*": ["./src/lib/metro/*"], + "@ui": ["./src/lib/ui"], + "@ui/*": ["./src/lib/ui/*"], + "@types": ["./src/lib/utils/types.ts"], + "bunny-build": ["./src/modules.d.ts"] }, + + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + + "noEmit": true, + + "allowJs": true, + "checkJs": true, + "plugins": [ { "transform": "typescript-transform-paths", "afterDeclarations": true } - ] + ], + + "jsx": "react", + "target": "esnext" } }