import path from 'node:path' import { performance } from 'node:perf_hooks' import { minify } from 'oxc-minify' import PQueue from 'p-queue' import { MinifyError } from './errors/CustomError.js' import Telemetry from './utils/telemetry.utils.js' const minifyQueue = new PQueue({ concurrency: 4 }) function modulePath(identifier: string) { // the format of module paths is // '(!)?/path/to/module.js' // OR // 'javascript/esm|/some/path' let loaderRegex = /.*!/ const withoutLoader = identifier.replace(loaderRegex, '') if (withoutLoader.includes('|')) return withoutLoader.split('|')[1] return withoutLoader } function getUtf8Size(value: string) { const size = Buffer.byteLength(value, 'utf8') if (process.env.DEBUG_SIZE && Math.random() < 0.01) { // Only debug 1% to avoid spam console.log(`Size: ${size} bytes`) } return size } /** * Extract all package names from a module path to build a dependency chain. * For example: /node_modules/a/node_modules/b/index.js returns ['a', 'b'] * This preserves the old behavior of showing nested dependencies. */ function extractPackageNamesFromPath(moduleFilePath: string): string[] { // pnpm will serve packages from a global symlink (.pnpm/package@version/node_modules/package) // needs to be stripped off const pnpmPrefix = '.pnpm\\' + path.sep + '.+\\' + path.sep + 'node_modules\\' + path.sep const packages = moduleFilePath.split( new RegExp( '\\' + path.sep + 'node_modules\\' + path.sep + `(?:${pnpmPrefix})?`, ), ) if (packages.length <= 1) return [] const lastSegment = packages.pop() if (!lastSegment) return [] // Extract the package name from the last segment let lastPackageName if (lastSegment[0] === '@') { // package is a scoped package const offset = lastSegment.indexOf(path.sep) + 1 lastPackageName = lastSegment.slice( 0, offset + lastSegment.slice(offset).indexOf(path.sep), ) } else { lastPackageName = lastSegment.slice(0, lastSegment.indexOf(path.sep)) } packages.push(lastPackageName) packages.shift() // Remove the first empty element return packages } async function minifyDependencyCode(source: string, filename: string) { if (process.env.DEBUG_SIZE) { console.log('Minifying dependency code...') } try { const startTime = Date.now() const result = await minify(filename, source, { compress: true, mangle: true, module: true, // Treat as ES module to support import/export }) if (result.errors.length > 0) { const error = new Error( result.errors.map(diagnostic => diagnostic.message).join('\n'), ) Object.assign(error, { filename }) throw error } const minifyTime = Date.now() - startTime if (process.env.DEBUG_SIZE) { console.log(`Minify completed in ${minifyTime}ms`) } return { code: result.code } } catch (error) { if (process.env.DEBUG_SIZE) { console.log('Minify error occurred') } console.error('Oxc minify error:', error) throw error } } type RspackStatsCompilation = NonNullable< ReturnType> > type RspackModule = NonNullable[0] type StatsChild = { path: string packageName: string sources: string[] children: StatsChild[] } type StatsTree = { packageName: string sources: string[] children: StatsChild[] } function normaliseModuleSource(mod: RspackModule) { const identifier = mod.identifier || '' const isJSON = identifier.endsWith('.json') const rawSource = mod.source if (process.env.DEBUG_SIZE) { console.log(`Normalising module: ${identifier}`) } if (rawSource === undefined || rawSource === null) { if (process.env.DEBUG_SIZE) { console.log(`No source for module: ${identifier}`) } return null } let source: string if (typeof rawSource === 'string') { source = rawSource if (process.env.DEBUG_SIZE) { console.log(`Source is string`) } } else if (Buffer.isBuffer(rawSource)) { source = rawSource.toString('utf8') if (process.env.DEBUG_SIZE) { console.log(`Source is buffer`) } } else { source = String(rawSource) if (process.env.DEBUG_SIZE) { console.log(`Source is other type`) } } const finalSource = isJSON ? `$a$=${source}` : source if (process.env.DEBUG_SIZE) { console.log(`Module source normalised (length: ${finalSource.length})`) } return finalSource } async function bundleSizeTree( packageName: string, stats: RspackStatsCompilation, ) { const startTime = performance.now() const statsTree: StatsTree = { packageName: '', sources: [], children: [], } if (!stats.modules) return [] // Collect modules with their sources const modules: Array<{ path: string; source: string }> = [] const makeModule = ( mod: RspackModule, ): { path: string; source: string } | null => { const identifier = mod.identifier || '' const resolvedPath = modulePath(identifier) const source = normaliseModuleSource(mod) if (!source) return null return { path: resolvedPath, source, } } const filteredModules = stats.modules.filter( mod => !(mod.name?.startsWith('external') || mod.moduleType === 'runtime'), ) if (process.env.DEBUG_SIZE) { console.log( `\n[LOCAL] ==================== ${packageName} ====================`, ) } filteredModules.forEach(mod => { if (mod.modules) { if (process.env.DEBUG_SIZE) { console.log(`Module has ${mod.modules.length} sub-modules`) } mod.modules.forEach(subMod => { const made = makeModule(subMod) if (made) modules.push(made) }) } else { const made = makeModule(mod) if (made) modules.push(made) } }) if (process.env.DEBUG_SIZE) { console.log(`Collected ${modules.length} total modules`) } modules.sort((a, b) => { if (a === b) { return 0 } else { return a < b ? -1 : 1 } }) // Build tree structure from module paths modules.forEach((mod, modIndex) => { const packages = extractPackageNamesFromPath(mod.path) if (process.env.DEBUG_SIZE && modIndex < 5) { console.log(`[${modIndex}] Extracted packages: ${packages.join(' > ')}`) } if (packages.length === 0) { if (process.env.DEBUG_SIZE && modIndex < 5) { console.log(`[${modIndex}] No packages found in path`) } return } let parent = statsTree packages.forEach(pkg => { const existing = parent.children.filter( child => child.packageName === pkg, ) if (existing.length > 0) { existing[0].sources.push(mod.source) if (process.env.DEBUG_SIZE && modIndex < 5) { console.log(`[${modIndex}] Added to existing package: ${pkg}`) } parent = existing[0] } else { const newChild: StatsChild = { path: mod.path, packageName: pkg, sources: [mod.source], children: [], } parent.children.push(newChild) if (process.env.DEBUG_SIZE && modIndex < 5) { console.log(`[${modIndex}] Created new package: ${pkg}`) } parent = newChild } }) }) // The old webpack implementation returned only the first-level children // We need to preserve that behavior const flattenedItems = statsTree.children if (process.env.DEBUG_SIZE) { console.log( `\n[LOCAL] Tree structure built with ${flattenedItems.length} top-level dependencies:`, ) } const treeItems = flattenedItems .filter(treeItem => treeItem.packageName !== packageName) .map(treeItem => ({ ...treeItem, sources: treeItem.sources.filter(source => !!source), })) .filter(treeItem => treeItem.sources.length) const resultPromises = treeItems.map(async treeItem => { if (process.env.DEBUG_SIZE) { console.log(`\n[LOCAL] Processing dependency: ${treeItem.packageName}`) } try { const minifiedSizes = await Promise.all( treeItem.sources.map((code: string, idx) => minifyQueue.add(async () => { const originalSize = getUtf8Size(code) if (process.env.DEBUG_SIZE) { console.log(`Source ${idx}: ${originalSize} bytes (original)`) } const minified = await minifyDependencyCode( code, `${treeItem.packageName}-${idx}.js`, ) const minifiedSize = getUtf8Size(minified.code || '') if (process.env.DEBUG_SIZE) { console.log(`Source ${idx}: ${minifiedSize} bytes (minified)`) } return minifiedSize }), ), ) const size = minifiedSizes.reduce( (total, minifiedSize) => total + minifiedSize, 0, ) if (process.env.DEBUG_SIZE) { console.log(`Final size for ${treeItem.packageName}: ${size} bytes`) } return { name: treeItem.packageName, approximateSize: size, } } catch (error: any) { const { message, filename } = error throw new MinifyError(error, { message: message, filePath: filename, }) } }) try { const results = await Promise.all(resultPromises) Telemetry.dependencySizes(packageName, startTime, true, { minifier: 'oxc' }) return results } catch (e) { Telemetry.dependencySizes( packageName, startTime, false, { minifier: 'oxc' }, e, ) throw e } } export default bundleSizeTree