this repo has no description
at v1.2.3 14 kB view raw
1import { 2 PatchReplace, 3 PatchReplaceType, 4 ExplicitExtensionDependency, 5 IdentifiedPatch, 6 IdentifiedWebpackModule, 7 WebpackJsonp, 8 WebpackJsonpEntry, 9 WebpackModuleFunc, 10 WebpackRequireType 11} from "@moonlight-mod/types"; 12import Logger from "./util/logger"; 13import calculateDependencies, { Dependency } from "./util/dependency"; 14import WebpackRequire from "@moonlight-mod/types/discord/require"; 15import { EventType } from "@moonlight-mod/types/core/event"; 16 17const logger = new Logger("core/patch"); 18 19// Can't be Set because we need splice 20const patches: IdentifiedPatch[] = []; 21let webpackModules: Set<IdentifiedWebpackModule> = new Set(); 22let webpackRequire: WebpackRequireType | null = null; 23 24const moduleLoadSubscriptions: Map<string, ((moduleId: string) => void)[]> = new Map(); 25 26export function registerPatch(patch: IdentifiedPatch) { 27 patches.push(patch); 28 moonlight.unpatched.add(patch); 29} 30 31export function registerWebpackModule(wp: IdentifiedWebpackModule) { 32 webpackModules.add(wp); 33 if (wp.dependencies?.length) { 34 moonlight.pendingModules.add(wp); 35 } 36} 37 38export function onModuleLoad(module: string | string[], callback: (moduleId: string) => void): void { 39 let moduleIds = module; 40 41 if (typeof module === "string") { 42 moduleIds = [module]; 43 } 44 45 for (const moduleId of moduleIds) { 46 if (moduleLoadSubscriptions.has(moduleId)) { 47 moduleLoadSubscriptions.get(moduleId)?.push(callback); 48 } else { 49 moduleLoadSubscriptions.set(moduleId, [callback]); 50 } 51 } 52} 53 54/* 55 The patching system functions by matching a string or regex against the 56 .toString()'d copy of a Webpack module. When a patch happens, we reconstruct 57 the module with the patched source and replace it, wrapping it in the process. 58 59 We keep track of what modules we've patched (and their original sources), both 60 so we don't wrap them twice and so we can debug what extensions are patching 61 what Webpack modules. 62*/ 63const moduleCache: Record<string, string> = {}; 64const patched: Record<string, Array<string>> = {}; 65 66function patchModules(entry: WebpackJsonpEntry[1]) { 67 function patchModule(id: string, patchId: string, replaced: string) { 68 // Store what extensions patched what modules for easier debugging 69 patched[id] = patched[id] || []; 70 patched[id].push(patchId); 71 72 // Webpack module arguments are minified, so we replace them with consistent names 73 // We have to wrap it so things don't break, though 74 const patchedStr = patched[id].sort().join(", "); 75 76 const wrapped = 77 `(${replaced}).apply(this, arguments)\n` + 78 `// Patched by moonlight: ${patchedStr}\n` + 79 `//# sourceURL=Webpack-Module-${id}`; 80 81 try { 82 const func = new Function("module", "exports", "require", wrapped) as WebpackModuleFunc; 83 entry[id] = func; 84 entry[id].__moonlight = true; 85 return true; 86 } catch (e) { 87 logger.warn("Error constructing function for patch", patchId, e); 88 patched[id].pop(); 89 return false; 90 } 91 } 92 93 // Populate the module cache 94 for (const [id, func] of Object.entries(entry)) { 95 if (!Object.hasOwn(moduleCache, id) && func.__moonlight !== true) { 96 moduleCache[id] = func.toString().replace(/\n/g, ""); 97 moonlight.moonmap.parseScript(id, moduleCache[id]); 98 } 99 } 100 101 for (const [id, func] of Object.entries(entry)) { 102 if (func.__moonlight === true) continue; 103 let moduleString = moduleCache[id]; 104 105 for (let i = 0; i < patches.length; i++) { 106 const patch = patches[i]; 107 if (patch.prerequisite != null && !patch.prerequisite()) { 108 continue; 109 } 110 111 if (patch.find instanceof RegExp && patch.find.global) { 112 // Reset state because global regexes are stateful for some reason 113 patch.find.lastIndex = 0; 114 } 115 116 // indexOf is faster than includes by 0.25% lmao 117 const match = 118 typeof patch.find === "string" ? moduleString.indexOf(patch.find) !== -1 : patch.find.test(moduleString); 119 120 // Global regexes apply to all modules 121 const shouldRemove = typeof patch.find === "string" ? true : !patch.find.global; 122 123 if (match) { 124 // We ensured all arrays get turned into normal PatchReplace objects on register 125 const replace = patch.replace as PatchReplace; 126 127 if (replace.type === undefined || replace.type === PatchReplaceType.Normal) { 128 // Add support for \i to match rspack's minified names 129 if (typeof replace.match !== "string") { 130 replace.match = new RegExp(replace.match.source.replace(/\\i/g, "[A-Za-z_$][\\w$]*"), replace.match.flags); 131 } 132 // tsc fails to detect the overloads for this, so I'll just do this 133 // Verbose, but it works 134 let replaced; 135 if (typeof replace.replacement === "string") { 136 replaced = moduleString.replace(replace.match, replace.replacement); 137 } else { 138 replaced = moduleString.replace(replace.match, replace.replacement); 139 } 140 141 if (replaced === moduleString) { 142 logger.warn("Patch replacement failed", id, patch); 143 continue; 144 } 145 146 if (patchModule(id, `${patch.ext}#${patch.id}`, replaced)) { 147 moduleString = replaced; 148 } 149 } else if (replace.type === PatchReplaceType.Module) { 150 // Directly replace the module with a new one 151 const newModule = replace.replacement(moduleString); 152 entry[id] = newModule; 153 entry[id].__moonlight = true; 154 moduleString = newModule.toString().replace(/\n/g, "") + `//# sourceURL=Webpack-Module-${id}`; 155 } 156 157 moonlight.unpatched.delete(patch); 158 159 if (shouldRemove) { 160 patches.splice(i--, 1); 161 } 162 } 163 } 164 165 moduleCache[id] = moduleString; 166 167 try { 168 const parsed = moonlight.lunast.parseScript(id, moduleString); 169 if (parsed != null) { 170 for (const [parsedId, parsedScript] of Object.entries(parsed)) { 171 if (patchModule(parsedId, "lunast", parsedScript)) { 172 moduleCache[parsedId] = parsedScript; 173 } 174 } 175 } 176 } catch (e) { 177 logger.error("Failed to parse script for LunAST", id, e); 178 } 179 180 if (moonlightNode.config.patchAll === true) { 181 if ((typeof id !== "string" || !id.includes("_")) && !entry[id].__moonlight) { 182 const wrapped = `(${moduleCache[id]}).apply(this, arguments)\n` + `//# sourceURL=Webpack-Module-${id}`; 183 entry[id] = new Function("module", "exports", "require", wrapped) as WebpackModuleFunc; 184 entry[id].__moonlight = true; 185 } 186 } 187 188 // Dispatch module load event subscription 189 if (moduleLoadSubscriptions.has(id)) { 190 const loadCallbacks = moduleLoadSubscriptions.get(id)!; 191 for (const callback of loadCallbacks) { 192 try { 193 callback(id); 194 } catch (e) { 195 logger.error("Error in module load subscription: " + e); 196 } 197 } 198 moduleLoadSubscriptions.delete(id); 199 } 200 201 moduleCache[id] = moduleString; 202 } 203} 204 205/* 206 Similar to patching, we also want to inject our own custom Webpack modules 207 into Discord's Webpack instance. We abuse pollution on the push function to 208 mark when we've completed it already. 209*/ 210let chunkId = Number.MAX_SAFE_INTEGER; 211 212function depToString(x: ExplicitExtensionDependency) { 213 return x.ext != null ? `${x.ext}_${x.id}` : x.id; 214} 215 216function handleModuleDependencies() { 217 const modules = Array.from(webpackModules.values()); 218 219 const dependencies: Dependency<string, IdentifiedWebpackModule>[] = modules.map((wp) => { 220 return { 221 id: depToString(wp), 222 data: wp 223 }; 224 }); 225 226 const [sorted, _] = calculateDependencies(dependencies, { 227 fetchDep: (id) => { 228 return modules.find((x) => id === depToString(x)) ?? null; 229 }, 230 231 getDeps: (item) => { 232 const deps = item.data?.dependencies ?? []; 233 return ( 234 deps.filter( 235 (dep) => !(dep instanceof RegExp || typeof dep === "string") && dep.ext != null 236 ) as ExplicitExtensionDependency[] 237 ).map(depToString); 238 } 239 }); 240 241 webpackModules = new Set(sorted.map((x) => x.data)); 242} 243 244const injectedWpModules: IdentifiedWebpackModule[] = []; 245function injectModules(entry: WebpackJsonpEntry[1]) { 246 const modules: Record<string, WebpackModuleFunc> = {}; 247 const entrypoints: string[] = []; 248 let inject = false; 249 250 for (const [_modId, mod] of Object.entries(entry)) { 251 const modStr = mod.toString(); 252 for (const wpModule of webpackModules) { 253 const id = depToString(wpModule); 254 if (wpModule.dependencies) { 255 const deps = new Set(wpModule.dependencies); 256 257 // FIXME: This dependency resolution might fail if the things we want 258 // got injected earlier. If weird dependencies fail, this is likely why. 259 if (deps.size) { 260 for (const dep of deps) { 261 if (typeof dep === "string") { 262 if (modStr.includes(dep)) deps.delete(dep); 263 } else if (dep instanceof RegExp) { 264 if (dep.test(modStr)) deps.delete(dep); 265 } else if ( 266 dep.ext != null 267 ? injectedWpModules.find((x) => x.ext === dep.ext && x.id === dep.id) 268 : injectedWpModules.find((x) => x.id === dep.id) 269 ) { 270 deps.delete(dep); 271 } 272 } 273 274 if (deps.size !== 0) { 275 wpModule.dependencies = Array.from(deps); 276 continue; 277 } 278 279 wpModule.dependencies = Array.from(deps); 280 } 281 } 282 283 webpackModules.delete(wpModule); 284 moonlight.pendingModules.delete(wpModule); 285 injectedWpModules.push(wpModule); 286 287 inject = true; 288 289 if (wpModule.run) { 290 modules[id] = wpModule.run; 291 wpModule.run.__moonlight = true; 292 } 293 if (wpModule.entrypoint) entrypoints.push(id); 294 } 295 if (!webpackModules.size) break; 296 } 297 298 for (const [name, func] of Object.entries(moonlight.moonmap.getWebpackModules("window.moonlight.moonmap"))) { 299 injectedWpModules.push({ id: name, run: func }); 300 modules[name] = func; 301 inject = true; 302 } 303 304 if (webpackRequire != null) { 305 for (const id of moonlight.moonmap.getLazyModules()) { 306 webpackRequire.e(id); 307 } 308 } 309 310 if (inject) { 311 logger.debug("Injecting modules:", modules, entrypoints); 312 window.webpackChunkdiscord_app.push([ 313 [--chunkId], 314 modules, 315 (require: typeof WebpackRequire) => entrypoints.map(require) 316 ]); 317 } 318} 319 320declare global { 321 interface Window { 322 webpackChunkdiscord_app: WebpackJsonp; 323 } 324} 325 326function moduleSourceGetter(id: string) { 327 return moduleCache[id] ?? null; 328} 329 330/* 331 Webpack modules are bundled into an array of arrays that hold each function. 332 Since we run code before Discord, we can create our own Webpack array and 333 hijack the .push function on it. 334 335 From there, we iterate over the object (mapping IDs to functions) and patch 336 them accordingly. 337*/ 338export async function installWebpackPatcher() { 339 await handleModuleDependencies(); 340 341 moonlight.lunast.setModuleSourceGetter(moduleSourceGetter); 342 moonlight.moonmap.setModuleSourceGetter(moduleSourceGetter); 343 344 const wpRequireFetcher: WebpackModuleFunc = (module, exports, require) => { 345 webpackRequire = require; 346 }; 347 wpRequireFetcher.__moonlight = true; 348 webpackModules.add({ 349 id: "moonlight", 350 entrypoint: true, 351 run: wpRequireFetcher 352 }); 353 354 let realWebpackJsonp: WebpackJsonp | null = null; 355 Object.defineProperty(window, "webpackChunkdiscord_app", { 356 set: (jsonp: WebpackJsonp) => { 357 // Don't let Sentry mess with Webpack 358 const stack = new Error().stack!; 359 if (stack.includes("sentry.")) return; 360 361 realWebpackJsonp = jsonp; 362 const realPush = jsonp.push; 363 if (jsonp.push.__moonlight !== true) { 364 jsonp.push = (items) => { 365 moonlight.events.dispatchEvent(EventType.ChunkLoad, { 366 chunkId: items[0], 367 modules: items[1], 368 require: items[2] 369 }); 370 371 patchModules(items[1]); 372 373 try { 374 const res = realPush.apply(realWebpackJsonp, [items]); 375 if (!realPush.__moonlight) { 376 logger.trace("Injecting Webpack modules", items[1]); 377 injectModules(items[1]); 378 } 379 380 return res; 381 } catch (err) { 382 logger.error("Failed to inject Webpack modules:", err); 383 return 0; 384 } 385 }; 386 387 jsonp.push.bind = (thisArg: any, ...args: any[]) => { 388 return realPush.bind(thisArg, ...args); 389 }; 390 391 jsonp.push.__moonlight = true; 392 if (!realPush.__moonlight) { 393 logger.debug("Injecting Webpack modules with empty entry"); 394 // Inject an empty entry to cause iteration to happen once 395 // Kind of a dirty hack but /shrug 396 injectModules({ deez: () => {} }); 397 } 398 } 399 }, 400 401 get: () => { 402 const stack = new Error().stack!; 403 if (stack.includes("sentry.")) return []; 404 return realWebpackJsonp; 405 } 406 }); 407 408 Object.defineProperty(Function.prototype, "m", { 409 configurable: true, 410 set(modules: any) { 411 const { stack } = new Error(); 412 if (stack!.includes("/assets/") && !Array.isArray(modules)) { 413 moonlight.events.dispatchEvent(EventType.ChunkLoad, { 414 modules: modules 415 }); 416 patchModules(modules); 417 418 if (!window.webpackChunkdiscord_app) window.webpackChunkdiscord_app = []; 419 injectModules(modules); 420 } 421 422 Object.defineProperty(this, "m", { 423 value: modules, 424 configurable: true, 425 enumerable: true, 426 writable: true 427 }); 428 } 429 }); 430}