Skip to content

Commit 423f43c

Browse files
committed
chore: new bundle size tool
1 parent 616b4b7 commit 423f43c

File tree

21 files changed

+498
-32
lines changed

21 files changed

+498
-32
lines changed

apps/bundle-size/.eslintrc.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"extends": ["plugin:@fluentui/eslint-plugin/node"],
3+
"root": true,
4+
"rules": {
5+
"import/no-extraneous-dependencies": "off"
6+
}
7+
}

apps/bundle-size/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
## `bundle-size`
2+
3+
A tool to measure bundle size locally and on CI.
4+
5+
**To ensure that we are measuring the same artifacts as customers `bundle-size` requires to build packages first.**
6+
7+
### Fixtures
8+
9+
A tool relies on fixtures represented by JavaScript files, fixtures are created inside each package.
10+
11+
For example:
12+
13+
```js
14+
import { Component } from '@fluentui/react-component';
15+
16+
console.log(Component);
17+
// 👆 "console.log()" is the easiest way to prevent tree-shaking
18+
19+
export default {
20+
name: 'Component',
21+
// 👆 defines a name for story that will be used in output
22+
};
23+
```
24+
25+
### `build`
26+
27+
```sh
28+
yarn bundle-size build [--verbose]
29+
```
30+
31+
Builds fixtures and produces artifacts.
32+
33+
For each fixture:
34+
35+
- `[fixture].fixture.js` - a modified fixture without a default export, used by a bundler
36+
- `[fixture].output.js` - a partially minified file, useful for debugging
37+
- `[fixture].min.js` - a fully minified file, used for measurements
38+
39+
A report file `bundle-size.json` that is used by other steps.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/usr/bin/env node
2+
require('../src/index');

apps/bundle-size/just.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { preset } from '@fluentui/scripts';
2+
3+
preset();

apps/bundle-size/package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "bundle-size",
3+
"version": "1.0.0",
4+
"private": true,
5+
"bin": {
6+
"bundle-size": "./bin/bundle-size.js"
7+
},
8+
"scripts": {
9+
"build": "tsc --noEmit",
10+
"lint": "just-scripts lint"
11+
}
12+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// @ts-check
2+
3+
const chalk = require('chalk');
4+
const Table = require('cli-table3');
5+
const fs = require('fs-extra');
6+
const glob = require('glob');
7+
const path = require('path');
8+
const prettyBytes = require('pretty-bytes');
9+
10+
const buildFixture = require('../utils/buildFixture');
11+
const { hrToSeconds } = require('../utils/helpers');
12+
const prepareFixture = require('../utils/prepareFixture');
13+
14+
/**
15+
* @param {{ verbose: boolean }} options
16+
*/
17+
async function build(options) {
18+
const { verbose } = options;
19+
20+
const startTime = process.hrtime();
21+
const artifactsDir = path.resolve(process.cwd(), 'dist', 'bundle-size');
22+
23+
await fs.remove(artifactsDir);
24+
25+
if (verbose) {
26+
console.log(`${chalk.blue('[i]')} artifacts dir is cleared`);
27+
}
28+
29+
const fixtures = glob.sync('bundle-size/*.fixture.js', {
30+
cwd: process.cwd(),
31+
});
32+
33+
if (verbose) {
34+
console.log(`${chalk.blue('[i]')} Measuring bundle size for ${fixtures.length} fixture(s)...`);
35+
console.log(fixtures.map(fixture => ` - ${fixture}`).join('\n'));
36+
}
37+
38+
const preparedFixtures = await Promise.all(fixtures.map(prepareFixture));
39+
const measurements = [];
40+
41+
for (const preparedFixture of preparedFixtures) {
42+
measurements.push(await buildFixture(preparedFixture, verbose));
43+
}
44+
45+
measurements.sort((a, b) => a.path.localeCompare(b.path));
46+
47+
await fs.writeJSON(path.resolve(process.cwd(), 'dist', 'bundle-size', 'bundle-size.json'), measurements);
48+
49+
if (verbose) {
50+
const table = new Table({
51+
head: ['Fixture', 'Minified size', 'GZIP size'],
52+
});
53+
54+
measurements.forEach(r => {
55+
table.push([r.name, prettyBytes(r.minifiedSize), prettyBytes(r.gzippedSize)]);
56+
});
57+
58+
console.log(table.toString());
59+
console.log(`Completed in ${hrToSeconds(process.hrtime(startTime))}`);
60+
}
61+
}
62+
63+
// ---
64+
65+
exports.command = 'build';
66+
exports.desc = 'builds bundle size fixtures and generates JSON report';
67+
exports.handler = build;

