bundle-cop-vercel-plugin 0.1.0
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 +56 -0
- package/dist/index.cjs +655 -0
- package/dist/index.d.cts +109 -0
- package/dist/index.d.ts +109 -0
- package/dist/index.js +616 -0
- package/next-adapter.cjs +9 -0
- package/package.json +70 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { join as join5 } from "path";
|
|
3
|
+
|
|
4
|
+
// src/budgets.ts
|
|
5
|
+
import { readFileSync, existsSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
var SIZE_RE = /^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/i;
|
|
8
|
+
function parseSize(input) {
|
|
9
|
+
const match = input.trim().match(SIZE_RE);
|
|
10
|
+
if (!match) {
|
|
11
|
+
throw new Error(`Invalid size: ${input}`);
|
|
12
|
+
}
|
|
13
|
+
const value = Number(match[1]);
|
|
14
|
+
const unit = (match[2] || "b").toLowerCase();
|
|
15
|
+
switch (unit) {
|
|
16
|
+
case "b":
|
|
17
|
+
return value;
|
|
18
|
+
case "kb":
|
|
19
|
+
return Math.round(value * 1024);
|
|
20
|
+
case "mb":
|
|
21
|
+
return Math.round(value * 1024 * 1024);
|
|
22
|
+
case "gb":
|
|
23
|
+
return Math.round(value * 1024 * 1024 * 1024);
|
|
24
|
+
default:
|
|
25
|
+
throw new Error(`Invalid size unit: ${unit}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function loadConfig(projectDir) {
|
|
29
|
+
const path = join(projectDir, "bundle-cop.config.json");
|
|
30
|
+
if (!existsSync(path)) {
|
|
31
|
+
return {
|
|
32
|
+
budgets: [],
|
|
33
|
+
ignore: [],
|
|
34
|
+
githubComment: true,
|
|
35
|
+
suggestions: true
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.warn("[bundle-cop] Failed to parse bundle-cop.config.json:", error);
|
|
42
|
+
return { budgets: [], suggestions: true, githubComment: true };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function checkBudgets(report, config) {
|
|
46
|
+
const budgets = config.budgets ?? [];
|
|
47
|
+
return budgets.map((rule) => {
|
|
48
|
+
const maxBytes = parseSize(rule.maxSize);
|
|
49
|
+
const matching = report.chunks.filter((chunk) => {
|
|
50
|
+
if (rule.path === "/*" || rule.path === "/") return true;
|
|
51
|
+
const route = chunk.route || "";
|
|
52
|
+
return route === rule.path || route.startsWith(rule.path.replace(/\/$/, ""));
|
|
53
|
+
});
|
|
54
|
+
const actualBytes = matching.length > 0 ? matching.reduce((sum, c) => sum + c.size, 0) : report.totalBytes;
|
|
55
|
+
return {
|
|
56
|
+
path: rule.path,
|
|
57
|
+
maxBytes,
|
|
58
|
+
actualBytes,
|
|
59
|
+
enforce: rule.enforce,
|
|
60
|
+
exceeded: actualBytes > maxBytes
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function enforceBudgets(results) {
|
|
65
|
+
const errors = results.filter((r) => r.exceeded && r.enforce === "error");
|
|
66
|
+
for (const warn of results.filter((r) => r.exceeded && r.enforce === "warn")) {
|
|
67
|
+
console.warn(
|
|
68
|
+
`[bundle-cop] Budget warn ${warn.path}: ${formatBytes(warn.actualBytes)} > ${formatBytes(warn.maxBytes)}`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (errors.length === 0) return;
|
|
72
|
+
const details = errors.map(
|
|
73
|
+
(e) => `${e.path}: ${formatBytes(e.actualBytes)} exceeds ${formatBytes(e.maxBytes)}`
|
|
74
|
+
).join("; ");
|
|
75
|
+
throw new Error(`[bundle-cop] Budget exceeded \u2014 ${details}`);
|
|
76
|
+
}
|
|
77
|
+
function formatBytes(bytes) {
|
|
78
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
79
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB`;
|
|
80
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/find-next-dir.ts
|
|
84
|
+
import { existsSync as existsSync2, readdirSync, statSync } from "fs";
|
|
85
|
+
import { join as join2 } from "path";
|
|
86
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
87
|
+
"node_modules",
|
|
88
|
+
".git",
|
|
89
|
+
"dist",
|
|
90
|
+
"coverage",
|
|
91
|
+
".turbo",
|
|
92
|
+
".vercel"
|
|
93
|
+
]);
|
|
94
|
+
function findNextDir(startDir, preferredDir) {
|
|
95
|
+
if (preferredDir && existsSync2(preferredDir)) {
|
|
96
|
+
return preferredDir;
|
|
97
|
+
}
|
|
98
|
+
const direct = join2(startDir, ".next");
|
|
99
|
+
if (existsSync2(direct)) {
|
|
100
|
+
return direct;
|
|
101
|
+
}
|
|
102
|
+
let current = startDir;
|
|
103
|
+
for (let i = 0; i < 5; i++) {
|
|
104
|
+
const found = walkForNext(current, 4);
|
|
105
|
+
if (found) return found;
|
|
106
|
+
const parent = join2(current, "..");
|
|
107
|
+
if (parent === current) break;
|
|
108
|
+
current = parent;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
function walkForNext(dir, depth) {
|
|
113
|
+
if (depth < 0) return null;
|
|
114
|
+
let entries;
|
|
115
|
+
try {
|
|
116
|
+
entries = readdirSync(dir);
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const nested = join2(dir, ".next");
|
|
121
|
+
if (existsSync2(nested)) return nested;
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (SKIP_DIRS.has(entry) || entry.startsWith(".")) continue;
|
|
124
|
+
const full = join2(dir, entry);
|
|
125
|
+
try {
|
|
126
|
+
if (!statSync(full).isDirectory()) continue;
|
|
127
|
+
} catch {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const found = walkForNext(full, depth - 1);
|
|
131
|
+
if (found) return found;
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/parser.ts
|
|
137
|
+
import {
|
|
138
|
+
existsSync as existsSync3,
|
|
139
|
+
readdirSync as readdirSync2,
|
|
140
|
+
readFileSync as readFileSync2,
|
|
141
|
+
statSync as statSync2
|
|
142
|
+
} from "fs";
|
|
143
|
+
import { join as join3, relative } from "path";
|
|
144
|
+
|
|
145
|
+
// src/attribution.ts
|
|
146
|
+
function isUserFile(filePath) {
|
|
147
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
148
|
+
if (!normalized) return false;
|
|
149
|
+
if (normalized.includes("node_modules/")) return false;
|
|
150
|
+
if (normalized.includes("/.next/")) return false;
|
|
151
|
+
if (normalized.startsWith(".next/")) return false;
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
function toPath(entry) {
|
|
155
|
+
if (typeof entry === "string") return entry;
|
|
156
|
+
return entry.path || entry.name || "";
|
|
157
|
+
}
|
|
158
|
+
function toName(entry) {
|
|
159
|
+
if (typeof entry === "string") return entry;
|
|
160
|
+
return entry.name || entry.path || "";
|
|
161
|
+
}
|
|
162
|
+
function findCulprit(module) {
|
|
163
|
+
const raw = module.issuerPath ?? [];
|
|
164
|
+
const chain = raw.map(toName).filter(Boolean);
|
|
165
|
+
for (let i = raw.length - 1; i >= 0; i--) {
|
|
166
|
+
const path = toPath(raw[i]);
|
|
167
|
+
if (isUserFile(path)) {
|
|
168
|
+
return { culpritFile: path, chain };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (isUserFile(module.name)) {
|
|
172
|
+
return { culpritFile: module.name, chain };
|
|
173
|
+
}
|
|
174
|
+
return { culpritFile: "unknown", chain };
|
|
175
|
+
}
|
|
176
|
+
var ALTERNATIVES = {
|
|
177
|
+
moment: "date-fns (save ~270kb) -> npm i date-fns",
|
|
178
|
+
lodash: "lodash-es + tree-shake or individual imports",
|
|
179
|
+
"react-icons": "import directly: react-icons/fa/FaIcon",
|
|
180
|
+
"date-fns": "use subpath imports: date-fns/format"
|
|
181
|
+
};
|
|
182
|
+
function suggestAlternative(moduleName) {
|
|
183
|
+
const normalized = moduleName.replace(/\\/g, "/").toLowerCase();
|
|
184
|
+
for (const [key, suggestion] of Object.entries(ALTERNATIVES)) {
|
|
185
|
+
if (normalized === key || normalized.includes(`node_modules/${key}/`) || normalized.startsWith(`${key}/`) || normalized.includes(`/${key}/`)) {
|
|
186
|
+
return suggestion;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/parser.ts
|
|
193
|
+
var KNOWN_HEAVY = [
|
|
194
|
+
"moment",
|
|
195
|
+
"lodash",
|
|
196
|
+
"lodash-es",
|
|
197
|
+
"date-fns",
|
|
198
|
+
"react-icons",
|
|
199
|
+
"rxjs",
|
|
200
|
+
"antd",
|
|
201
|
+
"chart.js",
|
|
202
|
+
"recharts"
|
|
203
|
+
];
|
|
204
|
+
async function parseStats(options) {
|
|
205
|
+
const {
|
|
206
|
+
nextDir,
|
|
207
|
+
projectDir,
|
|
208
|
+
commitSha = null,
|
|
209
|
+
withSuggestions = true
|
|
210
|
+
} = options;
|
|
211
|
+
const fromDiagnostics = tryParseDiagnostics(nextDir, withSuggestions);
|
|
212
|
+
if (fromDiagnostics && fromDiagnostics.modules.length > 0) {
|
|
213
|
+
return finalize(fromDiagnostics, commitSha);
|
|
214
|
+
}
|
|
215
|
+
const fromAnalyze = tryParseAnalyzeClient(nextDir, withSuggestions);
|
|
216
|
+
if (fromAnalyze && fromAnalyze.modules.length > 0) {
|
|
217
|
+
return finalize(fromAnalyze, commitSha);
|
|
218
|
+
}
|
|
219
|
+
const fromChunks = parseChunkFallback(nextDir);
|
|
220
|
+
const fromRouteStats = parseRouteBundleStats(nextDir);
|
|
221
|
+
const chunks = fromRouteStats.chunks.length > 0 ? fromRouteStats.chunks : fromChunks.chunks;
|
|
222
|
+
const totalBytes = fromRouteStats.totalBytes || fromChunks.totalBytes || chunks.reduce((s, c) => s + c.size, 0);
|
|
223
|
+
const modules = enrichWithKnownPackages({
|
|
224
|
+
nextDir,
|
|
225
|
+
projectDir: projectDir || join3(nextDir, ".."),
|
|
226
|
+
withSuggestions
|
|
227
|
+
});
|
|
228
|
+
return finalize(
|
|
229
|
+
{
|
|
230
|
+
totalBytes,
|
|
231
|
+
modules: sortBySize(modules),
|
|
232
|
+
chunks: sortBySize(chunks)
|
|
233
|
+
},
|
|
234
|
+
commitSha
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
function finalize(partial, commitSha) {
|
|
238
|
+
return {
|
|
239
|
+
version: 1,
|
|
240
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
241
|
+
commitSha,
|
|
242
|
+
...partial
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function tryParseDiagnostics(nextDir, withSuggestions) {
|
|
246
|
+
const dir = join3(nextDir, "diagnostics", "analyze");
|
|
247
|
+
if (!existsSync3(dir)) return null;
|
|
248
|
+
const modules = [];
|
|
249
|
+
const chunks = [];
|
|
250
|
+
for (const file of listJsonFiles(dir)) {
|
|
251
|
+
try {
|
|
252
|
+
const data = JSON.parse(readFileSync2(file, "utf8"));
|
|
253
|
+
absorbStats(data, modules, chunks, withSuggestions);
|
|
254
|
+
} catch {
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (modules.length === 0 && chunks.length === 0) return null;
|
|
258
|
+
const totalBytes = chunks.reduce((s, c) => s + c.size, 0) || modules.reduce((s, m) => s + m.size, 0);
|
|
259
|
+
return { totalBytes, modules: sortBySize(modules), chunks: sortBySize(chunks) };
|
|
260
|
+
}
|
|
261
|
+
function tryParseAnalyzeClient(nextDir, withSuggestions) {
|
|
262
|
+
const clientPath = join3(nextDir, "analyze", "client.json");
|
|
263
|
+
if (!existsSync3(clientPath)) return null;
|
|
264
|
+
try {
|
|
265
|
+
const data = JSON.parse(readFileSync2(clientPath, "utf8"));
|
|
266
|
+
const modules = [];
|
|
267
|
+
const chunks = [];
|
|
268
|
+
absorbStats(data, modules, chunks, withSuggestions);
|
|
269
|
+
const totalBytes = chunks.reduce((s, c) => s + c.size, 0) || modules.reduce((s, m) => s + m.size, 0);
|
|
270
|
+
return {
|
|
271
|
+
totalBytes,
|
|
272
|
+
modules: sortBySize(modules),
|
|
273
|
+
chunks: sortBySize(chunks)
|
|
274
|
+
};
|
|
275
|
+
} catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function parseRouteBundleStats(nextDir) {
|
|
280
|
+
const path = join3(nextDir, "diagnostics", "route-bundle-stats.json");
|
|
281
|
+
if (!existsSync3(path)) {
|
|
282
|
+
return { totalBytes: 0, modules: [], chunks: [] };
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const rows = JSON.parse(readFileSync2(path, "utf8"));
|
|
286
|
+
const chunks = rows.map((row) => ({
|
|
287
|
+
name: row.route || "unknown",
|
|
288
|
+
size: Number(row.firstLoadUncompressedJsBytes || 0),
|
|
289
|
+
route: row.route
|
|
290
|
+
}));
|
|
291
|
+
const totalBytes = Math.max(...chunks.map((c) => c.size), 0);
|
|
292
|
+
return { totalBytes, modules: [], chunks: sortBySize(chunks) };
|
|
293
|
+
} catch {
|
|
294
|
+
return { totalBytes: 0, modules: [], chunks: [] };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function parseChunkFallback(nextDir) {
|
|
298
|
+
const chunksDir = join3(nextDir, "static", "chunks");
|
|
299
|
+
const chunks = [];
|
|
300
|
+
if (existsSync3(chunksDir)) {
|
|
301
|
+
for (const file of walkFiles(chunksDir)) {
|
|
302
|
+
if (!file.endsWith(".js")) continue;
|
|
303
|
+
try {
|
|
304
|
+
const size = statSync2(file).size;
|
|
305
|
+
const name = file.slice(chunksDir.length + 1);
|
|
306
|
+
chunks.push({ name, size, route: guessRoute(name) });
|
|
307
|
+
} catch {
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
for (const manifestName of ["build-manifest.json", "app-build-manifest.json"]) {
|
|
312
|
+
const manifestPath = join3(nextDir, manifestName);
|
|
313
|
+
if (!existsSync3(manifestPath)) continue;
|
|
314
|
+
try {
|
|
315
|
+
const manifest = JSON.parse(readFileSync2(manifestPath, "utf8"));
|
|
316
|
+
for (const [route, files] of Object.entries(manifest.pages ?? {})) {
|
|
317
|
+
for (const file of files) {
|
|
318
|
+
const abs = join3(nextDir, file.replace(/^\//, ""));
|
|
319
|
+
if (!existsSync3(abs)) continue;
|
|
320
|
+
const size = statSync2(abs).size;
|
|
321
|
+
if (!chunks.some((c) => c.name === file)) {
|
|
322
|
+
chunks.push({ name: file, size, route });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
} catch {
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const totalBytes = chunks.reduce((s, c) => s + c.size, 0);
|
|
330
|
+
return { totalBytes, modules: [], chunks: sortBySize(chunks) };
|
|
331
|
+
}
|
|
332
|
+
function enrichWithKnownPackages(opts) {
|
|
333
|
+
const chunksDir = join3(opts.nextDir, "static", "chunks");
|
|
334
|
+
const serverDir = join3(opts.nextDir, "server");
|
|
335
|
+
const scanDirs = [chunksDir, serverDir].filter((d) => existsSync3(d));
|
|
336
|
+
if (scanDirs.length === 0) return [];
|
|
337
|
+
const present = /* @__PURE__ */ new Set();
|
|
338
|
+
for (const dir of scanDirs) {
|
|
339
|
+
for (const file of walkFiles(dir)) {
|
|
340
|
+
if (!file.endsWith(".js")) continue;
|
|
341
|
+
let text = "";
|
|
342
|
+
try {
|
|
343
|
+
const buf = readFileSync2(file);
|
|
344
|
+
text = buf.subarray(0, Math.min(buf.length, 2e6)).toString("utf8");
|
|
345
|
+
} catch {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
for (const pkg of KNOWN_HEAVY) {
|
|
349
|
+
if (text.includes(`node_modules/${pkg}`) || text.includes(`/${pkg}/`) || text.includes(`"${pkg}"`) || text.includes(`'${pkg}'`)) {
|
|
350
|
+
present.add(pkg);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const modules = [];
|
|
356
|
+
for (const pkg of present) {
|
|
357
|
+
const size = estimatePackageChunkSize(scanDirs, pkg);
|
|
358
|
+
const culpritFile = findImportCulprit(opts.projectDir, pkg) || "unknown";
|
|
359
|
+
modules.push({
|
|
360
|
+
name: pkg,
|
|
361
|
+
size: size || 50 * 1024,
|
|
362
|
+
culpritFile,
|
|
363
|
+
chain: [pkg, culpritFile],
|
|
364
|
+
issuerPath: [pkg, culpritFile],
|
|
365
|
+
suggestion: opts.withSuggestions ? suggestAlternative(pkg) : null
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
return modules;
|
|
369
|
+
}
|
|
370
|
+
function estimatePackageChunkSize(dirs, pkg) {
|
|
371
|
+
let best = 0;
|
|
372
|
+
for (const dir of dirs) {
|
|
373
|
+
for (const file of walkFiles(dir)) {
|
|
374
|
+
if (!file.endsWith(".js")) continue;
|
|
375
|
+
try {
|
|
376
|
+
const buf = readFileSync2(file);
|
|
377
|
+
const text = buf.subarray(0, Math.min(buf.length, 5e5)).toString("utf8");
|
|
378
|
+
if (text.includes(pkg)) {
|
|
379
|
+
best = Math.max(best, statSync2(file).size);
|
|
380
|
+
}
|
|
381
|
+
} catch {
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return best;
|
|
386
|
+
}
|
|
387
|
+
function findImportCulprit(projectDir, pkg) {
|
|
388
|
+
const roots = ["app", "pages", "src", "components"];
|
|
389
|
+
for (const root of roots) {
|
|
390
|
+
const dir = join3(projectDir, root);
|
|
391
|
+
if (!existsSync3(dir)) continue;
|
|
392
|
+
const hit = searchImport(dir, pkg, projectDir, 6);
|
|
393
|
+
if (hit) return hit;
|
|
394
|
+
}
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
function searchImport(dir, pkg, projectDir, depth) {
|
|
398
|
+
if (depth < 0) return null;
|
|
399
|
+
let entries;
|
|
400
|
+
try {
|
|
401
|
+
entries = readdirSync2(dir);
|
|
402
|
+
} catch {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
for (const entry of entries) {
|
|
406
|
+
if (entry === "node_modules" || entry.startsWith(".")) continue;
|
|
407
|
+
const full = join3(dir, entry);
|
|
408
|
+
let st;
|
|
409
|
+
try {
|
|
410
|
+
st = statSync2(full);
|
|
411
|
+
} catch {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (st.isDirectory()) {
|
|
415
|
+
const found = searchImport(full, pkg, projectDir, depth - 1);
|
|
416
|
+
if (found) return found;
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (!/\.(tsx?|jsx?|mjs|cjs)$/.test(entry)) continue;
|
|
420
|
+
try {
|
|
421
|
+
const text = readFileSync2(full, "utf8");
|
|
422
|
+
if (text.includes(`from '${pkg}'`) || text.includes(`from "${pkg}"`) || text.includes(`require('${pkg}')`) || text.includes(`require("${pkg}")`) || text.includes(`from '${pkg}/`) || text.includes(`from "${pkg}/`)) {
|
|
423
|
+
return relative(projectDir, full).replace(/\\/g, "/");
|
|
424
|
+
}
|
|
425
|
+
} catch {
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
function absorbStats(data, modules, chunks, withSuggestions) {
|
|
431
|
+
if (!data || typeof data !== "object") return;
|
|
432
|
+
const root = data;
|
|
433
|
+
if (Array.isArray(root.chunks)) {
|
|
434
|
+
for (const chunk of root.chunks) {
|
|
435
|
+
chunks.push({
|
|
436
|
+
name: String(chunk.name ?? chunk.id ?? "chunk"),
|
|
437
|
+
size: Number(chunk.size ?? chunk.parsedSize ?? 0),
|
|
438
|
+
route: typeof chunk.route === "string" ? chunk.route : void 0
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const moduleList = [];
|
|
443
|
+
collectModules(root.modules, moduleList);
|
|
444
|
+
collectModules(root.tree, moduleList);
|
|
445
|
+
for (const mod of moduleList) {
|
|
446
|
+
const size = Number(mod.size ?? mod.gzipSize ?? 0);
|
|
447
|
+
if (!mod.name || size <= 0) continue;
|
|
448
|
+
const { culpritFile, chain } = findCulprit(mod);
|
|
449
|
+
modules.push({
|
|
450
|
+
name: mod.name,
|
|
451
|
+
size,
|
|
452
|
+
issuerPath: chain,
|
|
453
|
+
culpritFile,
|
|
454
|
+
chain,
|
|
455
|
+
suggestion: withSuggestions ? suggestAlternative(mod.name) : null
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
function collectModules(node, out) {
|
|
460
|
+
if (!node) return;
|
|
461
|
+
if (Array.isArray(node)) {
|
|
462
|
+
for (const item of node) collectModules(item, out);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (typeof node !== "object") return;
|
|
466
|
+
const obj = node;
|
|
467
|
+
if (typeof obj.name === "string" && (obj.size != null || obj.gzipSize != null)) {
|
|
468
|
+
out.push(obj);
|
|
469
|
+
}
|
|
470
|
+
if (Array.isArray(obj.children)) collectModules(obj.children, out);
|
|
471
|
+
if (Array.isArray(obj.modules)) collectModules(obj.modules, out);
|
|
472
|
+
}
|
|
473
|
+
function listJsonFiles(dir) {
|
|
474
|
+
return walkFiles(dir).filter((f) => f.endsWith(".json"));
|
|
475
|
+
}
|
|
476
|
+
function walkFiles(dir) {
|
|
477
|
+
const out = [];
|
|
478
|
+
let entries;
|
|
479
|
+
try {
|
|
480
|
+
entries = readdirSync2(dir);
|
|
481
|
+
} catch {
|
|
482
|
+
return out;
|
|
483
|
+
}
|
|
484
|
+
for (const entry of entries) {
|
|
485
|
+
const full = join3(dir, entry);
|
|
486
|
+
try {
|
|
487
|
+
const st = statSync2(full);
|
|
488
|
+
if (st.isDirectory()) out.push(...walkFiles(full));
|
|
489
|
+
else out.push(full);
|
|
490
|
+
} catch {
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return out;
|
|
494
|
+
}
|
|
495
|
+
function guessRoute(chunkName) {
|
|
496
|
+
const match = chunkName.match(/(?:^|\/)app(\/.*)\/(?:page|layout|route)/);
|
|
497
|
+
if (match?.[1]) return match[1] || "/";
|
|
498
|
+
return void 0;
|
|
499
|
+
}
|
|
500
|
+
function sortBySize(items) {
|
|
501
|
+
return [...items].sort((a, b) => b.size - a.size);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// src/uploader.ts
|
|
505
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
506
|
+
import { dirname, join as join4 } from "path";
|
|
507
|
+
function writeLocalReport(projectDir, report) {
|
|
508
|
+
const outPaths = [
|
|
509
|
+
join4(projectDir, "bundle-report.json"),
|
|
510
|
+
join4(projectDir, ".vercel", "output", "static", "bundle-report.json")
|
|
511
|
+
];
|
|
512
|
+
let primary = outPaths[0];
|
|
513
|
+
for (const outPath of outPaths) {
|
|
514
|
+
try {
|
|
515
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
516
|
+
writeFileSync(outPath, JSON.stringify(report, null, 2));
|
|
517
|
+
primary = outPath;
|
|
518
|
+
} catch (error) {
|
|
519
|
+
console.warn(`[bundle-cop] Could not write ${outPath}:`, error);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return primary;
|
|
523
|
+
}
|
|
524
|
+
async function uploadReport(report) {
|
|
525
|
+
const token = process.env.BLOB_READ_WRITE_TOKEN;
|
|
526
|
+
const sha = report.commitSha || process.env.VERCEL_GIT_COMMIT_SHA || process.env.GITHUB_SHA || "local";
|
|
527
|
+
if (!token) {
|
|
528
|
+
console.warn(
|
|
529
|
+
"[bundle-cop] BLOB_READ_WRITE_TOKEN not set \u2014 skipping Blob upload"
|
|
530
|
+
);
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
const { put } = await import("@vercel/blob");
|
|
535
|
+
const pathname = `bundle-reports/${sha}.json`;
|
|
536
|
+
const result = await put(pathname, JSON.stringify(report), {
|
|
537
|
+
access: "private",
|
|
538
|
+
contentType: "application/json",
|
|
539
|
+
addRandomSuffix: false,
|
|
540
|
+
allowOverwrite: true,
|
|
541
|
+
token
|
|
542
|
+
});
|
|
543
|
+
console.log(`[bundle-cop] Uploaded report to Blob: ${pathname}`);
|
|
544
|
+
return result.url;
|
|
545
|
+
} catch (error) {
|
|
546
|
+
console.warn("[bundle-cop] Blob upload failed:", error);
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// src/index.ts
|
|
552
|
+
async function runBundleCop(ctx) {
|
|
553
|
+
const nextDir = findNextDir(ctx.projectDir, ctx.distDir) || join5(ctx.projectDir, ".next");
|
|
554
|
+
const config = loadConfig(ctx.projectDir);
|
|
555
|
+
const report = await parseStats({
|
|
556
|
+
nextDir,
|
|
557
|
+
projectDir: ctx.projectDir,
|
|
558
|
+
commitSha: process.env.VERCEL_GIT_COMMIT_SHA || process.env.GITHUB_SHA || null,
|
|
559
|
+
withSuggestions: config.suggestions !== false
|
|
560
|
+
});
|
|
561
|
+
const budgetResults = checkBudgets(report, config);
|
|
562
|
+
report.budgetResults = budgetResults;
|
|
563
|
+
writeLocalReport(ctx.projectDir, report);
|
|
564
|
+
await uploadReport(report);
|
|
565
|
+
enforceBudgets(budgetResults);
|
|
566
|
+
console.log(
|
|
567
|
+
`[bundle-cop] Report total=${report.totalBytes}B modules=${report.modules.length} chunks=${report.chunks.length}`
|
|
568
|
+
);
|
|
569
|
+
return report;
|
|
570
|
+
}
|
|
571
|
+
var adapter = {
|
|
572
|
+
name: "bundle-cop",
|
|
573
|
+
async modifyConfig(config, ctx) {
|
|
574
|
+
if (ctx.phase !== "phase-production-build") {
|
|
575
|
+
return config;
|
|
576
|
+
}
|
|
577
|
+
if (!process.env.BUNDLE_COP_ANALYZE) {
|
|
578
|
+
process.env.BUNDLE_COP_ANALYZE = "1";
|
|
579
|
+
}
|
|
580
|
+
const experimental = typeof config.experimental === "object" && config.experimental ? { ...config.experimental } : {};
|
|
581
|
+
return {
|
|
582
|
+
...config,
|
|
583
|
+
experimental
|
|
584
|
+
};
|
|
585
|
+
},
|
|
586
|
+
async onBuildComplete(ctx) {
|
|
587
|
+
const started = Date.now();
|
|
588
|
+
try {
|
|
589
|
+
await runBundleCop({
|
|
590
|
+
projectDir: ctx.projectDir || ctx.repoRoot,
|
|
591
|
+
distDir: ctx.distDir
|
|
592
|
+
});
|
|
593
|
+
} catch (error) {
|
|
594
|
+
if (error instanceof Error && error.message.startsWith("[bundle-cop] Budget exceeded")) {
|
|
595
|
+
throw error;
|
|
596
|
+
}
|
|
597
|
+
console.warn("[bundle-cop] onBuildComplete error:", error);
|
|
598
|
+
} finally {
|
|
599
|
+
console.log(`[bundle-cop] finished in ${Date.now() - started}ms`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
var index_default = adapter;
|
|
604
|
+
export {
|
|
605
|
+
adapter,
|
|
606
|
+
checkBudgets,
|
|
607
|
+
index_default as default,
|
|
608
|
+
findCulprit,
|
|
609
|
+
findNextDir,
|
|
610
|
+
formatBytes,
|
|
611
|
+
loadConfig,
|
|
612
|
+
parseSize,
|
|
613
|
+
parseStats,
|
|
614
|
+
runBundleCop,
|
|
615
|
+
suggestAlternative
|
|
616
|
+
};
|
package/next-adapter.cjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Next.js loads adapters via `import(pathToFileURL(require.resolve(...)))`
|
|
5
|
+
* and then `interopDefault`. Exporting the adapter object as `module.exports`
|
|
6
|
+
* (not a namespace with `.default`) makes `onBuildComplete` available.
|
|
7
|
+
*/
|
|
8
|
+
const mod = require('./dist/index.cjs')
|
|
9
|
+
module.exports = mod.adapter || mod.default || mod
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bundle-cop-vercel-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Next.js adapter that attributes bundle cost to source files and enforces budgets",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./adapter": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"require": "./next-adapter.cjs",
|
|
19
|
+
"import": "./next-adapter.cjs",
|
|
20
|
+
"default": "./next-adapter.cjs"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"next-adapter.cjs",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
30
|
+
"dev": "tsup src/index.ts --format esm,cjs --dts --watch",
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"prepublishOnly": "pnpm run build"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"next": ">=15"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@vercel/blob": "^2.8.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^22.13.10",
|
|
45
|
+
"next": "^16.3.3",
|
|
46
|
+
"tsup": "^8.4.0",
|
|
47
|
+
"typescript": "^5.8.2"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"keywords": [
|
|
53
|
+
"nextjs",
|
|
54
|
+
"vercel",
|
|
55
|
+
"bundle",
|
|
56
|
+
"webpack",
|
|
57
|
+
"turbopack",
|
|
58
|
+
"adapter",
|
|
59
|
+
"budget"
|
|
60
|
+
],
|
|
61
|
+
"repository": {
|
|
62
|
+
"type": "git",
|
|
63
|
+
"url": "git+https://github.com/eboxnadeem/bundle-cop.git",
|
|
64
|
+
"directory": "packages/vercel-plugin"
|
|
65
|
+
},
|
|
66
|
+
"bugs": {
|
|
67
|
+
"url": "https://github.com/eboxnadeem/bundle-cop/issues"
|
|
68
|
+
},
|
|
69
|
+
"homepage": "https://github.com/eboxnadeem/bundle-cop#readme"
|
|
70
|
+
}
|