diff --git a/src/debug/loadLazyChunks.ts b/src/debug/loadLazyChunks.ts index 427acd11a..9c06e3aa4 100644 --- a/src/debug/loadLazyChunks.ts +++ b/src/debug/loadLazyChunks.ts @@ -20,8 +20,7 @@ export async function loadLazyChunks() { const invalidChunks = new Set(); const deferredRequires = new Set(); - let chunksSearchingResolve: (value: void) => void; - const chunksSearchingDone = new Promise(r => chunksSearchingResolve = r); + const { promise: chunksSearchingDone, resolve: chunksSearchingResolve } = Promise.withResolvers(); // True if resolved, false otherwise const chunksSearchPromises = [] as Array<() => boolean>; diff --git a/src/debug/runReporter.ts b/src/debug/runReporter.ts index 7a14609e1..8898242e0 100644 --- a/src/debug/runReporter.ts +++ b/src/debug/runReporter.ts @@ -17,8 +17,7 @@ async function runReporter() { try { ReporterLogger.log("Starting test..."); - let loadLazyChunksResolve: (value: void) => void; - const loadLazyChunksDone = new Promise(r => loadLazyChunksResolve = r); + const { promise: loadLazyChunksDone, resolve: loadLazyChunksResolve } = Promise.withResolvers(); // The main patch for starting the reporter chunk loading addPatch({ diff --git a/src/webpack/patchWebpack.ts b/src/webpack/patchWebpack.ts index 14c1888c9..9c91eee9a 100644 --- a/src/webpack/patchWebpack.ts +++ b/src/webpack/patchWebpack.ts @@ -17,6 +17,7 @@ import { AnyModuleFactory, AnyWebpackRequire, MaybePatchedModuleFactory, ModuleE export const patches = [] as Patch[]; +export const SYM_IS_PROXIED_FACTORY = Symbol("WebpackPatcher.isProxiedFactory"); export const SYM_ORIGINAL_FACTORY = Symbol("WebpackPatcher.originalFactory"); export const SYM_PATCHED_SOURCE = Symbol("WebpackPatcher.patchedSource"); export const SYM_PATCHED_BY = Symbol("WebpackPatcher.patchedBy"); @@ -79,7 +80,8 @@ const define: typeof Reflect.defineProperty = (target, p, attributes) => { }; // wreq.m is the Webpack object containing module factories. It is pre-populated with factories, and is also populated via webpackGlobal.push -// We use this setter to intercept when wreq.m is defined and apply patching to its factories. +// We use this setter to intercept when wreq.m is defined and setup our setters which decide whether we should patch these module factories +// and the Webpack instance where they are being defined. // Factories can be patched in two ways. Eagerly or lazily. // If we are patching eagerly, pre-populated factories are patched immediately and new factories are patched when set. @@ -97,7 +99,7 @@ define(Function.prototype, "m", { set(this: AnyWebpackRequire, originalModules: AnyWebpackRequire["m"]) { define(this, "m", { value: originalModules }); - // Ensure this is one of Discord main Webpack instances. + // Ensure this is likely one of Discord main Webpack instances. // We may catch Discord bundled libs, React Devtools or other extensions Webpack instances here. const { stack } = new Error(); if (!stack?.includes("http") || stack.match(/at \d+? \(/) || !String(this).includes("exports:{}")) { @@ -105,53 +107,90 @@ define(Function.prototype, "m", { } const fileName = stack.match(/\/assets\/(.+?\.js)/)?.[1]; - logger.info("Found Webpack module factories" + interpolateIfDefined` in ${fileName}`); - allWebpackInstances.add(this); - - // Define a setter for the ensureChunk property of WebpackRequire. Only the main Webpack (which is the only that includes chunk loading) has this property. - // So if the setter is called, this means we can initialize the internal references to WebpackRequire. - define(this, "e", { + // Define a setter for the bundlePath property of WebpackRequire. Only Webpack instances which include chunk loading functionality, + // like the main Discord Webpack, have this property. + // So if the setter is called with the Discord bundlePath, this means we should patch this instance and initialize the internal references to WebpackRequire. + define(this, "p", { enumerable: false, - set(this: WebpackRequire, ensureChunk: WebpackRequire["e"]) { - define(this, "e", { value: ensureChunk }); - clearTimeout(setterTimeout); + set(this: AnyWebpackRequire, bundlePath: NonNullable) { + define(this, "p", { value: bundlePath }); + clearTimeout(bundlePathTimeout); - logger.info("Main WebpackInstance found" + interpolateIfDefined` in ${fileName}` + ", initializing internal references to WebpackRequire"); - _initWebpack(this); + if (bundlePath !== "/assets/") { + return; + } + + patchThisInstance(); + + if (wreq == null && this.c != null) { + logger.info("Main WebpackInstance found" + interpolateIfDefined` in ${fileName}` + ", initializing internal references to WebpackRequire"); + _initWebpack(this as WebpackRequire); + } } }); - // setImmediate to clear this property setter if this is not the main Webpack. - // If this is the main Webpack, wreq.e will always be set before the timeout runs. - const setterTimeout = setTimeout(() => Reflect.deleteProperty(this, "e"), 0); - // Patch the pre-populated factories - for (const moduleId in originalModules) { - const originalFactory = originalModules[moduleId]; + // In the past, the sentry Webpack instance which we also wanted to patch used to rely on chunks being loaded before initting sentry. + // This Webpack instance did not include actual chunk loading, and only awaited for them to be loaded, which means it did not include the bundlePath property. + // To keep backwards compability, in case this is ever the case again, and keep patching this type of instance, we explicity patch instances which include wreq.O and not wreq.p. + // Since we cannot check what is the bundlePath of the instance to filter for the Discord bundlePath, we only patch it if wreq.p is not included, + // which means the instance relies on another instance which does chunk loading, and that makes it very likely to only target Discord Webpack instances like the old sentry. - if (updateExistingFactory(originalModules, moduleId, originalFactory, originalModules, true)) { - continue; + // Instead of patching when wreq.O is defined, wait for when wreq.O.j is defined, since that will be one of the last things to happen, + // which can assure wreq.p could have already been defined before. + define(this, "O", { + enumerable: false, + + set(this: AnyWebpackRequire, onChunksLoaded: NonNullable) { + define(this, "O", { value: onChunksLoaded }); + clearTimeout(onChunksLoadedTimeout); + + const wreq = this; + define(onChunksLoaded, "j", { + enumerable: false, + + set(this: NonNullable, j: NonNullable["j"]) { + define(this, "j", { value: j }); + + if (wreq.p == null) { + patchThisInstance(); + } + } + }); } - - notifyFactoryListeners(moduleId, originalFactory); - - const proxiedFactory = new Proxy(Settings.eagerPatches ? patchFactory(moduleId, originalFactory) : originalFactory, moduleFactoryHandler); - define(originalModules, moduleId, { value: proxiedFactory }); - } - - define(originalModules, Symbol.toStringTag, { - value: "ModuleFactories", - enumerable: false }); - const proxiedModuleFactories = new Proxy(originalModules, moduleFactoriesHandler); - /* - If Webpack ever decides to set module factories using the variable of the modules object directly, instead of wreq.m, switch the proxy to the prototype - Reflect.setPrototypeOf(originalModules, new Proxy(originalModules, moduleFactoriesHandler)); - */ + // If neither of these properties setters were triggered, delete them as they are not needed anymore. + const bundlePathTimeout = setTimeout(() => Reflect.deleteProperty(this, "p"), 0); + const onChunksLoadedTimeout = setTimeout(() => Reflect.deleteProperty(this, "O"), 0); - define(this, "m", { value: proxiedModuleFactories }); + /** + * Patch the current Webpack instance assigned to `this` context. + * This should only be called if this instance was later found to be one we need to patch. + */ + const patchThisInstance = () => { + logger.info("Found Webpack module factories" + interpolateIfDefined` in ${fileName}`); + allWebpackInstances.add(this); + + // Proxy (and maybe patch) pre-populated factories + for (const moduleId in originalModules) { + updateExistingOrProxyFactory(originalModules, moduleId, originalModules[moduleId], originalModules, true); + } + + define(originalModules, Symbol.toStringTag, { + value: "ModuleFactories", + enumerable: false + }); + + const proxiedModuleFactories = new Proxy(originalModules, moduleFactoriesHandler); + /* + If Webpack ever decides to set module factories using the variable of the modules object directly, instead of wreq.m, switch the proxy to the prototype + Reflect.setPrototypeOf(originalModules, new Proxy(originalModules, moduleFactoriesHandler)); + */ + + define(this, "m", { value: proxiedModuleFactories }); + }; } }); @@ -172,93 +211,102 @@ const moduleFactoriesHandler: ProxyHandler = { }, */ - set(target, p, newValue, receiver) { - if (updateExistingFactory(target, p, newValue, receiver)) { - return true; - } - - notifyFactoryListeners(p, newValue); - - const proxiedFactory = new Proxy(Settings.eagerPatches ? patchFactory(p, newValue) : newValue, moduleFactoryHandler); - return Reflect.set(target, p, proxiedFactory, receiver); - } + set: updateExistingOrProxyFactory }; // The proxy for patching lazily and/or running factories with our wrapper. const moduleFactoryHandler: ProxyHandler = { apply(target, thisArg: unknown, argArray: Parameters) { - // SAFETY: Factories have `name` as their key in the module factories object, and that is always their module id - const moduleId = target.name; - // SYM_ORIGINAL_FACTORY means the factory has already been patched if (target[SYM_ORIGINAL_FACTORY] != null) { - return runFactoryWithWrap(moduleId, target as PatchedModuleFactory, thisArg, argArray); + return runFactoryWithWrap(target as PatchedModuleFactory, thisArg, argArray); } + // SAFETY: Factories have `name` as their key in the module factories object, and that is always their module id + const moduleId: string = target.name; + const patchedFactory = patchFactory(moduleId, target); - return runFactoryWithWrap(moduleId, patchedFactory, thisArg, argArray); + return runFactoryWithWrap(patchedFactory, thisArg, argArray); }, get(target, p, receiver) { - if (target[SYM_ORIGINAL_FACTORY] != null && (p === SYM_PATCHED_SOURCE || p === SYM_PATCHED_BY)) { - return Reflect.get(target[SYM_ORIGINAL_FACTORY], p, target[SYM_ORIGINAL_FACTORY]); + if (p === SYM_IS_PROXIED_FACTORY) { + return true; } - const v = Reflect.get(target, p, receiver); + const originalFactory: AnyModuleFactory = target[SYM_ORIGINAL_FACTORY] ?? target; - // Make proxied factories `toString` return their original factory `toString` - if (p === "toString") { - return v.bind(target[SYM_ORIGINAL_FACTORY] ?? target); + // Redirect these properties to the original factory, including making `toString` return the original factory `toString` + if (p === "toString" || p === SYM_PATCHED_SOURCE || p === SYM_PATCHED_BY) { + const v = Reflect.get(originalFactory, p, originalFactory); + return p === "toString" ? v.bind(originalFactory) : v; } - return v; + return Reflect.get(target, p, receiver); } }; +function updateExistingOrProxyFactory(moduleFactories: AnyWebpackRequire["m"], moduleId: PropertyKey, newFactory: AnyModuleFactory, receiver: any, ignoreExistingInTarget = false) { + if (updateExistingFactory(moduleFactories, moduleId, newFactory, receiver, ignoreExistingInTarget)) { + return true; + } + + notifyFactoryListeners(moduleId, newFactory); + + const proxiedFactory = new Proxy(Settings.eagerPatches ? patchFactory(moduleId, newFactory) : newFactory, moduleFactoryHandler); + return Reflect.set(moduleFactories, moduleId, proxiedFactory, receiver); +} + /** - * Update a factory that exists in any Webpack instance with a new original factory. + * Update a duplicated factory that exists in any of the Webpack instances we track with a new original factory. * - * @param moduleFactoriesTarget The module factories where this new original factory is being set + * @param moduleFactories The module factories where this new original factory is being set * @param moduleId The id of the module * @param newFactory The new original factory - * @param receiver The receiver of the new factory - * @param ignoreExistingInTarget Whether to ignore checking if the factory already exists in the moduleFactoriesTarget - * @returns Whether the original factory was updated, or false if it doesn't exist in any Webpack instance + * @param receiver The receiver of the factory + * @param ignoreExistingInTarget Whether to ignore checking if the factory already exists in the moduleFactories where it is being set + * @returns Whether the original factory was updated, or false if it doesn't exist in any of the tracked Webpack instances */ -function updateExistingFactory(moduleFactoriesTarget: AnyWebpackRequire["m"], moduleId: PropertyKey, newFactory: AnyModuleFactory, receiver: any, ignoreExistingInTarget: boolean = false) { - let existingFactory: TypedPropertyDescriptor | undefined; +function updateExistingFactory(moduleFactories: AnyWebpackRequire["m"], moduleId: PropertyKey, newFactory: AnyModuleFactory, receiver: any, ignoreExistingInTarget) { + let existingFactory: AnyModuleFactory | undefined; let moduleFactoriesWithFactory: AnyWebpackRequire["m"] | undefined; for (const wreq of allWebpackInstances) { - if (ignoreExistingInTarget && wreq.m === moduleFactoriesTarget) { + if (ignoreExistingInTarget && wreq.m === moduleFactories) { continue; } if (Object.hasOwn(wreq.m, moduleId)) { - existingFactory = Reflect.getOwnPropertyDescriptor(wreq.m, moduleId); + existingFactory = wreq.m[moduleId]; moduleFactoriesWithFactory = wreq.m; break; } } if (existingFactory != null) { - // If existingFactory exists in any Webpack instance, it's either wrapped in our proxy, or it has already been required. - // In the case it is wrapped in our proxy, we need the Webpack instance with this new original factory to also have our proxy. - // So, define the descriptor of the existing factory on it. - if (moduleFactoriesWithFactory !== moduleFactoriesTarget) { - Reflect.defineProperty(receiver, moduleId, existingFactory); + // Sanity check to make sure these factories are equal + if (String(newFactory) !== String(existingFactory)) { + return false; } - const existingFactoryValue = moduleFactoriesWithFactory![moduleId]; + // If existingFactory exists in any of the Webpack instances we track, it's either wrapped in our proxy, or it has already been required. + // In the case it is wrapped in our proxy, and the instance we are setting does not already have it, we need to make sure the instance contains our proxy too. + if (moduleFactoriesWithFactory !== moduleFactories && existingFactory[SYM_IS_PROXIED_FACTORY]) { + Reflect.set(moduleFactories, moduleId, existingFactory, receiver); + } + // Else, if it is not wrapped in our proxy, set this new original factory in all the instances + else { + defineInWebpackInstances(moduleId, newFactory); + } - // Update with the new original factory, if it does have a current original factory - if (existingFactoryValue[SYM_ORIGINAL_FACTORY] != null) { - existingFactoryValue[SYM_ORIGINAL_FACTORY] = newFactory; + // Update existingFactory with the new original, if it does have a current original factory + if (existingFactory[SYM_ORIGINAL_FACTORY] != null) { + existingFactory[SYM_ORIGINAL_FACTORY] = newFactory; } // Persist patched source and patched by in the new original factory if (IS_DEV) { - newFactory[SYM_PATCHED_SOURCE] = existingFactoryValue[SYM_PATCHED_SOURCE]; - newFactory[SYM_PATCHED_BY] = existingFactoryValue[SYM_PATCHED_BY]; + newFactory[SYM_PATCHED_SOURCE] = existingFactory[SYM_PATCHED_SOURCE]; + newFactory[SYM_PATCHED_BY] = existingFactory[SYM_PATCHED_BY]; } return true; @@ -267,6 +315,18 @@ function updateExistingFactory(moduleFactoriesTarget: AnyWebpackRequire["m"], mo return false; } +/** + * Define a module factory in all the Webpack instances we track. + * + * @param moduleId The id of the module + * @param factory The factory + */ +function defineInWebpackInstances(moduleId: PropertyKey, factory: AnyModuleFactory) { + for (const wreq of allWebpackInstances) { + define(wreq.m, moduleId, { value: factory }); + } +} + /** * Notify all factory listeners. * @@ -286,12 +346,11 @@ function notifyFactoryListeners(moduleId: PropertyKey, factory: AnyModuleFactory /** * Run a (possibly) patched module factory with a wrapper which notifies our listeners. * - * @param moduleId The id of the module * @param patchedFactory The (possibly) patched module factory * @param thisArg The `value` of the call to the factory * @param argArray The arguments of the call to the factory */ -function runFactoryWithWrap(moduleId: PropertyKey, patchedFactory: PatchedModuleFactory, thisArg: unknown, argArray: Parameters) { +function runFactoryWithWrap(patchedFactory: PatchedModuleFactory, thisArg: unknown, argArray: Parameters) { const originalFactory = patchedFactory[SYM_ORIGINAL_FACTORY]; if (patchedFactory === originalFactory) { @@ -299,25 +358,23 @@ function runFactoryWithWrap(moduleId: PropertyKey, patchedFactory: PatchedModule delete patchedFactory[SYM_ORIGINAL_FACTORY]; } - // Restore the original factory in all the module factories objects, discarding our proxy and allowing it to be garbage collected - for (const wreq of allWebpackInstances) { - define(wreq.m, moduleId, { value: originalFactory }); - } - let [module, exports, require] = argArray; + // Restore the original factory in all the module factories objects, discarding our proxy and allowing it to be garbage collected + defineInWebpackInstances(module.id, originalFactory); + if (wreq == null) { if (!wreqFallbackApplied) { wreqFallbackApplied = true; // Make sure the require argument is actually the WebpackRequire function - if (typeof require === "function" && require.m != null) { + if (typeof require === "function" && require.m != null && require.c != null) { const { stack } = new Error(); const webpackInstanceFileName = stack?.match(/\/assets\/(.+?\.js)/)?.[1]; logger.warn( "WebpackRequire was not initialized, falling back to WebpackRequire passed to the first called wrapped module factory (" + - `id: ${String(moduleId)}` + interpolateIfDefined`, WebpackInstance origin: ${webpackInstanceFileName}` + + `id: ${String(module.id)}` + interpolateIfDefined`, WebpackInstance origin: ${webpackInstanceFileName}` + ")" ); @@ -355,8 +412,8 @@ function runFactoryWithWrap(moduleId: PropertyKey, patchedFactory: PatchedModule if (shouldIgnoreModule) { if (require.c != null) { - Object.defineProperty(require.c, moduleId, { - value: require.c[moduleId], + Object.defineProperty(require.c, module.id, { + value: require.c[module.id], enumerable: false, configurable: true, writable: true @@ -369,7 +426,7 @@ function runFactoryWithWrap(moduleId: PropertyKey, patchedFactory: PatchedModule for (const callback of moduleListeners) { try { - callback(exports, moduleId); + callback(exports, module.id); } catch (err) { logger.error("Error in Webpack module listener:\n", err, callback); } @@ -379,7 +436,7 @@ function runFactoryWithWrap(moduleId: PropertyKey, patchedFactory: PatchedModule try { if (filter(exports)) { waitForSubscriptions.delete(filter); - callback(exports, moduleId); + callback(exports, module.id); continue; } @@ -392,7 +449,7 @@ function runFactoryWithWrap(moduleId: PropertyKey, patchedFactory: PatchedModule if (exportValue != null && filter(exportValue)) { waitForSubscriptions.delete(filter); - callback(exportValue, moduleId); + callback(exportValue, module.id); break; } } diff --git a/src/webpack/webpack.ts b/src/webpack/webpack.ts index 09c04a2a1..fe939d9b9 100644 --- a/src/webpack/webpack.ts +++ b/src/webpack/webpack.ts @@ -97,10 +97,6 @@ export const moduleListeners = new Set(); export const factoryListeners = new Set(); export function _initWebpack(webpackRequire: WebpackRequire) { - if (webpackRequire.c == null) { - return; - } - wreq = webpackRequire; cache = webpackRequire.c;