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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kea0811
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # bundle-cost-cli
2
+
3
+ ![tests](https://img.shields.io/badge/tests-passing-brightgreen.svg)
4
+ ![coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)
5
+ ![license](https://img.shields.io/badge/license-MIT-blue.svg)
6
+
7
+ **🌐 [Live demo →](https://bundle-cost-cli.vercel.app)**
8
+
9
+ > Print the real over-the-wire cost of your build output — and the delta on every save.
10
+
11
+ You already know your bundle is "kinda big." What you don't know, in the moment you add a dependency, is _how much_ bigger it just got. `bundle-cost` answers that in one line: point it at your built files and it prints raw, gzip, and (optionally) brotli sizes. Run it with `--watch` and it reprints a **delta** every time you save — so a stray `import { everything } from 'lodash'` shows up as `+71 KB` before you commit it, not after a user complains.
12
+
13
+ No bundler plugin, no config file, no telemetry. Just bytes.
14
+
15
+ ## Install
16
+
17
+ **From GitHub** (always works):
18
+
19
+ ```bash
20
+ pnpm add -g github:kea0811/bundle-cost-cli
21
+ ```
22
+
23
+ **From npm** _(when published to npm)_:
24
+
25
+ ```bash
26
+ pnpm add -g bundle-cost-cli
27
+ ```
28
+
29
+ > Using npm or yarn? `npm install -g bundle-cost-cli` / `yarn global add bundle-cost-cli` work too. Or skip the install entirely and run it once with `pnpm dlx bundle-cost-cli dist/index.js` (`npx` works the same way).
30
+
31
+ Requires Node 18+.
32
+
33
+ ## Quick start
34
+
35
+ Point it at one or more files:
36
+
37
+ ```bash
38
+ bundle-cost dist/index.js dist/index.cjs
39
+ ```
40
+
41
+ ```text
42
+ File Raw Gzip
43
+ dist/index.js 9.89 KB 3.37 KB
44
+ dist/index.cjs 11.87 KB 4.06 KB
45
+ total 21.76 KB 7.43 KB
46
+ ```
47
+
48
+ ### Watch mode — a delta on every save
49
+
50
+ ```bash
51
+ bundle-cost dist/index.js --watch
52
+ ```
53
+
54
+ The first render is your baseline. Every save after that adds a `Δ gzip` column, colored green when you shrank it and red when you grew it:
55
+
56
+ ```text
57
+ File Raw Gzip Δ gzip
58
+ dist/index.js 10.4 KB 3.71 KB +340 B
59
+ total 10.4 KB 3.71 KB +340 B
60
+ ```
61
+
62
+ Pair it with your bundler's own watch mode in another terminal and you get a live cost readout for free.
63
+
64
+ ### Set a budget (great for CI)
65
+
66
+ ```bash
67
+ bundle-cost dist/index.js --limit 50kb
68
+ ```
69
+
70
+ If the **total gzip** size is over budget, `bundle-cost` prints a red `✗` line and exits with code `1` — so it fails your pipeline instead of silently shipping a regression. Under budget, it exits `0` with a green `✓`.
71
+
72
+ ### Machine-readable output
73
+
74
+ ```bash
75
+ bundle-cost dist/index.js --json
76
+ ```
77
+
78
+ ```json
79
+ {
80
+ "files": [
81
+ { "path": "dist/index.js", "raw": 10123, "gzip": 3451, "brotli": 3063, "delta": null }
82
+ ],
83
+ "total": { "raw": 10123, "gzip": 3451, "brotli": 3063, "delta": null }
84
+ }
85
+ ```
86
+
87
+ Pipe it into `jq`, store it as a build artifact, or diff two runs yourself.
88
+
89
+ ## Options
90
+
91
+ | Flag | Description |
92
+ | --- | --- |
93
+ | `-w, --watch` | Watch the files and print the size delta on every save. |
94
+ | `-b, --brotli` | Add a brotli column alongside gzip. |
95
+ | `--no-gzip` | Hide the gzip column (show raw bytes only). |
96
+ | `-j, --json` | Print machine-readable JSON instead of a table. |
97
+ | `-l, --limit <size>` | Fail (exit `1`) if total gzip exceeds this budget, e.g. `50kb`, `1.5mb`. |
98
+ | `--no-color` | Disable ANSI colors (also respects `NO_COLOR`). |
99
+ | `-h, --help` | Show usage and examples. |
100
+
101
+ Sizes accept `b`, `kb`, `mb`, `gb`, `tb` (case-insensitive), or a bare byte count.
102
+
103
+ ## Programmatic API
104
+
105
+ The same building blocks ship as a typed ESM/CJS module, so you can wire bundle cost into your own scripts:
106
+
107
+ ```ts
108
+ import { measureFile, buildReport, renderReport, formatBytes, createColors } from 'bundle-cost-cli';
109
+
110
+ const size = await measureFile('dist/index.js');
111
+ console.log(`gzip: ${formatBytes(size.gzip)}`);
112
+
113
+ const report = buildReport([{ path: 'dist/index.js', size }]);
114
+ console.log(renderReport(report, { gzip: true, brotli: false, json: false, colors: createColors(false) }));
115
+ ```
116
+
117
+ `run(argv, deps)` is exported too — the whole CLI with injectable `log`, `error`, `cwd`, `env`, and `watch`, which is exactly how the test suite drives it.
118
+
119
+ ## How it works
120
+
121
+ There's no minifier in here and no bundler hook. `bundle-cost` reads each file as-is and runs it through Node's built-in `zlib` — `gzipSync` and `brotliCompressSync` — because gzip and brotli are what a CDN actually serves. That keeps the tool dependency-light and honest: it measures the bytes you ship, not an estimate.
122
+
123
+ Watch mode keeps the previous measurement in memory and diffs against it, so the delta is always "since the last save in this session." All of the I/O — stdout, the filesystem watcher, the clock — is injected, which is why the test suite hits 100% coverage without touching your real terminal.
124
+
125
+ ## Live demo
126
+
127
+ See it in action at **[bundle-cost-cli.vercel.app](https://bundle-cost-cli.vercel.app)** — a static landing page with a sample run.
128
+
129
+ ## Contributing
130
+
131
+ PRs welcome — especially new output formats and budget ergonomics. To hack on it:
132
+
133
+ ```bash
134
+ pnpm install
135
+ pnpm test
136
+ pnpm build
137
+ ```
138
+
139
+ `pnpm test:coverage` enforces 100% coverage, and `pnpm dev` rebuilds on change.
140
+
141
+ ## License
142
+
143
+ MIT © [kea0811](https://github.com/kea0811)
package/dist/bin.cjs ADDED
@@ -0,0 +1,343 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+
26
+ // src/cli.ts
27
+ var import_node_path = require("path");
28
+ var import_commander = require("commander");
29
+
30
+ // src/color.ts
31
+ function wrap(open, close) {
32
+ return (text) => `\x1B[${open}m${text}\x1B[${close}m`;
33
+ }
34
+ var identity = (text) => text;
35
+ function createColors(enabled) {
36
+ if (!enabled) {
37
+ return { green: identity, red: identity, dim: identity, cyan: identity, bold: identity };
38
+ }
39
+ return {
40
+ green: wrap(32, 39),
41
+ red: wrap(31, 39),
42
+ dim: wrap(2, 22),
43
+ cyan: wrap(36, 39),
44
+ bold: wrap(1, 22)
45
+ };
46
+ }
47
+
48
+ // src/format.ts
49
+ var UNITS = ["B", "KB", "MB", "GB", "TB"];
50
+ var SIZE_RE = /^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)?$/i;
51
+ var MULTIPLIERS = {
52
+ b: 1,
53
+ kb: 1024,
54
+ mb: 1024 ** 2,
55
+ gb: 1024 ** 3,
56
+ tb: 1024 ** 4
57
+ };
58
+ function formatBytes(bytes) {
59
+ if (bytes < 1) return "0 B";
60
+ const exponent = Math.min(
61
+ Math.floor(Math.log(bytes) / Math.log(1024)),
62
+ UNITS.length - 1
63
+ );
64
+ const value = bytes / 1024 ** exponent;
65
+ const rendered = exponent === 0 ? String(Math.round(value)) : value.toFixed(2);
66
+ return `${rendered} ${UNITS[exponent]}`;
67
+ }
68
+ function formatDelta(bytes) {
69
+ if (bytes === 0) return "0 B";
70
+ const sign = bytes > 0 ? "+" : "-";
71
+ return `${sign}${formatBytes(Math.abs(bytes))}`;
72
+ }
73
+ function parseSize(input) {
74
+ const match = SIZE_RE.exec(input.trim());
75
+ if (!match) {
76
+ throw new Error(
77
+ `Invalid size: "${input}" (try "50kb", "1.5mb", or a raw byte count)`
78
+ );
79
+ }
80
+ const value = Number(match[1]);
81
+ const unit = (match[2] ?? "b").toLowerCase();
82
+ return Math.round(value * MULTIPLIERS[unit]);
83
+ }
84
+
85
+ // src/render.ts
86
+ var plainCell = (text) => ({ plain: text, colored: text });
87
+ function colorDelta(delta, metric, colors) {
88
+ if (delta === null) return { plain: "new", colored: colors.cyan("new") };
89
+ const value = delta[metric];
90
+ const text = formatDelta(value);
91
+ if (value < 0) return { plain: text, colored: colors.green(text) };
92
+ if (value > 0) return { plain: text, colored: colors.red(text) };
93
+ return { plain: text, colored: colors.dim(text) };
94
+ }
95
+ function buildCells(label, size, delta, metric, hasDelta, options, bold) {
96
+ const cells = [
97
+ { plain: label, colored: bold ? options.colors.bold(label) : label },
98
+ plainCell(formatBytes(size.raw))
99
+ ];
100
+ if (options.gzip) cells.push(plainCell(formatBytes(size.gzip)));
101
+ if (options.brotli) cells.push(plainCell(formatBytes(size.brotli)));
102
+ if (hasDelta) cells.push(colorDelta(delta, metric, options.colors));
103
+ return cells;
104
+ }
105
+ function renderJson(report) {
106
+ return JSON.stringify(
107
+ {
108
+ files: report.rows.map((row) => ({
109
+ path: row.path,
110
+ raw: row.size.raw,
111
+ gzip: row.size.gzip,
112
+ brotli: row.size.brotli,
113
+ delta: row.delta
114
+ })),
115
+ total: {
116
+ raw: report.total.size.raw,
117
+ gzip: report.total.size.gzip,
118
+ brotli: report.total.size.brotli,
119
+ delta: report.total.delta
120
+ }
121
+ },
122
+ null,
123
+ 2
124
+ );
125
+ }
126
+ function renderTable(report, options) {
127
+ const metric = options.gzip ? "gzip" : "raw";
128
+ const hasDelta = report.rows.some((row) => row.delta !== null) || report.total.delta !== null;
129
+ const headers = ["File", "Raw"];
130
+ if (options.gzip) headers.push("Gzip");
131
+ if (options.brotli) headers.push("Brotli");
132
+ if (hasDelta) headers.push(`\u0394 ${metric}`);
133
+ const headerCells = headers.map((header) => ({
134
+ plain: header,
135
+ colored: options.colors.dim(header)
136
+ }));
137
+ const bodyRows = report.rows.map(
138
+ (row) => buildCells(row.path, row.size, row.delta, metric, hasDelta, options, false)
139
+ );
140
+ const totalRow = buildCells(
141
+ "total",
142
+ report.total.size,
143
+ report.total.delta,
144
+ metric,
145
+ hasDelta,
146
+ options,
147
+ true
148
+ );
149
+ const matrix = [headerCells, ...bodyRows, totalRow];
150
+ const widths = headers.map(
151
+ (_, column) => Math.max(...matrix.map((row) => row[column].plain.length))
152
+ );
153
+ return matrix.map(
154
+ (row) => row.map((cell, column) => {
155
+ const padding = " ".repeat(widths[column] - cell.plain.length);
156
+ return column === 0 ? cell.colored + padding : padding + cell.colored;
157
+ }).join(" ")
158
+ ).join("\n");
159
+ }
160
+ function renderReport(report, options) {
161
+ return options.json ? renderJson(report) : renderTable(report, options);
162
+ }
163
+
164
+ // src/report.ts
165
+ function subtract(next, prev) {
166
+ return {
167
+ raw: next.raw - prev.raw,
168
+ gzip: next.gzip - prev.gzip,
169
+ brotli: next.brotli - prev.brotli
170
+ };
171
+ }
172
+ function sum(sizes) {
173
+ return sizes.reduce(
174
+ (acc, size) => ({
175
+ raw: acc.raw + size.raw,
176
+ gzip: acc.gzip + size.gzip,
177
+ brotli: acc.brotli + size.brotli
178
+ }),
179
+ { raw: 0, gzip: 0, brotli: 0 }
180
+ );
181
+ }
182
+ function buildReport(measurements, previous) {
183
+ const rows = measurements.map((measurement) => {
184
+ const prev = previous?.get(measurement.path);
185
+ return {
186
+ path: measurement.path,
187
+ size: measurement.size,
188
+ delta: prev ? subtract(measurement.size, prev) : null
189
+ };
190
+ });
191
+ const totalSize = sum(measurements.map((m) => m.size));
192
+ const previousTotal = previous ? sum([...previous.values()]) : void 0;
193
+ return {
194
+ rows,
195
+ total: {
196
+ size: totalSize,
197
+ delta: previousTotal ? subtract(totalSize, previousTotal) : null
198
+ }
199
+ };
200
+ }
201
+
202
+ // src/sizes.ts
203
+ var import_promises = require("fs/promises");
204
+ var import_node_zlib = require("zlib");
205
+ function measureBuffer(buffer) {
206
+ return {
207
+ raw: buffer.byteLength,
208
+ gzip: (0, import_node_zlib.gzipSync)(buffer).byteLength,
209
+ brotli: (0, import_node_zlib.brotliCompressSync)(buffer).byteLength
210
+ };
211
+ }
212
+ async function measureFile(path) {
213
+ const buffer = await (0, import_promises.readFile)(path);
214
+ return measureBuffer(buffer);
215
+ }
216
+
217
+ // src/watch.ts
218
+ var fs = __toESM(require("fs"), 1);
219
+ var defaultImpl = (path, onChange) => {
220
+ const watcher = fs.watch(path, () => onChange());
221
+ return { close: () => watcher.close() };
222
+ };
223
+ function watchPaths(paths, onChange, impl = defaultImpl) {
224
+ const watchers = paths.map((path) => impl(path, onChange));
225
+ return () => {
226
+ for (const watcher of watchers) watcher.close();
227
+ };
228
+ }
229
+
230
+ // src/cli.ts
231
+ var DESCRIPTION = "Print the gzipped bundle cost of your files \u2014 and the delta on every save.";
232
+ var HELP_EXAMPLES = `
233
+ Examples:
234
+ $ bundle-cost dist/index.js
235
+ $ bundle-cost dist/*.js --brotli
236
+ $ bundle-cost dist/index.js --limit 50kb
237
+ $ bundle-cost dist/index.js --watch
238
+ $ bundle-cost dist/index.js --json
239
+ `;
240
+ var trimTrailingNewline = (text) => text.replace(/\n+$/, "");
241
+ async function measureAll(targets) {
242
+ return Promise.all(
243
+ targets.map(async (target) => ({ ...target, size: await measureFile(target.path) }))
244
+ );
245
+ }
246
+ var toMeasurements = (measured) => measured.map((m) => ({ path: m.label, size: m.size }));
247
+ var snapshot = (measured) => new Map(measured.map((m) => [m.label, m.size]));
248
+ function startWatch(targets, renderOptions, initial, ctx) {
249
+ let previous = snapshot(initial);
250
+ ctx.log(renderOptions.colors.dim("watching for changes\u2026 (ctrl-c to stop)"));
251
+ ctx.watch(
252
+ targets.map((target) => target.path),
253
+ async () => {
254
+ let next;
255
+ try {
256
+ next = await measureAll(targets);
257
+ } catch (err) {
258
+ ctx.error(renderOptions.colors.red(`Could not read file: ${err.message}`));
259
+ return;
260
+ }
261
+ ctx.log(renderReport(buildReport(toMeasurements(next), previous), renderOptions));
262
+ previous = snapshot(next);
263
+ }
264
+ );
265
+ }
266
+ function reportBudget(totalGzip, limit, colors, ctx) {
267
+ const headline = `${formatBytes(totalGzip)} gzip`;
268
+ if (totalGzip > limit) {
269
+ ctx.error(colors.red(`\u2717 ${headline} exceeds the ${formatBytes(limit)} budget`));
270
+ return 1;
271
+ }
272
+ ctx.log(colors.green(`\u2713 ${headline} is within the ${formatBytes(limit)} budget`));
273
+ return 0;
274
+ }
275
+ async function execute(files, options, ctx) {
276
+ const colorsEnabled = options.color !== false && !ctx.env.NO_COLOR;
277
+ const renderOptions = {
278
+ gzip: options.gzip !== false,
279
+ brotli: options.brotli === true,
280
+ json: options.json === true,
281
+ colors: createColors(colorsEnabled)
282
+ };
283
+ let limit;
284
+ if (options.limit !== void 0) {
285
+ try {
286
+ limit = parseSize(options.limit);
287
+ } catch (err) {
288
+ ctx.error(renderOptions.colors.red(err.message));
289
+ return 1;
290
+ }
291
+ }
292
+ const targets = files.map((file) => ({ label: file, path: (0, import_node_path.resolve)(ctx.cwd, file) }));
293
+ let measured;
294
+ try {
295
+ measured = await measureAll(targets);
296
+ } catch (err) {
297
+ ctx.error(renderOptions.colors.red(`Could not read file: ${err.message}`));
298
+ return 1;
299
+ }
300
+ const report = buildReport(toMeasurements(measured));
301
+ ctx.log(renderReport(report, renderOptions));
302
+ if (options.watch) {
303
+ startWatch(targets, renderOptions, measured, ctx);
304
+ return 0;
305
+ }
306
+ if (limit !== void 0) {
307
+ return reportBudget(report.total.size.gzip, limit, renderOptions.colors, ctx);
308
+ }
309
+ return 0;
310
+ }
311
+ async function run(argv, deps = {}) {
312
+ const log = deps.log ?? ((message) => process.stdout.write(`${message}
313
+ `));
314
+ const error = deps.error ?? ((message) => process.stderr.write(`${message}
315
+ `));
316
+ const ctx = {
317
+ log,
318
+ error,
319
+ cwd: deps.cwd ?? process.cwd(),
320
+ env: deps.env ?? process.env,
321
+ watch: deps.watch ?? watchPaths
322
+ };
323
+ const program = new import_commander.Command();
324
+ 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({
325
+ writeOut: (text) => log(trimTrailingNewline(text)),
326
+ writeErr: (text) => error(trimTrailingNewline(text))
327
+ });
328
+ let exitCode = 0;
329
+ program.action(async (files, options) => {
330
+ exitCode = await execute(files, options, ctx);
331
+ });
332
+ try {
333
+ await program.parseAsync(argv, { from: "user" });
334
+ } catch (err) {
335
+ return err.exitCode;
336
+ }
337
+ return exitCode;
338
+ }
339
+
340
+ // src/bin.ts
341
+ run(process.argv.slice(2)).then((code) => {
342
+ if (code !== 0) process.exitCode = code;
343
+ });