bundle-cost-cli 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/LICENSE +21 -0
- package/README.md +143 -0
- package/dist/bin.cjs +343 -0
- package/dist/bin.js +320 -0
- package/dist/index.cjs +369 -0
- package/dist/index.d.cts +110 -0
- package/dist/index.d.ts +110 -0
- package/dist/index.js +323 -0
- package/package.json +73 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
|
|
7
|
+
// src/color.ts
|
|
8
|
+
function wrap(open, close) {
|
|
9
|
+
return (text) => `\x1B[${open}m${text}\x1B[${close}m`;
|
|
10
|
+
}
|
|
11
|
+
var identity = (text) => text;
|
|
12
|
+
function createColors(enabled) {
|
|
13
|
+
if (!enabled) {
|
|
14
|
+
return { green: identity, red: identity, dim: identity, cyan: identity, bold: identity };
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
green: wrap(32, 39),
|
|
18
|
+
red: wrap(31, 39),
|
|
19
|
+
dim: wrap(2, 22),
|
|
20
|
+
cyan: wrap(36, 39),
|
|
21
|
+
bold: wrap(1, 22)
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/format.ts
|
|
26
|
+
var UNITS = ["B", "KB", "MB", "GB", "TB"];
|
|
27
|
+
var SIZE_RE = /^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)?$/i;
|
|
28
|
+
var MULTIPLIERS = {
|
|
29
|
+
b: 1,
|
|
30
|
+
kb: 1024,
|
|
31
|
+
mb: 1024 ** 2,
|
|
32
|
+
gb: 1024 ** 3,
|
|
33
|
+
tb: 1024 ** 4
|
|
34
|
+
};
|
|
35
|
+
function formatBytes(bytes) {
|
|
36
|
+
if (bytes < 1) return "0 B";
|
|
37
|
+
const exponent = Math.min(
|
|
38
|
+
Math.floor(Math.log(bytes) / Math.log(1024)),
|
|
39
|
+
UNITS.length - 1
|
|
40
|
+
);
|
|
41
|
+
const value = bytes / 1024 ** exponent;
|
|
42
|
+
const rendered = exponent === 0 ? String(Math.round(value)) : value.toFixed(2);
|
|
43
|
+
return `${rendered} ${UNITS[exponent]}`;
|
|
44
|
+
}
|
|
45
|
+
function formatDelta(bytes) {
|
|
46
|
+
if (bytes === 0) return "0 B";
|
|
47
|
+
const sign = bytes > 0 ? "+" : "-";
|
|
48
|
+
return `${sign}${formatBytes(Math.abs(bytes))}`;
|
|
49
|
+
}
|
|
50
|
+
function parseSize(input) {
|
|
51
|
+
const match = SIZE_RE.exec(input.trim());
|
|
52
|
+
if (!match) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Invalid size: "${input}" (try "50kb", "1.5mb", or a raw byte count)`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const value = Number(match[1]);
|
|
58
|
+
const unit = (match[2] ?? "b").toLowerCase();
|
|
59
|
+
return Math.round(value * MULTIPLIERS[unit]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/render.ts
|
|
63
|
+
var plainCell = (text) => ({ plain: text, colored: text });
|
|
64
|
+
function colorDelta(delta, metric, colors) {
|
|
65
|
+
if (delta === null) return { plain: "new", colored: colors.cyan("new") };
|
|
66
|
+
const value = delta[metric];
|
|
67
|
+
const text = formatDelta(value);
|
|
68
|
+
if (value < 0) return { plain: text, colored: colors.green(text) };
|
|
69
|
+
if (value > 0) return { plain: text, colored: colors.red(text) };
|
|
70
|
+
return { plain: text, colored: colors.dim(text) };
|
|
71
|
+
}
|
|
72
|
+
function buildCells(label, size, delta, metric, hasDelta, options, bold) {
|
|
73
|
+
const cells = [
|
|
74
|
+
{ plain: label, colored: bold ? options.colors.bold(label) : label },
|
|
75
|
+
plainCell(formatBytes(size.raw))
|
|
76
|
+
];
|
|
77
|
+
if (options.gzip) cells.push(plainCell(formatBytes(size.gzip)));
|
|
78
|
+
if (options.brotli) cells.push(plainCell(formatBytes(size.brotli)));
|
|
79
|
+
if (hasDelta) cells.push(colorDelta(delta, metric, options.colors));
|
|
80
|
+
return cells;
|
|
81
|
+
}
|
|
82
|
+
function renderJson(report) {
|
|
83
|
+
return JSON.stringify(
|
|
84
|
+
{
|
|
85
|
+
files: report.rows.map((row) => ({
|
|
86
|
+
path: row.path,
|
|
87
|
+
raw: row.size.raw,
|
|
88
|
+
gzip: row.size.gzip,
|
|
89
|
+
brotli: row.size.brotli,
|
|
90
|
+
delta: row.delta
|
|
91
|
+
})),
|
|
92
|
+
total: {
|
|
93
|
+
raw: report.total.size.raw,
|
|
94
|
+
gzip: report.total.size.gzip,
|
|
95
|
+
brotli: report.total.size.brotli,
|
|
96
|
+
delta: report.total.delta
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
null,
|
|
100
|
+
2
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
function renderTable(report, options) {
|
|
104
|
+
const metric = options.gzip ? "gzip" : "raw";
|
|
105
|
+
const hasDelta = report.rows.some((row) => row.delta !== null) || report.total.delta !== null;
|
|
106
|
+
const headers = ["File", "Raw"];
|
|
107
|
+
if (options.gzip) headers.push("Gzip");
|
|
108
|
+
if (options.brotli) headers.push("Brotli");
|
|
109
|
+
if (hasDelta) headers.push(`\u0394 ${metric}`);
|
|
110
|
+
const headerCells = headers.map((header) => ({
|
|
111
|
+
plain: header,
|
|
112
|
+
colored: options.colors.dim(header)
|
|
113
|
+
}));
|
|
114
|
+
const bodyRows = report.rows.map(
|
|
115
|
+
(row) => buildCells(row.path, row.size, row.delta, metric, hasDelta, options, false)
|
|
116
|
+
);
|
|
117
|
+
const totalRow = buildCells(
|
|
118
|
+
"total",
|
|
119
|
+
report.total.size,
|
|
120
|
+
report.total.delta,
|
|
121
|
+
metric,
|
|
122
|
+
hasDelta,
|
|
123
|
+
options,
|
|
124
|
+
true
|
|
125
|
+
);
|
|
126
|
+
const matrix = [headerCells, ...bodyRows, totalRow];
|
|
127
|
+
const widths = headers.map(
|
|
128
|
+
(_, column) => Math.max(...matrix.map((row) => row[column].plain.length))
|
|
129
|
+
);
|
|
130
|
+
return matrix.map(
|
|
131
|
+
(row) => row.map((cell, column) => {
|
|
132
|
+
const padding = " ".repeat(widths[column] - cell.plain.length);
|
|
133
|
+
return column === 0 ? cell.colored + padding : padding + cell.colored;
|
|
134
|
+
}).join(" ")
|
|
135
|
+
).join("\n");
|
|
136
|
+
}
|
|
137
|
+
function renderReport(report, options) {
|
|
138
|
+
return options.json ? renderJson(report) : renderTable(report, options);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/report.ts
|
|
142
|
+
function subtract(next, prev) {
|
|
143
|
+
return {
|
|
144
|
+
raw: next.raw - prev.raw,
|
|
145
|
+
gzip: next.gzip - prev.gzip,
|
|
146
|
+
brotli: next.brotli - prev.brotli
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function sum(sizes) {
|
|
150
|
+
return sizes.reduce(
|
|
151
|
+
(acc, size) => ({
|
|
152
|
+
raw: acc.raw + size.raw,
|
|
153
|
+
gzip: acc.gzip + size.gzip,
|
|
154
|
+
brotli: acc.brotli + size.brotli
|
|
155
|
+
}),
|
|
156
|
+
{ raw: 0, gzip: 0, brotli: 0 }
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
function buildReport(measurements, previous) {
|
|
160
|
+
const rows = measurements.map((measurement) => {
|
|
161
|
+
const prev = previous?.get(measurement.path);
|
|
162
|
+
return {
|
|
163
|
+
path: measurement.path,
|
|
164
|
+
size: measurement.size,
|
|
165
|
+
delta: prev ? subtract(measurement.size, prev) : null
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
const totalSize = sum(measurements.map((m) => m.size));
|
|
169
|
+
const previousTotal = previous ? sum([...previous.values()]) : void 0;
|
|
170
|
+
return {
|
|
171
|
+
rows,
|
|
172
|
+
total: {
|
|
173
|
+
size: totalSize,
|
|
174
|
+
delta: previousTotal ? subtract(totalSize, previousTotal) : null
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/sizes.ts
|
|
180
|
+
import { readFile } from "fs/promises";
|
|
181
|
+
import { brotliCompressSync, gzipSync } from "zlib";
|
|
182
|
+
function measureBuffer(buffer) {
|
|
183
|
+
return {
|
|
184
|
+
raw: buffer.byteLength,
|
|
185
|
+
gzip: gzipSync(buffer).byteLength,
|
|
186
|
+
brotli: brotliCompressSync(buffer).byteLength
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
async function measureFile(path) {
|
|
190
|
+
const buffer = await readFile(path);
|
|
191
|
+
return measureBuffer(buffer);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// src/watch.ts
|
|
195
|
+
import * as fs from "fs";
|
|
196
|
+
var defaultImpl = (path, onChange) => {
|
|
197
|
+
const watcher = fs.watch(path, () => onChange());
|
|
198
|
+
return { close: () => watcher.close() };
|
|
199
|
+
};
|
|
200
|
+
function watchPaths(paths, onChange, impl = defaultImpl) {
|
|
201
|
+
const watchers = paths.map((path) => impl(path, onChange));
|
|
202
|
+
return () => {
|
|
203
|
+
for (const watcher of watchers) watcher.close();
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// src/cli.ts
|
|
208
|
+
var DESCRIPTION = "Print the gzipped bundle cost of your files \u2014 and the delta on every save.";
|
|
209
|
+
var HELP_EXAMPLES = `
|
|
210
|
+
Examples:
|
|
211
|
+
$ bundle-cost dist/index.js
|
|
212
|
+
$ bundle-cost dist/*.js --brotli
|
|
213
|
+
$ bundle-cost dist/index.js --limit 50kb
|
|
214
|
+
$ bundle-cost dist/index.js --watch
|
|
215
|
+
$ bundle-cost dist/index.js --json
|
|
216
|
+
`;
|
|
217
|
+
var trimTrailingNewline = (text) => text.replace(/\n+$/, "");
|
|
218
|
+
async function measureAll(targets) {
|
|
219
|
+
return Promise.all(
|
|
220
|
+
targets.map(async (target) => ({ ...target, size: await measureFile(target.path) }))
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
var toMeasurements = (measured) => measured.map((m) => ({ path: m.label, size: m.size }));
|
|
224
|
+
var snapshot = (measured) => new Map(measured.map((m) => [m.label, m.size]));
|
|
225
|
+
function startWatch(targets, renderOptions, initial, ctx) {
|
|
226
|
+
let previous = snapshot(initial);
|
|
227
|
+
ctx.log(renderOptions.colors.dim("watching for changes\u2026 (ctrl-c to stop)"));
|
|
228
|
+
ctx.watch(
|
|
229
|
+
targets.map((target) => target.path),
|
|
230
|
+
async () => {
|
|
231
|
+
let next;
|
|
232
|
+
try {
|
|
233
|
+
next = await measureAll(targets);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
ctx.error(renderOptions.colors.red(`Could not read file: ${err.message}`));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
ctx.log(renderReport(buildReport(toMeasurements(next), previous), renderOptions));
|
|
239
|
+
previous = snapshot(next);
|
|
240
|
+
}
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
function reportBudget(totalGzip, limit, colors, ctx) {
|
|
244
|
+
const headline = `${formatBytes(totalGzip)} gzip`;
|
|
245
|
+
if (totalGzip > limit) {
|
|
246
|
+
ctx.error(colors.red(`\u2717 ${headline} exceeds the ${formatBytes(limit)} budget`));
|
|
247
|
+
return 1;
|
|
248
|
+
}
|
|
249
|
+
ctx.log(colors.green(`\u2713 ${headline} is within the ${formatBytes(limit)} budget`));
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
async function execute(files, options, ctx) {
|
|
253
|
+
const colorsEnabled = options.color !== false && !ctx.env.NO_COLOR;
|
|
254
|
+
const renderOptions = {
|
|
255
|
+
gzip: options.gzip !== false,
|
|
256
|
+
brotli: options.brotli === true,
|
|
257
|
+
json: options.json === true,
|
|
258
|
+
colors: createColors(colorsEnabled)
|
|
259
|
+
};
|
|
260
|
+
let limit;
|
|
261
|
+
if (options.limit !== void 0) {
|
|
262
|
+
try {
|
|
263
|
+
limit = parseSize(options.limit);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
ctx.error(renderOptions.colors.red(err.message));
|
|
266
|
+
return 1;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const targets = files.map((file) => ({ label: file, path: resolve(ctx.cwd, file) }));
|
|
270
|
+
let measured;
|
|
271
|
+
try {
|
|
272
|
+
measured = await measureAll(targets);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
ctx.error(renderOptions.colors.red(`Could not read file: ${err.message}`));
|
|
275
|
+
return 1;
|
|
276
|
+
}
|
|
277
|
+
const report = buildReport(toMeasurements(measured));
|
|
278
|
+
ctx.log(renderReport(report, renderOptions));
|
|
279
|
+
if (options.watch) {
|
|
280
|
+
startWatch(targets, renderOptions, measured, ctx);
|
|
281
|
+
return 0;
|
|
282
|
+
}
|
|
283
|
+
if (limit !== void 0) {
|
|
284
|
+
return reportBudget(report.total.size.gzip, limit, renderOptions.colors, ctx);
|
|
285
|
+
}
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
async function run(argv, deps = {}) {
|
|
289
|
+
const log = deps.log ?? ((message) => process.stdout.write(`${message}
|
|
290
|
+
`));
|
|
291
|
+
const error = deps.error ?? ((message) => process.stderr.write(`${message}
|
|
292
|
+
`));
|
|
293
|
+
const ctx = {
|
|
294
|
+
log,
|
|
295
|
+
error,
|
|
296
|
+
cwd: deps.cwd ?? process.cwd(),
|
|
297
|
+
env: deps.env ?? process.env,
|
|
298
|
+
watch: deps.watch ?? watchPaths
|
|
299
|
+
};
|
|
300
|
+
const program = new Command();
|
|
301
|
+
program.name("bundle-cost").description(DESCRIPTION).argument("<files...>", "one or more files to measure (e.g. dist/index.js)").option("-w, --watch", "watch the files and print the size delta on every save").option("-b, --brotli", "include a brotli column alongside gzip").option("--no-gzip", "hide the gzip column (show raw bytes only)").option("-j, --json", "print machine-readable JSON instead of a table").option("-l, --limit <size>", "fail if the total gzip size exceeds this budget (e.g. 50kb)").option("--no-color", "disable ANSI colors").addHelpText("after", HELP_EXAMPLES).exitOverride().configureOutput({
|
|
302
|
+
writeOut: (text) => log(trimTrailingNewline(text)),
|
|
303
|
+
writeErr: (text) => error(trimTrailingNewline(text))
|
|
304
|
+
});
|
|
305
|
+
let exitCode = 0;
|
|
306
|
+
program.action(async (files, options) => {
|
|
307
|
+
exitCode = await execute(files, options, ctx);
|
|
308
|
+
});
|
|
309
|
+
try {
|
|
310
|
+
await program.parseAsync(argv, { from: "user" });
|
|
311
|
+
} catch (err) {
|
|
312
|
+
return err.exitCode;
|
|
313
|
+
}
|
|
314
|
+
return exitCode;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/bin.ts
|
|
318
|
+
run(process.argv.slice(2)).then((code) => {
|
|
319
|
+
if (code !== 0) process.exitCode = code;
|
|
320
|
+
});
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
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 src_exports = {};
|
|
32
|
+
__export(src_exports, {
|
|
33
|
+
buildReport: () => buildReport,
|
|
34
|
+
createColors: () => createColors,
|
|
35
|
+
formatBytes: () => formatBytes,
|
|
36
|
+
formatDelta: () => formatDelta,
|
|
37
|
+
measureBuffer: () => measureBuffer,
|
|
38
|
+
measureFile: () => measureFile,
|
|
39
|
+
parseSize: () => parseSize,
|
|
40
|
+
renderReport: () => renderReport,
|
|
41
|
+
run: () => run,
|
|
42
|
+
watchPaths: () => watchPaths
|
|
43
|
+
});
|
|
44
|
+
module.exports = __toCommonJS(src_exports);
|
|
45
|
+
|
|
46
|
+
// src/color.ts
|
|
47
|
+
function wrap(open, close) {
|
|
48
|
+
return (text) => `\x1B[${open}m${text}\x1B[${close}m`;
|
|
49
|
+
}
|
|
50
|
+
var identity = (text) => text;
|
|
51
|
+
function createColors(enabled) {
|
|
52
|
+
if (!enabled) {
|
|
53
|
+
return { green: identity, red: identity, dim: identity, cyan: identity, bold: identity };
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
green: wrap(32, 39),
|
|
57
|
+
red: wrap(31, 39),
|
|
58
|
+
dim: wrap(2, 22),
|
|
59
|
+
cyan: wrap(36, 39),
|
|
60
|
+
bold: wrap(1, 22)
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/format.ts
|
|
65
|
+
var UNITS = ["B", "KB", "MB", "GB", "TB"];
|
|
66
|
+
var SIZE_RE = /^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)?$/i;
|
|
67
|
+
var MULTIPLIERS = {
|
|
68
|
+
b: 1,
|
|
69
|
+
kb: 1024,
|
|
70
|
+
mb: 1024 ** 2,
|
|
71
|
+
gb: 1024 ** 3,
|
|
72
|
+
tb: 1024 ** 4
|
|
73
|
+
};
|
|
74
|
+
function formatBytes(bytes) {
|
|
75
|
+
if (bytes < 1) return "0 B";
|
|
76
|
+
const exponent = Math.min(
|
|
77
|
+
Math.floor(Math.log(bytes) / Math.log(1024)),
|
|
78
|
+
UNITS.length - 1
|
|
79
|
+
);
|
|
80
|
+
const value = bytes / 1024 ** exponent;
|
|
81
|
+
const rendered = exponent === 0 ? String(Math.round(value)) : value.toFixed(2);
|
|
82
|
+
return `${rendered} ${UNITS[exponent]}`;
|
|
83
|
+
}
|
|
84
|
+
function formatDelta(bytes) {
|
|
85
|
+
if (bytes === 0) return "0 B";
|
|
86
|
+
const sign = bytes > 0 ? "+" : "-";
|
|
87
|
+
return `${sign}${formatBytes(Math.abs(bytes))}`;
|
|
88
|
+
}
|
|
89
|
+
function parseSize(input) {
|
|
90
|
+
const match = SIZE_RE.exec(input.trim());
|
|
91
|
+
if (!match) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Invalid size: "${input}" (try "50kb", "1.5mb", or a raw byte count)`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const value = Number(match[1]);
|
|
97
|
+
const unit = (match[2] ?? "b").toLowerCase();
|
|
98
|
+
return Math.round(value * MULTIPLIERS[unit]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/sizes.ts
|
|
102
|
+
var import_promises = require("fs/promises");
|
|
103
|
+
var import_node_zlib = require("zlib");
|
|
104
|
+
function measureBuffer(buffer) {
|
|
105
|
+
return {
|
|
106
|
+
raw: buffer.byteLength,
|
|
107
|
+
gzip: (0, import_node_zlib.gzipSync)(buffer).byteLength,
|
|
108
|
+
brotli: (0, import_node_zlib.brotliCompressSync)(buffer).byteLength
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
async function measureFile(path) {
|
|
112
|
+
const buffer = await (0, import_promises.readFile)(path);
|
|
113
|
+
return measureBuffer(buffer);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/report.ts
|
|
117
|
+
function subtract(next, prev) {
|
|
118
|
+
return {
|
|
119
|
+
raw: next.raw - prev.raw,
|
|
120
|
+
gzip: next.gzip - prev.gzip,
|
|
121
|
+
brotli: next.brotli - prev.brotli
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function sum(sizes) {
|
|
125
|
+
return sizes.reduce(
|
|
126
|
+
(acc, size) => ({
|
|
127
|
+
raw: acc.raw + size.raw,
|
|
128
|
+
gzip: acc.gzip + size.gzip,
|
|
129
|
+
brotli: acc.brotli + size.brotli
|
|
130
|
+
}),
|
|
131
|
+
{ raw: 0, gzip: 0, brotli: 0 }
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
function buildReport(measurements, previous) {
|
|
135
|
+
const rows = measurements.map((measurement) => {
|
|
136
|
+
const prev = previous?.get(measurement.path);
|
|
137
|
+
return {
|
|
138
|
+
path: measurement.path,
|
|
139
|
+
size: measurement.size,
|
|
140
|
+
delta: prev ? subtract(measurement.size, prev) : null
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
const totalSize = sum(measurements.map((m) => m.size));
|
|
144
|
+
const previousTotal = previous ? sum([...previous.values()]) : void 0;
|
|
145
|
+
return {
|
|
146
|
+
rows,
|
|
147
|
+
total: {
|
|
148
|
+
size: totalSize,
|
|
149
|
+
delta: previousTotal ? subtract(totalSize, previousTotal) : null
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// src/render.ts
|
|
155
|
+
var plainCell = (text) => ({ plain: text, colored: text });
|
|
156
|
+
function colorDelta(delta, metric, colors) {
|
|
157
|
+
if (delta === null) return { plain: "new", colored: colors.cyan("new") };
|
|
158
|
+
const value = delta[metric];
|
|
159
|
+
const text = formatDelta(value);
|
|
160
|
+
if (value < 0) return { plain: text, colored: colors.green(text) };
|
|
161
|
+
if (value > 0) return { plain: text, colored: colors.red(text) };
|
|
162
|
+
return { plain: text, colored: colors.dim(text) };
|
|
163
|
+
}
|
|
164
|
+
function buildCells(label, size, delta, metric, hasDelta, options, bold) {
|
|
165
|
+
const cells = [
|
|
166
|
+
{ plain: label, colored: bold ? options.colors.bold(label) : label },
|
|
167
|
+
plainCell(formatBytes(size.raw))
|
|
168
|
+
];
|
|
169
|
+
if (options.gzip) cells.push(plainCell(formatBytes(size.gzip)));
|
|
170
|
+
if (options.brotli) cells.push(plainCell(formatBytes(size.brotli)));
|
|
171
|
+
if (hasDelta) cells.push(colorDelta(delta, metric, options.colors));
|
|
172
|
+
return cells;
|
|
173
|
+
}
|
|
174
|
+
function renderJson(report) {
|
|
175
|
+
return JSON.stringify(
|
|
176
|
+
{
|
|
177
|
+
files: report.rows.map((row) => ({
|
|
178
|
+
path: row.path,
|
|
179
|
+
raw: row.size.raw,
|
|
180
|
+
gzip: row.size.gzip,
|
|
181
|
+
brotli: row.size.brotli,
|
|
182
|
+
delta: row.delta
|
|
183
|
+
})),
|
|
184
|
+
total: {
|
|
185
|
+
raw: report.total.size.raw,
|
|
186
|
+
gzip: report.total.size.gzip,
|
|
187
|
+
brotli: report.total.size.brotli,
|
|
188
|
+
delta: report.total.delta
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
null,
|
|
192
|
+
2
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
function renderTable(report, options) {
|
|
196
|
+
const metric = options.gzip ? "gzip" : "raw";
|
|
197
|
+
const hasDelta = report.rows.some((row) => row.delta !== null) || report.total.delta !== null;
|
|
198
|
+
const headers = ["File", "Raw"];
|
|
199
|
+
if (options.gzip) headers.push("Gzip");
|
|
200
|
+
if (options.brotli) headers.push("Brotli");
|
|
201
|
+
if (hasDelta) headers.push(`\u0394 ${metric}`);
|
|
202
|
+
const headerCells = headers.map((header) => ({
|
|
203
|
+
plain: header,
|
|
204
|
+
colored: options.colors.dim(header)
|
|
205
|
+
}));
|
|
206
|
+
const bodyRows = report.rows.map(
|
|
207
|
+
(row) => buildCells(row.path, row.size, row.delta, metric, hasDelta, options, false)
|
|
208
|
+
);
|
|
209
|
+
const totalRow = buildCells(
|
|
210
|
+
"total",
|
|
211
|
+
report.total.size,
|
|
212
|
+
report.total.delta,
|
|
213
|
+
metric,
|
|
214
|
+
hasDelta,
|
|
215
|
+
options,
|
|
216
|
+
true
|
|
217
|
+
);
|
|
218
|
+
const matrix = [headerCells, ...bodyRows, totalRow];
|
|
219
|
+
const widths = headers.map(
|
|
220
|
+
(_, column) => Math.max(...matrix.map((row) => row[column].plain.length))
|
|
221
|
+
);
|
|
222
|
+
return matrix.map(
|
|
223
|
+
(row) => row.map((cell, column) => {
|
|
224
|
+
const padding = " ".repeat(widths[column] - cell.plain.length);
|
|
225
|
+
return column === 0 ? cell.colored + padding : padding + cell.colored;
|
|
226
|
+
}).join(" ")
|
|
227
|
+
).join("\n");
|
|
228
|
+
}
|
|
229
|
+
function renderReport(report, options) {
|
|
230
|
+
return options.json ? renderJson(report) : renderTable(report, options);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// src/watch.ts
|
|
234
|
+
var fs = __toESM(require("fs"), 1);
|
|
235
|
+
var defaultImpl = (path, onChange) => {
|
|
236
|
+
const watcher = fs.watch(path, () => onChange());
|
|
237
|
+
return { close: () => watcher.close() };
|
|
238
|
+
};
|
|
239
|
+
function watchPaths(paths, onChange, impl = defaultImpl) {
|
|
240
|
+
const watchers = paths.map((path) => impl(path, onChange));
|
|
241
|
+
return () => {
|
|
242
|
+
for (const watcher of watchers) watcher.close();
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/cli.ts
|
|
247
|
+
var import_node_path = require("path");
|
|
248
|
+
var import_commander = require("commander");
|
|
249
|
+
var DESCRIPTION = "Print the gzipped bundle cost of your files \u2014 and the delta on every save.";
|
|
250
|
+
var HELP_EXAMPLES = `
|
|
251
|
+
Examples:
|
|
252
|
+
$ bundle-cost dist/index.js
|
|
253
|
+
$ bundle-cost dist/*.js --brotli
|
|
254
|
+
$ bundle-cost dist/index.js --limit 50kb
|
|
255
|
+
$ bundle-cost dist/index.js --watch
|
|
256
|
+
$ bundle-cost dist/index.js --json
|
|
257
|
+
`;
|
|
258
|
+
var trimTrailingNewline = (text) => text.replace(/\n+$/, "");
|
|
259
|
+
async function measureAll(targets) {
|
|
260
|
+
return Promise.all(
|
|
261
|
+
targets.map(async (target) => ({ ...target, size: await measureFile(target.path) }))
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
var toMeasurements = (measured) => measured.map((m) => ({ path: m.label, size: m.size }));
|
|
265
|
+
var snapshot = (measured) => new Map(measured.map((m) => [m.label, m.size]));
|
|
266
|
+
function startWatch(targets, renderOptions, initial, ctx) {
|
|
267
|
+
let previous = snapshot(initial);
|
|
268
|
+
ctx.log(renderOptions.colors.dim("watching for changes\u2026 (ctrl-c to stop)"));
|
|
269
|
+
ctx.watch(
|
|
270
|
+
targets.map((target) => target.path),
|
|
271
|
+
async () => {
|
|
272
|
+
let next;
|
|
273
|
+
try {
|
|
274
|
+
next = await measureAll(targets);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
ctx.error(renderOptions.colors.red(`Could not read file: ${err.message}`));
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
ctx.log(renderReport(buildReport(toMeasurements(next), previous), renderOptions));
|
|
280
|
+
previous = snapshot(next);
|
|
281
|
+
}
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
function reportBudget(totalGzip, limit, colors, ctx) {
|
|
285
|
+
const headline = `${formatBytes(totalGzip)} gzip`;
|
|
286
|
+
if (totalGzip > limit) {
|
|
287
|
+
ctx.error(colors.red(`\u2717 ${headline} exceeds the ${formatBytes(limit)} budget`));
|
|
288
|
+
return 1;
|
|
289
|
+
}
|
|
290
|
+
ctx.log(colors.green(`\u2713 ${headline} is within the ${formatBytes(limit)} budget`));
|
|
291
|
+
return 0;
|
|
292
|
+
}
|
|
293
|
+
async function execute(files, options, ctx) {
|
|
294
|
+
const colorsEnabled = options.color !== false && !ctx.env.NO_COLOR;
|
|
295
|
+
const renderOptions = {
|
|
296
|
+
gzip: options.gzip !== false,
|
|
297
|
+
brotli: options.brotli === true,
|
|
298
|
+
json: options.json === true,
|
|
299
|
+
colors: createColors(colorsEnabled)
|
|
300
|
+
};
|
|
301
|
+
let limit;
|
|
302
|
+
if (options.limit !== void 0) {
|
|
303
|
+
try {
|
|
304
|
+
limit = parseSize(options.limit);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
ctx.error(renderOptions.colors.red(err.message));
|
|
307
|
+
return 1;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const targets = files.map((file) => ({ label: file, path: (0, import_node_path.resolve)(ctx.cwd, file) }));
|
|
311
|
+
let measured;
|
|
312
|
+
try {
|
|
313
|
+
measured = await measureAll(targets);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
ctx.error(renderOptions.colors.red(`Could not read file: ${err.message}`));
|
|
316
|
+
return 1;
|
|
317
|
+
}
|
|
318
|
+
const report = buildReport(toMeasurements(measured));
|
|
319
|
+
ctx.log(renderReport(report, renderOptions));
|
|
320
|
+
if (options.watch) {
|
|
321
|
+
startWatch(targets, renderOptions, measured, ctx);
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
if (limit !== void 0) {
|
|
325
|
+
return reportBudget(report.total.size.gzip, limit, renderOptions.colors, ctx);
|
|
326
|
+
}
|
|
327
|
+
return 0;
|
|
328
|
+
}
|
|
329
|
+
async function run(argv, deps = {}) {
|
|
330
|
+
const log = deps.log ?? ((message) => process.stdout.write(`${message}
|
|
331
|
+
`));
|
|
332
|
+
const error = deps.error ?? ((message) => process.stderr.write(`${message}
|
|
333
|
+
`));
|
|
334
|
+
const ctx = {
|
|
335
|
+
log,
|
|
336
|
+
error,
|
|
337
|
+
cwd: deps.cwd ?? process.cwd(),
|
|
338
|
+
env: deps.env ?? process.env,
|
|
339
|
+
watch: deps.watch ?? watchPaths
|
|
340
|
+
};
|
|
341
|
+
const program = new import_commander.Command();
|
|
342
|
+
program.name("bundle-cost").description(DESCRIPTION).argument("<files...>", "one or more files to measure (e.g. dist/index.js)").option("-w, --watch", "watch the files and print the size delta on every save").option("-b, --brotli", "include a brotli column alongside gzip").option("--no-gzip", "hide the gzip column (show raw bytes only)").option("-j, --json", "print machine-readable JSON instead of a table").option("-l, --limit <size>", "fail if the total gzip size exceeds this budget (e.g. 50kb)").option("--no-color", "disable ANSI colors").addHelpText("after", HELP_EXAMPLES).exitOverride().configureOutput({
|
|
343
|
+
writeOut: (text) => log(trimTrailingNewline(text)),
|
|
344
|
+
writeErr: (text) => error(trimTrailingNewline(text))
|
|
345
|
+
});
|
|
346
|
+
let exitCode = 0;
|
|
347
|
+
program.action(async (files, options) => {
|
|
348
|
+
exitCode = await execute(files, options, ctx);
|
|
349
|
+
});
|
|
350
|
+
try {
|
|
351
|
+
await program.parseAsync(argv, { from: "user" });
|
|
352
|
+
} catch (err) {
|
|
353
|
+
return err.exitCode;
|
|
354
|
+
}
|
|
355
|
+
return exitCode;
|
|
356
|
+
}
|
|
357
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
358
|
+
0 && (module.exports = {
|
|
359
|
+
buildReport,
|
|
360
|
+
createColors,
|
|
361
|
+
formatBytes,
|
|
362
|
+
formatDelta,
|
|
363
|
+
measureBuffer,
|
|
364
|
+
measureFile,
|
|
365
|
+
parseSize,
|
|
366
|
+
renderReport,
|
|
367
|
+
run,
|
|
368
|
+
watchPaths
|
|
369
|
+
});
|