314 lines
8.4 KiB
JavaScript
314 lines
8.4 KiB
JavaScript
|
|
#!/usr/bin/env node
|
|||
|
|
/* eslint-env node */
|
|||
|
|
/* global console, process */
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 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 "npm 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();
|