Deezer Webpack Patcher

Library script to patch the code of webpack modules at runtime. Exposes a global register_webpack_patches function.

// ==UserScript==
// @name        Deezer Webpack Patcher
// @description Library script to patch the code of webpack modules at runtime. Exposes a global register_webpack_patches function.
// @author      bertigert
// @version     1.0.0
// @icon        https://www.google.com/s2/favicons?sz=64&domain=deezer.com
// @namespace   Violentmonkey Scripts
// @match       https://www.deezer.com/*
// @grant       none
// @run-at      document-start
// ==/UserScript==


(function() {
    "use strict";
    /**
     * Class to patch webpack modules by hooking into the webpack global object array push
     *
     * **It is important that the patcher is initialized as early as possible to catch modules as they are loaded**
     */
    class WebpackPatcher {
        static VERSION = 1;

        constructor(global_obj_key, logger, benchmark=false) {
            this.global_obj = window[global_obj_key];
            this.patch_targets = new Map();
            this.patched_modules = new Set();
            this.patch_counts = new Map();
            this.hooked = false;
            this.benchmark = benchmark;
            this.logger = logger || window.console;
            this._module_cache = new Map();
            this._function_string_cache = new Map();
        }

        /**
         * Find a module by its ID in the webpack modules array
         * @param {number} id - The webpack module ID to find
         * @returns {Array|null} - The module array [ids, exports, ...] or null if not found
        */
        get_module(id) {
            if (this._module_cache.has(id)) {
                return this._module_cache.get(id);
            }

            for (const e of this.global_obj) {
                if (e[0].some(mod_id => mod_id == id)) {
                    this._module_cache.set(id, e);
                    return e;
                }
            }
            return null;
        }

        /**
         * Register a module to be patched when it becomes available. Should be called as early as possible.
         * @param {number} module_id - The webpack module ID to target
         * @param {Array} methods - Array of method patch configurations from PATCHES structure
         */
        register_patch(module_id, methods) {
            // Merge with existing patches for this module
            if (this.patch_targets.has(module_id)) {
                const existing_methods = this.patch_targets.get(module_id);
                this.patch_targets.set(module_id, [...existing_methods, ...methods]);
            } else {
                this.patch_targets.set(module_id, methods);
                this.patch_counts.set(module_id, 0);
            }

            const existing_module = this.get_module(module_id);
            if (existing_module && methods.length > 0) {
                this.patch_module(existing_module, module_id, methods);
            }

            if (!this.hooked) {
                this._hook_webpack_array_push();
            }
        }

        /**
         * Register multiple patches at once. Should be called as early as possible.
         * @param {Object} patches - The PATCHES structure mapping module IDs to patch configurations
         */
        register_patches(patches) {
            Object.entries(patches).forEach(([id, {methods}]) => {
                this.register_patch(parseInt(id), methods);
            });
        }

        /**
         * Patch a module using the PATCHES structure
         * @param {*} module
         * @param {number} module_id
         * @param {Array} methods - Array of method patch configurations
         * @returns true if a method was patched, false otherwise
         */
        patch_module(module, module_id, methods) {
            const start_time = this.benchmark ? performance.now() : 0;
            try {
                let patched_count = 0;
                const module_exports = module[1];
                const current_patch_count = this.patch_counts.get(module_id) || 0;

                for (const [name, method] of Object.entries(module_exports)) {
                    if (typeof method !== 'function') {
                        continue;
                    }

                    if (current_patch_count + patched_count >= this.patch_targets.get(module_id).length) {
                        break;
                    }

                    const matching_method = methods.find(({identifiers}) =>
                        identifiers.every(identifier => this.does_method_match(method, identifier, module_id, name))
                    );

                    if (matching_method) {
                        const patch_start = this.benchmark ? performance.now() : 0;
                        const patched_method = this.patch_method_with_regexp_or_raw_string(
                            method,
                            matching_method.matches_and_replacements,
                            module_id,
                            name
                        );
                        const patch_end = this.benchmark ? performance.now() : 0;

                        module_exports[name] = patched_method;

                        if (this.benchmark) {
                            this.logger.debug(`Patched method '${name}' in module ${module_id} (${(patch_end - patch_start).toFixed(2)}ms)`);
                        } else {
                            this.logger.debug(`Patched method '${name}' in module ${module_id}`);
                        }
                        patched_count++;
                    }
                }

                if (patched_count > 0) {
                    this.patched_modules.add(module_id);
                    this.patch_counts.set(module_id, current_patch_count + patched_count);

                    const all_methods = this.patch_targets.get(module_id);
                    if (this.patch_counts.get(module_id) >= all_methods.length) {
                        this.logger.debug(`Reached max patches (${all_methods.length}) for module ${module_id}, unregistering`);
                        this.patch_targets.delete(module_id);
                    }

                    if (this.benchmark) {
                        const total_time = performance.now() - start_time;
                        this.logger.debug(`Successfully patched ${patched_count} method(s) in module ${module_id} (total: ${this.patch_counts.get(module_id)}) - ${total_time.toFixed(2)}ms`);
                    } else {
                        this.logger.debug(`Successfully patched ${patched_count} method(s) in module ${module_id} (total: ${this.patch_counts.get(module_id)})`);
                    }
                    return true;
                }

                return false;
            } catch (e) {
                if (this.benchmark) {
                    const total_time = performance.now() - start_time;
                    this.logger.error(`Error patching method in module ${module_id} after ${total_time.toFixed(2)}ms:`, e);
                } else {
                    this.logger.error(`Error patching method in module ${module_id}:`, e);
                }
                return false;
            }
        }

        _hook_webpack_array_push() {
            const original_push = this.global_obj.push;
            const self = this;

            this.global_obj.push = function(...args) {
                for (const module of args) {
                    if (Array.isArray(module) && module.length >= 2) {
                        const module_ids = module[0];

                        for (const module_id of module_ids) {
                            const patch_methods = self.patch_targets.get(module_id);
                            if (patch_methods && !self.patched_modules.has(module_id)) {
                                self.logger.debug(`Target module ${module_id} added, applying patch`);
                                self.patch_module(module, module_id, patch_methods);
                            }
                        }
                    }
                }
                return original_push.apply(this, args);
            }

            this.hooked = true;
            this.logger.debug("Webpack array push hooked");
        }

        _get_cached_function_string(func, module_id, method_name) {
            if (typeof func !== 'function') {
                return func;
            }

            const cache_key = `${module_id}_${method_name}`;
            if (this._function_string_cache.has(cache_key)) {
                return this._function_string_cache.get(cache_key);
            }

            const func_str = func.toString();
            this._function_string_cache.set(cache_key, func_str);
            return func_str;
        }
        _set_cached_function_string(func_str, module_id, method_name) {
            const cache_key = `${module_id}_${method_name}`;
            this._function_string_cache.set(cache_key, func_str);
        }

        /**
         * Method for cached method matching
         * @param {Function} method - The method to check
         * @param {string|RegExp} string_or_regex - The string or regex to match against
         * @param {number} module_id - Module in which the method is. Used for the key for caching
         * @param {string} method_name - Used for the key for caching
         * @returns {boolean} true if the method matches, false otherwise
         */
        does_method_match(method, string_or_regex, module_id, method_name) {
            const method_str = this._get_cached_function_string(method, module_id, method_name);
            if (typeof string_or_regex === 'string') {
                return method_str.includes(string_or_regex);
            } else if (string_or_regex instanceof RegExp) {
                return string_or_regex.test(method_str);
            }
            return false;
        }

        /**
         * Replace parts of a method's code using regexes or raw strings with caching
         * @param {Function} original_method - The method to patch, can also be a string of the method code
         * @param {Array} matches_and_replacements - Array of {match: RegExp|string, replacement: string|function, global: boolean} objects
         * @param {number} module_id - Module in which the method is. Used for the key for caching
         * @param {string} method_name - Used for the key for caching
         * @returns {Function} Patched method
         */
        patch_method_with_regexp_or_raw_string(original_method, matches_and_replacements, module_id, method_name) {
            const start_time = this.benchmark ? performance.now() : 0;
            let patched_code;
            try {
                const original_code = this._get_cached_function_string(original_method, module_id, method_name);
                patched_code = original_code;
                let total_replacements = 0;

                for (let i = 0; i < matches_and_replacements.length; i++) {
                    const match_and_replacement = matches_and_replacements[i];
                    const regex_start = this.benchmark ? performance.now() : 0;

                    let replacement_occurred = false;

                    const { match, replace, global } = match_and_replacement;
                    const func = global || (match instanceof RegExp && match.global) ? "replaceAll" : "replace";
                    if (typeof replace === 'function') {
                        patched_code = patched_code[func](match, (...args) => {
                            replacement_occurred = true;
                            return replace(...args);
                        });
                    } else {
                        patched_code = patched_code[func](match, (...args) => {
                            replacement_occurred = true;
                            return replace;
                        });
                    }

                    if (replacement_occurred) {
                        total_replacements++;
                    } else {
                        this.logger.warn(`Replacement ${i + 1}/${matches_and_replacements.length} skipped (no match) for method ${method_name} in module ${module_id}`);
                    }

                    if (this.benchmark) {
                        const regex_end = performance.now();
                        this.logger.debug(`Replacement ${i + 1}/${matches_and_replacements.length} ${replacement_occurred ? 'applied' : 'skipped (no match)'} in ${(regex_end - regex_start).toFixed(2)}ms`);
                    }
                }

                if (total_replacements === 0) {
                    this.logger.warn(`No replacements occured in method ${method_name} in module ${module_id}, returning original method`);
                    return original_method;
                }

                const patched_method = new Function("return " + patched_code)();
                this.logger.debug("Patched method:", patched_method);
                this._set_cached_function_string(patched_code, module_id, method_name);
                if (this.benchmark) {
                    const total_time = performance.now() - start_time;
                    this.logger.debug(`${total_replacements} replacement(s) applied (cached), method patched in ${total_time.toFixed(2)}ms`);
                }
                return patched_method;
            } catch (e) {
                if (this.benchmark) {
                    const total_time = performance.now() - start_time;
                    this.logger.error(`Replacement based patching failed after ${total_time.toFixed(2)}ms:`, e, "patched code:", patched_code);
                } else {
                    this.logger.error(`Replacement based patching failed:`, e, "patched code:", patched_code);
                }
                return original_method;
            }
        }

        /**
         * Static helper to reliably detect and hook webpack global object
         * @param {string} global_obj_key - Key of the global webpack object array (e.g. "webpackJsonpDeezer")
         * @param {function} callback - Callback to invoke once webpack is detected
         */
        static detect_and_hook_webpack(global_obj_key, callback) {
            if (window[global_obj_key]) {
                callback();
                return;
            }

            let webpack_found = false;
            Object.defineProperty(window, global_obj_key, {
                configurable: true,
                enumerable: true,
                set: function(value) {
                    if (!webpack_found) {
                        webpack_found = true;

                        Object.defineProperty(window, global_obj_key, {
                            configurable: true,
                            enumerable: true,
                            writable: true,
                            value: value
                        });

                        setTimeout(callback, 0);
                    }
                }
            });
        }

        /**
         * Initialize the global webpack patcher asynchronously
         */
        static async initialize(global_obj_key, logger, benchmark = false) {
            return new Promise((resolve) => {
                WebpackPatcher.detect_and_hook_webpack(global_obj_key, () => {
                    const patcher = new WebpackPatcher(global_obj_key, logger, benchmark);
                    logger.log("Global WebpackPatcher initialized");
                    resolve(patcher);
                });
            });
        }
    }


    /**
     * Helper class for registering patches with the global WebpackPatcher
     */
    class WebpackPatchRegistrar {
        constructor(patcher_promise) {
            this.patcher_promise = patcher_promise;
        }

        /**
         * Register multiple patches from a PATCHES object. Should be called as early as possible.
         *
         * @param {Object} patches - PATCHES object with the following structure:
         *
         * @structure
         * ```
         * PATCHES = {
         *   [module_id]: {
         *     methods: [
         *       {
         *         identifiers: [string | RegExp],    // Array of strings/regexes that all need to match
         *         matches_and_replacements: [        // Array of replacements to apply
         *           {
         *             match: string | RegExp,        // Pattern to match in method code
         *             replace: string | function,    // Replacement string or callback function
         *             global: boolean                // Whether to replace all occurrences (optional)
         *           }
         *         ]
         *       }
         *     ]
         *   }
         * }
         * ```
         *
         * Structure details:
         *   - **module_id**: Webpack module ID (number)
         *   - **methods**: Array of method patch configurations
         *   - **identifiers**: Array of strings or regexes that all need to match the target method.
         *                      **Identifiers should be as close to the start of the function as possible for performance**
         *   - **matches_and_replacements**: Array of text replacements to apply to the method
         *     - **match**: Pattern to find in method source code (string or RegExp)
         *     - **replace**: Replacement text (string) or callback function (match, ...groups) => string.
         *                   Functions should be used if you need access to capture groups or dynamic replacements.
         *     - **global**: If true, replaces all occurrences. For RegExp, uses regex.global flag if not specified
         */
        async register_patches(patches) {
            const patcher = await this.patcher_promise;
            patcher.register_patches(patches);
            logger.debug("Patches registered with WebpackPatcher");
            return patcher;
        }
    }


    class Logger {
        static PREFIX = "[WebpackPatcher]";

        constructor(debug=false) {
            this.should_debug = debug;
        }

        debug(...args) {if (this.should_debug) console.debug(Logger.PREFIX, ...args);}
        log(...args) {console.log(Logger.PREFIX, ...args);}
        warn(...args) {console.warn(Logger.PREFIX, ...args);}
        error(...args) {console.error(Logger.PREFIX, ...args);}
    }

    const logger = new Logger(false);

    const patcher_promise = WebpackPatcher.initialize("webpackJsonpDeezer", logger, false);
    const webpack_patch_registrar = new WebpackPatchRegistrar(patcher_promise);
    window.register_webpack_patches = webpack_patch_registrar.register_patches.bind(webpack_patch_registrar);
})();