webpack-chunk-report-plugin
Version:
Webpack Chunk Report Plugin
460 lines (459 loc) • 21.4 kB
JavaScript
;
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;