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