UNPKG

webpack-chunk-report-plugin

Version:

Webpack Chunk Report Plugin

460 lines (459 loc) 21.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ChunkReportPlugin = void 0; // libs const path_1 = __importDefault(require("path")); const fs_1 = __importDefault(require("fs")); const cjs_module_lexer_1 = require("cjs-module-lexer"); // @ts-ignore const runtime_1 = require("webpack/lib/util/runtime"); // @ts-ignore const ConcatenatedModule_1 = __importDefault(require("webpack/lib/optimize/ConcatenatedModule")); const webpack_1 = require("webpack"); const typescript_1 = __importDefault(require("typescript")); // utils const markAsUsed_1 = require("./utils/markAsUsed"); const isTargetChunk_1 = require("./utils/isTargetChunk"); const addScript_1 = require("./utils/addScript"); const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); const waitUntilFileExists = async (filePath, timeout = 10000, interval = 100) => { const start = Date.now(); while (!fs_1.default.existsSync(filePath)) { if (Date.now() - start > timeout) { throw new Error(`Timeout waiting for file: ${filePath}`); } await sleep(interval); } }; function isSourceCommonJS(sourceCode) { let isCommonJS = false; function visit(node) { if (typescript_1.default.isCallExpression(node)) { if (typescript_1.default.isIdentifier(node.expression) && node.expression.text === "require") { isCommonJS = true; } } // Detect module.exports/exports assignments if (typescript_1.default.isBinaryExpression(node)) { if (typescript_1.default.isPropertyAccessExpression(node.left) && node.left.expression.getText() === "module" && node.left.name.text === "exports") { isCommonJS = true; } if (typescript_1.default.isPropertyAccessExpression(node.left) && node.left.expression.getText() === "exports") { isCommonJS = true; } } // Detect Object.assign(exports, ...) or Object.defineProperty(exports, ...) if (typescript_1.default.isCallExpression(node) && typescript_1.default.isPropertyAccessExpression(node.expression) && node.expression.expression.getText() === "Object") { const methodName = node.expression.name.text; if ((methodName === "assign" || methodName === "defineProperty") && node.arguments.some(arg => arg.getText() === "exports")) { isCommonJS = true; } } typescript_1.default.forEachChild(node, visit); } visit(sourceCode); return isCommonJS; } function collectExports(node, exportNames) { if (typescript_1.default.isExportDeclaration(node)) { if (node.exportClause && typescript_1.default.isNamedExports(node.exportClause)) { for (const element of node.exportClause.elements) { exportNames.add(element.name.text); // `export { x as y }` → y } } } else if (typescript_1.default.isExportAssignment(node)) { if (!node.isExportEquals) { exportNames.add("default"); // `export default something` } } else if ((typescript_1.default.isFunctionDeclaration(node) || typescript_1.default.isClassDeclaration(node)) && node.modifiers?.some(m => m.kind === typescript_1.default.SyntaxKind.DefaultKeyword)) { exportNames.add("default"); // `export default function foo() {}` → only "default" } else if ((typescript_1.default.isFunctionDeclaration(node) || typescript_1.default.isClassDeclaration(node) || typescript_1.default.isInterfaceDeclaration(node) || typescript_1.default.isTypeAliasDeclaration(node) || typescript_1.default.isEnumDeclaration(node)) && node.modifiers?.some(m => m.kind === typescript_1.default.SyntaxKind.ExportKeyword)) { if (node.name) { exportNames.add(node.name.text); // `export class Foo {}` → Foo } } else if (typescript_1.default.isVariableStatement(node) && node.modifiers?.some(m => m.kind === typescript_1.default.SyntaxKind.ExportKeyword)) { for (const decl of node.declarationList.declarations) { if (typescript_1.default.isIdentifier(decl.name)) { exportNames.add(decl.name.text); // `export const bar = 1` → bar } } } typescript_1.default.forEachChild(node, child => collectExports(child, exportNames)); } const isConcatenatedModule = (datum) => !!(datum.concatenated || datum.label.includes("concatenated")); const getChunkId = (label) => { // Remove path prefixes let filename = label.replace("static/chunks/", "").replaceAll("../", ""); // Check for standard webpack hash pattern (8+ hex chars before extension) const hashPattern = /([.-])([0-9a-f]{8,})(?=\.|$)/i; const hashMatch = filename.match(hashPattern); if (hashMatch) { // Split at the hash separator position return filename.substring(0, hashMatch.index); } // If no hash found, just remove the extension if present const dotIndex = filename.lastIndexOf("."); return dotIndex > 0 ? filename.substring(0, dotIndex) : filename; }; const flattenGroups = (groups) => { if (!groups) return []; const result = []; const stack = [...groups]; while (stack.length > 0) { const current = stack.pop(); const { groups: nested, ...rest } = current; result.push({ ...rest, concatenated: isConcatenatedModule(rest) }); if (nested?.length) { stack.push(...nested); } } return result; }; const readAndAdaptFile = (fileName) => { const source = fs_1.default.readFileSync(fileName, "utf-8"); // Add chunk ids const data = JSON.parse(source); const adaptedData = data.map(datum => { return { ...datum, id: getChunkId(datum.label), groups: flattenGroups(datum.groups), }; }); const chunkIdVsChunkData = adaptedData.reduce((acc, chunk) => { acc[chunk.id] = { parsedSize: chunk.parsedSize || 0, gzipSize: chunk.gzipSize || 0, statSize: chunk.statSize || 0, }; return acc; }, {}); const chunkIdVsModuleData = adaptedData.reduce((acc, chunk) => { const id = String(chunk.id); // Ensures the key is a string acc[id] = (chunk.groups ?? []); return acc; }, {}); return { chunkIdVsModuleData, chunkIdVsChunkData }; }; class ChunkReportPlugin { #filePath; runtime; reportFilename; outputDirectory; emitChunkIdVsModuleData; disableTreeShaking; constructor({ runtime, reportFilename, outputDirectory, emitChunkIdVsModuleData, disableTreeShaking, }) { this.runtime = runtime; this.reportFilename = reportFilename; this.#filePath = ""; this.outputDirectory = outputDirectory; this.emitChunkIdVsModuleData = emitChunkIdVsModuleData; this.disableTreeShaking = disableTreeShaking; } getChunkType = ({ isInitial, modules, getIncomingConnections, }) => { const reasons = []; for (const module of modules) { for (const dep of getIncomingConnections(module)) { const depModule = dep.originModule; if (depModule && dep.dependency && dep.dependency.loc) { const locStr = dep.dependency.loc.toString(); if (locStr.includes("webpackPrefetch")) { reasons.push("prefetch"); } else if (locStr.includes("webpackPreload")) { reasons.push("preload"); } else { reasons.push("dynamic"); } } } } // Classify type let type = "sync"; if (!isInitial) { if (reasons.includes("prefetch")) { type = "prefetch"; } else if (reasons.includes("preload")) { type = "preload"; } else { type = "lazy"; } } return type; }; getModuleExportsMetadata = (module, compilation, chunk) => { const filePath = module.resource; if (!filePath || !fs_1.default.existsSync(filePath)) { return { isCommonJS: false, exports: [], treeShakenExports: [] }; } const sourceCode = fs_1.default.readFileSync(module.resource, "utf-8"); const sourceFile = typescript_1.default.createSourceFile(filePath, sourceCode, typescript_1.default.ScriptTarget.Latest, true); const exportNames = new Set(); // Check if CJS or ECMA Module const isCommonJS = !sourceCode.includes("__esModule") && isSourceCommonJS(sourceFile); // ECMA Modules collectExports(sourceFile, exportNames); // CJS Modules try { const { exports, reexports } = (0, cjs_module_lexer_1.parse)(sourceCode); exports.forEach(ex => { exportNames.add(ex); }); reexports.forEach(ex => { exportNames.add(ex); }); } catch (e) { // Fail silently - ?? No clue } const treeShakenExports = new Set(); const used = compilation.moduleGraph.getUsedExports(module, chunk.runtime); if (!used) { exportNames.forEach(e => treeShakenExports.add(e)); } else if (used !== true) { exportNames.forEach(e => { if (used && !used.has(e)) { treeShakenExports.add(e); } }); } return { isCommonJS, exports: Array.from(exportNames), treeShakenExports: Array.from(treeShakenExports), }; }; getSourceModules(module) { const modules = []; const recurse = (mod) => { if (!mod || typeof mod !== "object") return; if (mod instanceof ConcatenatedModule_1.default && Array.isArray(mod.modules)) { for (const inner of mod.modules) { recurse(inner); } } else if (mod instanceof webpack_1.NormalModule) { modules.push(mod); } }; recurse(module); return modules; } getModuleIdentifier(compilation, module) { if (module instanceof webpack_1.NormalModule) { const resourcePath = module.resource; const relativePath = path_1.default.relative(compilation.compiler.context, resourcePath); return relativePath; } else { const pipedIdentifier = module.identifier().split("|"); const fileWithLoaders = pipedIdentifier.find(item => item.includes(process.cwd())) ?? ""; const loaderMetadata = fileWithLoaders.split("!"); const resourcePath = loaderMetadata[loaderMetadata.length - 1]; const relativePath = path_1.default.relative(compilation.compiler.context, resourcePath); const modules = this.getSourceModules(module); return module instanceof ConcatenatedModule_1.default ? `${relativePath} + ${modules.length - 1} modules (concatenated)` : relativePath; } } getReasons(compilation, module) { const reasons = []; // Get all incoming connections (why this module was included) for (const connection of compilation.moduleGraph.getIncomingConnections(module)) { if (connection.originModule) { reasons.push({ from: this.getModuleIdentifier(compilation, connection.originModule), explanation: connection.explanation, type: connection.dependency?.type, }); } } return reasons; } async compute(compiler, stats) { const compilation = stats.compilation; const { chunkGraph } = compilation; const chunkDataMap = {}; const fileName = this.#filePath; const { chunkIdVsModuleData, chunkIdVsChunkData } = readAndAdaptFile(fileName); for (const chunk of compilation.chunks) { const chunkId = chunk.name || chunk.id?.toString() || `chunk-${chunk.debugId}`; chunkIdVsModuleData[chunkId] ??= []; const modules = chunkGraph.getChunkModulesIterable(chunk); const isInitial = chunk.canBeInitial(); const chunkType = this.getChunkType({ isInitial, modules, getIncomingConnections: mod => compilation.moduleGraph.getIncomingConnections(mod), }); const chunkData = { ...chunkIdVsChunkData[chunkId], id: chunkId, modules: [], type: chunkType, }; for (const module of modules) { const moduleId = chunkGraph.getModuleId(module); const moduleType = module instanceof webpack_1.NormalModule ? "Normal" : module instanceof ConcatenatedModule_1.default ? "Concatenated" : "External"; const fileName = this.getModuleIdentifier(compilation, module); const moduleMetadata = { type: moduleType, id: moduleId, fileName, exports: [], treeShakenExports: [], parsedSize: 0, gzipSize: 0, statSize: 0, subModules: [], reasons: this.getReasons(compilation, module), isCommonJS: false, }; const modulesInChunk = chunkIdVsModuleData[chunkId]; if (module instanceof webpack_1.NormalModule) { const normalModule = module; if (fileName) { const { exports, treeShakenExports, isCommonJS } = this.getModuleExportsMetadata(normalModule, compilation, chunk); moduleMetadata.exports = exports; moduleMetadata.treeShakenExports = treeShakenExports; moduleMetadata.fileName = fileName; const moduleIndex = modulesInChunk.findIndex(mod => mod.path && mod.path.includes(fileName)); moduleMetadata.gzipSize = moduleIndex === -1 ? 0 : modulesInChunk[moduleIndex].gzipSize; moduleMetadata.statSize = moduleIndex === -1 ? 0 : modulesInChunk[moduleIndex].statSize; moduleMetadata.parsedSize = moduleIndex === -1 ? 0 : modulesInChunk[moduleIndex].parsedSize; moduleMetadata.isCommonJS = isCommonJS; } } else if (module instanceof ConcatenatedModule_1.default) { const concatenatedModuleFilename = `./${fileName}`; const concatenatedModuleIndex = modulesInChunk.findIndex(mod => mod.path && mod.path.includes(concatenatedModuleFilename)); moduleMetadata.gzipSize = concatenatedModuleIndex === -1 ? 0 : modulesInChunk[concatenatedModuleIndex].gzipSize; moduleMetadata.statSize = concatenatedModuleIndex === -1 ? 0 : modulesInChunk[concatenatedModuleIndex].statSize; moduleMetadata.parsedSize = concatenatedModuleIndex === -1 ? 0 : modulesInChunk[concatenatedModuleIndex].parsedSize; const sourceModules = this.getSourceModules(module); for (const mod of sourceModules) { const { exports, treeShakenExports, isCommonJS } = this.getModuleExportsMetadata(mod, compilation, chunk); const relativePath = path_1.default.relative(compilation.compiler.context, mod.resource); const identifier = `${concatenatedModuleFilename}/${relativePath}`; const moduleIndex = modulesInChunk.findIndex(mod => mod.path && mod.path.includes(identifier)); moduleMetadata.subModules.push({ type: mod instanceof webpack_1.NormalModule ? "Normal" : "Concatenated", id: chunkGraph.getModuleId(mod) ?? moduleId, fileName: relativePath, exports, treeShakenExports, gzipSize: moduleIndex === -1 ? 0 : modulesInChunk[moduleIndex].gzipSize, statSize: moduleIndex === -1 ? 0 : modulesInChunk[moduleIndex].statSize, parsedSize: moduleIndex === -1 ? 0 : modulesInChunk[moduleIndex].parsedSize, subModules: [], reasons: this.getReasons(compilation, mod), isCommonJS, }); } } else { const moduleIndex = modulesInChunk.findIndex(mod => mod.path && mod.path.includes(fileName)); moduleMetadata.gzipSize = moduleIndex === -1 ? 0 : modulesInChunk[moduleIndex].gzipSize; moduleMetadata.statSize = moduleIndex === -1 ? 0 : modulesInChunk[moduleIndex].statSize; moduleMetadata.parsedSize = moduleIndex === -1 ? 0 : modulesInChunk[moduleIndex].parsedSize; } chunkData.modules.push(moduleMetadata); } chunkDataMap[chunkId] = chunkData; } const chunkDataMapJSON = JSON.stringify(chunkDataMap); const chunkDataMapOutputDirectory = this.outputDirectory ?? path_1.default.resolve(process.cwd(), "chunk-reports"); const htmlOutputFile = path_1.default.resolve(chunkDataMapOutputDirectory, `${this.runtime}.html`); fs_1.default.mkdirSync(chunkDataMapOutputDirectory, { recursive: true }); const content = `window.CHUNK_DATA = ${chunkDataMapJSON};`; await (0, addScript_1.addScriptToHtml)(content, htmlOutputFile); if (this.emitChunkIdVsModuleData) { const chunkIdVsModuleDataJSON = JSON.stringify(chunkIdVsModuleData, null, 2); const chunkIdVsModuleDataOutputFile = path_1.default.resolve(chunkDataMapOutputDirectory, `chunkIdVsModuleData-${this.runtime}.json`); fs_1.default.writeFileSync(chunkIdVsModuleDataOutputFile, chunkIdVsModuleDataJSON, "utf-8"); } } apply(compiler) { compiler.hooks.compilation.tap("ChunkReportPlugin", compilation => { const { moduleGraph } = compilation; compilation.hooks.afterOptimizeChunkModules.tap("ChunkReportPlugin", chunks => { const targetChunks = Array.from(chunks).filter(chunk => chunk.name && (0, isTargetChunk_1.isTargetChunk)(chunk.name, this.disableTreeShaking)); let runtime = undefined; for (const [name, { options }] of compilation.entries) { runtime = (0, runtime_1.mergeRuntimeOwned)(runtime, (0, runtime_1.getEntryRuntime)(compilation, name, options)); } targetChunks.forEach(targetChunk => { const mods = compilation.chunkGraph.getChunkModulesIterable(targetChunk); for (const mod of mods) { if (mod.type.startsWith("javascript/")) { (0, markAsUsed_1.markAsUsed)(mod, moduleGraph, runtime); if (module instanceof ConcatenatedModule_1.default) { (0, markAsUsed_1.markAsUsed)(mod.rootModule, moduleGraph, runtime); } } } }); }); }); compiler.hooks.done.tapAsync("ChunkReportPlugin", async (stats, callback) => { try { this.#filePath = path_1.default.resolve(compiler.outputPath, this.reportFilename || "report.json"); await waitUntilFileExists(this.#filePath); // Waits until file exists or throws after timeout await this.compute(compiler, stats); callback(); } catch (err) { console.error("Error in ChunkReportPlugin:", err); callback(err); } }); } } exports.ChunkReportPlugin = ChunkReportPlugin;