Skip to content

Commit 82745a5

Browse files
authored
test(size-benchmark): generate benchmark and validation for package size (#3018)
1 parent 179afb9 commit 82745a5

27 files changed

+2531
-2
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,6 @@ codegen/.settings/
5151
codegen/*/.project
5252
codegen/*/.classpath
5353
codegen/*/.settings/
54-
codegen/*/bin
54+
codegen/*/bin
55+
56+
benchmark/size/raw

.prettierignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
CHANGELOG.md
22
**/*/CHANGELOG.md
33
.github/*
4+
**/*.hbs
5+
**/*/report.md

benchmark/size/report.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
| Package | Version | Publish Size | browser:Webpack | browser:Rollup | browser:EsBuild |
2+
| :------ | :------ | :----------- | :------ | :----- | :------- |
3+
|@aws-sdk/abort-controller|3.40.0|41.4 KB|✅(5.62.1)|✅(2.59.0)|✅(0.13.12)|
4+
|@aws-sdk/client-s3|3.41.0|3.4 MB|✅(5.62.1)|✅(2.59.0)|✅(0.13.12)|
5+
|@aws-sdk/credential-provider-cognito-identity|3.41.0|116.4 KB|✅(5.62.1)|✅(2.59.0)|✅(0.13.12)|
6+
|@aws-sdk/credential-provider-env|3.40.0|45.9 KB|N/A|N/A|N/A|
7+
|@aws-sdk/credential-provider-imds|3.40.0|76.9 KB|N/A|N/A|N/A|
8+
|@aws-sdk/credential-provider-ini|3.41.0|59.9 KB|N/A|N/A|N/A|
9+
|@aws-sdk/credential-provider-node|3.41.0|60 KB|N/A|N/A|N/A|
10+
|@aws-sdk/credential-provider-process|3.40.0|46.9 KB|N/A|N/A|N/A|
11+
|@aws-sdk/credential-provider-sso|3.41.0|34.9 KB|N/A|N/A|N/A|
12+
|@aws-sdk/credential-provider-web-identity|3.41.0|34.4 KB|✅(5.62.1)|✅(2.59.0)|✅(0.13.12)|
13+
|@aws-sdk/credential-providers|3.41.0|78.2 KB|✅(5.62.1)|✅(2.59.0)|✅(0.13.12)|
14+
|@aws-sdk/fetch-http-handler|3.40.0|70.3 KB|✅(5.62.1)|✅(2.59.0)|✅(0.13.12)|
15+
|@aws-sdk/lib-dynamodb|3.41.0|145.1 KB|✅(5.62.1)|✅(2.59.0)|✅(0.13.12)|
16+
|@aws-sdk/lib-storage|3.41.0|66.6 KB|✅(5.62.1)|✅(2.59.0)|✅(0.13.12)|
17+
|@aws-sdk/node-http-handler|3.40.0|101.9 KB|N/A|N/A|N/A|
18+
|@aws-sdk/polly-request-presigner|3.41.0|36.7 KB|✅(5.62.1)|✅(2.59.0)|✅(0.13.12)|
19+
|@aws-sdk/s3-presigned-post|3.41.0|38.3 KB|✅(5.62.1)|✅(2.59.0)|✅(0.13.12)|
20+
|@aws-sdk/s3-request-presigner|3.41.0|79 KB|✅(5.62.1)|✅(2.59.0)|✅(0.13.12)|
21+
|@aws-sdk/signature-v4|3.40.0|177.6 KB|✅(5.62.1)|✅(2.59.0)|✅(0.13.12)|
22+
|@aws-sdk/signature-v4-crt|3.41.0|77.8 KB|N/A|N/A|N/A|
23+
|@aws-sdk/smithy-client|3.41.0|117.1 KB|✅(5.62.1)|✅(2.59.0)|✅(0.13.12)|
24+
|@aws-sdk/types|3.40.0|135.2 KB|✅(5.62.1)|✅(2.59.0)|✅(0.13.12)|

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"pretest:e2e": "yarn build:crypto-dependencies && lerna run --scope '@aws-sdk/{client-cloudformation,karma-credential-loader}' --include-dependencies build",
3535
"test:e2e": "node ./tests/e2e/index.js",
3636
"test:versions": "jest --config tests/versions/jest.config.js tests/versions/index.spec.ts",
37+
"test:size": "cd scripts/benchmark-size/runner && yarn && ./cli.ts",
3738
"local-publish": "node ./scripts/verdaccio-publish/index.js",
3839
"lerna:version": "lerna version --exact --conventional-commits --no-push --no-git-tag-version --no-commit-hooks --loglevel silent --no-private --yes",
3940
"test:unit": "jest --config jest.config.js"
@@ -101,6 +102,7 @@
101102
"ts-jest": "^26.4.1",
102103
"ts-loader": "^7.0.5",
103104
"ts-mocha": "8.0.0",
105+
"ts-node": "^10.4.0",
104106
"typedoc-plugin-lerna-packages": "^0.3.1",
105107
"typescript": "~4.3.5",
106108
"verdaccio": "^4.7.2",
@@ -136,4 +138,4 @@
136138
],
137139
"**/*.{ts,js,md,json}": "prettier --write"
138140
}
139-
}
141+
}

