packlyze 2.0.2 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -1
- package/dist/analyzer/packlyze.d.ts +15 -1
- package/dist/analyzer/packlyze.d.ts.map +1 -1
- package/dist/analyzer/packlyze.js +408 -36
- package/dist/analyzer/packlyze.js.map +1 -1
- package/dist/cli.js +213 -17
- package/dist/cli.js.map +1 -1
- package/dist/config/loader.d.ts +13 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +64 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/exporters/formats.d.ts +4 -0
- package/dist/exporters/formats.d.ts.map +1 -0
- package/dist/exporters/formats.js +130 -0
- package/dist/exporters/formats.js.map +1 -0
- package/dist/tracking/history.d.ts +22 -0
- package/dist/tracking/history.d.ts.map +1 -0
- package/dist/tracking/history.js +89 -0
- package/dist/tracking/history.js.map +1 -0
- package/dist/types.d.ts +25 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/visualization/dependency-graph.d.ts +19 -0
- package/dist/visualization/dependency-graph.d.ts.map +1 -0
- package/dist/visualization/dependency-graph.js +101 -0
- package/dist/visualization/dependency-graph.js.map +1 -0
- package/dist/visualization/reports.js +217 -0
- package/dist/visualization/reports.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,12 +11,20 @@ Advanced bundle analyzer with insights, recommendations, historical tracking, an
|
|
|
11
11
|
## 📊 Features
|
|
12
12
|
|
|
13
13
|
- **Package Analysis**: Parse and analyze webpack, rollup, and esbuild stats files.
|
|
14
|
+
- **Package-Level Insights**: Group modules by npm package to identify heavy dependencies.
|
|
14
15
|
- **Smart Recommendations**: Suggestions to optimize bundle size and structure.
|
|
15
16
|
- **Tree-Shaking Detection**: Identify modules and patterns that block tree-shaking.
|
|
16
17
|
- **Duplicate Detection**: Find and quantify duplicate modules with potential savings.
|
|
17
|
-
- **
|
|
18
|
+
- **Chunk Analysis**: Analyze code-splitting efficiency and get optimization recommendations.
|
|
19
|
+
- **Unused Code Detection**: Identify potentially unused modules in your bundle.
|
|
20
|
+
- **Historical Tracking**: Track bundle size trends over time with `packlyze trends`.
|
|
21
|
+
- **Dependency Graph**: Generate Graphviz DOT files to visualize module dependencies.
|
|
22
|
+
- **Beautiful HTML Report**: Sleek, dark-themed, interactive report with search, filter, and sort.
|
|
18
23
|
- **Baseline Comparison**: Compare current vs previous stats to see regressions and improvements.
|
|
24
|
+
- **Multiple Export Formats**: Export to HTML, CSV, or Markdown.
|
|
25
|
+
- **Config File Support**: Use `.packlyzerc` or `packlyze.config.json` for project defaults.
|
|
19
26
|
- **CLI Tool**: Easy-to-use command-line interface with filters and CI-friendly thresholds.
|
|
27
|
+
- **Brotli Estimates**: Get Brotli compression size estimates (17% smaller than gzip).
|
|
20
28
|
- **TypeScript Ready**: Full TypeScript support with type definitions.
|
|
21
29
|
|
|
22
30
|
## 🚀 Quick Start
|
|
@@ -201,6 +209,32 @@ packlyze analyze dist/stats.json \
|
|
|
201
209
|
--no-html
|
|
202
210
|
```
|
|
203
211
|
|
|
212
|
+
### Historical tracking
|
|
213
|
+
|
|
214
|
+
Track bundle size over time:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
# Automatically saves to .packlyze/history.json after each analysis
|
|
218
|
+
packlyze analyze stats.json
|
|
219
|
+
|
|
220
|
+
# View trends
|
|
221
|
+
packlyze trends
|
|
222
|
+
|
|
223
|
+
# View more entries
|
|
224
|
+
packlyze trends --limit 20
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Dependency graph
|
|
228
|
+
|
|
229
|
+
Generate a dependency graph visualization:
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
packlyze analyze stats.json --dependency-graph graph.dot
|
|
233
|
+
|
|
234
|
+
# Render with Graphviz
|
|
235
|
+
dot -Tsvg graph.dot -o graph.svg
|
|
236
|
+
```
|
|
237
|
+
|
|
204
238
|
## 🎯 Use Cases
|
|
205
239
|
|
|
206
240
|
Common scenarios where Packlyze is helpful:
|
|
@@ -2,8 +2,11 @@ import { AnalysisResult } from '../types.js';
|
|
|
2
2
|
export declare class Packlyze {
|
|
3
3
|
private statsData;
|
|
4
4
|
private baseDir;
|
|
5
|
-
|
|
5
|
+
private verboseLog?;
|
|
6
|
+
constructor(statsPath: string, verboseLog?: (message: string) => void);
|
|
7
|
+
private log;
|
|
6
8
|
private loadStats;
|
|
9
|
+
private validateStats;
|
|
7
10
|
analyze(): Promise<AnalysisResult>;
|
|
8
11
|
private extractBundleStats;
|
|
9
12
|
private getTotalSize;
|
|
@@ -12,7 +15,18 @@ export declare class Packlyze {
|
|
|
12
15
|
private getInitialBundleSize;
|
|
13
16
|
private generateRecommendations;
|
|
14
17
|
private detectTreeshakingIssues;
|
|
18
|
+
/**
|
|
19
|
+
* Extract package name from module path.
|
|
20
|
+
* Handles node_modules paths like:
|
|
21
|
+
* - node_modules/lodash/index.js -> lodash
|
|
22
|
+
* - node_modules/@types/node/index.d.ts -> @types/node
|
|
23
|
+
* - node_modules/react-dom/client.js -> react-dom
|
|
24
|
+
*/
|
|
25
|
+
private extractPackageName;
|
|
15
26
|
private findDuplicates;
|
|
27
|
+
private analyzePackages;
|
|
28
|
+
private detectUnusedModules;
|
|
29
|
+
private analyzeChunks;
|
|
16
30
|
private calculateMetrics;
|
|
17
31
|
}
|
|
18
32
|
//# sourceMappingURL=packlyze.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"packlyze.d.ts","sourceRoot":"","sources":["../../src/analyzer/packlyze.ts"],"names":[],"mappings":"AAEA,OAAO,
|
|
1
|
+
{"version":3,"file":"packlyze.d.ts","sourceRoot":"","sources":["../../src/analyzer/packlyze.ts"],"names":[],"mappings":"AAEA,OAAO,EAUL,cAAc,EACf,MAAM,aAAa,CAAC;AAErB,qBAAa,QAAQ;IACnB,OAAO,CAAC,SAAS,CAcV;IACP,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,UAAU,CAAC,CAA4B;gBAEnC,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI;IASrE,OAAO,CAAC,GAAG;IAMX,OAAO,CAAC,SAAS;IA8BjB,OAAO,CAAC,aAAa;IA0Cf,OAAO,IAAI,OAAO,CAAC,cAAc,CAAC;IAuCxC,OAAO,CAAC,kBAAkB;IAqG1B,OAAO,CAAC,YAAY;IA2CpB,OAAO,CAAC,gBAAgB;IAuCxB,OAAO,CAAC,aAAa;IAUrB,OAAO,CAAC,oBAAoB;IAM5B,OAAO,CAAC,uBAAuB;IAkD/B,OAAO,CAAC,uBAAuB;IAmB/B;;;;;;OAMG;IACH,OAAO,CAAC,kBAAkB;IAc1B,OAAO,CAAC,cAAc;IAoCtB,OAAO,CAAC,eAAe;IAqCvB,OAAO,CAAC,mBAAmB;IAkD3B,OAAO,CAAC,aAAa;IAgErB,OAAO,CAAC,gBAAgB;CAuBzB"}
|
|
@@ -7,53 +7,200 @@ exports.Packlyze = void 0;
|
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
9
|
class Packlyze {
|
|
10
|
-
constructor(statsPath) {
|
|
10
|
+
constructor(statsPath, verboseLog) {
|
|
11
11
|
this.statsData = {};
|
|
12
|
+
this.verboseLog = verboseLog;
|
|
12
13
|
if (!fs_1.default.existsSync(statsPath)) {
|
|
13
14
|
throw new Error(`Stats file not found: ${statsPath}`);
|
|
14
15
|
}
|
|
15
16
|
this.baseDir = path_1.default.dirname(statsPath);
|
|
16
17
|
this.loadStats(statsPath);
|
|
17
18
|
}
|
|
19
|
+
log(message) {
|
|
20
|
+
if (this.verboseLog) {
|
|
21
|
+
this.verboseLog(message);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
18
24
|
loadStats(statsPath) {
|
|
25
|
+
this.log('Reading stats file...');
|
|
19
26
|
const content = fs_1.default.readFileSync(statsPath, 'utf-8');
|
|
27
|
+
this.log(`Stats file size: ${(content.length / 1024).toFixed(2)} KB`);
|
|
20
28
|
try {
|
|
29
|
+
this.log('Parsing JSON...');
|
|
21
30
|
this.statsData = JSON.parse(content);
|
|
31
|
+
this.log('Validating stats structure...');
|
|
32
|
+
this.validateStats();
|
|
33
|
+
// Count modules from different sources
|
|
34
|
+
const topLevelModules = this.statsData.modules?.length || 0;
|
|
35
|
+
const chunks = this.statsData.chunks || [];
|
|
36
|
+
let chunkModules = 0;
|
|
37
|
+
chunks.forEach((chunk) => {
|
|
38
|
+
if (Array.isArray(chunk.modules)) {
|
|
39
|
+
chunkModules += chunk.modules.length;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
this.log(`Found ${topLevelModules} top-level modules, ${chunkModules} modules in chunks, ${chunks.length} chunks`);
|
|
22
43
|
}
|
|
23
44
|
catch (e) {
|
|
45
|
+
if (e instanceof Error && e.message.includes('Invalid stats')) {
|
|
46
|
+
throw e;
|
|
47
|
+
}
|
|
24
48
|
throw new Error(`Invalid JSON in stats file: ${e}`);
|
|
25
49
|
}
|
|
26
50
|
}
|
|
51
|
+
validateStats() {
|
|
52
|
+
if (!this.statsData || typeof this.statsData !== 'object') {
|
|
53
|
+
throw new Error('Invalid stats: stats file must be a valid JSON object');
|
|
54
|
+
}
|
|
55
|
+
// Check for required structure (at least one of assets, modules, or chunks should exist)
|
|
56
|
+
const hasAssets = Array.isArray(this.statsData.assets);
|
|
57
|
+
const hasModules = Array.isArray(this.statsData.modules);
|
|
58
|
+
const hasChunks = Array.isArray(this.statsData.chunks);
|
|
59
|
+
if (!hasAssets && !hasModules && !hasChunks) {
|
|
60
|
+
throw new Error('Invalid stats: stats file must contain at least one of: assets, modules, or chunks arrays. ' +
|
|
61
|
+
'Ensure you generated the stats file correctly (e.g., webpack --profile --json stats.json)');
|
|
62
|
+
}
|
|
63
|
+
// Validate modules if present
|
|
64
|
+
if (hasModules && Array.isArray(this.statsData.modules)) {
|
|
65
|
+
for (const module of this.statsData.modules) {
|
|
66
|
+
if (module && typeof module !== 'object') {
|
|
67
|
+
throw new Error('Invalid stats: all modules must be objects');
|
|
68
|
+
}
|
|
69
|
+
if (module && module.size !== undefined && (typeof module.size !== 'number' || module.size < 0)) {
|
|
70
|
+
throw new Error('Invalid stats: module sizes must be non-negative numbers');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Validate assets if present
|
|
75
|
+
if (hasAssets && Array.isArray(this.statsData.assets)) {
|
|
76
|
+
for (const asset of this.statsData.assets) {
|
|
77
|
+
if (asset && typeof asset !== 'object') {
|
|
78
|
+
throw new Error('Invalid stats: all assets must be objects');
|
|
79
|
+
}
|
|
80
|
+
if (asset && asset.size !== undefined && (typeof asset.size !== 'number' || asset.size < 0)) {
|
|
81
|
+
throw new Error('Invalid stats: asset sizes must be non-negative numbers');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
27
86
|
async analyze() {
|
|
87
|
+
this.log('Extracting bundle statistics...');
|
|
28
88
|
const bundleStats = this.extractBundleStats();
|
|
89
|
+
this.log('Analyzing packages...');
|
|
90
|
+
const packages = this.analyzePackages(bundleStats);
|
|
91
|
+
this.log('Detecting duplicate modules...');
|
|
92
|
+
const duplicates = this.findDuplicates(bundleStats);
|
|
93
|
+
this.log('Detecting tree-shaking issues...');
|
|
94
|
+
const treeshakingIssues = this.detectTreeshakingIssues(bundleStats);
|
|
95
|
+
this.log('Generating recommendations...');
|
|
29
96
|
const recommendations = this.generateRecommendations(bundleStats);
|
|
30
|
-
|
|
31
|
-
const duplicates = this.findDuplicates();
|
|
97
|
+
this.log('Calculating metrics...');
|
|
32
98
|
const metrics = this.calculateMetrics(bundleStats);
|
|
99
|
+
this.log('Analyzing chunks...');
|
|
100
|
+
const chunkAnalysis = this.analyzeChunks(bundleStats);
|
|
101
|
+
this.log('Detecting unused modules...');
|
|
102
|
+
const unusedModules = this.detectUnusedModules(bundleStats);
|
|
103
|
+
this.log('Analysis complete!');
|
|
33
104
|
return {
|
|
34
105
|
bundleStats,
|
|
35
106
|
recommendations,
|
|
36
107
|
treeshakingIssues,
|
|
37
108
|
duplicates,
|
|
109
|
+
packages,
|
|
110
|
+
chunkAnalysis,
|
|
111
|
+
unusedModules,
|
|
38
112
|
metrics,
|
|
39
113
|
timestamp: new Date().toISOString()
|
|
40
114
|
};
|
|
41
115
|
}
|
|
42
116
|
extractBundleStats() {
|
|
43
117
|
const assets = this.statsData.assets || [];
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
:
|
|
118
|
+
// Extract modules from top-level or from chunks (webpack 5+ format)
|
|
119
|
+
let modules = [];
|
|
120
|
+
// First, try top-level modules array
|
|
121
|
+
if (Array.isArray(this.statsData.modules) && this.statsData.modules.length > 0) {
|
|
122
|
+
modules = this.statsData.modules.map((m) => ({
|
|
123
|
+
name: m.name || 'unknown',
|
|
124
|
+
size: m.size || 0,
|
|
125
|
+
gzipSize: m.gzipSize,
|
|
126
|
+
percentage: 0, // Will calculate after we know total size
|
|
127
|
+
reasons: Array.isArray(m.reasons)
|
|
128
|
+
? m.reasons.map((r) => typeof r === 'string' ? r : r.moduleName ?? '')
|
|
129
|
+
: []
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
// Extract modules from chunks (webpack 5+ format)
|
|
134
|
+
const moduleMap = new Map();
|
|
135
|
+
const chunks = this.statsData.chunks || [];
|
|
136
|
+
chunks.forEach((chunk) => {
|
|
137
|
+
if (Array.isArray(chunk.modules)) {
|
|
138
|
+
chunk.modules.forEach((m) => {
|
|
139
|
+
// Handle both object and string formats
|
|
140
|
+
let moduleName;
|
|
141
|
+
let moduleSize;
|
|
142
|
+
let moduleGzipSize;
|
|
143
|
+
let moduleReasons;
|
|
144
|
+
if (typeof m === 'string') {
|
|
145
|
+
// Module is just a name string
|
|
146
|
+
moduleName = m;
|
|
147
|
+
moduleSize = 0; // Size unknown, will be calculated from chunk or assets
|
|
148
|
+
moduleGzipSize = undefined;
|
|
149
|
+
moduleReasons = [];
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
// Module is an object
|
|
153
|
+
moduleName = m.name || 'unknown';
|
|
154
|
+
moduleSize = m.size || 0;
|
|
155
|
+
moduleGzipSize = m.gzipSize;
|
|
156
|
+
moduleReasons = m.reasons;
|
|
157
|
+
}
|
|
158
|
+
if (!moduleMap.has(moduleName)) {
|
|
159
|
+
moduleMap.set(moduleName, {
|
|
160
|
+
name: moduleName,
|
|
161
|
+
size: moduleSize,
|
|
162
|
+
gzipSize: moduleGzipSize,
|
|
163
|
+
percentage: 0,
|
|
164
|
+
reasons: Array.isArray(moduleReasons)
|
|
165
|
+
? moduleReasons.map((r) => typeof r === 'string' ? r : r.moduleName ?? '')
|
|
166
|
+
: []
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
// If module appears in multiple chunks, use the maximum size (not sum)
|
|
171
|
+
// because the same module shouldn't be counted multiple times
|
|
172
|
+
const existing = moduleMap.get(moduleName);
|
|
173
|
+
existing.size = Math.max(existing.size, moduleSize);
|
|
174
|
+
if (moduleGzipSize) {
|
|
175
|
+
existing.gzipSize = existing.gzipSize ? Math.max(existing.gzipSize, moduleGzipSize) : moduleGzipSize;
|
|
176
|
+
}
|
|
177
|
+
// Merge reasons if available
|
|
178
|
+
if (Array.isArray(moduleReasons) && moduleReasons.length > 0) {
|
|
179
|
+
const existingReasons = new Set(existing.reasons);
|
|
180
|
+
moduleReasons.forEach((r) => {
|
|
181
|
+
const reasonStr = typeof r === 'string' ? r : r.moduleName ?? '';
|
|
182
|
+
if (reasonStr)
|
|
183
|
+
existingReasons.add(reasonStr);
|
|
184
|
+
});
|
|
185
|
+
existing.reasons = Array.from(existingReasons);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
modules = Array.from(moduleMap.values());
|
|
192
|
+
}
|
|
193
|
+
// Calculate total size from assets or modules
|
|
194
|
+
const totalSize = this.getTotalSize() || modules.reduce((sum, m) => sum + (m.size || 0), 0);
|
|
195
|
+
// Calculate percentages
|
|
196
|
+
modules = modules.map((m) => ({
|
|
197
|
+
...m,
|
|
198
|
+
percentage: totalSize > 0 ? ((m.size || 0) / totalSize) * 100 : 0
|
|
52
199
|
}));
|
|
53
200
|
return {
|
|
54
201
|
name: this.statsData.name || 'bundle',
|
|
55
|
-
size:
|
|
56
|
-
gzipSize: this.getTotalGzipSize(),
|
|
202
|
+
size: totalSize,
|
|
203
|
+
gzipSize: this.getTotalGzipSize() || modules.reduce((sum, m) => sum + (m.gzipSize || 0), 0),
|
|
57
204
|
modules: modules.sort((a, b) => b.size - a.size),
|
|
58
205
|
chunks: this.extractChunks(),
|
|
59
206
|
isInitialBySize: this.getInitialBundleSize(),
|
|
@@ -63,11 +210,71 @@ class Packlyze {
|
|
|
63
210
|
}
|
|
64
211
|
getTotalSize() {
|
|
65
212
|
const assets = this.statsData.assets || [];
|
|
66
|
-
|
|
213
|
+
const assetSize = assets.reduce((sum, asset) => sum + (asset.size || 0), 0);
|
|
214
|
+
// If assets are empty or zero, try to calculate from modules
|
|
215
|
+
if (assetSize === 0) {
|
|
216
|
+
// Try top-level modules
|
|
217
|
+
if (Array.isArray(this.statsData.modules) && this.statsData.modules.length > 0) {
|
|
218
|
+
return this.statsData.modules.reduce((sum, m) => sum + (m.size || 0), 0);
|
|
219
|
+
}
|
|
220
|
+
// Try modules from chunks
|
|
221
|
+
const chunks = this.statsData.chunks || [];
|
|
222
|
+
let moduleSize = 0;
|
|
223
|
+
const seenModules = new Set();
|
|
224
|
+
chunks.forEach((chunk) => {
|
|
225
|
+
if (Array.isArray(chunk.modules)) {
|
|
226
|
+
chunk.modules.forEach((m) => {
|
|
227
|
+
const moduleName = m.name || 'unknown';
|
|
228
|
+
// Only count each module once (in case it appears in multiple chunks)
|
|
229
|
+
if (!seenModules.has(moduleName)) {
|
|
230
|
+
seenModules.add(moduleName);
|
|
231
|
+
moduleSize += (m.size || 0);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
if (moduleSize > 0) {
|
|
237
|
+
return moduleSize;
|
|
238
|
+
}
|
|
239
|
+
// Try chunk sizes as fallback
|
|
240
|
+
const chunkSize = chunks.reduce((sum, c) => sum + (c.size || 0), 0);
|
|
241
|
+
if (chunkSize > 0) {
|
|
242
|
+
return chunkSize;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return assetSize;
|
|
67
246
|
}
|
|
68
247
|
getTotalGzipSize() {
|
|
69
248
|
const assets = this.statsData.assets || [];
|
|
70
|
-
|
|
249
|
+
const assetGzipSize = assets.reduce((sum, asset) => sum + (asset.gzipSize || 0), 0);
|
|
250
|
+
// If assets are empty or zero, try to calculate from modules
|
|
251
|
+
if (assetGzipSize === 0) {
|
|
252
|
+
// Try top-level modules
|
|
253
|
+
if (Array.isArray(this.statsData.modules) && this.statsData.modules.length > 0) {
|
|
254
|
+
return this.statsData.modules.reduce((sum, m) => sum + (m.gzipSize || 0), 0);
|
|
255
|
+
}
|
|
256
|
+
// Try modules from chunks
|
|
257
|
+
const chunks = this.statsData.chunks || [];
|
|
258
|
+
let moduleGzipSize = 0;
|
|
259
|
+
const seenModules = new Set();
|
|
260
|
+
chunks.forEach((chunk) => {
|
|
261
|
+
if (Array.isArray(chunk.modules)) {
|
|
262
|
+
chunk.modules.forEach((m) => {
|
|
263
|
+
const moduleName = m.name || 'unknown';
|
|
264
|
+
if (!seenModules.has(moduleName)) {
|
|
265
|
+
seenModules.add(moduleName);
|
|
266
|
+
moduleGzipSize += (m.gzipSize || 0);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
if (moduleGzipSize > 0) {
|
|
272
|
+
return moduleGzipSize;
|
|
273
|
+
}
|
|
274
|
+
// Try chunk gzip sizes as fallback
|
|
275
|
+
return chunks.reduce((sum, c) => sum + (c.gzipSize || 0), 0);
|
|
276
|
+
}
|
|
277
|
+
return assetGzipSize;
|
|
71
278
|
}
|
|
72
279
|
extractChunks() {
|
|
73
280
|
return (this.statsData.chunks || []).map((chunk) => ({
|
|
@@ -110,7 +317,7 @@ class Packlyze {
|
|
|
110
317
|
});
|
|
111
318
|
}
|
|
112
319
|
// Duplicate check
|
|
113
|
-
const duplicates = this.findDuplicates();
|
|
320
|
+
const duplicates = this.findDuplicates(stats);
|
|
114
321
|
if (duplicates.length > 0) {
|
|
115
322
|
recommendations.push({
|
|
116
323
|
severity: 'warning',
|
|
@@ -128,45 +335,210 @@ class Packlyze {
|
|
|
128
335
|
}
|
|
129
336
|
return recommendations;
|
|
130
337
|
}
|
|
131
|
-
detectTreeshakingIssues() {
|
|
338
|
+
detectTreeshakingIssues(stats) {
|
|
132
339
|
const issues = [];
|
|
133
|
-
|
|
134
|
-
modules.forEach((m) => {
|
|
135
|
-
|
|
340
|
+
// Check modules from BundleStats (which includes extracted modules)
|
|
341
|
+
stats.modules.forEach((m) => {
|
|
342
|
+
// Try to get source from original stats data
|
|
343
|
+
const originalModule = Array.isArray(this.statsData.modules)
|
|
344
|
+
? this.statsData.modules.find((om) => om.name === m.name)
|
|
345
|
+
: null;
|
|
346
|
+
if (originalModule && typeof originalModule.source === 'string' &&
|
|
347
|
+
(originalModule.source.includes('module.exports') || originalModule.source.includes('require('))) {
|
|
136
348
|
issues.push(`${m.name}: Uses CommonJS - reduces tree-shaking effectiveness`);
|
|
137
349
|
}
|
|
138
350
|
});
|
|
139
351
|
return issues.slice(0, 10); // Limit to top 10
|
|
140
352
|
}
|
|
141
|
-
|
|
142
|
-
|
|
353
|
+
/**
|
|
354
|
+
* Extract package name from module path.
|
|
355
|
+
* Handles node_modules paths like:
|
|
356
|
+
* - node_modules/lodash/index.js -> lodash
|
|
357
|
+
* - node_modules/@types/node/index.d.ts -> @types/node
|
|
358
|
+
* - node_modules/react-dom/client.js -> react-dom
|
|
359
|
+
*/
|
|
360
|
+
extractPackageName(modulePath) {
|
|
361
|
+
if (!modulePath)
|
|
362
|
+
return null;
|
|
363
|
+
// Match node_modules packages
|
|
364
|
+
const nodeModulesMatch = modulePath.match(/node_modules[/\\](@[^/\\]+[/\\][^/\\]+|[^/\\]+)/);
|
|
365
|
+
if (nodeModulesMatch) {
|
|
366
|
+
return nodeModulesMatch[1].replace(/[/\\]/g, '/');
|
|
367
|
+
}
|
|
368
|
+
// For non-node_modules paths, use basename as fallback
|
|
369
|
+
// This helps catch duplicates in source code (e.g., utils/helper.js and components/helper.js)
|
|
370
|
+
return path_1.default.basename(modulePath);
|
|
371
|
+
}
|
|
372
|
+
findDuplicates(stats) {
|
|
373
|
+
// Group by package name (for node_modules) or basename (for source files)
|
|
374
|
+
const packageMap = new Map();
|
|
143
375
|
const duplicates = [];
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
376
|
+
stats.modules.forEach((module) => {
|
|
377
|
+
const moduleName = module.name || '';
|
|
378
|
+
const packageName = this.extractPackageName(moduleName) || 'unknown';
|
|
379
|
+
if (!packageMap.has(packageName)) {
|
|
380
|
+
packageMap.set(packageName, []);
|
|
148
381
|
}
|
|
149
|
-
|
|
382
|
+
packageMap.get(packageName).push(module);
|
|
150
383
|
});
|
|
151
|
-
|
|
384
|
+
// Find actual duplicates (same package/module appearing multiple times)
|
|
385
|
+
packageMap.forEach((modules) => {
|
|
152
386
|
if (modules.length > 1) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
totalSize,
|
|
158
|
-
|
|
159
|
-
|
|
387
|
+
// Only consider it a duplicate if the modules have different full paths
|
|
388
|
+
// (same package imported from different locations)
|
|
389
|
+
const uniquePaths = new Set(modules.map(m => m.name));
|
|
390
|
+
if (uniquePaths.size > 1) {
|
|
391
|
+
const totalSize = modules.reduce((s, m) => s + (m.size || 0), 0);
|
|
392
|
+
const minSize = Math.min(...modules.map(m => m.size || 0));
|
|
393
|
+
duplicates.push({
|
|
394
|
+
names: modules.map(m => m.name),
|
|
395
|
+
totalSize,
|
|
396
|
+
savings: totalSize - minSize
|
|
397
|
+
});
|
|
398
|
+
}
|
|
160
399
|
}
|
|
161
400
|
});
|
|
162
401
|
return duplicates.sort((a, b) => b.totalSize - a.totalSize).slice(0, 10);
|
|
163
402
|
}
|
|
403
|
+
analyzePackages(stats) {
|
|
404
|
+
const packageMap = new Map();
|
|
405
|
+
const bundleTotalSize = stats.size;
|
|
406
|
+
stats.modules.forEach((module) => {
|
|
407
|
+
const packageName = this.extractPackageName(module.name);
|
|
408
|
+
if (!packageName)
|
|
409
|
+
return;
|
|
410
|
+
if (!packageMap.has(packageName)) {
|
|
411
|
+
packageMap.set(packageName, { modules: [], totalGzip: 0 });
|
|
412
|
+
}
|
|
413
|
+
const pkg = packageMap.get(packageName);
|
|
414
|
+
pkg.modules.push(module);
|
|
415
|
+
if (module.gzipSize) {
|
|
416
|
+
pkg.totalGzip += module.gzipSize;
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
const packages = [];
|
|
420
|
+
packageMap.forEach((pkg, name) => {
|
|
421
|
+
const packageTotalSize = pkg.modules.reduce((sum, m) => sum + m.size, 0);
|
|
422
|
+
packages.push({
|
|
423
|
+
name,
|
|
424
|
+
totalSize: packageTotalSize,
|
|
425
|
+
gzipSize: pkg.totalGzip > 0 ? pkg.totalGzip : undefined,
|
|
426
|
+
moduleCount: pkg.modules.length,
|
|
427
|
+
modules: pkg.modules.map(m => m.name),
|
|
428
|
+
percentage: bundleTotalSize > 0 ? (packageTotalSize / bundleTotalSize) * 100 : 0
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
return packages
|
|
432
|
+
.sort((a, b) => b.totalSize - a.totalSize)
|
|
433
|
+
.slice(0, 20); // Top 20 packages
|
|
434
|
+
}
|
|
435
|
+
detectUnusedModules(stats) {
|
|
436
|
+
const unused = [];
|
|
437
|
+
const importedModules = new Set();
|
|
438
|
+
// Build set of all modules that are imported by others
|
|
439
|
+
stats.modules.forEach(module => {
|
|
440
|
+
const reasons = Array.isArray(module.reasons) ? module.reasons : [];
|
|
441
|
+
reasons.forEach(reason => {
|
|
442
|
+
// Extract module name from reason (format varies by bundler)
|
|
443
|
+
if (typeof reason === 'string' && reason !== 'entry' && reason !== 'cjs require') {
|
|
444
|
+
// Try to extract module path from reason
|
|
445
|
+
const match = reason.match(/([^\s]+\.(js|ts|jsx|tsx))/);
|
|
446
|
+
if (match && match[1]) {
|
|
447
|
+
importedModules.add(match[1]);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
// Find modules that are not entry points and not imported
|
|
453
|
+
stats.modules.forEach(module => {
|
|
454
|
+
const reasons = Array.isArray(module.reasons) ? module.reasons : [];
|
|
455
|
+
const isEntry = reasons.some(r => {
|
|
456
|
+
const reasonStr = typeof r === 'string' ? r : String(r);
|
|
457
|
+
return reasonStr === 'entry' || reasonStr.includes('entry');
|
|
458
|
+
});
|
|
459
|
+
const isImported = importedModules.has(module.name);
|
|
460
|
+
if (!isEntry && !isImported && (module.size || 0) > 0) {
|
|
461
|
+
// Additional check: module might be in a chunk but not actually used
|
|
462
|
+
const inChunk = stats.chunks.some(chunk => {
|
|
463
|
+
const chunkModules = Array.isArray(chunk.modules) ? chunk.modules : [];
|
|
464
|
+
return chunkModules.includes(module.name);
|
|
465
|
+
});
|
|
466
|
+
if (inChunk) {
|
|
467
|
+
unused.push({
|
|
468
|
+
name: module.name || 'unknown',
|
|
469
|
+
size: module.size || 0,
|
|
470
|
+
reason: 'Module in bundle but no clear import path detected'
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
return unused
|
|
476
|
+
.sort((a, b) => b.size - a.size)
|
|
477
|
+
.slice(0, 20); // Top 20 potentially unused modules
|
|
478
|
+
}
|
|
479
|
+
analyzeChunks(stats) {
|
|
480
|
+
const chunks = stats.chunks;
|
|
481
|
+
if (chunks.length === 0) {
|
|
482
|
+
return {
|
|
483
|
+
averageChunkSize: 0,
|
|
484
|
+
averageModulesPerChunk: 0,
|
|
485
|
+
largestChunk: { id: 0, name: 'N/A', size: 0, modules: [] },
|
|
486
|
+
smallestChunk: { id: 0, name: 'N/A', size: 0, modules: [] },
|
|
487
|
+
initialChunkSize: 0,
|
|
488
|
+
recommendations: []
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
const chunkSizes = chunks.map(c => c.size || 0);
|
|
492
|
+
const totalChunkSize = chunkSizes.reduce((a, b) => a + b, 0);
|
|
493
|
+
const averageChunkSize = chunks.length > 0 ? totalChunkSize / chunks.length : 0;
|
|
494
|
+
const modulesPerChunk = chunks.map(c => (c.modules && Array.isArray(c.modules)) ? c.modules.length : 0);
|
|
495
|
+
const totalModules = modulesPerChunk.reduce((a, b) => a + b, 0);
|
|
496
|
+
const averageModulesPerChunk = chunks.length > 0 ? totalModules / chunks.length : 0;
|
|
497
|
+
const sortedBySize = [...chunks].sort((a, b) => (b.size || 0) - (a.size || 0));
|
|
498
|
+
const largestChunk = sortedBySize[0] || { id: 0, name: 'N/A', size: 0, modules: [] };
|
|
499
|
+
const smallestChunk = sortedBySize[sortedBySize.length - 1] || { id: 0, name: 'N/A', size: 0, modules: [] };
|
|
500
|
+
const initialChunks = chunks.filter(c => {
|
|
501
|
+
// Try to identify initial chunks
|
|
502
|
+
const chunkInfo = stats.chunks.find(ch => ch.id === c.id);
|
|
503
|
+
return chunkInfo && 'initial' in chunkInfo && chunkInfo.initial === true;
|
|
504
|
+
});
|
|
505
|
+
const initialChunkSize = initialChunks.reduce((sum, c) => sum + (c.size || 0), 0);
|
|
506
|
+
const recommendations = [];
|
|
507
|
+
// Large chunks recommendation
|
|
508
|
+
if (averageChunkSize > 500000) {
|
|
509
|
+
recommendations.push(`Average chunk size is ${(averageChunkSize / 1024).toFixed(2)}KB - consider splitting large chunks`);
|
|
510
|
+
}
|
|
511
|
+
// Too many small chunks
|
|
512
|
+
if (chunks.length > 20 && averageChunkSize < 50000) {
|
|
513
|
+
recommendations.push(`Many small chunks (${chunks.length}) - consider combining related chunks`);
|
|
514
|
+
}
|
|
515
|
+
// Initial chunk too large
|
|
516
|
+
if (initialChunkSize > 500000) {
|
|
517
|
+
recommendations.push(`Initial chunk is ${(initialChunkSize / 1024).toFixed(2)}KB - implement code-splitting for better load times`);
|
|
518
|
+
}
|
|
519
|
+
// Chunk size imbalance
|
|
520
|
+
if (smallestChunk.size > 0 && largestChunk.size > smallestChunk.size * 10) {
|
|
521
|
+
recommendations.push(`Chunk size imbalance detected - largest chunk is ${(largestChunk.size / smallestChunk.size).toFixed(1)}x larger than smallest`);
|
|
522
|
+
}
|
|
523
|
+
return {
|
|
524
|
+
averageChunkSize,
|
|
525
|
+
averageModulesPerChunk,
|
|
526
|
+
largestChunk,
|
|
527
|
+
smallestChunk,
|
|
528
|
+
initialChunkSize,
|
|
529
|
+
recommendations
|
|
530
|
+
};
|
|
531
|
+
}
|
|
164
532
|
calculateMetrics(stats) {
|
|
165
533
|
const sizes = stats.modules.map(m => m.size);
|
|
166
534
|
const averageSize = sizes.length > 0 ? sizes.reduce((a, b) => a + b, 0) / sizes.length : 0;
|
|
535
|
+
const gzipSize = stats.gzipSize || 0;
|
|
536
|
+
// Brotli is typically 15-20% smaller than gzip, estimate at 17% reduction
|
|
537
|
+
const brotliSize = gzipSize > 0 ? Math.round(gzipSize * 0.83) : undefined;
|
|
167
538
|
return {
|
|
168
539
|
totalSize: stats.size,
|
|
169
|
-
totalGzipSize:
|
|
540
|
+
totalGzipSize: gzipSize,
|
|
541
|
+
totalBrotliSize: brotliSize,
|
|
170
542
|
moduleCount: stats.modules.length,
|
|
171
543
|
chunkCount: stats.chunks.length,
|
|
172
544
|
largestModule: stats.modules[0] || {
|