#!/usr/bin/env node /* eslint-env node */ /** * Bundle size monitoring script * Analyzes bundle size and reports on performance metrics */ import { readFileSync, writeFileSync, existsSync } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const BUNDLE_SIZE_LIMIT = { // Size limits in KB total: 1000, // 1MB total individual: 250, // 250KB per chunk vendor: 500, // 500KB for vendor chunks }; // Note: Performance budgets can be added here when integrated class BundleMonitor { constructor() { this.projectRoot = join(__dirname, ".."); this.buildDir = join(this.projectRoot, ".next"); this.reportFile = join(this.projectRoot, "bundle-report.json"); } /** * Analyze bundle size from Next.js build output */ analyzeBundleSize() { const buildManifest = join(this.buildDir, "build-manifest.json"); if (!existsSync(buildManifest)) { console.error('Build manifest not found. Run "pnpm run build" first.'); process.exit(1); } try { const manifest = JSON.parse(readFileSync(buildManifest, "utf8")); const chunks = []; let totalSize = 0; // Analyze JavaScript chunks Object.entries(manifest.pages || {}).forEach(([page, files]) => { files.forEach(file => { if (file.endsWith(".js")) { const filePath = join(this.buildDir, "static", file); if (existsSync(filePath)) { const stats = this.getFileStats(filePath); chunks.push({ page, file, size: stats.size, gzippedSize: stats.gzippedSize, }); totalSize += stats.size; } } }); }); return { totalSize, chunks, timestamp: Date.now(), }; } catch (error) { console.error("Error analyzing bundle:", error.message); process.exit(1); } } /** * Get file statistics including gzipped size */ getFileStats(filePath) { try { const content = readFileSync(filePath); const size = content.length; // Estimate gzipped size (rough approximation) const gzippedSize = Math.round(size * 0.3); // Typical compression ratio return { size, gzippedSize }; } catch { return { size: 0, gzippedSize: 0 }; } } /** * Check if bundle sizes are within limits */ checkBundleLimits(analysis) { const issues = []; // Check total size const totalSizeKB = analysis.totalSize / 1024; if (totalSizeKB > BUNDLE_SIZE_LIMIT.total) { issues.push({ type: "total_size", message: `Total bundle size (${totalSizeKB.toFixed(1)}KB) exceeds limit (${BUNDLE_SIZE_LIMIT.total}KB)`, severity: "error", }); } // Check individual chunks analysis.chunks.forEach(chunk => { const sizeKB = chunk.size / 1024; if (sizeKB > BUNDLE_SIZE_LIMIT.individual) { const isVendor = chunk.file.includes("vendor") || chunk.file.includes("node_modules"); const limit = isVendor ? BUNDLE_SIZE_LIMIT.vendor : BUNDLE_SIZE_LIMIT.individual; if (sizeKB > limit) { issues.push({ type: "chunk_size", message: `Chunk ${chunk.file} (${sizeKB.toFixed(1)}KB) exceeds limit (${limit}KB)`, severity: "warning", chunk: chunk.file, }); } } }); return issues; } /** * Generate recommendations for bundle optimization */ generateRecommendations(analysis) { const recommendations = []; // Large chunks recommendations const largeChunks = analysis.chunks .filter(chunk => chunk.size / 1024 > 100) .sort((a, b) => b.size - a.size); if (largeChunks.length > 0) { recommendations.push({ type: "code_splitting", message: "Consider implementing code splitting for large chunks", chunks: largeChunks.slice(0, 5).map(c => c.file), }); } // Vendor chunk recommendations const vendorChunks = analysis.chunks.filter( chunk => chunk.file.includes("vendor") || chunk.file.includes("framework") ); if (vendorChunks.some(chunk => chunk.size / 1024 > 300)) { recommendations.push({ type: "vendor_optimization", message: "Consider optimizing vendor chunks or using dynamic imports", }); } // Duplicate code detection (simplified) const pageChunks = analysis.chunks.filter(chunk => chunk.page !== "_app"); if (pageChunks.length > 10) { recommendations.push({ type: "common_chunks", message: "Consider extracting common code into shared chunks", }); } return recommendations; } /** * Load previous report for comparison */ loadPreviousReport() { if (existsSync(this.reportFile)) { try { return JSON.parse(readFileSync(this.reportFile, "utf8")); } catch (error) { console.warn("Could not load previous report:", error.message); } } return null; } /** * Save current report */ saveReport(report) { try { writeFileSync(this.reportFile, JSON.stringify(report, null, 2)); console.log(`Report saved to ${this.reportFile}`); } catch (error) { console.error("Could not save report:", error.message); } } /** * Compare with previous report */ compareWithPrevious(current, previous) { if (!previous) return null; const currentTotal = current.analysis.totalSize; const previousTotal = previous.analysis.totalSize; const sizeDiff = currentTotal - previousTotal; const percentChange = (sizeDiff / previousTotal) * 100; return { sizeDiff, percentChange, isRegression: sizeDiff > 10240, // 10KB threshold }; } /** * Generate and display report */ run() { console.log("šŸ” Analyzing bundle size...\n"); const analysis = this.analyzeBundleSize(); const issues = this.checkBundleLimits(analysis); const recommendations = this.generateRecommendations(analysis); const previous = this.loadPreviousReport(); const comparison = this.compareWithPrevious({ analysis }, previous); const report = { timestamp: Date.now(), analysis, issues, recommendations, comparison, }; // Display results this.displayReport(report); // Save report this.saveReport(report); // Exit with error code if there are critical issues const hasErrors = issues.some(issue => issue.severity === "error"); if (hasErrors) { console.log("\nāŒ Bundle analysis failed due to critical issues."); process.exit(1); } else { console.log("\nāœ… Bundle analysis completed successfully."); } } /** * Display formatted report */ displayReport(report) { const { analysis, issues, recommendations, comparison } = report; // Bundle size summary console.log("šŸ“Š Bundle Size Summary"); console.log("─".repeat(50)); console.log(`Total Size: ${(analysis.totalSize / 1024).toFixed(1)}KB`); console.log(`Chunks: ${analysis.chunks.length}`); if (comparison) { const sign = comparison.sizeDiff > 0 ? "+" : ""; const color = comparison.isRegression ? "\x1b[31m" : "\x1b[32m"; console.log( `Change: ${color}${sign}${(comparison.sizeDiff / 1024).toFixed(1)}KB (${comparison.percentChange.toFixed(1)}%)\x1b[0m` ); } // Top chunks console.log("\nšŸ“¦ Largest Chunks"); console.log("─".repeat(50)); analysis.chunks .sort((a, b) => b.size - a.size) .slice(0, 10) .forEach(chunk => { console.log(`${(chunk.size / 1024).toFixed(1)}KB - ${chunk.file}`); }); // Issues if (issues.length > 0) { console.log("\nāš ļø Issues Found"); console.log("─".repeat(50)); issues.forEach(issue => { const icon = issue.severity === "error" ? "āŒ" : "āš ļø "; console.log(`${icon} ${issue.message}`); }); } // Recommendations if (recommendations.length > 0) { console.log("\nšŸ’” Recommendations"); console.log("─".repeat(50)); recommendations.forEach(rec => { console.log(`• ${rec.message}`); if (rec.chunks) { rec.chunks.forEach(chunk => console.log(` - ${chunk}`)); } }); } } } // Run the monitor const monitor = new BundleMonitor(); monitor.run();