Skip to content

Commit ac62c80

Browse files
authored
chore: command to get package size info (#20)
* meta: command to get package size info * chore: make pkg-size its own internal package code quality improved by a factor of roughly 874 quintillion * doc: add note about building before trying to get size report
1 parent 4f6e12a commit ac62c80

File tree

16 files changed

+673
-10
lines changed

16 files changed

+673
-10
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,5 @@ tsconfig.tsbuildinfo
1414

1515
.idea
1616
.DS_Store
17+
18+
.pkgsize

README.md

+34
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,37 @@ pnpm run -r build
6767
pnpm run pull
6868
pnpm run -r generate
6969
```
70+
71+
### checking package sizes
72+
73+
to observe the size of packages (both install size and bundled size), there is a `pkg-size-report`
74+
tool doing just that. you can also save the package sizes at a given time and inspect the impact of
75+
changes to the final bundle size. the tool uses `esbuild` to produce a minified bundle to get the
76+
size of each entrypoint.
77+
78+
<!-- prettier-ignore-start -->
79+
<!-- Otherwise it wrecks the gfm alertbox ugh -->
80+
81+
> [!WARNING]
82+
> run `pnpm run -r build` before running the command. otherwise, the command **may not run**, or **give bad measurements**.
83+
84+
<!-- prettier-ignore-end -->
85+
86+
```sh
87+
# See the size of packages.
88+
# If package sizes were saved previously, will also show the diff.
89+
pnpm pkg-size-report
90+
91+
# Save esbuild metafiles and package size information.
92+
pnpm pkg-size-report --save
93+
94+
# Save just esbuild metafiles.
95+
pnpm pkg-size-report --save-meta
96+
97+
# Show only the packages whose size have changed.
98+
pnpm pkg-size-report --compare
99+
100+
# Keep the result bundle produced by esbuild.
101+
# Will be left in /tmp/[...]--[pkgname]--[random]
102+
pnpm pkg-size-report --keep-builds
103+
```

package.json

+3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"private": true,
23
"type": "module",
34
"scripts": {
45
"pull": "node ./scripts/pull-lexicons.js",
@@ -8,6 +9,8 @@
89
},
910
"devDependencies": {
1011
"@mary/tar": "npm:@jsr/mary__tar@^0.2.4",
12+
"mitata": "^1.0.23",
13+
"pkg-size-report": "workspace:^",
1114
"prettier": "^3.4.2",
1215
"typescript": "5.7.2"
1316
}

packages/bluesky/richtext-segmenter/package.json

+1-2
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
"devDependencies": {
3131
"@atcute/bluesky": "workspace:^",
3232
"@atcute/client": "workspace:^",
33-
"@types/bun": "^1.1.14",
34-
"mitata": "^1.0.23"
33+
"@types/bun": "^1.1.14"
3534
}
3635
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/usr/bin/env node
2+
import './dist/index.js';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"private": true,
3+
"type": "module",
4+
"name": "pkg-size-report",
5+
"version": "1.0.1",
6+
"description": "cli tool to compute and analyze package sizes",
7+
"license": "MIT",
8+
"repository": {
9+
"url": "https://github.com/mary-ext/atcute",
10+
"directory": "packages/internal/pkg-size-report"
11+
},
12+
"bin": "./cli.mjs",
13+
"scripts": {
14+
"build": "tsc"
15+
},
16+
"dependencies": {
17+
"esbuild": "^0.24.2",
18+
"js-yaml": "^4.1.0",
19+
"picocolors": "^1.1.1"
20+
},
21+
"devDependencies": {
22+
"@types/js-yaml": "^4.0.9",
23+
"@types/node": "^22.10.2"
24+
}
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import * as path from 'node:path';
2+
import * as os from 'node:os';
3+
import * as fs from 'node:fs';
4+
import * as url from 'node:url';
5+
import * as esbuild from 'esbuild';
6+
7+
import { computeFolderSize } from './fs.js';
8+
9+
export function computeBundleInformation(entry: URL, entryQualifier: string, keepBuild = false) {
10+
const tmpDirPrefix = path.join(
11+
os.tmpdir(),
12+
`atcute-pkg-size-build--${entryQualifier.replaceAll('/', '__')}--`,
13+
);
14+
15+
const tmpDir = fs.mkdtempSync(tmpDirPrefix);
16+
17+
const { metafile } = esbuild.buildSync({
18+
bundle: true,
19+
minify: true,
20+
outdir: tmpDir,
21+
metafile: true,
22+
entryPoints: [url.fileURLToPath(entry)],
23+
});
24+
25+
const tmpDirUrl = url.pathToFileURL(tmpDir);
26+
const bundledSize = computeFolderSize(tmpDirUrl, true);
27+
28+
if (!keepBuild) fs.rmSync(tmpDir, { recursive: true });
29+
30+
return {
31+
metafile,
32+
...bundledSize,
33+
};
34+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const WORKSPACE_ROOT = new URL('../../../../', import.meta.url);
2+
3+
export const PKGSIZE_FOLDER = new URL('.pkgsize/', WORKSPACE_ROOT);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { Metafile } from 'esbuild';
2+
3+
import * as fs from 'node:fs';
4+
import { PKGSIZE_FOLDER } from './consts.js';
5+
6+
const PKGSIZE_DATA = new URL(`data.json`, PKGSIZE_FOLDER);
7+
8+
export interface EntrypointSizeInformation {
9+
name: string;
10+
size: number;
11+
gzip: number;
12+
brotli: number;
13+
metafile: Metafile;
14+
}
15+
16+
export interface PackageSizeInformation {
17+
name: string;
18+
installSize: number;
19+
entries: EntrypointSizeInformation[];
20+
}
21+
22+
export function saveSizeData(data: PackageSizeInformation[]): void {
23+
const json = JSON.stringify(data, (k, v) => (k === 'metafile' ? void 0 : v), '\t');
24+
fs.writeFileSync(PKGSIZE_DATA, json);
25+
}
26+
27+
export function readSizeData(): PackageSizeInformation[] | null {
28+
if (!fs.existsSync(PKGSIZE_DATA)) return null;
29+
30+
const json = fs.readFileSync(PKGSIZE_DATA, 'utf8');
31+
return JSON.parse(json);
32+
}
33+
34+
export function saveEsbuildMetafiles(data: PackageSizeInformation[]): void {
35+
for (const pkg of data) {
36+
for (const { name, metafile } of pkg.entries) {
37+
const path = new URL(`${name.replaceAll('/', '__')}-esbuild-metafile.json`, PKGSIZE_FOLDER);
38+
const json = JSON.stringify(metafile, null, '\t');
39+
fs.writeFileSync(path, json);
40+
}
41+
}
42+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { PackageSizeInformation } from './data.js';
2+
3+
export interface DiffInformation {
4+
[key: string]: {
5+
diff: number;
6+
entries: {
7+
[key: string]: { diff: number; gzip: number; brotli: number };
8+
};
9+
};
10+
}
11+
12+
export function computeSizeDiff(
13+
data: PackageSizeInformation[],
14+
old: PackageSizeInformation[],
15+
): DiffInformation {
16+
const diff: DiffInformation = {};
17+
for (const pkg of data) {
18+
const oldPkg = old.find((p) => p.name == pkg.name);
19+
if (!oldPkg) continue;
20+
21+
diff[pkg.name] = { diff: pkg.installSize - oldPkg.installSize, entries: {} };
22+
for (const entry of pkg.entries) {
23+
const oldEntry = oldPkg.entries.find((e) => e.name === entry.name);
24+
if (!oldEntry) continue;
25+
26+
diff[pkg.name].entries[entry.name] = {
27+
diff: entry.size - oldEntry.size,
28+
gzip: entry.gzip - oldEntry.gzip,
29+
brotli: entry.brotli - oldEntry.brotli,
30+
};
31+
}
32+
}
33+
34+
return diff;
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import * as fs from 'node:fs';
2+
import * as zlib from 'node:zlib';
3+
4+
export function computeFolderSize(folder: URL, detailed = false) {
5+
folder.pathname += '/';
6+
let res = { size: 0, gzip: 0, brotli: 0 };
7+
8+
for (const entry of fs.readdirSync(folder)) {
9+
const path = new URL(entry, folder);
10+
const stat = fs.statSync(path);
11+
12+
if (stat.isDirectory()) {
13+
const nested = computeFolderSize(path);
14+
res.size += nested.size;
15+
res.gzip += nested.gzip;
16+
res.brotli += nested.brotli;
17+
}
18+
19+
if (stat.isFile()) {
20+
res.size += stat.size;
21+
if (detailed) {
22+
const buf = fs.readFileSync(path);
23+
const gzipBuf = zlib.gzipSync(buf);
24+
const brotliBuf = zlib.brotliCompressSync(buf);
25+
res.gzip += gzipBuf.length;
26+
res.brotli += brotliBuf.length;
27+
}
28+
}
29+
}
30+
31+
return res;
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import pc from 'picocolors';
2+
import { computePackageSizeInformation, getAllWorkspacePackages } from './package.js';
3+
import { readSizeData, saveEsbuildMetafiles, saveSizeData } from './data.js';
4+
import { computeSizeDiff } from './diff.js';
5+
6+
const TREE_SYM_HAS_NEXT = '├';
7+
const TREE_SYM_FINAL = '└';
8+
9+
const FLAG_SAVE = process.argv.includes('--save');
10+
const FLAG_SAVE_META = process.argv.includes('--save-meta');
11+
const FLAG_COMPARE = process.argv.includes('--compare');
12+
const FLAG_KEEP_BUILDS = process.argv.includes('--keep-builds');
13+
14+
function formatSize(size: number) {
15+
return `${(size / 1000).toFixed(2)}kB`.padEnd(7, ' ');
16+
}
17+
18+
function formatDiffSize(size: number) {
19+
const str = Math.abs(size) < 1000 ? `${size}B` : `${(size / 1000).toFixed(3)}kB`;
20+
return size < 0 ? pc.green(str) : pc.red(`+${str}`);
21+
}
22+
23+
// Get package info
24+
const packages = getAllWorkspacePackages();
25+
const sizeData = packages.map((pkg) => computePackageSizeInformation(pkg, FLAG_KEEP_BUILDS));
26+
27+
// Compute diff if applicable
28+
const prevData = readSizeData();
29+
const diff = prevData && computeSizeDiff(sizeData, prevData);
30+
31+
// Save files
32+
if (FLAG_SAVE) saveSizeData(sizeData);
33+
if (FLAG_SAVE || FLAG_SAVE_META) saveEsbuildMetafiles(sizeData);
34+
35+
// Print to stdout.
36+
for (const pkg of sizeData) {
37+
const pkgDiff = diff?.[pkg.name];
38+
if (FLAG_COMPARE && pkgDiff?.diff === 0) continue;
39+
40+
let pkgInfo = `${pkg.name.padEnd(34, ' ')}\t${formatSize(pkg.installSize).padEnd(8, ' ')} ${pc.gray('install size')}`;
41+
if (pkgDiff?.diff) pkgInfo += ` ${formatDiffSize(pkgDiff.diff)}`;
42+
43+
console.log(pkgInfo);
44+
45+
for (let i = 0; i < pkg.entries.length; i++) {
46+
const entry = pkg.entries[i];
47+
const entryDiff = pkgDiff?.entries[entry.name];
48+
49+
const size = formatSize(entry.size);
50+
const gzip = `${formatSize(entry.gzip)} ${pc.gray('gzip')}`;
51+
const brotli = `${formatSize(entry.brotli)} ${pc.gray('brotli')}`;
52+
53+
const treeSym = i === pkg.entries.length - 1 ? TREE_SYM_FINAL : TREE_SYM_HAS_NEXT;
54+
let entryInfo = `${treeSym}── ${entry.name.padEnd(30, ' ')}\t${treeSym}── ${size}\t${gzip}\t${brotli}`;
55+
if (entryDiff?.diff) {
56+
const size = formatDiffSize(entryDiff.diff);
57+
const gzip = formatDiffSize(entryDiff.gzip);
58+
const brotli = formatDiffSize(entryDiff.brotli);
59+
entryInfo += ` ${size}/${gzip}/${brotli}`;
60+
}
61+
62+
console.log(entryInfo);
63+
}
64+
65+
console.log();
66+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { PackageSizeInformation } from './data.js';
2+
3+
import * as fs from 'node:fs';
4+
import { load as parseYaml } from 'js-yaml';
5+
import { WORKSPACE_ROOT } from './consts.js';
6+
import { computeFolderSize } from './fs.js';
7+
import { computeBundleInformation } from './bundle.js';
8+
9+
interface PackageJsonData {
10+
folder: URL;
11+
relpath: string;
12+
name: string;
13+
private: boolean;
14+
exports: Record<string, string> | null; // let's pretend the export map doesn't exist for now shall we :)
15+
}
16+
17+
const PNPM_LOCKFILE = new URL('pnpm-lock.yaml', WORKSPACE_ROOT);
18+
19+
export function getAllWorkspacePackages(): PackageJsonData[] {
20+
const pnpmLockfileYaml = fs.readFileSync(PNPM_LOCKFILE, 'utf8');
21+
const pnpmLockfile = parseYaml(pnpmLockfileYaml) as any;
22+
const packages = Object.keys(pnpmLockfile.importers);
23+
24+
return packages
25+
.map((p) => {
26+
const packageFolder = new URL(`${p}/`, WORKSPACE_ROOT);
27+
const packageJsonFile = new URL('package.json', packageFolder);
28+
const packageJson = fs.readFileSync(packageJsonFile, 'utf8');
29+
return Object.assign({}, JSON.parse(packageJson), {
30+
folder: packageFolder,
31+
relpath: p,
32+
}) as PackageJsonData;
33+
})
34+
.filter((p) => !p.private);
35+
}
36+
37+
export function computePackageSizeInformation(
38+
pkg: PackageJsonData,
39+
keepBuilds = false,
40+
): PackageSizeInformation {
41+
const dist = new URL('dist/', pkg.folder);
42+
const { size: installSize } = computeFolderSize(dist);
43+
44+
const pkgSizeInformation: PackageSizeInformation = {
45+
name: pkg.name,
46+
installSize,
47+
entries: [],
48+
};
49+
50+
// Those are just declaration packages with no useful payload.
51+
if (pkg.relpath.includes('definitions')) return pkgSizeInformation;
52+
53+
// CLI & what not
54+
if (!pkg.exports) return pkgSizeInformation;
55+
56+
for (const entry in pkg.exports) {
57+
if (!Object.hasOwn(pkg.exports, entry)) continue;
58+
59+
const entryQualifier = pkg.name + entry.slice(1);
60+
const entryFile = new URL(pkg.exports[entry], pkg.folder);
61+
62+
const data = computeBundleInformation(entryFile, entryQualifier, keepBuilds);
63+
pkgSizeInformation.entries.push({
64+
name: entryQualifier,
65+
...data,
66+
});
67+
}
68+
69+
return pkgSizeInformation;
70+
}

0 commit comments

Comments
 (0)