From 8791f3a1686074261971e2e62237152b3dd24a2e Mon Sep 17 00:00:00 2001 From: adryd Date: Tue, 5 Dec 2023 22:40:56 -0500 Subject: [PATCH] rename wpTools (the require helper) to spacepackLite. --- README.md | 45 +++++++++++----- TODO.md | 16 ++---- src/Patcher.js | 36 ++++++++++--- src/index.js | 14 +++-- src/injectEverywhere.js | 56 -------------------- src/spacepackEverywhere.js | 58 ++++++++++++++++++++ src/{wpTools.js => spacepackLite.js} | 79 +++++++++++++++++++++++++--- src/validate.js | 17 ------ userscriptTemplate.js | 58 +++++++++++++++----- 9 files changed, 251 insertions(+), 128 deletions(-) delete mode 100644 src/injectEverywhere.js create mode 100644 src/spacepackEverywhere.js rename src/{wpTools.js => spacepackLite.js} (68%) delete mode 100644 src/validate.js diff --git a/README.md b/README.md index 3db1f1f..2f9bd9f 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,47 @@ -Hi, while this "Works" right now, its not well documented; and a lot is subject to change. +# webpackTools + +⚠️ All (including the name of the project) is subject to change, I still haven't settled entirely on the structure of everything ⚠️ + +A userscript for reverse engineering, debugging, and modifying sites using webpack. ## Installing The runtime with an example config for twitter can be installed from https://moonlight-mod.github.io/webpackTools/webpackTools.user.js -## Updating -To update to the latest webpackTools runtime while maintaining your existing config, edit your userscript and replace the string after `const runtime = ` with https://moonlight-mod.github.io/webpackTools/webpackTools.runtime.json. +## Usage + +An example config exists in the userscript with all fields documented. + +### Spacepack Everywhere + +By enabling `spacepackEverywhere` in your config, spacepack will automatically be injected into any webpack instances found. + +spacepack will then be accessible from `window.spacepack` or window.spacepack_ if there happens to be multiple webpack instances -## How to determine if something is Webpack 4 or Webpack 5 -TODO: actually explain this +This version of spacepack is *slightly* different to the one in moonlight, however usage should be nearly the same. `__namedRequire`, `chunkObject`, and `name` are new to this version however. -Webpack 5 typically names its jsonp array webpackChunk\ -Webpack 4 typically names its jsonp array webpackJsonp +TODO: Either write own docs about spacepack or link to moonlight docs -to double check you can find the first array with 3 entries -if the 3rd entry is an array with strings, its webpack 4 -if the 3rd entry is a function it's webpack 5 +### Full patching support -rspack seems to compile to webpack 5 runtime +TODO + +## Updating +To update to the latest webpackTools runtime while maintaining your existing config, edit your userscript and replace the string after `const runtime = ` with https://moonlight-mod.github.io/webpackTools/webpackTools.runtime.json. ## Caveats Some sites, namely Discord, will start multiple webpack runtimes running on the same webpackChunk object. Duplicate runtimes can be found in `window.wpTools.runtimes`. Injected modules will run multiple times, one for each runtime. +Some sites don't expose their export cache, making `spacepack.findModulesByExports` unusable and `spacepack.exports` undefined. + ## Credits -A lot of this is based on research by [Mary](https://github.com/mstrodl) and [Cynthia](https://github.com/cynosphere) (HH3), and [Twilight Sparkle](https://github.com/twilight-sparkle-irl/) (webcrack, crispr) \ No newline at end of file + +A lot of this is based on research by [Mary](https://github.com/mstrodl) and [Cynthia](https://github.com/cynosphere) (HH3), and [Twilight Sparkle](https://github.com/twilight-sparkle-irl/) (webcrack, crispr, Endpwn) + +## Terminology + +We use our own names for a lot of things as we feel they make more sense, however here's some mappings to webpack's "official" namings and it can be slightly confusing + + - modules, or module cache: `moduleFactories` + - exports, or export cache: `moduleCache` + - chunkObject (typically webpackJsonp or webpackChunk): `chunkCallback` or `chunkLoadingGlobal` diff --git a/TODO.md b/TODO.md index 88e663a..d5e33ab 100644 --- a/TODO.md +++ b/TODO.md @@ -1,13 +1,7 @@ # TODO -- [x] injecting wpTools module on all webpack sites (like magicrequire everywhere). with webpack 3 support -- [ ] webpack 3 support for everything? -- [ ] glob matching for site names (for examle: \*.twitter.com) in userscript part -- [x] config validation with descriptive errors -- [x] rework configs -- [ ] check if Function.prototype.toString() is faster than checking for \_\_wpt_funcStr -- [ ] wpTools/findByExports: recurse into objects when searching? getters could pose a problem however -- [ ] add obfuscated code helpers and swc helpers in wpTools -- [ ] find a better way to do userscripts, ideally not fetching remotely -- [ ] log errors while patching (parse, patches that dont fire, etc) -- [ ] actually good documentation and tutorials \ No newline at end of file +- [ ] Support for Webpack versions 3 or lower +- [ ] Glob matching for site names (for examle: \*.twitter.com) in userscript part +- [ ] Benchmark: Check if Function.prototype.toString() is faster than checking for \_\_wpt_funcStr +- [ ] Log errors while patching (parse, patches that dont fire, etc) +- [ ] Possibly create a browser extension with a devtools support \ No newline at end of file diff --git a/src/Patcher.js b/src/Patcher.js index 25104b6..8bb47df 100644 --- a/src/Patcher.js +++ b/src/Patcher.js @@ -1,6 +1,23 @@ import matchModule from "./matchModule"; -import { validateProperty } from "./validate"; -import { getWpToolsFunc } from "./wpTools"; +import { getWpToolsFunc } from "./spacepackLite"; + +class ConfigValidationError extends Error {} + +function validateProperty(name, object, key, required, validationCallback) { + if (!Object.prototype.hasOwnProperty.call(object, [key])) { + if (required) { + throw new ConfigValidationError(`Required property not found, missing ${key} in ${name}`); + } else { + return; + } + } else { + if (!validationCallback(object[key])) { + throw new ConfigValidationError( + `Failed to validate ${key} in ${name}. The following check failed: \n${validationCallback.toString()}`, + ); + } + } +} export default class Patcher { constructor(config) { @@ -8,7 +25,7 @@ export default class Patcher { this.name = config.name; this.chunkObject = config.chunkObject; this.webpackVersion = config.webpackVersion.toString(); - this.inspectAll = config.inspectAll; + this.patchAll = config.patchAll; this.modules = new Set(config.modules ?? []); for (const module of this.modules) { @@ -38,9 +55,9 @@ export default class Patcher { } } - if (config.injectWpTools !== false) { + if (config.injectSpacepack !== false) { this.modulesToInject.add({ - name: "wpTools", + name: "spacepack", // This is sorta a scope hack. // If we rewrap this function, it will lose its scope (in this case the match module import and the chunk object name) run: getWpToolsFunc(this.chunkObject), @@ -119,7 +136,7 @@ export default class Patcher { funcStr = funcStr.replace(patch.replace.match, patch.replace.replacement); } - if (matchingPatches.length > 0 || this.inspectAll) { + if (matchingPatches.length > 0 || this.patchAll) { let debugString = ""; if (matchingPatches.length > 0) { debugString += "Patched by: " + matchingPatches.map((patch) => patch.name).join(", "); @@ -226,7 +243,7 @@ export default class Patcher { return ["4", "5"].includes(value.toString()); }); - validateProperty(`siteConfigs[${name}]`, config, "inspectAll", false, (value) => { + validateProperty(`siteConfigs[${name}]`, config, "patchAll", false, (value) => { return typeof value === "boolean"; }); @@ -238,7 +255,7 @@ export default class Patcher { return value instanceof Array; }); - validateProperty(`siteConfigs[${name}]`, config, "injectWpTools", false, (value) => { + validateProperty(`siteConfigs[${name}]`, config, "injectSpacepack", false, (value) => { return typeof value === "boolean"; }); } @@ -303,6 +320,9 @@ export default class Patcher { validateProperty(`siteConfigs[${this.name}].modules[${name}]`, config, "entry", false, (value) => { return typeof value === "boolean"; }); + if (config.entry === undefined) { + config.entry = false; + } // Possible future thing // validateProperty(`siteConfigs[${this.name}].modules[${name}]`, config, "rewrap", false, (value) => { diff --git a/src/index.js b/src/index.js index efd911b..ff3db5e 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,5 @@ import Patcher from "./Patcher"; -import { injectEverywhere } from "./injectEverywhere"; +import { spacepackEverywhere } from "./spacepackEverywhere"; export const globalConfig = window.__webpackTools_config; delete window.__webpackTools_config; @@ -15,16 +15,20 @@ for (let siteConfig of globalConfig.siteConfigs) { window.wpTools = { globalConfig, activeSiteConfigs: siteConfigs, - + spacepackEverywhereDetect: () => { + spacepackEverywhere(globalConfig.spacepackEverywhere); + }, + runtimes: {}, }; -// todo: magicrequire everywhere impl if (siteConfigs.size > 0) { for (const siteConfig of siteConfigs) { const patcher = new Patcher(siteConfig); patcher.run(); } -} else if (globalConfig.wpToolsEverywhere) { - window.addEventListener("load", injectEverywhere); +} else if (globalConfig?.spacepackEverywhere?.enabled !== false) { + window.addEventListener("load", () => { + spacepackEverywhere(globalConfig.spacepackEverywhere); + }); } diff --git a/src/injectEverywhere.js b/src/injectEverywhere.js deleted file mode 100644 index d904c6b..0000000 --- a/src/injectEverywhere.js +++ /dev/null @@ -1,56 +0,0 @@ -import { getWpToolsFunc } from "./wpTools"; - -function getVersion(chunkObject) { - if (chunkObject instanceof Array) { - return "modern"; - } else { - return "legacy"; - } -} - -function injectWpTools(chunkObjectName) { - const chunkObject = window[chunkObjectName]; - - if (chunkObject.__wpt_everywhere_injected) { - return; - } - const version = getVersion(chunkObject); - - console.log("[wpTools] Detected " + chunkObjectName + " using webpack " + version); - - switch (version) { - case "modern": - // Gross Hack to support both webpack 4, webpack 5 and cursed rspack discord shenanagains - var load = function (webpackRequire) { - webpackRequire("wpTools"); - }; - load[0] = ["wpTools"]; - load[Symbol.iterator] = function () { - return { - read: false, - next() { - if (!this.read) { - this.read = true; - return { done: false, value: 0 }; - } else { - return { done: true }; - } - }, - }; - }; - chunkObject.__wpt_everywhere_injected = true; - chunkObject.push([["wpTools"], { wpTools: getWpToolsFunc(chunkObjectName, true) }, load]); - break; - } -} - -export function injectEverywhere() { - for (const key of Object.getOwnPropertyNames(window)) { - if ( - (key.includes("webpackJsonp") || key.includes("webpackChunk") || key.includes("__LOADABLE_LOADED_CHUNKS__")) && - !key.startsWith("wpTools") - ) { - injectWpTools(key); - } - } -} diff --git a/src/spacepackEverywhere.js b/src/spacepackEverywhere.js new file mode 100644 index 0000000..f27cc84 --- /dev/null +++ b/src/spacepackEverywhere.js @@ -0,0 +1,58 @@ +import { getWpToolsFunc } from "./spacepackLite"; + +function getWebpackVersion(chunkObject) { + if (chunkObject instanceof Array) { + return "modern"; + } else { + return "legacy"; + } +} + +// Gross Hack to support both webpack 4, webpack 5 +const onChunkLoaded = function (webpackRequire) { + webpackRequire("spacepack"); +}; +onChunkLoaded[0] = ["spacepack"]; +onChunkLoaded[Symbol.iterator] = function () { + return { + read: false, + next() { + if (!this.read) { + this.read = true; + return { done: false, value: 0 }; + } else { + return { done: true }; + } + }, + }; +}; + +function pushSpacepack(chunkObjectName) { + const chunkObject = window[chunkObjectName]; + if (chunkObject.__spacepack_everywhere_injected) { + return; + } + const version = getWebpackVersion(chunkObject); + console.log("[wpTools] Detected " + chunkObjectName + " using webpack " + version); + switch (version) { + case "modern": + chunkObject.__spacepack_everywhere_injected = true; + chunkObject.push([["spacepack"], { spacepack: getWpToolsFunc(chunkObjectName, true) }, onChunkLoaded]); + break; + } +} + +export function spacepackEverywhere(config) { + if (config?.ignoreSites?.includes(window.location.host)) { + return; + } + + for (const key of Object.getOwnPropertyNames(window)) { + if ( + (key.includes("webpackJsonp") || key.includes("webpackChunk") || key.includes("__LOADABLE_LOADED_CHUNKS__")) && + !key.startsWith("spacepack") && !config?.ignoreChunkObjects?.includes(key) + ) { + pushSpacepack(key); + } + } +} diff --git a/src/wpTools.js b/src/spacepackLite.js similarity index 68% rename from src/wpTools.js rename to src/spacepackLite.js index c6cb5ad..c6abdc4 100644 --- a/src/wpTools.js +++ b/src/spacepackLite.js @@ -70,7 +70,7 @@ function getNamedRequire(webpackRequire) { export function getWpToolsFunc(chunkObject, logSuccess = false) { function wpTools(module, exports, webpackRequire) { if (logSuccess) { - console.log("[wpTools] wpTools loaded in " + chunkObject); + console.log("[wpTools] spacepack loaded in " + chunkObject); } // https://github.com/webpack/webpack/blob/main/lib/RuntimeGlobals.js @@ -120,7 +120,65 @@ export function getWpToolsFunc(chunkObject, logSuccess = false) { }); } - function inspectModule(moduleId) { + function findObjectFromKey(exports, key) { + let subKey; + if (key.indexOf(".") > -1) { + const splitKey = key.split("."); + key = splitKey[0]; + subKey = splitKey[1]; + } + for (const exportKey in exports) { + const obj = exports[exportKey]; + if (obj && obj[key] !== undefined) { + if (subKey) { + if (obj[key][subKey]) return obj; + } else { + return obj; + } + } + } + return null; + } + + function findObjectFromValue(exports, value) { + for (const exportKey in exports) { + const obj = exports[exportKey]; + // eslint-disable-next-line eqeqeq + if (obj == value) return obj; + for (const subKey in obj) { + // eslint-disable-next-line eqeqeq + if (obj && obj[subKey] == value) { + return obj; + } + } + } + return null; + } + + function findObjectFromKeyValuePair(exports, key, value) { + for (const exportKey in exports) { + const obj = exports[exportKey]; + // eslint-disable-next-line eqeqeq + if (obj && obj[key] == value) { + return obj; + } + } + return null; + } + + function findFunctionByStrings(exports, ...strings) { + return ( + Object.entries(exports).filter( + ([index, func]) => + typeof func === "function" && + !strings.some( + (query) => !(query instanceof RegExp ? func.toString().match(query) : func.toString().includes(query)), + ), + )?.[0]?.[1] ?? null + ); + } + + function inspect(moduleId) { /* TODO: rewrap modules if not patched. * This used to isolate modules like wrapping them in the patcher stage did, * however this seems to have broken in newer browsers */ @@ -132,12 +190,20 @@ export function getWpToolsFunc(chunkObject, logSuccess = false) { exports.default = { require: webpackRequire, - named: getNamedRequire(webpackRequire), - chunkCallback: window[chunkObject], + modules: webpackRequire.m, + cache: webpackRequire.c, + + __namedRequire: getNamedRequire(webpackRequire), + chunkObject: window[chunkObject], + name: chunkObject, findModulesByCode, findModulesByExports, - inspectModule, + findObjectFromKey, + findObjectFromKeyValuePair, + findObjectFromValue, + findFunctionByStrings, + inspect, }); const runtimesRegistry = window.wpTools.runtimes; @@ -160,7 +226,8 @@ export function getWpToolsFunc(chunkObject, logSuccess = false) { runtimesRegistry[chunkObject] = exportedRequire; } runtimesRegistry[chunkObject] = exportedRequire; - window["wpTools_" + chunkObject] = exportedRequire; + window["spacepack_" + chunkObject] = exportedRequire; + window["spacepack"] = exportedRequire; } // Mark as processed as to not loose scope if somehow passed to Patcher._patchModules() diff --git a/src/validate.js b/src/validate.js deleted file mode 100644 index 70cd33d..0000000 --- a/src/validate.js +++ /dev/null @@ -1,17 +0,0 @@ -class ConfigValidationError extends Error {} - -export function validateProperty(name, object, key, required, validationCallback) { - if (!Object.prototype.hasOwnProperty.call(object, [key])) { - if (required) { - throw new ConfigValidationError(`Required property not found, missing ${key} in ${name}`); - } else { - return; - } - } else { - if (!validationCallback(object[key])) { - throw new ConfigValidationError( - `Failed to validate ${key} in ${name}. The following check failed: \n${validationCallback.toString()}`, - ); - } - } -} diff --git a/userscriptTemplate.js b/userscriptTemplate.js index 00e3717..6c498c6 100644 --- a/userscriptTemplate.js +++ b/userscriptTemplate.js @@ -11,22 +11,45 @@ // ==/UserScript== (() => { - // Example config const config = { - wpToolsEverywhere: true, // Automatically detect webpack objects and inject them with wpTools (not full patching) + }; + + /* // Example config + const config = { + spacepackEverywhere: { + enabled: true, // Automatically detect webpack objects and inject them with spacepack (Default: true) + ignoreSites: [], // Don't inject spacepack on matching sites (Default: []) + + // Don't inject spacepack on matching webpack objects (Default: []) + ignoreChunkObjects: [ + "webpackChunkruffle_extension", // https://ruffle.rs/ + ], + }, siteConfigs: [ { - name: "twitter", // Required, for documentation and debug logging - matchSites: ["twitter.com"], // String or Array of strings of sites to inject on. Matches globs (eg. *.discord.com) - chunkObject: "webpackChunk_twitter_responsive_web", // Name of webpack chunk object to intercept - webpackVersion: "5", // Version of webpack used to compile. TODO: Document this. Supported are 4 and 5 - inspectAll: true, // Whether to isolate every module. Allows for viewing an individual module in devtools without the whole rest of the chunk, but has a huge performance impact + name: "twitter", // For debug logging (Required) + chunkObject: "webpackChunk_twitter_responsive_web", // Name of webpack chunk object to intercept (Required) + webpackVersion: "5", // Version of webpack used to compile. (Required) + + // String or Array of strings of sites to inject on. (Required) + matchSites: ["twitter.com"], + + // Whether to isolate every module. with //# sourceURL=. Allows for viewing an individual module in devtools + // without the whole rest of the chunk, but has a noticable performance impact (Default: false) + patchAll: true, + injectSpacepack: true, // Whether to inject spacepack (Default: true) patches: [ { - name: "patchingDemo", // Used for debugging purposes, logging if a patch fails (TODO) and a comment of which patches affected a module - find: "(window.__INITIAL_STATE__", // String, regexp or an array of them to match a module we're patching. Best to keep this a single string if possible for performance reasons + // Used for debugging purposes, logging if a patch fails (TODO) and a comment of which + // patches affected a module + name: "patchingDemo", + + // String, regexp or an array of them to match a module we're patching. Best to keep this a single string if + // possible for performance reasons (Required.) + find: "(window.__INITIAL_STATE__", + + // match and replace are literally passed to `String.prototype.replace(match, replacement)` replace: { - // match and replace are literally passed to `String.prototype.replace(match, replacement)` match: /(const|var) .{1,3}=.\..\(window\.__INITIAL_STATE__/, replacement: (orig) => `console.log('Patches work!!!');${orig}`, }, @@ -34,9 +57,17 @@ ], modules: [ { - name: "modulesDemo", // Id of the module being injected (required) - needs: new Set(), // set of strings, or regexes of modules that need to be loaded before injecting this one. can also be `{moduleId: }` if depending on other injected or named modules - entry: true, // if true, the module will evaluate immediately uppon injection. otherwise it will not evaluate until it's require()'d by another module + // ID of the module being injected. If this ID is identical to one of another module it will be replaced + // with this one. (Required) + name: "modulesDemo", + + // Set of strings, or regexes of modules that need to be loaded before injecting this one. can also be + // `{moduleId: }` if depending on other injected or named modules. (Default: null) + needs: new Set(), + entry: true, // Whether to load immediately or wait to be required by another module (Default: false) + + // The actual webpack module! Treat this sort of like in node where you can require other modules and export + // your own values. (Required). Hint: you can require("spacepack") if injectSpacepack isn't false. run: function (module, exports, webpackRequire) { // the actual webpack module. console.log("Module injection works!!!"); @@ -46,6 +77,7 @@ }, ], }; + */ unsafeWindow.__webpackTools_config = config;