apps/bundle-size/src/index.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// @ts-check
2+
3+
require('yargs')
4+
.commandDir('commands')
5+
6+
.option('verbose', {
7+
alias: 'v',
8+
type: 'boolean',
9+
description: 'Run with verbose logging',
10+
default: false,
11+
})
12+
.help().argv;
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// @ts-check
2+
3+
const chalk = require('chalk');
4+
const gzipSize = require('gzip-size');
5+
const fs = require('fs-extra');
6+
const path = require('path');
7+
const { minify } = require('terser');
8+
const webpack = require('webpack');
9+
10+
const { hrToSeconds } = require('./helpers');
11+
12+
/**
13+
* @param {string} fixturePath
14+
* @param {string} outputPath
15+
*
16+
* @return {import("webpack").Configuration}
17+
*/
18+
function createWebpackConfig(fixturePath, outputPath) {
19+
return {
20+
name: 'client',
21+
target: 'web',
22+
mode: 'production',
23+
24+
cache: {
25+
type: 'memory',
26+
},
27+
externals: {
28+
react: 'React',
29+
'react-dom': 'ReactDOM',
30+
},
31+
32+
entry: fixturePath,
33+
output: {
34+
filename: path.basename(outputPath),
35+
path: path.dirname(outputPath),
36+
37+
pathinfo: true,
38+
},
39+
performance: {
40+
hints: false,
41+
},
42+
optimization: {
43+
minimize: false,
44+
},
45+
stats: {
46+
optimizationBailout: true,
47+
},
48+
};
49+
}
50+
51+
/**
52+
* @param {import("webpack").Configuration} webpackConfig
53+
* @return {Promise<null>}
54+
*/
55+
function webpackAsync(webpackConfig) {
56+
return new Promise((resolve, reject) => {
57+
const compiler = webpack(webpackConfig);
58+
59+
compiler.run(err => {
60+
if (err) {
61+
reject(err);
62+
}
63+
64+
resolve(null);
65+
});
66+
});
67+
}
68+
69+
// ---
70+
71+
/** @typedef {{ name: string, path: string, minifiedSize: number, gzippedSize: number }} BuildResult */
72+
73+
/**
74+
* Builds a fixture with Webpack and then minifies it with Terser. Produces two files as artifacts:
75+
* - partially minified file (.output.js) for debugging
76+
* - fully minified file (.min.js)
77+
*
78+
* @param {import('./prepareFixture').PreparedFixture} preparedFixture
79+
* @param {boolean} verbose
80+
*
81+
* @return {Promise<BuildResult>}
82+
*/
83+
module.exports = async function buildFixture(preparedFixture, verbose) {
84+
const webpackStartTime = process.hrtime();
85+
86+
const webpackOutputPath = preparedFixture.absolutePath.replace(/.fixture.js$/, '.output.js');
87+
const config = createWebpackConfig(preparedFixture.absolutePath, webpackOutputPath);
88+
89+
await webpackAsync(config);
90+
91+
if (verbose) {
92+
console.log(
93+
[
94+
chalk.blue('[i]'),
95+
`"${path.basename(preparedFixture.relativePath)}": Webpack in ${hrToSeconds(process.hrtime(webpackStartTime))}`,
96+
].join(' '),
97+
);
98+
}
99+
100+
// ---
101+
102+
const terserStartTime = process.hrtime();
103+
const terserOutputPath = preparedFixture.absolutePath.replace(/.fixture.js$/, '.min.js');
104+
105+
const webpackOutput = (await fs.promises.readFile(webpackOutputPath)).toString();
106+
107+
const [terserOutput, terserOutputMinified] = await Promise.all([
108+
// Performs only dead-code elimination
109+
/* eslint-disable @typescript-eslint/naming-convention */
110+
minify(webpackOutput, {
111+
mangle: false,
112+
output: {
113+
beautify: true,
114+
comments: true,
115+
preserve_annotations: true,
116+
},
117+
}),
118+
minify(webpackOutput, {
119+
output: {
120+
comments: false,
121+
},
122+
}),
123+
/* eslint-enable @typescript-eslint/naming-convention */
124+
]);
125+
126+
await fs.promises.writeFile(webpackOutputPath, terserOutput.code);
127+
await fs.promises.writeFile(terserOutputPath, terserOutputMinified.code);
128+
129+
if (verbose) {
130+
console.log(
131+
[
132+
chalk.blue('[i]'),
133+
`"${path.basename(preparedFixture.relativePath)}": Terser in ${hrToSeconds(process.hrtime(terserStartTime))}`,
134+
].join(' '),
135+
);
136+
}
137+
138+
const minifiedSize = (await fs.promises.stat(terserOutputPath)).size;
139+
const gzippedSize = await gzipSize.file(terserOutputPath);
140+
141+
return {
142+
name: preparedFixture.name,
143+
path: preparedFixture.relativePath,
144+
145+
minifiedSize,
146+
gzippedSize,
147+
};
148+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// @ts-check
2+
3+
/**
4+
* @param {[number, number]} hrtime
5+
* @return {string}
6+
*/
7+
function hrToSeconds(hrtime) {
8+
const raw = hrtime[0] + hrtime[1] / 1e9;
9+
10+
return raw.toFixed(2) + 's';
11+
}
12+
13+
module.exports = {
14+
hrToSeconds,
15+
};

0 commit comments

Comments
 (0)