diff --git a/.changeset/bright-bundles-parse.md b/.changeset/bright-bundles-parse.md new file mode 100644 index 00000000..f24adbad --- /dev/null +++ b/.changeset/bright-bundles-parse.md @@ -0,0 +1,5 @@ +--- +"webpack-bundle-analyzer": patch +--- + +Parse Webpack 5 module maps nested in UMD wrappers, using bundle module IDs and Webpack runtime structure to choose among IIFE candidates. diff --git a/src/analyzer.js b/src/analyzer.js index a41b9a02..076fd6a1 100644 --- a/src/analyzer.js +++ b/src/analyzer.js @@ -317,6 +317,20 @@ function getViewerData(bundleStats, bundleDir, opts) { ); }); + const rootModules = getBundleModules(bundleStats); + const rootModulesByChunk = getModulesByChunk(rootModules); + /** @type {Map} */ + const rootAssetModules = new Map(); + + for (const statAsset of bundleStats.assets) { + if (!statAsset.isChild) { + rootAssetModules.set( + statAsset, + getAssetModulesByChunk(statAsset, rootModulesByChunk), + ); + } + } + // Trying to parse bundle assets and get real module sizes if `bundleDir` is provided /** @type {Record | null} */ let bundlesSources = null; @@ -329,11 +343,27 @@ function getViewerData(bundleStats, bundleDir, opts) { for (const statAsset of bundleStats.assets) { const assetFile = path.join(bundleDir, statAsset.name); + const expectedModuleIds = statAsset.isChild + ? [] + : /** @type {StatsModule[]} */ (rootAssetModules.get(statAsset)).reduce( + (moduleIds, statsModule) => { + if ( + typeof statsModule.id === "string" || + typeof statsModule.id === "number" + ) { + moduleIds.push(statsModule.id); + } + + return moduleIds; + }, + /** @type {(string | number)[]} */ ([]), + ); let bundleInfo; try { bundleInfo = parseBundle(assetFile, { sourceType: statAsset.info.javascriptModule ? "module" : "script", + expectedModuleIds, }); } catch (err) { const msg = @@ -364,9 +394,6 @@ function getViewerData(bundleStats, bundleDir, opts) { /** @typedef {{ size: number, parsedSize?: number, gzipSize?: number, brotliSize?: number, zstdSize?: number, modules: StatsModule[], tree: Folder }} Asset */ - const rootModules = getBundleModules(bundleStats); - const rootModulesByChunk = getModulesByChunk(rootModules); - const assets = bundleStats.assets.reduce((result, statAsset) => { /** @type {StatsModule[]} */ let assetModules; @@ -383,7 +410,9 @@ function getViewerData(bundleStats, bundleDir, opts) { assetHasModule(statAsset, statModule), ); } else { - assetModules = getAssetModulesByChunk(statAsset, rootModulesByChunk); + assetModules = /** @type {StatsModule[]} */ ( + rootAssetModules.get(statAsset) + ); } const asset = (result[statAsset.name] = /** @type {Asset} */ ({ diff --git a/src/parseUtils.js b/src/parseUtils.js index 4a6918aa..c03ef878 100644 --- a/src/parseUtils.js +++ b/src/parseUtils.js @@ -151,6 +151,7 @@ function getModuleLocation(node) { } /** @typedef {Record} ModulesLocations */ +/** @typedef {{ locations: ModulesLocations, hasWebpackRuntime: boolean, isTopLevel: boolean }} Webpack5IIFECandidate */ /** * @param {Expression | SpreadElement} node node @@ -216,27 +217,170 @@ function getModulesLocations(node) { /** * @param {ExpressionStatement} node node - * @returns {boolean} true when IIFE, otherwise false + * @returns {CallExpression | null} IIFE call expression */ -function isIIFE(node) { +function getIIFECallExpression(node) { + if (node.expression.type === "CallExpression") { + return node.expression; + } + + if ( + node.expression.type === "UnaryExpression" && + node.expression.argument.type === "CallExpression" + ) { + return node.expression.argument; + } + + return null; +} + +/** + * @param {Node} node node + * @param {string} modulesVariableName modules variable name + * @returns {boolean} true when the node calls a module wrapper + */ +function callsModulesMap(node, modulesVariableName) { + let callsModule = false; + + walk.simple(node, { + CallExpression(callExpression) { + const { callee } = callExpression; + + if ( + callee.type === "MemberExpression" && + ((callee.object.type === "Identifier" && + callee.object.name === modulesVariableName) || + (callee.object.type === "MemberExpression" && + callee.object.object.type === "Identifier" && + callee.object.object.name === modulesVariableName)) + ) { + callsModule = true; + } + }, + }); + + return callsModule; +} + +/** + * @param {import("acorn").BlockStatement} body body + * @param {import("acorn").VariableDeclaration} modulesDeclaration modules declaration + * @param {string} modulesVariableName modules variable name + * @returns {boolean} true when the candidate contains Webpack module loading + */ +function hasWebpackModulesRuntime( + body, + modulesDeclaration, + modulesVariableName, +) { + const declarationIndex = body.body.indexOf(modulesDeclaration); + + return body.body + .slice(declarationIndex + 1) + .some((statement) => callsModulesMap(statement, modulesVariableName)); +} + +/** + * @param {CallExpression} node node + * @param {boolean} isTopLevel is top-level IIFE + * @returns {Webpack5IIFECandidate | null} modules candidate + */ +function getWebpack5IIFEModulesCandidate(node, isTopLevel) { + if ( + node.arguments.length !== 0 || + (node.callee.type !== "FunctionExpression" && + node.callee.type !== "ArrowFunctionExpression") || + node.callee.params.length !== 0 || + node.callee.body.type !== "BlockStatement" + ) { + return null; + } + + const firstVariableDeclaration = node.callee.body.body.find( + (node) => node.type === "VariableDeclaration", + ); + + if (firstVariableDeclaration) { + for (const declaration of firstVariableDeclaration.declarations) { + if (declaration.init && isModulesList(declaration.init)) { + const locations = getModulesLocations(declaration.init); + + if (Object.keys(locations).length === 0) { + continue; + } + + return { + locations, + hasWebpackRuntime: + declaration.id.type === "Identifier" && + hasWebpackModulesRuntime( + node.callee.body, + firstVariableDeclaration, + declaration.id.name, + ), + isTopLevel, + }; + } + } + } + + return null; +} + +/** + * @param {Webpack5IIFECandidate[]} candidates candidates + * @returns {Webpack5IIFECandidate | null} selected candidate + */ +function selectWebpack5IIFECandidate(candidates) { return ( - node.type === "ExpressionStatement" && - (node.expression.type === "CallExpression" || - (node.expression.type === "UnaryExpression" && - node.expression.argument.type === "CallExpression")) + candidates.find( + (candidate) => candidate.hasWebpackRuntime && candidate.isTopLevel, + ) || + candidates.find((candidate) => candidate.hasWebpackRuntime) || + candidates.find((candidate) => candidate.isTopLevel) || + null ); } /** - * @param {ExpressionStatement} node node - * @returns {Expression} IIFE call expression + * @param {Webpack5IIFECandidate[]} candidates candidates + * @param {(string | number)[] | undefined} expectedModuleIds expected module ids + * @returns {ModulesLocations | null} selected modules locations */ -function getIIFECallExpression(node) { - if (node.expression.type === "UnaryExpression") { - return node.expression.argument; +function selectWebpack5IIFEModulesLocations(candidates, expectedModuleIds) { + if (candidates.length === 0) { + return null; + } + + if (expectedModuleIds?.length) { + const expectedIds = new Set(expectedModuleIds.map(String)); + const rankedCandidates = candidates + .map((candidate) => ({ + candidate, + expectedIdsIntersection: Object.keys(candidate.locations).filter( + (moduleId) => expectedIds.has(moduleId), + ).length, + })) + .toSorted( + (candidateA, candidateB) => + candidateB.expectedIdsIntersection - + candidateA.expectedIdsIntersection, + ); + + if (rankedCandidates[0].expectedIdsIntersection > 0) { + const bestCandidates = rankedCandidates + .filter( + (rankedCandidate) => + rankedCandidate.expectedIdsIntersection === + rankedCandidates[0].expectedIdsIntersection, + ) + .map((rankedCandidate) => rankedCandidate.candidate); + + return selectWebpack5IIFECandidate(bestCandidates)?.locations || null; + } } - return node.expression; + return selectWebpack5IIFECandidate(candidates)?.locations || null; } /** @@ -323,11 +467,11 @@ function isAsyncWebWorkerChunkExpression(node) { /** * @param {string} bundlePath bundle path - * @param {{ sourceType: "script" | "module" }} opts options + * @param {{ sourceType?: "script" | "module", expectedModuleIds?: (string | number)[] }} opts options * @returns {{ modules: Modules, src: string, runtimeSrc: string }} parsed result */ module.exports.parseBundle = function parseBundle(bundlePath, opts) { - const { sourceType = "script" } = opts || {}; + const { sourceType = "script", expectedModuleIds } = opts || {}; const content = fs.readFileSync(bundlePath, "utf8"); const ast = acorn.parse(content, { @@ -335,62 +479,26 @@ module.exports.parseBundle = function parseBundle(bundlePath, opts) { ecmaVersion: "latest", }); - /** @type {{ locations: ModulesLocations | null, expressionStatementDepth: number }} */ - const walkState = { - locations: null, - expressionStatementDepth: 0, - }; - - walk.recursive(ast, walkState, { - ExpressionStatement(node, state, callback) { - if (state.locations) return; + /** @type {Set} */ + const topLevelIIFECalls = new Set(); - state.expressionStatementDepth++; + for (const node of ast.body) { + if (node.type === "ExpressionStatement") { + const iifeCall = getIIFECallExpression(node); - if ( - // Webpack 5 stores modules in the the top-level IIFE - state.expressionStatementDepth === 1 && - ast.body.includes(node) && - isIIFE(node) - ) { - const fn = getIIFECallExpression(node); - - if ( - fn.type === "CallExpression" && - // It should not contain neither arguments - fn.arguments.length === 0 && - (fn.callee.type === "FunctionExpression" || - fn.callee.type === "ArrowFunctionExpression") && - // ...nor parameters - fn.callee.params.length === 0 && - fn.callee.body.type === "BlockStatement" - ) { - // Modules are stored in the very first variable declaration as hash - const firstVariableDeclaration = fn.callee.body.body.find( - (node) => node.type === "VariableDeclaration", - ); - - if (firstVariableDeclaration) { - for (const declaration of firstVariableDeclaration.declarations) { - if (declaration.init && isModulesList(declaration.init)) { - state.locations = getModulesLocations(declaration.init); - - if (state.locations) { - break; - } - } - } - } - } - } - - if (!state.locations) { - callback(node.expression, state); + if (iifeCall) { + topLevelIIFECalls.add(iifeCall); } + } + } - state.expressionStatementDepth--; - }, + /** @type {{ locations: ModulesLocations | null, webpack5IIFECandidates: Webpack5IIFECandidate[] }} */ + const walkState = { + locations: null, + webpack5IIFECandidates: [], + }; + walk.recursive(ast, walkState, { AssignmentExpression(node, state) { if (state.locations) return; @@ -417,6 +525,14 @@ module.exports.parseBundle = function parseBundle(bundlePath, opts) { if (state.locations) return; const args = node.arguments; + const webpack5IIFEModulesCandidate = getWebpack5IIFEModulesCandidate( + node, + topLevelIIFECalls.has(node), + ); + + if (webpack5IIFEModulesCandidate) { + state.webpack5IIFECandidates.push(webpack5IIFEModulesCandidate); + } // Main chunk with webpack loader. // Modules are stored in first argument: @@ -472,11 +588,17 @@ module.exports.parseBundle = function parseBundle(bundlePath, opts) { }, }); + const modulesLocations = + walkState.locations || + selectWebpack5IIFEModulesLocations( + walkState.webpack5IIFECandidates, + expectedModuleIds, + ); /** @type {Modules} */ const modules = {}; - if (walkState.locations) { - for (const [id, loc] of Object.entries(walkState.locations)) { + if (modulesLocations) { + for (const [id, loc] of Object.entries(modulesLocations)) { modules[id] = content.slice(loc.start, loc.end); } } @@ -484,6 +606,6 @@ module.exports.parseBundle = function parseBundle(bundlePath, opts) { return { modules, src: content, - runtimeSrc: getBundleRuntime(content, walkState.locations), + runtimeSrc: getBundleRuntime(content, modulesLocations), }; }; diff --git a/test/analyzerUtils.js b/test/analyzerUtils.js new file mode 100644 index 00000000..3e06846d --- /dev/null +++ b/test/analyzerUtils.js @@ -0,0 +1,85 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const { getViewerData } = require("../src/analyzer"); + +const BUNDLES_DIR = path.resolve(__dirname, "./bundles"); + +describe("getViewerData", () => { + it("passes asset module ids when parsing competing Webpack 5 IIFEs", () => { + const bundleName = "webpack5UmdBundleWithDecoyIIFE"; + const bundleFilename = `${bundleName}.js`; + const expectedModules = JSON.parse( + fs.readFileSync(`${BUNDLES_DIR}/${bundleName}.modules.json`), + ).modules; + const dependencyModule = { + id: 447, + identifier: "./src/dependency.js", + name: "./src/dependency.js", + size: 40, + chunks: [1], + depth: 1, + }; + const entryModule = { + id: 956, + identifier: "./src/entry.js", + name: "./src/entry.js", + size: 80, + chunks: [1], + depth: 0, + }; + const moduleWithoutId = { + identifier: "./src/no-id.js", + name: "./src/no-id.js", + size: 20, + chunks: [1], + depth: 1, + }; + let chunksAccessCount = 0; + const chunks = [ + { + id: 1, + modules: [dependencyModule, entryModule, moduleWithoutId], + }, + ]; + const stats = { + assets: [ + { + type: "asset", + name: bundleFilename, + size: fs.statSync(`${BUNDLES_DIR}/${bundleFilename}`).size, + info: { + javascriptModule: false, + }, + chunks: [1], + }, + { + type: "asset", + name: "validWebpack5UmdBundle.js", + size: fs.statSync(`${BUNDLES_DIR}/validWebpack5UmdBundle.js`).size, + info: { + javascriptModule: false, + }, + chunks: [2], + isChild: true, + }, + ], + get chunks() { + chunksAccessCount++; + return chunks; + }, + entrypoints: { + main: { + name: "main", + assets: [{ name: bundleFilename }], + }, + }, + }; + + getViewerData(stats, BUNDLES_DIR); + + expect(dependencyModule.parsedSrc).toBe(expectedModules[447]); + expect(entryModule.parsedSrc).toBe(expectedModules[956]); + expect(chunksAccessCount).toBe(1); + }); +}); diff --git a/test/bundles/validWebpack5NestedModuleAccessor.js b/test/bundles/validWebpack5NestedModuleAccessor.js new file mode 100644 index 00000000..3c712d7d --- /dev/null +++ b/test/bundles/validWebpack5NestedModuleAccessor.js @@ -0,0 +1,7 @@ +(() => { + var modules = { + 123: () => "nested-module-accessor", + }; + + modules.default[123](); +})(); diff --git a/test/bundles/validWebpack5NestedModuleAccessor.modules.json b/test/bundles/validWebpack5NestedModuleAccessor.modules.json new file mode 100644 index 00000000..9cad71b6 --- /dev/null +++ b/test/bundles/validWebpack5NestedModuleAccessor.modules.json @@ -0,0 +1,5 @@ +{ + "modules": { + "123": "() => \"nested-module-accessor\"" + } +} diff --git a/test/bundles/validWebpack5UmdBundle.js b/test/bundles/validWebpack5UmdBundle.js new file mode 100644 index 00000000..991e6fd5 --- /dev/null +++ b/test/bundles/validWebpack5UmdBundle.js @@ -0,0 +1,68 @@ +(function webpackUniversalModuleDefinition(root, factory) { + if(typeof exports === 'object' && typeof module === 'object') + module.exports = factory(); + else if(typeof define === 'function' && define.amd) + define([], factory); + else if(typeof exports === 'object') + exports["FixtureLibrary"] = factory(); + else + root["FixtureLibrary"] = factory(); +})(this, () => { +return /******/ (() => { // webpackBootstrap +/******/ var __webpack_modules__ = ({ + +/***/ 447 +(module) { + +module.exports = "fixture-dependency"; + + +/***/ }, + +/***/ 956 +(module, __unused_webpack_exports, __webpack_require__) { + +const dependency = __webpack_require__(447); + +module.exports = { dependency }; + + +/***/ } + +/******/ }); +/************************************************************************/ +/******/ // The module cache +/******/ var __webpack_module_cache__ = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ // Check if module is in cache +/******/ var cachedModule = __webpack_module_cache__[moduleId]; +/******/ if (cachedModule !== undefined) { +/******/ return cachedModule.exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = __webpack_module_cache__[moduleId] = { +/******/ // no module.id needed +/******/ // no module.loaded needed +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/************************************************************************/ +/******/ +/******/ // startup +/******/ // Load entry module and return exports +/******/ // This entry module is referenced by other modules so it can't be inlined +/******/ var __webpack_exports__ = __webpack_require__(956); +/******/ +/******/ return __webpack_exports__; +/******/ })() +; +}); diff --git a/test/bundles/validWebpack5UmdBundle.modules.json b/test/bundles/validWebpack5UmdBundle.modules.json new file mode 100644 index 00000000..3ffe153f --- /dev/null +++ b/test/bundles/validWebpack5UmdBundle.modules.json @@ -0,0 +1,6 @@ +{ + "modules": { + "447": "(module) {\n\nmodule.exports = \"fixture-dependency\";\n\n\n/***/ }", + "956": "(module, __unused_webpack_exports, __webpack_require__) {\n\nconst dependency = __webpack_require__(447);\n\nmodule.exports = { dependency };\n\n\n/***/ }" + } +} diff --git a/test/bundles/webpack5NestedModulesWithoutRuntime.js b/test/bundles/webpack5NestedModulesWithoutRuntime.js new file mode 100644 index 00000000..e883e960 --- /dev/null +++ b/test/bundles/webpack5NestedModulesWithoutRuntime.js @@ -0,0 +1,11 @@ +(function webpackUniversalModuleDefinition(root, factory) { + root["FixtureLibrary"] = factory(); +})(this, () => { + return (() => { + var modules = { + 123: () => "not-a-webpack-runtime", + }; + + return modules; + })(); +}); diff --git a/test/bundles/webpack5TopLevelModulesWithoutRuntime.js b/test/bundles/webpack5TopLevelModulesWithoutRuntime.js new file mode 100644 index 00000000..c0379ae4 --- /dev/null +++ b/test/bundles/webpack5TopLevelModulesWithoutRuntime.js @@ -0,0 +1,11 @@ +const marker = true; + +(() => { + var modules = { + 123: () => "top-level-fallback", + }; + + noop(); + + return modules; +})(); diff --git a/test/bundles/webpack5TopLevelModulesWithoutRuntime.modules.json b/test/bundles/webpack5TopLevelModulesWithoutRuntime.modules.json new file mode 100644 index 00000000..18f797e4 --- /dev/null +++ b/test/bundles/webpack5TopLevelModulesWithoutRuntime.modules.json @@ -0,0 +1,5 @@ +{ + "modules": { + "123": "() => \"top-level-fallback\"" + } +} diff --git a/test/bundles/webpack5UmdBundleWithDecoyIIFE.js b/test/bundles/webpack5UmdBundleWithDecoyIIFE.js new file mode 100644 index 00000000..e7be790d --- /dev/null +++ b/test/bundles/webpack5UmdBundleWithDecoyIIFE.js @@ -0,0 +1,71 @@ +(()=>{return "not-a-candidate";})(); +(()=>{var emptyModules={};return emptyModules;})(); +(()=>{var decoyModules={999:()=>"decoy-not-webpack"};return decoyModules;})(); +(function webpackUniversalModuleDefinition(root, factory) { + if(typeof exports === 'object' && typeof module === 'object') + module.exports = factory(); + else if(typeof define === 'function' && define.amd) + define([], factory); + else if(typeof exports === 'object') + exports["FixtureLibrary"] = factory(); + else + root["FixtureLibrary"] = factory(); +})(this, () => { +return /******/ (() => { // webpackBootstrap +/******/ var __webpack_modules__ = ({ + +/***/ 447 +(module) { + +module.exports = "fixture-dependency"; + + +/***/ }, + +/***/ 956 +(module, __unused_webpack_exports, __webpack_require__) { + +const dependency = __webpack_require__(447); + +module.exports = { dependency }; + + +/***/ } + +/******/ }); +/************************************************************************/ +/******/ // The module cache +/******/ var __webpack_module_cache__ = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ // Check if module is in cache +/******/ var cachedModule = __webpack_module_cache__[moduleId]; +/******/ if (cachedModule !== undefined) { +/******/ return cachedModule.exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = __webpack_module_cache__[moduleId] = { +/******/ // no module.id needed +/******/ // no module.loaded needed +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/************************************************************************/ +/******/ +/******/ // startup +/******/ // Load entry module and return exports +/******/ // This entry module is referenced by other modules so it can't be inlined +/******/ var __webpack_exports__ = __webpack_require__(956); +/******/ +/******/ return __webpack_exports__; +/******/ })() +; +}); diff --git a/test/bundles/webpack5UmdBundleWithDecoyIIFE.modules.json b/test/bundles/webpack5UmdBundleWithDecoyIIFE.modules.json new file mode 100644 index 00000000..3ffe153f --- /dev/null +++ b/test/bundles/webpack5UmdBundleWithDecoyIIFE.modules.json @@ -0,0 +1,6 @@ +{ + "modules": { + "447": "(module) {\n\nmodule.exports = \"fixture-dependency\";\n\n\n/***/ }", + "956": "(module, __unused_webpack_exports, __webpack_require__) {\n\nconst dependency = __webpack_require__(447);\n\nmodule.exports = { dependency };\n\n\n/***/ }" + } +} diff --git a/test/parseUtils.js b/test/parseUtils.js index c36b2ddb..bef7cbb3 100644 --- a/test/parseUtils.js +++ b/test/parseUtils.js @@ -26,6 +26,46 @@ describe("parseBundle", () => { }); } + for (const [description, expectedModuleIds] of [ + ["without module id hints", undefined], + ["with partial module id hints", [447]], + ["with tied partial module id hints", [447, 999]], + ["with non-matching module id hints", ["missing"]], + ]) { + it(`should ignore empty and decoy IIFEs ${description}`, () => { + const bundleName = "webpack5UmdBundleWithDecoyIIFE"; + const bundleFile = `${BUNDLES_DIR}/${bundleName}.js`; + const expectedModules = JSON.parse( + fs.readFileSync(`${BUNDLES_DIR}/${bundleName}.modules.json`), + ); + const bundle = parseBundle( + bundleFile, + expectedModuleIds ? { expectedModuleIds } : undefined, + ); + + expect(bundle.modules).toEqual(expectedModules.modules); + }); + } + + it("should preserve the top-level Webpack 5 module-map fallback", () => { + const bundleName = "webpack5TopLevelModulesWithoutRuntime"; + const bundleFile = `${BUNDLES_DIR}/${bundleName}.js`; + const expectedModules = JSON.parse( + fs.readFileSync(`${BUNDLES_DIR}/${bundleName}.modules.json`), + ); + + expect(parseBundle(bundleFile).modules).toEqual(expectedModules.modules); + }); + + it("should ignore nested module maps without a Webpack runtime", () => { + const bundleFile = `${BUNDLES_DIR}/webpack5NestedModulesWithoutRuntime.js`; + + expect(parseBundle(bundleFile).modules).toEqual({}); + expect( + parseBundle(bundleFile, { expectedModuleIds: [123] }).modules, + ).toEqual({}); + }); + it("should parse invalid bundle and return it's content and empty modules hash", () => { const bundleFile = `${BUNDLES_DIR}/invalidBundle.js`; const bundle = parseBundle(bundleFile);