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/index.d.cts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** A small set of ANSI color helpers. When disabled, every helper is the identity. */
|
|
2
|
+
interface Colors {
|
|
3
|
+
green(text: string): string;
|
|
4
|
+
red(text: string): string;
|
|
5
|
+
dim(text: string): string;
|
|
6
|
+
cyan(text: string): string;
|
|
7
|
+
bold(text: string): string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Build a {@link Colors} palette. Pass `false` (no TTY, `NO_COLOR`, `--no-color`)
|
|
11
|
+
* to get a palette where every helper returns its input untouched.
|
|
12
|
+
*/
|
|
13
|
+
declare function createColors(enabled: boolean): Colors;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Render a non-negative byte count as a human-friendly string.
|
|
17
|
+
* Bytes are shown whole; everything larger gets two decimals.
|
|
18
|
+
*/
|
|
19
|
+
declare function formatBytes(bytes: number): string;
|
|
20
|
+
/**
|
|
21
|
+
* Render a signed delta, e.g. `+1.20 KB`, `-512 B`, or `0 B` for no change.
|
|
22
|
+
*/
|
|
23
|
+
declare function formatDelta(bytes: number): string;
|
|
24
|
+
/**
|
|
25
|
+
* Parse a human size budget like `50kb`, `1.5mb`, or a raw byte count.
|
|
26
|
+
* Throws on anything it can't make sense of.
|
|
27
|
+
*/
|
|
28
|
+
declare function parseSize(input: string): number;
|
|
29
|
+
|
|
30
|
+
/** The three sizes we report for a file or a total. */
|
|
31
|
+
interface SizeSet {
|
|
32
|
+
/** Uncompressed bytes on disk. */
|
|
33
|
+
raw: number;
|
|
34
|
+
/** Bytes after gzip — what most CDNs serve. */
|
|
35
|
+
gzip: number;
|
|
36
|
+
/** Bytes after brotli — what modern CDNs serve when the client supports it. */
|
|
37
|
+
brotli: number;
|
|
38
|
+
}
|
|
39
|
+
/** A single measured file, keyed by the path the user typed. */
|
|
40
|
+
interface FileMeasurement {
|
|
41
|
+
path: string;
|
|
42
|
+
size: SizeSet;
|
|
43
|
+
}
|
|
44
|
+
/** One row of a report: a file, its sizes, and the delta vs. the last run (if any). */
|
|
45
|
+
interface ReportRow {
|
|
46
|
+
path: string;
|
|
47
|
+
size: SizeSet;
|
|
48
|
+
/** `null` when there is no previous measurement to compare against. */
|
|
49
|
+
delta: SizeSet | null;
|
|
50
|
+
}
|
|
51
|
+
/** A full report: every file plus a summed total. */
|
|
52
|
+
interface Report {
|
|
53
|
+
rows: ReportRow[];
|
|
54
|
+
total: {
|
|
55
|
+
size: SizeSet;
|
|
56
|
+
delta: SizeSet | null;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Measure raw, gzip, and brotli sizes of an in-memory buffer. */
|
|
61
|
+
declare function measureBuffer(buffer: Buffer): SizeSet;
|
|
62
|
+
/** Read a file from disk and measure it. Rejects if the file can't be read. */
|
|
63
|
+
declare function measureFile(path: string): Promise<SizeSet>;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Combine a set of measurements into a report. Pass the `previous` sizes (keyed
|
|
67
|
+
* by path) to compute per-file and total deltas; omit it for a first run.
|
|
68
|
+
*/
|
|
69
|
+
declare function buildReport(measurements: FileMeasurement[], previous?: Map<string, SizeSet>): Report;
|
|
70
|
+
|
|
71
|
+
interface RenderOptions {
|
|
72
|
+
/** Show the gzip column (and base the delta on gzip). */
|
|
73
|
+
gzip: boolean;
|
|
74
|
+
/** Show an extra brotli column. */
|
|
75
|
+
brotli: boolean;
|
|
76
|
+
/** Emit JSON instead of a table. */
|
|
77
|
+
json: boolean;
|
|
78
|
+
/** The color palette to paint with. */
|
|
79
|
+
colors: Colors;
|
|
80
|
+
}
|
|
81
|
+
/** Render a report as either an aligned table or a JSON document. */
|
|
82
|
+
declare function renderReport(report: Report, options: RenderOptions): string;
|
|
83
|
+
|
|
84
|
+
/** A close handle returned by a watcher. */
|
|
85
|
+
interface Closeable {
|
|
86
|
+
close(): void;
|
|
87
|
+
}
|
|
88
|
+
/** How a single path gets watched. Swappable so the orchestration is testable. */
|
|
89
|
+
type WatchImpl = (path: string, onChange: () => void) => Closeable;
|
|
90
|
+
/**
|
|
91
|
+
* Watch every path and call `onChange` whenever any of them changes.
|
|
92
|
+
* Returns a function that stops every watcher.
|
|
93
|
+
*/
|
|
94
|
+
declare function watchPaths(paths: string[], onChange: () => void, impl?: WatchImpl): () => void;
|
|
95
|
+
|
|
96
|
+
/** Hooks the test suite swaps in; production code falls back to real I/O. */
|
|
97
|
+
interface RunDeps {
|
|
98
|
+
log?: (message: string) => void;
|
|
99
|
+
error?: (message: string) => void;
|
|
100
|
+
cwd?: string;
|
|
101
|
+
env?: Record<string, string | undefined>;
|
|
102
|
+
watch?: typeof watchPaths;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Parse `argv` (user args, without `node` and the script path) and run the CLI.
|
|
106
|
+
* Returns the process exit code. All I/O is injectable via {@link RunDeps}.
|
|
107
|
+
*/
|
|
108
|
+
declare function run(argv: string[], deps?: RunDeps): Promise<number>;
|
|
109
|
+
|
|
110
|
+
export { type Closeable, type Colors, type FileMeasurement, type RenderOptions, type Report, type ReportRow, type RunDeps, type SizeSet, type WatchImpl, buildReport, createColors, formatBytes, formatDelta, measureBuffer, measureFile, parseSize, renderReport, run, watchPaths };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** A small set of ANSI color helpers. When disabled, every helper is the identity. */
|
|
2
|
+
interface Colors {
|
|
3
|
+
green(text: string): string;
|
|
4
|
+
red(text: string): string;
|
|
5
|
+
dim(text: string): string;
|
|
6
|
+
cyan(text: string): string;
|
|
7
|
+
bold(text: string): string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Build a {@link Colors} palette. Pass `false` (no TTY, `NO_COLOR`, `--no-color`)
|
|
11
|
+
* to get a palette where every helper returns its input untouched.
|
|
12
|
+
*/
|
|
13
|
+
declare function createColors(enabled: boolean): Colors;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Render a non-negative byte count as a human-friendly string.
|
|
17
|
+
* Bytes are shown whole; everything larger gets two decimals.
|
|
18
|
+
*/
|
|
19
|
+
declare function formatBytes(bytes: number): string;
|
|
20
|
+
/**
|
|
21
|
+
* Render a signed delta, e.g. `+1.20 KB`, `-512 B`, or `0 B` for no change.
|
|
22
|
+
*/
|
|
23
|
+
declare function formatDelta(bytes: number): string;
|
|
24
|
+
/**
|
|
25
|
+
* Parse a human size budget like `50kb`, `1.5mb`, or a raw byte count.
|
|
26
|
+
* Throws on anything it can't make sense of.
|
|
27
|
+
*/
|
|
28
|
+
declare function parseSize(input: string): number;
|
|
29
|
+
|
|
30
|
+
/** The three sizes we report for a file or a total. */
|
|
31
|
+
interface SizeSet {
|
|
32
|
+
/** Uncompressed bytes on disk. */
|
|
33
|
+
raw: number;
|
|
34
|
+
/** Bytes after gzip — what most CDNs serve. */
|
|
35
|
+
gzip: number;
|
|
36
|
+
/** Bytes after brotli — what modern CDNs serve when the client supports it. */
|
|
37
|
+
brotli: number;
|
|
38
|
+
}
|
|
39
|
+
/** A single measured file, keyed by the path the user typed. */
|
|
40
|
+
interface FileMeasurement {
|
|
41
|
+
path: string;
|
|
42
|
+
size: SizeSet;
|
|
43
|
+
}
|
|
44
|
+
/** One row of a report: a file, its sizes, and the delta vs. the last run (if any). */
|
|
45
|
+
interface ReportRow {
|
|
46
|
+
path: string;
|
|
47
|
+
size: SizeSet;
|
|
48
|
+
/** `null` when there is no previous measurement to compare against. */
|
|
49
|
+
delta: SizeSet | null;
|
|
50
|
+
}
|
|
51
|
+
/** A full report: every file plus a summed total. */
|
|
52
|
+
interface Report {
|
|
53
|
+
rows: ReportRow[];
|
|
54
|
+
total: {
|
|
55
|
+
size: SizeSet;
|
|
56
|
+
delta: SizeSet | null;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Measure raw, gzip, and brotli sizes of an in-memory buffer. */
|
|
61
|
+
declare function measureBuffer(buffer: Buffer): SizeSet;
|
|
62
|
+
/** Read a file from disk and measure it. Rejects if the file can't be read. */
|
|
63
|
+
declare function measureFile(path: string): Promise<SizeSet>;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Combine a set of measurements into a report. Pass the `previous` sizes (keyed
|
|
67
|
+
* by path) to compute per-file and total deltas; omit it for a first run.
|
|
68
|
+
*/
|
|
69
|
+
declare function buildReport(measurements: FileMeasurement[], previous?: Map<string, SizeSet>): Report;
|
|
70
|
+
|
|
71
|
+
interface RenderOptions {
|
|
72
|
+
/** Show the gzip column (and base the delta on gzip). */
|
|
73
|
+
gzip: boolean;
|
|
74
|
+
/** Show an extra brotli column. */
|
|
75
|
+
brotli: boolean;
|
|
76
|
+
/** Emit JSON instead of a table. */
|
|
77
|
+
json: boolean;
|
|
78
|
+
/** The color palette to paint with. */
|
|
79
|
+
colors: Colors;
|
|
80
|
+
}
|
|
81
|
+
/** Render a report as either an aligned table or a JSON document. */
|
|
82
|
+
declare function renderReport(report: Report, options: RenderOptions): string;
|
|
83
|
+
|
|
84
|
+
/** A close handle returned by a watcher. */
|
|
85
|
+
interface Closeable {
|
|
86
|
+
close(): void;
|
|
87
|
+
}
|
|
88
|
+
/** How a single path gets watched. Swappable so the orchestration is testable. */
|
|
89
|
+
type WatchImpl = (path: string, onChange: () => void) => Closeable;
|
|
90
|
+
/**
|
|
91
|
+
* Watch every path and call `onChange` whenever any of them changes.
|
|
92
|
+
* Returns a function that stops every watcher.
|
|
93
|
+
*/
|
|
94
|
+
declare function watchPaths(paths: string[], onChange: () => void, impl?: WatchImpl): () => void;
|
|
95
|
+
|
|
96
|
+
/** Hooks the test suite swaps in; production code falls back to real I/O. */
|
|
97
|
+
interface RunDeps {
|
|
98
|
+
log?: (message: string) => void;
|
|
99
|
+
error?: (message: string) => void;
|
|
100
|
+
cwd?: string;
|
|
101
|
+
env?: Record<string, string | undefined>;
|
|
102
|
+
watch?: typeof watchPaths;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Parse `argv` (user args, without `node` and the script path) and run the CLI.
|
|
106
|
+
* Returns the process exit code. All I/O is injectable via {@link RunDeps}.
|
|
107
|
+
*/
|
|
108
|
+
declare function run(argv: string[], deps?: RunDeps): Promise<number>;
|
|
109
|
+
|
|
110
|
+
export { type Closeable, type Colors, type FileMeasurement, type RenderOptions, type Report, type ReportRow, type RunDeps, type SizeSet, type WatchImpl, buildReport, createColors, formatBytes, formatDelta, measureBuffer, measureFile, parseSize, renderReport, run, watchPaths };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// src/color.ts
|
|
2
|
+
function wrap(open, close) {
|
|
3
|
+
return (text) => `\x1B[${open}m${text}\x1B[${close}m`;
|
|
4
|
+
}
|
|
5
|
+
var identity = (text) => text;
|
|
6
|
+
function createColors(enabled) {
|
|
7
|
+
if (!enabled) {
|
|
8
|
+
return { green: identity, red: identity, dim: identity, cyan: identity, bold: identity };
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
green: wrap(32, 39),
|
|
12
|
+
red: wrap(31, 39),
|
|
13
|
+
dim: wrap(2, 22),
|
|
14
|
+
cyan: wrap(36, 39),
|
|
15
|
+
bold: wrap(1, 22)
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// src/format.ts
|
|
20
|
+
var UNITS = ["B", "KB", "MB", "GB", "TB"];
|
|
21
|
+
var SIZE_RE = /^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)?$/i;
|
|
22
|
+
var MULTIPLIERS = {
|
|
23
|
+
b: 1,
|
|
24
|
+
kb: 1024,
|
|
25
|
+
mb: 1024 ** 2,
|
|
26
|
+
gb: 1024 ** 3,
|
|
27
|
+
tb: 1024 ** 4
|
|
28
|
+
};
|
|
29
|
+
function formatBytes(bytes) {
|
|
30
|
+
if (bytes < 1) return "0 B";
|
|
31
|
+
const exponent = Math.min(
|
|
32
|
+
Math.floor(Math.log(bytes) / Math.log(1024)),
|
|
33
|
+
UNITS.length - 1
|
|
34
|
+
);
|
|
35
|
+
const value = bytes / 1024 ** exponent;
|
|
36
|
+
const rendered = exponent === 0 ? String(Math.round(value)) : value.toFixed(2);
|
|
37
|
+
return `${rendered} ${UNITS[exponent]}`;
|
|
38
|
+
}
|
|
39
|
+
function formatDelta(bytes) {
|
|
40
|
+
if (bytes === 0) return "0 B";
|
|
41
|
+
const sign = bytes > 0 ? "+" : "-";
|
|
42
|
+
return `${sign}${formatBytes(Math.abs(bytes))}`;
|
|
43
|
+
}
|
|
44
|
+
function parseSize(input) {
|
|
45
|
+
const match = SIZE_RE.exec(input.trim());
|
|
46
|
+
if (!match) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Invalid size: "${input}" (try "50kb", "1.5mb", or a raw byte count)`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
const value = Number(match[1]);
|
|
52
|
+
const unit = (match[2] ?? "b").toLowerCase();
|
|
53
|
+
return Math.round(value * MULTIPLIERS[unit]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// src/sizes.ts
|
|
57
|
+
import { readFile } from "fs/promises";
|
|
58
|
+
import { brotliCompressSync, gzipSync } from "zlib";
|
|
59
|
+
function measureBuffer(buffer) {
|
|
60
|
+
return {
|
|
61
|
+
raw: buffer.byteLength,
|
|
62
|
+
gzip: gzipSync(buffer).byteLength,
|
|
63
|
+
brotli: brotliCompressSync(buffer).byteLength
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
async function measureFile(path) {
|
|
67
|
+
const buffer = await readFile(path);
|
|
68
|
+
return measureBuffer(buffer);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/report.ts
|
|
72
|
+
function subtract(next, prev) {
|
|
73
|
+
return {
|
|
74
|
+
raw: next.raw - prev.raw,
|
|
75
|
+
gzip: next.gzip - prev.gzip,
|
|
76
|
+
brotli: next.brotli - prev.brotli
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function sum(sizes) {
|
|
80
|
+
return sizes.reduce(
|
|
81
|
+
(acc, size) => ({
|
|
82
|
+
raw: acc.raw + size.raw,
|
|
83
|
+
gzip: acc.gzip + size.gzip,
|
|
84
|
+
brotli: acc.brotli + size.brotli
|
|
85
|
+
}),
|
|
86
|
+
{ raw: 0, gzip: 0, brotli: 0 }
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
function buildReport(measurements, previous) {
|
|
90
|
+
const rows = measurements.map((measurement) => {
|
|
91
|
+
const prev = previous?.get(measurement.path);
|
|
92
|
+
return {
|
|
93
|
+
path: measurement.path,
|
|
94
|
+
size: measurement.size,
|
|
95
|
+
delta: prev ? subtract(measurement.size, prev) : null
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
const totalSize = sum(measurements.map((m) => m.size));
|
|
99
|
+
const previousTotal = previous ? sum([...previous.values()]) : void 0;
|
|
100
|
+
return {
|
|
101
|
+
rows,
|
|
102
|
+
total: {
|
|
103
|
+
size: totalSize,
|
|
104
|
+
delta: previousTotal ? subtract(totalSize, previousTotal) : null
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/render.ts
|
|
110
|
+
var plainCell = (text) => ({ plain: text, colored: text });
|
|
111
|
+
function colorDelta(delta, metric, colors) {
|
|
112
|
+
if (delta === null) return { plain: "new", colored: colors.cyan("new") };
|
|
113
|
+
const value = delta[metric];
|
|
114
|
+
const text = formatDelta(value);
|
|
115
|
+
if (value < 0) return { plain: text, colored: colors.green(text) };
|
|
116
|
+
if (value > 0) return { plain: text, colored: colors.red(text) };
|
|
117
|
+
return { plain: text, colored: colors.dim(text) };
|
|
118
|
+
}
|
|
119
|
+
function buildCells(label, size, delta, metric, hasDelta, options, bold) {
|
|
120
|
+
const cells = [
|
|
121
|
+
{ plain: label, colored: bold ? options.colors.bold(label) : label },
|
|
122
|
+
plainCell(formatBytes(size.raw))
|
|
123
|
+
];
|
|
124
|
+
if (options.gzip) cells.push(plainCell(formatBytes(size.gzip)));
|
|
125
|
+
if (options.brotli) cells.push(plainCell(formatBytes(size.brotli)));
|
|
126
|
+
if (hasDelta) cells.push(colorDelta(delta, metric, options.colors));
|
|
127
|
+
return cells;
|
|
128
|
+
}
|
|
129
|
+
function renderJson(report) {
|
|
130
|
+
return JSON.stringify(
|
|
131
|
+
{
|
|
132
|
+
files: report.rows.map((row) => ({
|
|
133
|
+
path: row.path,
|
|
134
|
+
raw: row.size.raw,
|
|
135
|
+
gzip: row.size.gzip,
|
|
136
|
+
brotli: row.size.brotli,
|
|
137
|
+
delta: row.delta
|
|
138
|
+
})),
|
|
139
|
+
total: {
|
|
140
|
+
raw: report.total.size.raw,
|
|
141
|
+
gzip: report.total.size.gzip,
|
|
142
|
+
brotli: report.total.size.brotli,
|
|
143
|
+
delta: report.total.delta
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
null,
|
|
147
|
+
2
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
function renderTable(report, options) {
|
|
151
|
+
const metric = options.gzip ? "gzip" : "raw";
|
|
152
|
+
const hasDelta = report.rows.some((row) => row.delta !== null) || report.total.delta !== null;
|
|
153
|
+
const headers = ["File", "Raw"];
|
|
154
|
+
if (options.gzip) headers.push("Gzip");
|
|
155
|
+
if (options.brotli) headers.push("Brotli");
|
|
156
|
+
if (hasDelta) headers.push(`\u0394 ${metric}`);
|
|
157
|
+
const headerCells = headers.map((header) => ({
|
|
158
|
+
plain: header,
|
|
159
|
+
colored: options.colors.dim(header)
|
|
160
|
+
}));
|
|
161
|
+
const bodyRows = report.rows.map(
|
|
162
|
+
(row) => buildCells(row.path, row.size, row.delta, metric, hasDelta, options, false)
|
|
163
|
+
);
|
|
164
|
+
const totalRow = buildCells(
|
|
165
|
+
"total",
|
|
166
|
+
report.total.size,
|
|
167
|
+
report.total.delta,
|
|
168
|
+
metric,
|
|
169
|
+
hasDelta,
|
|
170
|
+
options,
|
|
171
|
+
true
|
|
172
|
+
);
|
|
173
|
+
const matrix = [headerCells, ...bodyRows, totalRow];
|
|
174
|
+
const widths = headers.map(
|
|
175
|
+
(_, column) => Math.max(...matrix.map((row) => row[column].plain.length))
|
|
176
|
+
);
|
|
177
|
+
return matrix.map(
|
|
178
|
+
(row) => row.map((cell, column) => {
|
|
179
|
+
const padding = " ".repeat(widths[column] - cell.plain.length);
|
|
180
|
+
return column === 0 ? cell.colored + padding : padding + cell.colored;
|
|
181
|
+
}).join(" ")
|
|
182
|
+
).join("\n");
|
|
183
|
+
}
|
|
184
|
+
function renderReport(report, options) {
|
|
185
|
+
return options.json ? renderJson(report) : renderTable(report, options);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// src/watch.ts
|
|
189
|
+
import * as fs from "fs";
|
|
190
|
+
var defaultImpl = (path, onChange) => {
|
|
191
|
+
const watcher = fs.watch(path, () => onChange());
|
|
192
|
+
return { close: () => watcher.close() };
|
|
193
|
+
};
|
|
194
|
+
function watchPaths(paths, onChange, impl = defaultImpl) {
|
|
195
|
+
const watchers = paths.map((path) => impl(path, onChange));
|
|
196
|
+
return () => {
|
|
197
|
+
for (const watcher of watchers) watcher.close();
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/cli.ts
|
|
202
|
+
import { resolve } from "path";
|
|
203
|
+
import { Command } from "commander";
|
|
204
|
+
var DESCRIPTION = "Print the gzipped bundle cost of your files \u2014 and the delta on every save.";
|
|
205
|
+
var HELP_EXAMPLES = `
|
|
206
|
+
Examples:
|
|
207
|
+
$ bundle-cost dist/index.js
|
|
208
|
+
$ bundle-cost dist/*.js --brotli
|
|
209
|
+
$ bundle-cost dist/index.js --limit 50kb
|
|
210
|
+
$ bundle-cost dist/index.js --watch
|
|
211
|
+
$ bundle-cost dist/index.js --json
|
|
212
|
+
`;
|
|
213
|
+
var trimTrailingNewline = (text) => text.replace(/\n+$/, "");
|
|
214
|
+
async function measureAll(targets) {
|
|
215
|
+
return Promise.all(
|
|
216
|
+
targets.map(async (target) => ({ ...target, size: await measureFile(target.path) }))
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
var toMeasurements = (measured) => measured.map((m) => ({ path: m.label, size: m.size }));
|
|
220
|
+
var snapshot = (measured) => new Map(measured.map((m) => [m.label, m.size]));
|
|
221
|
+
function startWatch(targets, renderOptions, initial, ctx) {
|
|
222
|
+
let previous = snapshot(initial);
|
|
223
|
+
ctx.log(renderOptions.colors.dim("watching for changes\u2026 (ctrl-c to stop)"));
|
|
224
|
+
ctx.watch(
|
|
225
|
+
targets.map((target) => target.path),
|
|
226
|
+
async () => {
|
|
227
|
+
let next;
|
|
228
|
+
try {
|
|
229
|
+
next = await measureAll(targets);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
ctx.error(renderOptions.colors.red(`Could not read file: ${err.message}`));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
ctx.log(renderReport(buildReport(toMeasurements(next), previous), renderOptions));
|
|
235
|
+
previous = snapshot(next);
|
|
236
|
+
}
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
function reportBudget(totalGzip, limit, colors, ctx) {
|
|
240
|
+
const headline = `${formatBytes(totalGzip)} gzip`;
|
|
241
|
+
if (totalGzip > limit) {
|
|
242
|
+
ctx.error(colors.red(`\u2717 ${headline} exceeds the ${formatBytes(limit)} budget`));
|
|
243
|
+
return 1;
|
|
244
|
+
}
|
|
245
|
+
ctx.log(colors.green(`\u2713 ${headline} is within the ${formatBytes(limit)} budget`));
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
|
248
|
+
async function execute(files, options, ctx) {
|
|
249
|
+
const colorsEnabled = options.color !== false && !ctx.env.NO_COLOR;
|
|
250
|
+
const renderOptions = {
|
|
251
|
+
gzip: options.gzip !== false,
|
|
252
|
+
brotli: options.brotli === true,
|
|
253
|
+
json: options.json === true,
|
|
254
|
+
colors: createColors(colorsEnabled)
|
|
255
|
+
};
|
|
256
|
+
let limit;
|
|
257
|
+
if (options.limit !== void 0) {
|
|
258
|
+
try {
|
|
259
|
+
limit = parseSize(options.limit);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
ctx.error(renderOptions.colors.red(err.message));
|
|
262
|
+
return 1;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const targets = files.map((file) => ({ label: file, path: resolve(ctx.cwd, file) }));
|
|
266
|
+
let measured;
|
|
267
|
+
try {
|
|
268
|
+
measured = await measureAll(targets);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
ctx.error(renderOptions.colors.red(`Could not read file: ${err.message}`));
|
|
271
|
+
return 1;
|
|
272
|
+
}
|
|
273
|
+
const report = buildReport(toMeasurements(measured));
|
|
274
|
+
ctx.log(renderReport(report, renderOptions));
|
|
275
|
+
if (options.watch) {
|
|
276
|
+
startWatch(targets, renderOptions, measured, ctx);
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
if (limit !== void 0) {
|
|
280
|
+
return reportBudget(report.total.size.gzip, limit, renderOptions.colors, ctx);
|
|
281
|
+
}
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
async function run(argv, deps = {}) {
|
|
285
|
+
const log = deps.log ?? ((message) => process.stdout.write(`${message}
|
|
286
|
+
`));
|
|
287
|
+
const error = deps.error ?? ((message) => process.stderr.write(`${message}
|
|
288
|
+
`));
|
|
289
|
+
const ctx = {
|
|
290
|
+
log,
|
|
291
|
+
error,
|
|
292
|
+
cwd: deps.cwd ?? process.cwd(),
|
|
293
|
+
env: deps.env ?? process.env,
|
|
294
|
+
watch: deps.watch ?? watchPaths
|
|
295
|
+
};
|
|
296
|
+
const program = new Command();
|
|
297
|
+
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({
|
|
298
|
+
writeOut: (text) => log(trimTrailingNewline(text)),
|
|
299
|
+
writeErr: (text) => error(trimTrailingNewline(text))
|
|
300
|
+
});
|
|
301
|
+
let exitCode = 0;
|
|
302
|
+
program.action(async (files, options) => {
|
|
303
|
+
exitCode = await execute(files, options, ctx);
|
|
304
|
+
});
|
|
305
|
+
try {
|
|
306
|
+
await program.parseAsync(argv, { from: "user" });
|
|
307
|
+
} catch (err) {
|
|
308
|
+
return err.exitCode;
|
|
309
|
+
}
|
|
310
|
+
return exitCode;
|
|
311
|
+
}
|
|
312
|
+
export {
|
|
313
|
+
buildReport,
|
|
314
|
+
createColors,
|
|
315
|
+
formatBytes,
|
|
316
|
+
formatDelta,
|
|
317
|
+
measureBuffer,
|
|
318
|
+
measureFile,
|
|
319
|
+
parseSize,
|
|
320
|
+
renderReport,
|
|
321
|
+
run,
|
|
322
|
+
watchPaths
|
|
323
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bundle-cost-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A tiny CLI that prints the gzipped bundle cost of your files — and the delta on every save.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bundle-size",
|
|
7
|
+
"bundle-cost",
|
|
8
|
+
"gzip",
|
|
9
|
+
"brotli",
|
|
10
|
+
"cli",
|
|
11
|
+
"size-limit",
|
|
12
|
+
"performance",
|
|
13
|
+
"budget",
|
|
14
|
+
"watch",
|
|
15
|
+
"dx"
|
|
16
|
+
],
|
|
17
|
+
"author": "kea0811",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"homepage": "https://github.com/kea0811/bundle-cost-cli#readme",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/kea0811/bundle-cost-cli.git"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/kea0811/bundle-cost-cli/issues"
|
|
26
|
+
},
|
|
27
|
+
"type": "module",
|
|
28
|
+
"main": "./dist/index.cjs",
|
|
29
|
+
"module": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"import": "./dist/index.js",
|
|
35
|
+
"require": "./dist/index.cjs"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"bin": {
|
|
39
|
+
"bundle-cost": "./dist/bin.js"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"dev": "tsup --watch",
|
|
48
|
+
"build": "tsup",
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"test:watch": "vitest",
|
|
51
|
+
"test:coverage": "vitest run --coverage",
|
|
52
|
+
"typecheck": "tsc --noEmit"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"commander": "^12.1.0"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@types/node": "^22.7.5",
|
|
59
|
+
"@vitest/coverage-v8": "^2.1.2",
|
|
60
|
+
"tsup": "^8.3.0",
|
|
61
|
+
"typescript": "^5.6.2",
|
|
62
|
+
"vitest": "^2.1.2"
|
|
63
|
+
},
|
|
64
|
+
"engines": {
|
|
65
|
+
"node": ">=18"
|
|
66
|
+
},
|
|
67
|
+
"packageManager": "pnpm@10.28.1",
|
|
68
|
+
"pnpm": {
|
|
69
|
+
"onlyBuiltDependencies": [
|
|
70
|
+
"esbuild"
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
}
|