@mui/internal-bundle-size-checker 1.0.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/src/types.d.ts ADDED
@@ -0,0 +1,106 @@
1
+ // WebpackEntry type
2
+ interface WebpackEntry {
3
+ import: string;
4
+ importName?: string;
5
+ }
6
+
7
+ // Webpack stats types
8
+ interface StatsAsset {
9
+ name: string;
10
+ size: number;
11
+ related?: {
12
+ find: (predicate: (asset: any) => boolean) => { size: number; type: string };
13
+ };
14
+ }
15
+
16
+ interface StatsChunkGroup {
17
+ name: string;
18
+ assets: Array<{ name: string; size: number }>;
19
+ }
20
+
21
+ interface WebpackStats {
22
+ hasErrors(): boolean;
23
+ toJson(options: any): {
24
+ assets?: StatsAsset[];
25
+ entrypoints?: Record<string, StatsChunkGroup>;
26
+ errors?: any[];
27
+ };
28
+ }
29
+
30
+ // Upload configuration with optional properties
31
+ interface UploadConfig {
32
+ repo?: string; // The repository name (e.g., "mui/material-ui")
33
+ branch?: string; // Optional branch name (defaults to current Git branch)
34
+ isPullRequest?: boolean; // Whether this is a pull request build (defaults to CI detection)
35
+ }
36
+
37
+ // Normalized upload configuration where all properties are defined
38
+ interface NormalizedUploadConfig {
39
+ repo: string; // The repository name (e.g., "mui/material-ui")
40
+ branch: string; // Branch name
41
+ isPullRequest: boolean; // Whether this is a pull request build
42
+ }
43
+
44
+ // EntryPoint types
45
+ type StringEntry = string;
46
+
47
+ interface ObjectEntry {
48
+ id: string; // Unique identifier for the entry (renamed from 'name')
49
+ code?: string; // Code to be executed in the virtual module (now optional)
50
+ import?: string; // Optional package name to import
51
+ importedNames?: string[]; // Optional array of named imports
52
+ externals?: string[]; // Optional array of packages to exclude from the bundle
53
+ }
54
+
55
+ type EntryPoint = StringEntry | ObjectEntry;
56
+
57
+ // Bundle size checker config with optional upload config
58
+ interface BundleSizeCheckerConfig {
59
+ entrypoints: EntryPoint[];
60
+ upload?: UploadConfig | boolean | null;
61
+ }
62
+
63
+ // Normalized bundle size checker config with all properties defined
64
+ interface NormalizedBundleSizeCheckerConfig {
65
+ entrypoints: ObjectEntry[];
66
+ upload: NormalizedUploadConfig | null; // null means upload is disabled
67
+ }
68
+
69
+ // Command line argument types
70
+ interface CommandLineArgs {
71
+ analyze?: boolean;
72
+ accurateBundles?: boolean;
73
+ output?: string;
74
+ verbose?: boolean;
75
+ filter?: string[];
76
+ }
77
+
78
+ // Diff command argument types
79
+ interface DiffCommandArgs {
80
+ base: string;
81
+ head?: string;
82
+ output?: 'json' | 'markdown';
83
+ reportUrl?: string;
84
+ }
85
+
86
+ // PR command argument types
87
+ interface PrCommandArgs {
88
+ prNumber: number;
89
+ output?: 'json' | 'markdown';
90
+ circleci?: string;
91
+ }
92
+
93
+ interface PrInfo {
94
+ number: number;
95
+ base: {
96
+ ref: string;
97
+ sha: string;
98
+ repo: {
99
+ full_name: string;
100
+ };
101
+ };
102
+ head: {
103
+ ref: string;
104
+ sha: string;
105
+ };
106
+ }
@@ -0,0 +1,72 @@
1
+ import fs from 'fs';
2
+ import { S3Client, PutObjectCommand, PutObjectTaggingCommand } from '@aws-sdk/client-s3';
3
+ import { execa } from 'execa';
4
+ import { fromEnv } from '@aws-sdk/credential-providers';
5
+
6
+ /**
7
+ * Gets the current Git commit SHA
8
+ * @returns {Promise<string>} The current commit SHA
9
+ */
10
+ async function getCurrentCommitSHA() {
11
+ const { stdout } = await execa('git', ['rev-parse', 'HEAD']);
12
+ return stdout.trim();
13
+ }
14
+
15
+ /**
16
+ * Uploads the size snapshot to S3
17
+ * @param {string} snapshotPath - The path to the size snapshot JSON file
18
+ * @param {NormalizedUploadConfig} uploadConfig - The normalized upload configuration
19
+ * @param {string} [commitSha] - Optional commit SHA (defaults to current Git HEAD)
20
+ * @returns {Promise<{key:string}>}
21
+ */
22
+ export async function uploadSnapshot(snapshotPath, uploadConfig, commitSha) {
23
+ // By the time this function is called, the config should be fully normalized
24
+ // No need to check for repo existence as it's required in the normalized config
25
+
26
+ // Run git operations and file reading in parallel
27
+ const [sha, fileContent] = await Promise.all([
28
+ // Get the current commit SHA if not provided
29
+ commitSha || getCurrentCommitSHA(),
30
+ // Read the snapshot file
31
+ fs.promises.readFile(snapshotPath),
32
+ ]);
33
+
34
+ // Use values from normalized config
35
+ const { branch, isPullRequest } = uploadConfig;
36
+
37
+ // Create S3 client (uses AWS credentials from environment)
38
+ const client = new S3Client({
39
+ region: process.env.AWS_REGION_ARTIFACTS || process.env.AWS_REGION || 'eu-central-1',
40
+ credentials: fromEnv(),
41
+ });
42
+
43
+ // S3 bucket and key
44
+ const bucket = 'mui-org-ci';
45
+ const key = `artifacts/${uploadConfig.repo}/${sha}/size-snapshot.json`;
46
+
47
+ // Upload the file first
48
+ await client.send(
49
+ new PutObjectCommand({
50
+ Bucket: bucket,
51
+ Key: key,
52
+ Body: fileContent,
53
+ ContentType: 'application/json',
54
+ }),
55
+ );
56
+
57
+ // Then add tags to the uploaded object
58
+ await client.send(
59
+ new PutObjectTaggingCommand({
60
+ Bucket: bucket,
61
+ Key: key,
62
+ Tagging: {
63
+ TagSet: [
64
+ { Key: 'isPullRequest', Value: isPullRequest ? 'yes' : 'no' },
65
+ { Key: 'branch', Value: branch },
66
+ ],
67
+ },
68
+ }),
69
+ );
70
+
71
+ return { key };
72
+ }
package/src/worker.js ADDED
@@ -0,0 +1,332 @@
1
+ import { promisify } from 'util';
2
+ import path from 'path';
3
+ import fs from 'fs/promises';
4
+ import { pathToFileURL } from 'url';
5
+ import webpackCallbackBased from 'webpack';
6
+ import CompressionPlugin from 'compression-webpack-plugin';
7
+ import TerserPlugin from 'terser-webpack-plugin';
8
+ import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
9
+ import { createRequire } from 'node:module';
10
+ import chalk from 'chalk';
11
+ import { byteSizeFormatter } from './formatUtils.js';
12
+
13
+ /**
14
+ * @type {(options: webpackCallbackBased.Configuration) => Promise<webpackCallbackBased.Stats>}
15
+ */
16
+ // @ts-expect-error Can't select the right overload
17
+ const webpack = promisify(webpackCallbackBased);
18
+ const rootDir = process.cwd();
19
+ const require = createRequire(import.meta.url);
20
+
21
+ // Type declarations are now in types.d.ts
22
+
23
+ /**
24
+ * Attempts to extract peer dependencies from a package's package.json
25
+ * @param {string} packageName - Package to extract peer dependencies from
26
+ * @returns {Promise<string[]|null>} - Array of peer dependency package names or null if not found
27
+ */
28
+ async function getPeerDependencies(packageName) {
29
+ try {
30
+ // Try to resolve packageName/package.json
31
+ const packageJsonPath = require.resolve(`${packageName}/package.json`, {
32
+ paths: [rootDir],
33
+ });
34
+
35
+ // Read and parse the package.json
36
+ const packageJsonContent = await fs.readFile(packageJsonPath, 'utf8');
37
+ const packageJson = JSON.parse(packageJsonContent);
38
+
39
+ // Extract peer dependencies
40
+ if (packageJson.peerDependencies) {
41
+ return Object.keys(packageJson.peerDependencies);
42
+ }
43
+
44
+ return null;
45
+ } catch (/** @type {any} */ error) {
46
+ console.warn(
47
+ chalk.yellow(
48
+ `Could not resolve peer dependencies for ${chalk.bold(packageName)}: ${error.message}`,
49
+ ),
50
+ );
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Creates webpack configuration for bundle size checking
57
+ * @param {ObjectEntry} entry - Entry point (string or object)
58
+ * @param {CommandLineArgs} args
59
+ * @returns {Promise<{configuration: import('webpack').Configuration, externalsArray: string[]}>}
60
+ */
61
+ async function createWebpackConfig(entry, args) {
62
+ const analyzerMode = args.analyze ? 'static' : 'disabled';
63
+ const concatenateModules = !args.accurateBundles;
64
+
65
+ const entryName = entry.id;
66
+ let entryContent;
67
+ let packageExternals = null;
68
+
69
+ // Process peer dependencies if externals aren't specified but import is
70
+ if (entry.import && !entry.externals) {
71
+ const packageRoot = entry.import
72
+ .split('/')
73
+ .slice(0, entry.import.startsWith('@') ? 2 : 1)
74
+ .join('/');
75
+ packageExternals = await getPeerDependencies(packageRoot);
76
+ }
77
+
78
+ if (entry.code && (entry.import || entry.importedNames)) {
79
+ console.warn(
80
+ chalk.yellow(
81
+ `Warning: Both code and import/importedNames are defined for entry "${chalk.bold(entry.id)}". Using code property.`,
82
+ ),
83
+ );
84
+ entryContent = entry.code;
85
+ } else if (entry.code) {
86
+ entryContent = entry.code;
87
+ } else if (entry.import) {
88
+ if (entry.importedNames && entry.importedNames.length > 0) {
89
+ // Generate named imports for each name in the importedNames array
90
+ const imports = entry.importedNames
91
+ .map((name) => `import { ${name} } from '${entry.import}';`)
92
+ .join('\n');
93
+ const logs = entry.importedNames.map((name) => `console.log(${name});`).join('\n');
94
+ entryContent = `${imports}\n${logs}`;
95
+ } else {
96
+ // Default to import * as if importedNames is not defined
97
+ entryContent = `import * as _ from '${entry.import}';\nconsole.log(_);`;
98
+ }
99
+ } else {
100
+ throw new Error(`Entry "${entry.id}" must have either code or import property defined`);
101
+ }
102
+
103
+ /**
104
+ * Generate externals function from an array of package names
105
+ * @param {string[]} packages - Array of package names to exclude (defaults to react and react-dom)
106
+ * @returns {function} - Function to determine if a request should be treated as external
107
+ */
108
+ function createExternalsFunction(packages = ['react', 'react-dom']) {
109
+ /**
110
+ * Check if a request should be treated as external
111
+ * Uses the new recommended format to avoid deprecation warnings
112
+ * @param {{ context: string, request: string }} params - Object containing context and request
113
+ * @param {Function} callback - Callback to handle the result
114
+ */
115
+ return ({ request }, callback) => {
116
+ // Iterate through all packages and check if request is equal to or starts with package + '/'
117
+ for (const pkg of packages) {
118
+ if (request === pkg || request.startsWith(`${pkg}/`)) {
119
+ return callback(null, `commonjs ${request}`);
120
+ }
121
+ }
122
+
123
+ return callback();
124
+ };
125
+ }
126
+
127
+ // Generate externals based on priorities:
128
+ // 1. Explicitly defined externals in the entry object
129
+ // 2. Peer dependencies from package.json if available
130
+ // 3. Default externals (react, react-dom)
131
+ const externalsArray =
132
+ typeof entry === 'object' && entry.externals
133
+ ? entry.externals
134
+ : (packageExternals ?? ['react', 'react-dom']);
135
+
136
+ /**
137
+ * @type {import('webpack').Configuration}
138
+ */
139
+ const configuration = {
140
+ externals: [
141
+ // @ts-expect-error -- webpack types are not compatible with the current version
142
+ createExternalsFunction(externalsArray),
143
+ ],
144
+ mode: 'production',
145
+ optimization: {
146
+ concatenateModules,
147
+ minimizer: [
148
+ new TerserPlugin({
149
+ test: /\.m?js(\?.*)?$/i,
150
+ // Avoid creating LICENSE.txt files for each module
151
+ // See https://github.com/webpack-contrib/terser-webpack-plugin#remove-comments
152
+ terserOptions: {
153
+ format: {
154
+ comments: false,
155
+ },
156
+ },
157
+ extractComments: false,
158
+ }),
159
+ ],
160
+ },
161
+ module: {
162
+ rules: [
163
+ {
164
+ test: /\.css$/,
165
+ use: [require.resolve('css-loader')],
166
+ },
167
+ {
168
+ test: /\.(png|svg|jpg|gif)$/,
169
+ use: [require.resolve('file-loader')],
170
+ },
171
+ ],
172
+ },
173
+ output: {
174
+ filename: '[name].js',
175
+ library: {
176
+ // TODO: Use `type: 'module'` once it is supported (currently incompatible with `externals`)
177
+ name: 'M',
178
+ type: 'var',
179
+ // type: 'module',
180
+ },
181
+ path: path.join(rootDir, 'build'),
182
+ },
183
+ plugins: [
184
+ new CompressionPlugin({
185
+ filename: '[path][base][fragment].gz',
186
+ }),
187
+ new BundleAnalyzerPlugin({
188
+ analyzerMode,
189
+ // We create a report for each bundle so around 120 reports.
190
+ // Opening them all is spam.
191
+ // If opened with `webpack --config . --analyze` it'll still open one new tab though.
192
+ openAnalyzer: false,
193
+ // '[name].html' not supported: https://github.com/webpack-contrib/webpack-bundle-analyzer/issues/12
194
+ reportFilename: `${entryName}.html`,
195
+ logLevel: 'warn',
196
+ }),
197
+ ],
198
+ // A context to the current dir, which has a node_modules folder with workspace dependencies
199
+ context: rootDir,
200
+ entry: {
201
+ // This format is a data: url combined with inline matchResource to obtain a virtual entry.
202
+ // See https://github.com/webpack/webpack/issues/6437#issuecomment-874466638
203
+ // See https://webpack.js.org/api/module-methods/#import
204
+ // See https://webpack.js.org/api/loaders/#inline-matchresource
205
+ [entryName]: `./index.js!=!data:text/javascript;charset=utf-8;base64,${Buffer.from(entryContent.trim()).toString('base64')}`,
206
+ },
207
+ // TODO: 'browserslist:modern'
208
+ // See https://github.com/webpack/webpack/issues/14203
209
+ target: 'web',
210
+ };
211
+
212
+ // Return both the configuration and the externals array
213
+ return { configuration, externalsArray };
214
+ }
215
+
216
+ /**
217
+ * Get sizes for a bundle
218
+ * @param {{ entry: ObjectEntry, args: CommandLineArgs, index: number, total: number }} options
219
+ * @returns {Promise<Array<[string, { parsed: number, gzip: number }]>>}
220
+ */
221
+ export default async function getSizes({ entry, args, index, total }) {
222
+ /** @type {Map<string, { parsed: number, gzip: number }>} */
223
+ const sizeMap = new Map();
224
+
225
+ // Create webpack configuration (now async to handle peer dependency resolution)
226
+ const { configuration, externalsArray } = await createWebpackConfig(entry, args);
227
+
228
+ // eslint-disable-next-line no-console -- process monitoring
229
+ console.log(chalk.blue(`Compiling ${index + 1}/${total}: ${chalk.bold(`[${entry.id}]`)}`));
230
+
231
+ const webpackStats = await webpack(configuration);
232
+
233
+ if (!webpackStats) {
234
+ throw new Error('No webpack stats were returned');
235
+ }
236
+
237
+ if (webpackStats.hasErrors()) {
238
+ const statsJson = webpackStats.toJson({
239
+ all: false,
240
+ entrypoints: true,
241
+ errors: true,
242
+ });
243
+
244
+ const entrypointKeys = statsJson.entrypoints ? Object.keys(statsJson.entrypoints) : [];
245
+
246
+ throw new Error(
247
+ `${chalk.red.bold('ERROR:')} The following errors occurred during bundling of ${chalk.yellow(entrypointKeys.join(', '))} with webpack: \n${(
248
+ statsJson.errors || []
249
+ )
250
+ .map((error) => {
251
+ return `${JSON.stringify(error, null, 2)}`;
252
+ })
253
+ .join('\n')}`,
254
+ );
255
+ }
256
+
257
+ const stats = webpackStats.toJson({
258
+ all: false,
259
+ assets: true,
260
+ entrypoints: true,
261
+ relatedAssets: true,
262
+ });
263
+
264
+ if (!stats.assets) {
265
+ return Array.from(sizeMap.entries());
266
+ }
267
+
268
+ const assets = new Map(stats.assets.map((asset) => [asset.name, asset]));
269
+
270
+ if (stats.entrypoints) {
271
+ Object.values(stats.entrypoints).forEach((entrypoint) => {
272
+ let parsedSize = 0;
273
+ let gzipSize = 0;
274
+
275
+ if (entrypoint.assets) {
276
+ entrypoint.assets.forEach(({ name, size }) => {
277
+ const asset = assets.get(name);
278
+ if (asset && asset.related) {
279
+ const gzippedAsset = asset.related.find((relatedAsset) => {
280
+ return relatedAsset.type === 'gzipped';
281
+ });
282
+
283
+ if (size !== undefined) {
284
+ parsedSize += size;
285
+ }
286
+
287
+ if (gzippedAsset && gzippedAsset.size !== undefined) {
288
+ gzipSize += gzippedAsset.size;
289
+ }
290
+ }
291
+ });
292
+ }
293
+
294
+ if (!entrypoint.name) {
295
+ throw new Error('Entrypoint name is undefined');
296
+ }
297
+
298
+ sizeMap.set(entrypoint.name, { parsed: parsedSize, gzip: gzipSize });
299
+ });
300
+ }
301
+
302
+ // Create a concise log message showing import details
303
+ let entryDetails = '';
304
+ if (entry.code) {
305
+ entryDetails = 'code import';
306
+ } else if (entry.import) {
307
+ entryDetails = `${entry.import}`;
308
+ if (entry.importedNames && entry.importedNames.length > 0) {
309
+ entryDetails += ` [${entry.importedNames.join(', ')}]`;
310
+ } else {
311
+ entryDetails += ' [*]';
312
+ }
313
+ }
314
+
315
+ // Print a summary with all the requested information
316
+ // Get the size entry for this entry.id from the map
317
+ const entrySize = sizeMap.get(entry.id) || { parsed: 0, gzip: 0 };
318
+
319
+ // eslint-disable-next-line no-console -- process monitoring
320
+ console.log(
321
+ `
322
+ ${chalk.green('✓')} ${chalk.green.bold(`Completed ${index + 1}/${total}: [${entry.id}]`)}
323
+ ${chalk.cyan('Import:')} ${entryDetails}
324
+ ${chalk.cyan('Externals:')} ${externalsArray.join(', ')}
325
+ ${chalk.cyan('Sizes:')} ${chalk.yellow(byteSizeFormatter.format(entrySize.parsed))} (${chalk.yellow(byteSizeFormatter.format(entrySize.gzip))} gzipped)
326
+ ${args.analyze ? ` ${chalk.cyan('Analysis:')} ${chalk.underline(pathToFileURL(path.join(rootDir, 'build', `${entry.id}.html`)).href)}` : ''}
327
+ `.trim(),
328
+ );
329
+
330
+ // Convert the Map to an array of entries for the return value
331
+ return Array.from(sizeMap.entries());
332
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "node",
6
+ "allowJs": true,
7
+ "checkJs": true,
8
+ "skipLibCheck": true,
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "resolveJsonModule": true,
12
+ "isolatedModules": true,
13
+ "outDir": "./build",
14
+ "noEmit": true
15
+ },
16
+ "include": ["**/*.js", "**/*.mjs", "**/*.ts"],
17
+ "exclude": ["node_modules", "build"]
18
+ }