/* MIT License http://www.opensource.org/licenses/mit-license.php Author Tobias Koppers @sokra */ "use strict"; const asyncLib = require("neo-async"); const ChunkGraph = require("../ChunkGraph"); const ModuleGraph = require("../ModuleGraph"); const { STAGE_DEFAULT } = require("../OptimizationStages"); const HarmonyImportDependency = require("../dependencies/HarmonyImportDependency"); const { compareModulesByIdentifier } = require("../util/comparators"); const { intersectRuntime, mergeRuntimeOwned, filterRuntime, runtimeToString, mergeRuntime } = require("../util/runtime"); const ConcatenatedModule = require("./ConcatenatedModule"); /** @typedef {import("../Compilation")} Compilation */ /** @typedef {import("../Compiler")} Compiler */ /** @typedef {import("../Module")} Module */ /** @typedef {import("../Module").BuildInfo} BuildInfo */ /** @typedef {import("../RequestShortener")} RequestShortener */ /** @typedef {import("../util/runtime").RuntimeSpec} RuntimeSpec */ /** * @typedef {object} Statistics * @property {number} cached * @property {number} alreadyInConfig * @property {number} invalidModule * @property {number} incorrectChunks * @property {number} incorrectDependency * @property {number} incorrectModuleDependency * @property {number} incorrectChunksOfImporter * @property {number} incorrectRuntimeCondition * @property {number} importerFailed * @property {number} added */ /** * @param {string} msg message * @returns {string} formatted message */ const formatBailoutReason = msg => `ModuleConcatenation bailout: ${msg}`; class ModuleConcatenationPlugin { /** * Apply the plugin * @param {Compiler} compiler the compiler instance * @returns {void} */ apply(compiler) { const { _backCompat: backCompat } = compiler; compiler.hooks.compilation.tap("ModuleConcatenationPlugin", compilation => { if (compilation.moduleMemCaches) { throw new Error( "optimization.concatenateModules can't be used with cacheUnaffected as module concatenation is a global effect" ); } const moduleGraph = compilation.moduleGraph; /** @type {Map string)>} */ const bailoutReasonMap = new Map(); /** * @param {Module} module the module * @param {string | ((requestShortener: RequestShortener) => string)} reason the reason */ const setBailoutReason = (module, reason) => { setInnerBailoutReason(module, reason); moduleGraph .getOptimizationBailout(module) .push( typeof reason === "function" ? rs => formatBailoutReason(reason(rs)) : formatBailoutReason(reason) ); }; /** * @param {Module} module the module * @param {string | ((requestShortener: RequestShortener) => string)} reason the reason */ const setInnerBailoutReason = (module, reason) => { bailoutReasonMap.set(module, reason); }; /** * @param {Module} module the module * @param {RequestShortener} requestShortener the request shortener * @returns {string | ((requestShortener: RequestShortener) => string) | undefined} the reason */ const getInnerBailoutReason = (module, requestShortener) => { const reason = bailoutReasonMap.get(module); if (typeof reason === "function") return reason(requestShortener); return reason; }; /** * @param {Module} module the module * @param {Module | function(RequestShortener): string} problem the problem * @returns {(requestShortener: RequestShortener) => string} the reason */ const formatBailoutWarning = (module, problem) => requestShortener => { if (typeof problem === "function") { return formatBailoutReason( `Cannot concat with ${module.readableIdentifier( requestShortener )}: ${problem(requestShortener)}` ); } const reason = getInnerBailoutReason(module, requestShortener); const reasonWithPrefix = reason ? `: ${reason}` : ""; if (module === problem) { return formatBailoutReason( `Cannot concat with ${module.readableIdentifier( requestShortener )}${reasonWithPrefix}` ); } return formatBailoutReason( `Cannot concat with ${module.readableIdentifier( requestShortener )} because of ${problem.readableIdentifier( requestShortener )}${reasonWithPrefix}` ); }; compilation.hooks.optimizeChunkModules.tapAsync( { name: "ModuleConcatenationPlugin", stage: STAGE_DEFAULT }, (allChunks, modules, callback) => { const logger = compilation.getLogger( "webpack.ModuleConcatenationPlugin" ); const { chunkGraph, moduleGraph } = compilation; const relevantModules = []; const possibleInners = new Set(); const context = { chunkGraph, moduleGraph }; logger.time("select relevant modules"); for (const module of modules) { let canBeRoot = true; let canBeInner = true; const bailoutReason = module.getConcatenationBailoutReason(context); if (bailoutReason) { setBailoutReason(module, bailoutReason); continue; } // Must not be an async module if (moduleGraph.isAsync(module)) { setBailoutReason(module, "Module is async"); continue; } // Must be in strict mode if (!(/** @type {BuildInfo} */ (module.buildInfo).strict)) { setBailoutReason(module, "Module is not in strict mode"); continue; } // Module must be in any chunk (we don't want to do useless work) if (chunkGraph.getNumberOfModuleChunks(module) === 0) { setBailoutReason(module, "Module is not in any chunk"); continue; } // Exports must be known (and not dynamic) const exportsInfo = moduleGraph.getExportsInfo(module); const relevantExports = exportsInfo.getRelevantExports(undefined); const unknownReexports = relevantExports.filter( exportInfo => exportInfo.isReexport() && !exportInfo.getTarget(moduleGraph) ); if (unknownReexports.length > 0) { setBailoutReason( module, `Reexports in this module do not have a static target (${Array.from( unknownReexports, exportInfo => `${ exportInfo.name || "other exports" }: ${exportInfo.getUsedInfo()}` ).join(", ")})` ); continue; } // Root modules must have a static list of exports const unknownProvidedExports = relevantExports.filter( exportInfo => exportInfo.provided !== true ); if (unknownProvidedExports.length > 0) { setBailoutReason( module, `List of module exports is dynamic (${Array.from( unknownProvidedExports, exportInfo => `${ exportInfo.name || "other exports" }: ${exportInfo.getProvidedInfo()} and ${exportInfo.getUsedInfo()}` ).join(", ")})` ); canBeRoot = false; } // Module must not be an entry point if (chunkGraph.isEntryModule(module)) { setInnerBailoutReason(module, "Module is an entry point"); canBeInner = false; } if (canBeRoot) relevantModules.push(module); if (canBeInner) possibleInners.add(module); } logger.timeEnd("select relevant modules"); logger.debug( `${relevantModules.length} potential root modules, ${possibleInners.size} potential inner modules` ); // sort by depth // modules with lower depth are more likely suited as roots // this improves performance, because modules already selected as inner are skipped logger.time("sort relevant modules"); relevantModules.sort( (a, b) => /** @type {number} */ (moduleGraph.getDepth(a)) - /** @type {number} */ (moduleGraph.getDepth(b)) ); logger.timeEnd("sort relevant modules"); /** @type {Statistics} */ const stats = { cached: 0, alreadyInConfig: 0, invalidModule: 0, incorrectChunks: 0, incorrectDependency: 0, incorrectModuleDependency: 0, incorrectChunksOfImporter: 0, incorrectRuntimeCondition: 0, importerFailed: 0, added: 0 }; let statsCandidates = 0; let statsSizeSum = 0; let statsEmptyConfigurations = 0; logger.time("find modules to concatenate"); const concatConfigurations = []; const usedAsInner = new Set(); for (const currentRoot of relevantModules) { // when used by another configuration as inner: // the other configuration is better and we can skip this one // TODO reconsider that when it's only used in a different runtime if (usedAsInner.has(currentRoot)) continue; let chunkRuntime; for (const r of chunkGraph.getModuleRuntimes(currentRoot)) { chunkRuntime = mergeRuntimeOwned(chunkRuntime, r); } const exportsInfo = moduleGraph.getExportsInfo(currentRoot); const filteredRuntime = filterRuntime(chunkRuntime, r => exportsInfo.isModuleUsed(r) ); const activeRuntime = filteredRuntime === true ? chunkRuntime : filteredRuntime === false ? undefined : filteredRuntime; // create a configuration with the root const currentConfiguration = new ConcatConfiguration( currentRoot, activeRuntime ); // cache failures to add modules const failureCache = new Map(); // potential optional import candidates /** @type {Set} */ const candidates = new Set(); // try to add all imports for (const imp of this._getImports( compilation, currentRoot, activeRuntime )) { candidates.add(imp); } for (const imp of candidates) { const impCandidates = new Set(); const problem = this._tryToAdd( compilation, currentConfiguration, imp, chunkRuntime, activeRuntime, possibleInners, impCandidates, failureCache, chunkGraph, true, stats ); if (problem) { failureCache.set(imp, problem); currentConfiguration.addWarning(imp, problem); } else { for (const c of impCandidates) { candidates.add(c); } } } statsCandidates += candidates.size; if (!currentConfiguration.isEmpty()) { const modules = currentConfiguration.getModules(); statsSizeSum += modules.size; concatConfigurations.push(currentConfiguration); for (const module of modules) { if (module !== currentConfiguration.rootModule) { usedAsInner.add(module); } } } else { statsEmptyConfigurations++; const optimizationBailouts = moduleGraph.getOptimizationBailout(currentRoot); for (const warning of currentConfiguration.getWarningsSorted()) { optimizationBailouts.push( formatBailoutWarning(warning[0], warning[1]) ); } } } logger.timeEnd("find modules to concatenate"); logger.debug( `${ concatConfigurations.length } successful concat configurations (avg size: ${ statsSizeSum / concatConfigurations.length }), ${statsEmptyConfigurations} bailed out completely` ); logger.debug( `${statsCandidates} candidates were considered for adding (${stats.cached} cached failure, ${stats.alreadyInConfig} already in config, ${stats.invalidModule} invalid module, ${stats.incorrectChunks} incorrect chunks, ${stats.incorrectDependency} incorrect dependency, ${stats.incorrectChunksOfImporter} incorrect chunks of importer, ${stats.incorrectModuleDependency} incorrect module dependency, ${stats.incorrectRuntimeCondition} incorrect runtime condition, ${stats.importerFailed} importer failed, ${stats.added} added)` ); // HACK: Sort configurations by length and start with the longest one // to get the biggest groups possible. Used modules are marked with usedModules // TODO: Allow to reuse existing configuration while trying to add dependencies. // This would improve performance. O(n^2) -> O(n) logger.time("sort concat configurations"); concatConfigurations.sort((a, b) => b.modules.size - a.modules.size); logger.timeEnd("sort concat configurations"); const usedModules = new Set(); logger.time("create concatenated modules"); asyncLib.each( concatConfigurations, (concatConfiguration, callback) => { const rootModule = concatConfiguration.rootModule; // Avoid overlapping configurations // TODO: remove this when todo above is fixed if (usedModules.has(rootModule)) return callback(); const modules = concatConfiguration.getModules(); for (const m of modules) { usedModules.add(m); } // Create a new ConcatenatedModule ConcatenatedModule.getCompilationHooks(compilation); const newModule = ConcatenatedModule.create( rootModule, modules, concatConfiguration.runtime, compilation, compiler.root, compilation.outputOptions.hashFunction ); const build = () => { newModule.build( compiler.options, compilation, /** @type {TODO} */ (null), /** @type {TODO} */ (null), err => { if (err) { if (!err.module) { err.module = newModule; } return callback(err); } integrate(); } ); }; const integrate = () => { if (backCompat) { ChunkGraph.setChunkGraphForModule(newModule, chunkGraph); ModuleGraph.setModuleGraphForModule(newModule, moduleGraph); } for (const warning of concatConfiguration.getWarningsSorted()) { moduleGraph .getOptimizationBailout(newModule) .push(formatBailoutWarning(warning[0], warning[1])); } moduleGraph.cloneModuleAttributes(rootModule, newModule); for (const m of modules) { // add to builtModules when one of the included modules was built if (compilation.builtModules.has(m)) { compilation.builtModules.add(newModule); } if (m !== rootModule) { // attach external references to the concatenated module too moduleGraph.copyOutgoingModuleConnections( m, newModule, c => c.originModule === m && !( c.dependency instanceof HarmonyImportDependency && modules.has(c.module) ) ); // remove module from chunk for (const chunk of chunkGraph.getModuleChunksIterable( rootModule )) { const sourceTypes = chunkGraph.getChunkModuleSourceTypes( chunk, m ); if (sourceTypes.size === 1) { chunkGraph.disconnectChunkAndModule(chunk, m); } else { const newSourceTypes = new Set(sourceTypes); newSourceTypes.delete("javascript"); chunkGraph.setChunkModuleSourceTypes( chunk, m, newSourceTypes ); } } } } compilation.modules.delete(rootModule); ChunkGraph.clearChunkGraphForModule(rootModule); ModuleGraph.clearModuleGraphForModule(rootModule); // remove module from chunk chunkGraph.replaceModule(rootModule, newModule); // replace module references with the concatenated module moduleGraph.moveModuleConnections(rootModule, newModule, c => { const otherModule = c.module === rootModule ? c.originModule : c.module; const innerConnection = c.dependency instanceof HarmonyImportDependency && modules.has(/** @type {Module} */ (otherModule)); return !innerConnection; }); // add concatenated module to the compilation compilation.modules.add(newModule); callback(); }; build(); }, err => { logger.timeEnd("create concatenated modules"); process.nextTick(callback.bind(null, err)); } ); } ); }); } /** * @param {Compilation} compilation the compilation * @param {Module} module the module to be added * @param {RuntimeSpec} runtime the runtime scope * @returns {Set} the imported modules */ _getImports(compilation, module, runtime) { const moduleGraph = compilation.moduleGraph; const set = new Set(); for (const dep of module.dependencies) { // Get reference info only for harmony Dependencies if (!(dep instanceof HarmonyImportDependency)) continue; const connection = moduleGraph.getConnection(dep); // Reference is valid and has a module if ( !connection || !connection.module || !connection.isTargetActive(runtime) ) { continue; } const importedNames = compilation.getDependencyReferencedExports( dep, undefined ); if ( importedNames.every(i => Array.isArray(i) ? i.length > 0 : i.name.length > 0 ) || Array.isArray(moduleGraph.getProvidedExports(module)) ) { set.add(connection.module); } } return set; } /** * @param {Compilation} compilation webpack compilation * @param {ConcatConfiguration} config concat configuration (will be modified when added) * @param {Module} module the module to be added * @param {RuntimeSpec} runtime the runtime scope of the generated code * @param {RuntimeSpec} activeRuntime the runtime scope of the root module * @param {Set} possibleModules modules that are candidates * @param {Set} candidates list of potential candidates (will be added to) * @param {Map} failureCache cache for problematic modules to be more performant * @param {ChunkGraph} chunkGraph the chunk graph * @param {boolean} avoidMutateOnFailure avoid mutating the config when adding fails * @param {Statistics} statistics gathering metrics * @returns {null | Module | function(RequestShortener): string} the problematic module */ _tryToAdd( compilation, config, module, runtime, activeRuntime, possibleModules, candidates, failureCache, chunkGraph, avoidMutateOnFailure, statistics ) { const cacheEntry = failureCache.get(module); if (cacheEntry) { statistics.cached++; return cacheEntry; } // Already added? if (config.has(module)) { statistics.alreadyInConfig++; return null; } // Not possible to add? if (!possibleModules.has(module)) { statistics.invalidModule++; failureCache.set(module, module); // cache failures for performance return module; } // Module must be in the correct chunks const missingChunks = Array.from( chunkGraph.getModuleChunksIterable(config.rootModule) ).filter(chunk => !chunkGraph.isModuleInChunk(module, chunk)); if (missingChunks.length > 0) { /** * @param {RequestShortener} requestShortener request shortener * @returns {string} problem description */ const problem = requestShortener => { const missingChunksList = Array.from( new Set(missingChunks.map(chunk => chunk.name || "unnamed chunk(s)")) ).sort(); const chunks = Array.from( new Set( Array.from(chunkGraph.getModuleChunksIterable(module)).map( chunk => chunk.name || "unnamed chunk(s)" ) ) ).sort(); return `Module ${module.readableIdentifier( requestShortener )} is not in the same chunk(s) (expected in chunk(s) ${missingChunksList.join( ", " )}, module is in chunk(s) ${chunks.join(", ")})`; }; statistics.incorrectChunks++; failureCache.set(module, problem); // cache failures for performance return problem; } const moduleGraph = compilation.moduleGraph; const incomingConnections = moduleGraph.getIncomingConnectionsByOriginModule(module); const incomingConnectionsFromNonModules = incomingConnections.get(null) || incomingConnections.get(undefined); if (incomingConnectionsFromNonModules) { const activeNonModulesConnections = incomingConnectionsFromNonModules.filter(connection => // We are not interested in inactive connections // or connections without dependency connection.isActive(runtime) ); if (activeNonModulesConnections.length > 0) { /** * @param {RequestShortener} requestShortener request shortener * @returns {string} problem description */ const problem = requestShortener => { const importingExplanations = new Set( activeNonModulesConnections.map(c => c.explanation).filter(Boolean) ); const explanations = Array.from(importingExplanations).sort(); return `Module ${module.readableIdentifier( requestShortener )} is referenced ${ explanations.length > 0 ? `by: ${explanations.join(", ")}` : "in an unsupported way" }`; }; statistics.incorrectDependency++; failureCache.set(module, problem); // cache failures for performance return problem; } } /** @type {Map} */ const incomingConnectionsFromModules = new Map(); for (const [originModule, connections] of incomingConnections) { if (originModule) { // Ignore connection from orphan modules if (chunkGraph.getNumberOfModuleChunks(originModule) === 0) continue; // We don't care for connections from other runtimes let originRuntime; for (const r of chunkGraph.getModuleRuntimes(originModule)) { originRuntime = mergeRuntimeOwned(originRuntime, r); } if (!intersectRuntime(runtime, originRuntime)) continue; // We are not interested in inactive connections const activeConnections = connections.filter(connection => connection.isActive(runtime) ); if (activeConnections.length > 0) incomingConnectionsFromModules.set(originModule, activeConnections); } } const incomingModules = Array.from(incomingConnectionsFromModules.keys()); // Module must be in the same chunks like the referencing module const otherChunkModules = incomingModules.filter(originModule => { for (const chunk of chunkGraph.getModuleChunksIterable( config.rootModule )) { if (!chunkGraph.isModuleInChunk(originModule, chunk)) { return true; } } return false; }); if (otherChunkModules.length > 0) { /** * @param {RequestShortener} requestShortener request shortener * @returns {string} problem description */ const problem = requestShortener => { const names = otherChunkModules .map(m => m.readableIdentifier(requestShortener)) .sort(); return `Module ${module.readableIdentifier( requestShortener )} is referenced from different chunks by these modules: ${names.join( ", " )}`; }; statistics.incorrectChunksOfImporter++; failureCache.set(module, problem); // cache failures for performance return problem; } /** @type {Map} */ const nonHarmonyConnections = new Map(); for (const [originModule, connections] of incomingConnectionsFromModules) { const selected = connections.filter( connection => !connection.dependency || !(connection.dependency instanceof HarmonyImportDependency) ); if (selected.length > 0) nonHarmonyConnections.set(originModule, connections); } if (nonHarmonyConnections.size > 0) { /** * @param {RequestShortener} requestShortener request shortener * @returns {string} problem description */ const problem = requestShortener => { const names = Array.from(nonHarmonyConnections) .map( ([originModule, connections]) => `${originModule.readableIdentifier( requestShortener )} (referenced with ${Array.from( new Set( connections .map(c => c.dependency && c.dependency.type) .filter(Boolean) ) ) .sort() .join(", ")})` ) .sort(); return `Module ${module.readableIdentifier( requestShortener )} is referenced from these modules with unsupported syntax: ${names.join( ", " )}`; }; statistics.incorrectModuleDependency++; failureCache.set(module, problem); // cache failures for performance return problem; } if (runtime !== undefined && typeof runtime !== "string") { // Module must be consistently referenced in the same runtimes /** @type {{ originModule: Module, runtimeCondition: RuntimeSpec }[]} */ const otherRuntimeConnections = []; outer: for (const [ originModule, connections ] of incomingConnectionsFromModules) { /** @type {false | RuntimeSpec} */ let currentRuntimeCondition = false; for (const connection of connections) { const runtimeCondition = filterRuntime(runtime, runtime => connection.isTargetActive(runtime) ); if (runtimeCondition === false) continue; if (runtimeCondition === true) continue outer; currentRuntimeCondition = currentRuntimeCondition !== false ? mergeRuntime(currentRuntimeCondition, runtimeCondition) : runtimeCondition; } if (currentRuntimeCondition !== false) { otherRuntimeConnections.push({ originModule, runtimeCondition: currentRuntimeCondition }); } } if (otherRuntimeConnections.length > 0) { /** * @param {RequestShortener} requestShortener request shortener * @returns {string} problem description */ const problem = requestShortener => `Module ${module.readableIdentifier( requestShortener )} is runtime-dependent referenced by these modules: ${Array.from( otherRuntimeConnections, ({ originModule, runtimeCondition }) => `${originModule.readableIdentifier( requestShortener )} (expected runtime ${runtimeToString( runtime )}, module is only referenced in ${runtimeToString( /** @type {RuntimeSpec} */ (runtimeCondition) )})` ).join(", ")}`; statistics.incorrectRuntimeCondition++; failureCache.set(module, problem); // cache failures for performance return problem; } } let backup; if (avoidMutateOnFailure) { backup = config.snapshot(); } // Add the module config.add(module); incomingModules.sort(compareModulesByIdentifier); // Every module which depends on the added module must be in the configuration too. for (const originModule of incomingModules) { const problem = this._tryToAdd( compilation, config, originModule, runtime, activeRuntime, possibleModules, candidates, failureCache, chunkGraph, false, statistics ); if (problem) { if (backup !== undefined) config.rollback(backup); statistics.importerFailed++; failureCache.set(module, problem); // cache failures for performance return problem; } } // Add imports to possible candidates list for (const imp of this._getImports(compilation, module, runtime)) { candidates.add(imp); } statistics.added++; return null; } } class ConcatConfiguration { /** * @param {Module} rootModule the root module * @param {RuntimeSpec} runtime the runtime */ constructor(rootModule, runtime) { this.rootModule = rootModule; this.runtime = runtime; /** @type {Set} */ this.modules = new Set(); this.modules.add(rootModule); /** @type {Map} */ this.warnings = new Map(); } /** * @param {Module} module the module */ add(module) { this.modules.add(module); } /** * @param {Module} module the module * @returns {boolean} true, when the module is in the module set */ has(module) { return this.modules.has(module); } isEmpty() { return this.modules.size === 1; } /** * @param {Module} module the module * @param {Module | function(RequestShortener): string} problem the problem */ addWarning(module, problem) { this.warnings.set(module, problem); } /** * @returns {Map} warnings */ getWarningsSorted() { return new Map( Array.from(this.warnings).sort((a, b) => { const ai = a[0].identifier(); const bi = b[0].identifier(); if (ai < bi) return -1; if (ai > bi) return 1; return 0; }) ); } /** * @returns {Set} modules as set */ getModules() { return this.modules; } snapshot() { return this.modules.size; } /** * @param {number} snapshot snapshot */ rollback(snapshot) { const modules = this.modules; for (const m of modules) { if (snapshot === 0) { modules.delete(m); } else { snapshot--; } } } } module.exports = ModuleConcatenationPlugin;