scripts/benchmark-size/README.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# SDK Size Benchmark
2+
3+
To avoid future issues like [#2747](https://github.com/aws/aws-sdk-js-v3/issues/2747),
4+
it's important to validate the v3 SDK packages' sizes are reasonable. This package
5+
provides a command line tool to benchmark and validate the SDK size
6+
metrics, including:
7+
8+
- **publish size**: The size of the package's source code published to npm.
9+
- **publish files**: The count of individual files fo the package's source code
10+
published to npm.
11+
- **Webpack compatibility**: Whether users can run Webpack command successfully
12+
with minimal configuration, and version of Webpack tool.
13+
- **Rollup compatibility**: Whether users can run Rollup command successfully
14+
with minimal configuration, and version of Rollup tool.
15+
- **Esbuild compatibility**: Whether users can run Esbuild command successfully
16+
with minimal configuration, and version of Esbuild tool.
17+
18+
By default, the report Markdown file is generated under [report.md](report)
19+
20+
## How to use it
21+
22+
By default, the tool doesn't require any options, but it offers some options in
23+
case you need to customize the behavior. You can see the full descriptions by
24+
running:
25+
26+
```console
27+
# from project root
28+
yarn test:size --help
29+
```
30+
31+
here's the options description:
32+
33+
```console
34+
Options:
35+
--version Show version number [boolean]
36+
--since Run the size benchmark on changed package since last
37+
release or main branch
38+
[string] [choices: "all", "last_release", "main"] [default: "all"]
39+
--scopeConfigPath
40+
[string] [default: "/path/to/project/root/scripts/benchmark-size/scope.json"]
41+
--limitConfigPath
42+
[string] [default: "/path/to/project/root/scripts/benchmark-size/limit.json"]
43+
--skipLocalPublish Skip publishing the packages locally. You can skip it if
44+
you didn't change any packages since last execution
45+
[boolean] [default: false]
46+
--rawOutputPath [string] [default: "/path/to/project/root/benchmark/size/raw"]
47+
--reportPath [string] [default:
48+
"/path/to/project/root/benchmark/size/report.md"]
49+
--skipRawOutput Whether to generate the raw benchmark data to configured
50+
path [boolean] [default: false]
51+
--help Show help [boolean]
52+
```
53+
54+
## Important Configuration Files
55+
56+
The maintainers only need to test the packages that is public for customer to
57+
consume publicly like all service clients and some utility packages like
58+
presigner. The other internal packages are tested as dependencies of those
59+
public packages. There are 2 config files that maintainers need to update update
60+
to make sure the tool runs on all packages it suppose to run and validate them
61+
with reasonable limit:
62+
63+
- [`scope.json`][scope-json]: Define what packages are considered as public, so
64+
they will exist as an entry in the report. You can use wild card in the `package`
65+
entry. For packages that requires peer dependencies, you can specify
66+
`dependencies` list. You can skip running the browser bundlers test if the package
67+
is Node.js runtime only by setting the `skipBundlerTests` boolean entry.
68+
- [`limit.json`][limit-json]: Define how to validate the packages' size. For each
69+
package you can validate either of the metrics the tool generates: `publishSize`
70+
and `publishFiles`. For each metrics, you can validate them by `limit` -- the
71+
absolute value, or `hike` -- the percentage of increase since recent value.
72+
73+
## How does it work
74+
75+
The command works in following process:
76+
77+
1. Detect the packages that changed since last release, thus requires validating
78+
their size metrics before next release.
79+
1. Validate they have been built locally and release to a local NPM registry
80+
1. For each changed package, creating a temporary project with [the project templates][templates]
81+
The templates generation context is exactly defined in [`scope.json`][scope-json].
82+
1. Run NPM install from the local registry and calculate the npm size metrics.
83+
1. Run Webpack, Rollup, and Esbuild bundlers on the temporary project and calculate
84+
the bundle size for each of them.
85+
1. Clear temporary projects.
86+
1. Update the [report.md][report]. Validating them according to [`limit.json`][limit-json]
87+
88+
[Prior arts][prior-arts] referred during the implementation.
89+
90+
[report]: ../../benchmark/size/report.md
91+
[scope-json]: ./scope.json
92+
[limit-json]: ./limit.json
93+
[templates]: ./templates
94+
[prior-arts]: https://github.com/styfle/packagephobia#how-is-this-different

scripts/benchmark-size/limit.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"default": {
3+
"publishSize": { "limit": "1 mb", "hike": "10 %" }
4+
},
5+
"@aws-sdk/client-ec2": {
6+
"publishSize": { "limit": "10 mb" }
7+
}
8+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import commonjsPlugin from "@rollup/plugin-commonjs";
2+
import jsonPlugin from "@rollup/plugin-json";
3+
import resolvePlugin from "@rollup/plugin-node-resolve";
4+
import { build as esbuild } from "esbuild";
5+
import { lstat } from "fs-extra";
6+
import { join } from "path";
7+
import { rollup } from "rollup";
8+
import { terser as terserPlugin } from "rollup-plugin-terser";
9+
import { promisify } from "util";
10+
import webpack from "webpack";
11+
12+
import type { SizeReportContext } from "../index";
13+
14+
export interface BundlerSizeReportContext extends SizeReportContext {
15+
projectDir: string;
16+
entryPoint: string;
17+
}
18+
19+
export const getWebpackSize = async (context: BundlerSizeReportContext): Promise<number> => {
20+
const webpackOutputDir = join(context.projectDir, "webpack-dist");
21+
const webpackInstance = webpack({
22+
entry: context.entryPoint,
23+
output: { path: webpackOutputDir, filename: "index.js" },
24+
});
25+
const run = promisify(webpackInstance.run);
26+
const webpackStats = await run.call(webpackInstance);
27+
if (webpackStats.hasErrors()) {
28+
throw new Error(webpackStats.toString());
29+
}
30+
return (await lstat(join(webpackOutputDir, "index.js"))).size;
31+
};
32+
33+
export const getRollupSize = async (context: BundlerSizeReportContext): Promise<number> => {
34+
const rollupOutputDir = join(context.projectDir, "rollup-dist");
35+
const bundle = await rollup({
36+
input: context.entryPoint,
37+
treeshake: true,
38+
plugins: [
39+
resolvePlugin({
40+
// Unlike webpack, nodejs plugin provides built-in polyfills for "events" in packages like lib-storage
41+
preferBuiltins: true,
42+
}),
43+
commonjsPlugin(),
44+
jsonPlugin(),
45+
terserPlugin(),
46+
],
47+
// Omit circular dependency warning: Circular dependency: node_modules/@aws-crypto/crc32/build/index.js ->
48+
// node_modules/@aws-crypto/crc32/build/aws_crc32.js -> node_modules/@aws-crypto/crc32/build/index.js
49+
onwarn: (warning, defaultHandler) => {
50+
if (warning.code !== "CIRCULAR_DEPENDENCY") {
51+
defaultHandler(warning);
52+
}
53+
},
54+
});
55+
await bundle.write({
56+
file: join(rollupOutputDir, "index.js"),
57+
});
58+
await bundle.close();
59+
return (await lstat(join(rollupOutputDir, "index.js"))).size;
60+
};
61+
62+
export const getEsbuildSize = async (context: BundlerSizeReportContext): Promise<number> => {
63+
const esbuildOutputDir = join(context.projectDir, "esbuild-dist");
64+
await esbuild({
65+
entryPoints: [context.entryPoint],
66+
outbase: context.projectDir,
67+
outdir: esbuildOutputDir,
68+
bundle: true,
69+
minify: true,
70+
treeShaking: true,
71+
});
72+
return (await lstat(join(esbuildOutputDir, "index.js"))).size;
73+
};
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import exec from "execa";
2+
import { promises as fsPromise } from "fs";
3+
import { join } from "path";
4+
import prettier from "prettier";
5+
6+
import { PackageContext } from "../load-test-scope";
7+
import type { PackageSizeReportOptions } from "./index";
8+
9+
export const generateProject = async (projectDir: string, options: PackageSizeReportOptions) => {
10+
const peerDependencies = await getPeerDependencies(options);
11+
const contextWithPeerDep: PackageContext = {
12+
...options.packageContext,
13+
dependencies: [...peerDependencies, ...(options.packageContext?.dependencies ?? [])],
14+
};
15+
// console.error("CONTEXT||||||||", contextWithPeerDep);
16+
for (const [name, template] of Object.entries(options.templates)) {
17+
const filePath = join(projectDir, name);
18+
const file = prettier.format(template(contextWithPeerDep), {
19+
filepath: filePath,
20+
});
21+
// console.error("FILE|||||||, ", file);
22+
await fsPromise.writeFile(filePath, file);
23+
}
24+
25+
await exec(
26+
"npm",
27+
[
28+
"install",
29+
"--cache",
30+
options.npmCacheDir,
31+
"--no-audit",
32+
"--no-update-notifier",
33+
"--no-package-lock",
34+
"--no-progress",
35+
"--production",
36+
"--silent",
37+
],
38+
{
39+
cwd: projectDir,
40+
env: {
41+
npm_config_registry: options.localRegistry,
42+
},
43+
}
44+
);
45+
};
46+
47+
type PeerDependencies = { name: string; version: string }[];
48+
49+
/**
50+
* Reads the peer dependencies from the package.json. These dependencies will be added to the
51+
* generated project's dependencies.
52+
*/
53+
const getPeerDependencies = async (options: PackageSizeReportOptions): Promise<PeerDependencies> => {
54+
// console.error("||||||||NAME: ", options.packageName);
55+
const packageInfo = options.workspacePackages.find((pkg) => pkg.name === options.packageName);
56+
if (!packageInfo) {
57+
throw new Error(`Cannot find package ${options.packageName} from the workspace`);
58+
}
59+
const packageJsonPath = join(packageInfo.location, "package.json");
60+
let peerDependencies: PeerDependencies;
61+
try {
62+
peerDependencies = JSON.parse(await fsPromise.readFile(packageJsonPath, "utf-8"))["peerDependencies"] ?? {};
63+
} catch (e) {
64+
throw new Error(`Cannot load the package.json file from ${packageJsonPath}`);
65+
}
66+
// Change the peer dependencies' version to "ci", referring to the current local package.
67+
// console.error("|||||PD: ", peerDependencies);
68+
// console.error("|||||PI: ", packageInfo);
69+
return Object.entries(peerDependencies).map(([key]) => ({ name: key, version: "ci" }));
70+
};
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { promises as fsPromise } from "fs";
2+
import { ListrContext, ListrTaskWrapper } from "listr2";
3+
import { join } from "path";
4+
5+
import { SizeReportContext } from "../index";
6+
import { PackageContext } from "../load-test-scope";
7+
import { getEsbuildSize, getRollupSize, getWebpackSize } from "./bundlers-size";
8+
import { generateProject } from "./generate-project";
9+
import { calculateNpmSize } from "./npm-size";
10+
11+
export interface PackageSizeReportOptions extends SizeReportContext {
12+
packageName: string;
13+
packageContext: PackageContext;
14+
}
15+
16+
export interface PackageSizeReportOutput {
17+
name: string;
18+
version: string;
19+
installSize: number;
20+
publishSize: number;
21+
webpackSize: number | undefined;
22+
esbuildSize: number | undefined;
23+
rollupSize: number | undefined;
24+
}
25+
26+
export const getPackageSizeReportRunner =
27+
(options: PackageSizeReportOptions) => async (context: ListrContext, task: ListrTaskWrapper<ListrContext, any>) => {
28+
task.output = "preparing...";
29+
const projectDir = join(options.tmpDir, options.packageName.replace("/", "_"));
30+
await fsPromise.rmdir(projectDir, { recursive: true });
31+
await fsPromise.mkdir(projectDir);
32+
const entryPoint = join(projectDir, "index.js");
33+
const bundlersContext = { ...options, entryPoint, projectDir };
34+
35+
task.output = "generating project and installing dependencies";
36+
await generateProject(projectDir, options);
37+
38+
task.output = "calculating npm size";
39+
const npmSizeResult = calculateNpmSize(projectDir, options.packageName);
40+
41+
const skipBundlerTests = bundlersContext.packageContext.skipBundlerTests;
42+
43+
task.output = "calculating webpack 5 full bundle size";
44+
const webpackSize = skipBundlerTests ? undefined : await getWebpackSize(bundlersContext);
45+
46+
task.output = "calculating rollup full bundle size";
47+
const rollupSize = skipBundlerTests ? undefined : await getRollupSize(bundlersContext);
48+
49+
task.output = "calculating esbuild full bundle size";
50+
const esbuildSize = skipBundlerTests ? undefined : await getEsbuildSize(bundlersContext);
51+
52+
task.output = "output results";
53+
const packageVersion = JSON.parse(
54+
await fsPromise.readFile(
55+
join(options.workspacePackages.filter((pkg) => pkg.name === options.packageName)[0].location, "package.json"),
56+
"utf8"
57+
)
58+
).version;
59+
options.output.push({
60+
name: options.packageName,
61+
version: packageVersion,
62+
...npmSizeResult,
63+
webpackSize,
64+
esbuildSize,
65+
rollupSize,
66+
});
67+
};

0 commit comments

Comments
 (0)