|
| 1 | +import type { Compiler, Module, Compilation } from 'webpack' |
| 2 | +import type { CompilerNameValues } from '../../../shared/lib/constants' |
| 3 | + |
| 4 | +/** |
| 5 | + * Plugin that detects and reports modules that take a long time to build. |
| 6 | + * Helps identify performance bottlenecks in the build process by: |
| 7 | + * 1. Tracking build time for each module |
| 8 | + * 2. Building a dependency tree of slow modules |
| 9 | + * 3. Generating a visual report with timing information |
| 10 | + */ |
| 11 | + |
| 12 | +// Configuration constants |
| 13 | +const PLUGIN_NAME = 'SlowModuleDetectionPlugin' |
| 14 | + |
| 15 | +// Console output styling - using ANSI escape codes for colors |
| 16 | +const ConsoleColors = { |
| 17 | + BOLD_BLUE: '\x1b[1;34m', |
| 18 | + BRIGHT_YELLOW: '\x1b[93m', |
| 19 | + BOLD_GREEN: '\x1b[1;32m', |
| 20 | + RESET: '\x1b[0m', |
| 21 | +} as const |
| 22 | + |
| 23 | +// Tree visualization characters for the dependency tree output |
| 24 | +const TreeSymbols = { |
| 25 | + VERTICAL_LINE: '│ ', // Used for showing depth levels |
| 26 | + BRANCH: '├─ ', // Used for showing module connections |
| 27 | +} as const |
| 28 | + |
| 29 | +// Matches node_modules paths, including pnpm-style paths (.pnpm folder) |
| 30 | +const NODE_MODULES_PATH_PATTERN = /node_modules(?:\/\.pnpm)?\/(.*)/ |
| 31 | + |
| 32 | +interface ModuleBuildTimeAnalyzerOptions { |
| 33 | + compilerType: CompilerNameValues |
| 34 | + slowModuleThresholdMs?: number |
| 35 | + pathTruncationLength?: number |
| 36 | +} |
| 37 | + |
| 38 | +interface ModuleNode { |
| 39 | + module: Module |
| 40 | + buildDuration: number // Time taken to build this module in milliseconds |
| 41 | +} |
| 42 | + |
| 43 | +/** |
| 44 | + * Gets a unique identifier for a module based on its debugId. |
| 45 | + * The debugId is a webpack-internal identifier that's guaranteed unique per module. |
| 46 | + */ |
| 47 | +const getModuleIdentifier = (module: Module): string => { |
| 48 | + const debugId = module.debugId |
| 49 | + if (!debugId) { |
| 50 | + throw new Error( |
| 51 | + "Unable to identify module: The module is missing a required debugId. This may indicate a problem with webpack's internal module tracking." |
| 52 | + ) |
| 53 | + } |
| 54 | + return String(debugId) |
| 55 | +} |
| 56 | + |
| 57 | +/** |
| 58 | + * Extracts a clean, readable module name from its resource path. |
| 59 | + * Handles both project files and node_modules: |
| 60 | + * - Project files: Converts absolute paths to project-relative paths |
| 61 | + * - node_modules: Simplifies to just the package path |
| 62 | + */ |
| 63 | +const getModuleDisplayName = (module: Module): string | undefined => { |
| 64 | + const resourcePath = (module as any).resource |
| 65 | + if (!resourcePath) { |
| 66 | + return undefined |
| 67 | + } |
| 68 | + |
| 69 | + // Convert absolute paths to project-relative paths for readability |
| 70 | + let displayPath = resourcePath.replace(process.cwd(), '.') |
| 71 | + |
| 72 | + // Simplify node_modules paths to just show the package path |
| 73 | + const nodeModulesMatch = displayPath.match(NODE_MODULES_PATH_PATTERN) |
| 74 | + if (nodeModulesMatch) { |
| 75 | + return nodeModulesMatch[1] |
| 76 | + } |
| 77 | + |
| 78 | + return displayPath |
| 79 | +} |
| 80 | + |
| 81 | +/** |
| 82 | + * Creates a new module node with initial build duration of 0 |
| 83 | + */ |
| 84 | +const createModuleNode = (module: Module): ModuleNode => { |
| 85 | + return { |
| 86 | + module, |
| 87 | + buildDuration: 0, |
| 88 | + } |
| 89 | +} |
| 90 | + |
| 91 | +/** |
| 92 | + * Tracks and analyzes module build times to generate a dependency tree report. |
| 93 | + * The analyzer maintains several data structures: |
| 94 | + * - pendingModules: Modules waiting to be processed |
| 95 | + * - moduleNodes: Map of all module nodes by ID |
| 96 | + * - moduleParents/Children: Bidirectional relationships between modules |
| 97 | + */ |
| 98 | +class ModuleBuildTimeAnalyzer { |
| 99 | + private pendingModules: [Module, number, Compilation][] = [] |
| 100 | + private moduleNodes = new Map<string, ModuleNode>() |
| 101 | + private moduleParents = new Map<ModuleNode, ModuleNode>() |
| 102 | + private moduleChildren = new Map<ModuleNode, Map<string, ModuleNode>>() |
| 103 | + private isFinalized = false |
| 104 | + private slowModuleThresholdMs: number |
| 105 | + private pathTruncationLength: number |
| 106 | + |
| 107 | + constructor(private options: ModuleBuildTimeAnalyzerOptions) { |
| 108 | + this.slowModuleThresholdMs = options.slowModuleThresholdMs ?? 500 |
| 109 | + this.pathTruncationLength = options.pathTruncationLength ?? 100 |
| 110 | + } |
| 111 | + |
| 112 | + recordModuleBuildTime( |
| 113 | + module: Module, |
| 114 | + duration: number, |
| 115 | + compilation: Compilation |
| 116 | + ) { |
| 117 | + if (this.isFinalized) { |
| 118 | + throw new Error( |
| 119 | + '[SlowModuleDetectionPlugin] Cannot record additional build times: The analysis has already been finalized.' |
| 120 | + ) |
| 121 | + } |
| 122 | + if (duration < this.slowModuleThresholdMs) { |
| 123 | + return // Skip fast modules to reduce noise |
| 124 | + } |
| 125 | + |
| 126 | + this.pendingModules.push([module, duration, compilation]) |
| 127 | + } |
| 128 | + |
| 129 | + private generateDependencyGraph() { |
| 130 | + if (this.isFinalized) { |
| 131 | + throw new Error( |
| 132 | + '[SlowModuleDetectionPlugin] Cannot regenerate dependency graph: The graph has already been generated.' |
| 133 | + ) |
| 134 | + } |
| 135 | + |
| 136 | + for (const [module, duration, compilation] of this.pendingModules) { |
| 137 | + // Build dependency chain by walking up the module graph |
| 138 | + const chain: Module[] = [] |
| 139 | + const { moduleGraph } = compilation |
| 140 | + const visitedModules = new Set<Module>() |
| 141 | + let currentModule = module |
| 142 | + |
| 143 | + chain.push(currentModule) |
| 144 | + visitedModules.add(currentModule) |
| 145 | + |
| 146 | + while (true) { |
| 147 | + const issuerModule = moduleGraph.getIssuer(currentModule) |
| 148 | + if (!issuerModule) break |
| 149 | + if (visitedModules.has(issuerModule)) { |
| 150 | + throw new Error( |
| 151 | + '[SlowModuleDetectionPlugin] Circular dependency detected in module graph.' |
| 152 | + ) |
| 153 | + } |
| 154 | + chain.push(issuerModule) |
| 155 | + visitedModules.add(issuerModule) |
| 156 | + currentModule = issuerModule |
| 157 | + } |
| 158 | + |
| 159 | + const moduleNodes: ModuleNode[] = [] |
| 160 | + |
| 161 | + // Create or reuse nodes for each module in the chain |
| 162 | + for (const mod of chain) { |
| 163 | + let node = this.moduleNodes.get(getModuleIdentifier(mod)) |
| 164 | + if (!node) { |
| 165 | + node = createModuleNode(mod) |
| 166 | + this.moduleNodes.set(getModuleIdentifier(mod), node) |
| 167 | + } |
| 168 | + moduleNodes.push(node) |
| 169 | + } |
| 170 | + |
| 171 | + // Add the build time to the module that triggered the build |
| 172 | + if (moduleNodes.length) { |
| 173 | + moduleNodes[0].buildDuration += duration |
| 174 | + } |
| 175 | + |
| 176 | + // Build the parent-child relationships for the dependency tree |
| 177 | + for (let i = 0; i < moduleNodes.length - 1; i++) { |
| 178 | + const child = moduleNodes[i] |
| 179 | + const parent = moduleNodes[i + 1] |
| 180 | + |
| 181 | + this.moduleParents.set(child, parent) |
| 182 | + |
| 183 | + let parentChildren = this.moduleChildren.get(parent) |
| 184 | + if (!parentChildren) { |
| 185 | + parentChildren = new Map() |
| 186 | + this.moduleChildren.set(parent, parentChildren) |
| 187 | + } |
| 188 | + parentChildren.set(getModuleIdentifier(child.module), child) |
| 189 | + } |
| 190 | + } |
| 191 | + |
| 192 | + this.isFinalized = true |
| 193 | + } |
| 194 | + |
| 195 | + /** |
| 196 | + * Generates a visual tree report of slow modules and their relationships. |
| 197 | + * The tree shows: |
| 198 | + * - Module paths (truncated if too long) |
| 199 | + * - Build durations (in yellow) |
| 200 | + * - Module relationships (using tree symbols) |
| 201 | + */ |
| 202 | + generateReport() { |
| 203 | + this.generateDependencyGraph() |
| 204 | + |
| 205 | + if (!this.isFinalized) { |
| 206 | + throw new Error( |
| 207 | + '[SlowModuleDetectionPlugin] Cannot generate performance report: The module analysis has not been finalized.' |
| 208 | + ) |
| 209 | + } |
| 210 | + |
| 211 | + // Find root modules (those with no parents) |
| 212 | + const rootModules = [...this.moduleNodes.values()].filter( |
| 213 | + (node) => !this.moduleParents.has(node) |
| 214 | + ) |
| 215 | + |
| 216 | + // Helper to truncate long paths for better readability |
| 217 | + const truncatePath = ( |
| 218 | + path: string, |
| 219 | + maxLength = this.pathTruncationLength |
| 220 | + ): string => { |
| 221 | + if (path.length <= maxLength) return path |
| 222 | + const startSegment = path.slice(0, maxLength / 3) |
| 223 | + const endSegment = path.slice((-maxLength * 2) / 3) |
| 224 | + return `${startSegment}...${endSegment}` |
| 225 | + } |
| 226 | + |
| 227 | + // Helper to format duration with consistent padding |
| 228 | + const formatDuration = (ms: number): string => { |
| 229 | + return `${ms}ms`.padStart(5) |
| 230 | + } |
| 231 | + |
| 232 | + // Recursively format a module and its children |
| 233 | + const formatModuleNode = (node: ModuleNode, depth: number): string => { |
| 234 | + const moduleName = getModuleDisplayName(node.module) || '' |
| 235 | + |
| 236 | + if (!moduleName) { |
| 237 | + return formatChildModules(node, depth) |
| 238 | + } |
| 239 | + |
| 240 | + const truncatedName = truncatePath(moduleName) |
| 241 | + const indentation = TreeSymbols.VERTICAL_LINE.repeat(depth) |
| 242 | + |
| 243 | + const duration = |
| 244 | + node.buildDuration > 0 |
| 245 | + ? `(${ConsoleColors.BRIGHT_YELLOW}${formatDuration(node.buildDuration)}${ConsoleColors.RESET})` |
| 246 | + : '' |
| 247 | + const nameWithStyle = `${ConsoleColors.BOLD_BLUE}${truncatedName}${ConsoleColors.RESET}` |
| 248 | + const line = `${indentation}${TreeSymbols.BRANCH}${nameWithStyle} ${duration}` |
| 249 | + |
| 250 | + return ` ${line}\n${formatChildModules(node, depth + 1)}` |
| 251 | + } |
| 252 | + |
| 253 | + // Format all children of a module |
| 254 | + const formatChildModules = (node: ModuleNode, depth: number): string => { |
| 255 | + const children = this.moduleChildren.get(node) |
| 256 | + if (!children) return '' |
| 257 | + |
| 258 | + return [...children] |
| 259 | + .map(([_, child]) => formatModuleNode(child, depth)) |
| 260 | + .join('') |
| 261 | + } |
| 262 | + |
| 263 | + // Build the complete report |
| 264 | + let report = '' |
| 265 | + for (const root of rootModules) { |
| 266 | + report += formatModuleNode(root, 0) |
| 267 | + } |
| 268 | + |
| 269 | + // Only output if there are slow modules to report |
| 270 | + if (report) { |
| 271 | + console.log( |
| 272 | + `${ConsoleColors.BOLD_GREEN}🐌 Detected slow modules while compiling ${this.options.compilerType}:${ConsoleColors.RESET}` |
| 273 | + ) |
| 274 | + console.log(report) |
| 275 | + } |
| 276 | + } |
| 277 | +} |
| 278 | + |
| 279 | +/** |
| 280 | + * Webpack plugin that analyzes and reports modules that are slow to build. |
| 281 | + * Hooks into webpack's compilation process to: |
| 282 | + * 1. Track when module builds start |
| 283 | + * 2. Record build durations when modules complete |
| 284 | + * 3. Generate a report when compilation is done |
| 285 | + */ |
| 286 | +export default class SlowModuleDetectionPlugin { |
| 287 | + private analyzer: ModuleBuildTimeAnalyzer | null = null |
| 288 | + private moduleBuildStartTimes = new WeakMap<Module, number>() |
| 289 | + |
| 290 | + constructor(private options: ModuleBuildTimeAnalyzerOptions) {} |
| 291 | + |
| 292 | + apply = (compiler: Compiler) => { |
| 293 | + compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { |
| 294 | + this.analyzer = new ModuleBuildTimeAnalyzer(this.options) |
| 295 | + |
| 296 | + // Record start time when module build begins |
| 297 | + compilation.hooks.buildModule.tap(PLUGIN_NAME, (module) => { |
| 298 | + this.moduleBuildStartTimes.set(module, Date.now()) |
| 299 | + }) |
| 300 | + |
| 301 | + // Calculate and record duration when module build succeeds |
| 302 | + compilation.hooks.succeedModule.tap(PLUGIN_NAME, (module) => { |
| 303 | + const startTime = this.moduleBuildStartTimes.get(module) |
| 304 | + if (!startTime) { |
| 305 | + throw new Error( |
| 306 | + '[SlowModuleDetectionPlugin] Module build timing error: Unable to find the start time for a module build.' |
| 307 | + ) |
| 308 | + } |
| 309 | + if (!this.analyzer) { |
| 310 | + throw new Error( |
| 311 | + '[SlowModuleDetectionPlugin] Module analyzer initialization error: The analyzer was not properly initialized.' |
| 312 | + ) |
| 313 | + } |
| 314 | + this.analyzer.recordModuleBuildTime( |
| 315 | + module, |
| 316 | + Date.now() - startTime, |
| 317 | + compilation |
| 318 | + ) |
| 319 | + }) |
| 320 | + }) |
| 321 | + |
| 322 | + // Generate the report when compilation is complete |
| 323 | + compiler.hooks.done.tap(PLUGIN_NAME, () => { |
| 324 | + if (!this.analyzer) { |
| 325 | + return // No modules were built |
| 326 | + } |
| 327 | + this.analyzer.generateReport() |
| 328 | + }) |
| 329 | + } |
| 330 | +} |
0 commit comments