@mui/internal-bundle-size-checker 1.0.3 → 1.0.5

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.
@@ -0,0 +1,229 @@
1
+ import path from 'path';
2
+ import fs from 'fs/promises';
3
+ import { gzipSync } from 'zlib';
4
+ import { build, transformWithEsbuild } from 'vite';
5
+ import { visualizer } from 'rollup-plugin-visualizer';
6
+
7
+ const rootDir = process.cwd();
8
+
9
+ /**
10
+ * @typedef {Object} ManifestChunk
11
+ * @property {string} file - Hashed filename of the chunk
12
+ * @property {string} [name] - Optional name of the chunk
13
+ * @property {string} [src] - Original source path
14
+ * @property {string[]} [css] - Associated CSS files
15
+ * @property {boolean} [isEntry] - Indicates if this is an entry point
16
+ * @property {boolean} [isDynamicEntry] - Indicates if this is a dynamic entry point
17
+ * @property {string[]} [imports] - Imported chunk keys
18
+ * @property {string[]} [dynamicImports] - Dynamically imported chunk keys
19
+ */
20
+
21
+ /**
22
+ * @typedef {Record<string, ManifestChunk>} Manifest
23
+ */
24
+
25
+ /**
26
+ * Creates vite configuration for bundle size checking
27
+ * @param {ObjectEntry} entry - Entry point (string or object)
28
+ * @param {CommandLineArgs} args
29
+ * @returns {Promise<{configuration: import('vite').InlineConfig, externalsArray: string[]}>}
30
+ */
31
+ async function createViteConfig(entry, args) {
32
+ const entryName = entry.id;
33
+ let entryContent;
34
+
35
+ if (entry.code && (entry.import || entry.importedNames)) {
36
+ entryContent = entry.code;
37
+ } else if (entry.code) {
38
+ entryContent = entry.code;
39
+ } else if (entry.import) {
40
+ if (entry.importedNames && entry.importedNames.length > 0) {
41
+ // Generate named imports for each name in the importedNames array
42
+ const imports = entry.importedNames
43
+ .map((name) => `import { ${name} } from '${entry.import}';`)
44
+ .join('\n');
45
+ const logs = entry.importedNames.map((name) => `console.log(${name});`).join('\n');
46
+ entryContent = `${imports}\n${logs}`;
47
+ } else {
48
+ // Default to import * as if importedNames is not defined
49
+ entryContent = `import * as _ from '${entry.import}';\nconsole.log(_);`;
50
+ }
51
+ } else {
52
+ throw new Error(`Entry "${entry.id}" must have either code or import property defined`);
53
+ }
54
+
55
+ // Use externals from the entry object
56
+ const externalsArray = entry.externals || ['react', 'react-dom'];
57
+
58
+ // Ensure build directory exists
59
+ const outDir = path.join(rootDir, 'build', entryName);
60
+ await fs.mkdir(outDir, { recursive: true });
61
+ /**
62
+ * @type {import('vite').InlineConfig}
63
+ */
64
+ const configuration = {
65
+ configFile: false,
66
+ root: rootDir,
67
+
68
+ build: {
69
+ write: true,
70
+ minify: true,
71
+ outDir,
72
+ emptyOutDir: true,
73
+ rollupOptions: {
74
+ input: '/index.tsx',
75
+ external: externalsArray,
76
+ plugins: [
77
+ ...(args.analyze
78
+ ? [
79
+ // File sizes are not accurate, use it only for relative comparison
80
+ visualizer({
81
+ filename: `${outDir}.html`,
82
+ title: `Bundle Size Analysis: ${entryName}`,
83
+ projectRoot: rootDir,
84
+ open: false,
85
+ gzipSize: true,
86
+ brotliSize: false,
87
+ template: 'treemap',
88
+ }),
89
+ ]
90
+ : []),
91
+ ],
92
+ },
93
+ manifest: true,
94
+ reportCompressedSize: true,
95
+ target: 'esnext',
96
+ },
97
+
98
+ esbuild: {
99
+ legalComments: 'none',
100
+ },
101
+
102
+ define: {
103
+ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'),
104
+ },
105
+ logLevel: args.verbose ? 'info' : 'silent',
106
+ // Add plugins to handle virtual entry points
107
+ plugins: [
108
+ {
109
+ name: 'virtual-entry',
110
+ resolveId(id) {
111
+ if (id === '/index.tsx') {
112
+ return `\0virtual:index.tsx`;
113
+ }
114
+ if (id === '/entry.tsx') {
115
+ return `\0virtual:entry.tsx`;
116
+ }
117
+ return null;
118
+ },
119
+ load(id) {
120
+ if (id === `\0virtual:index.tsx`) {
121
+ return transformWithEsbuild(`import('/entry.tsx').then(console.log)`, id);
122
+ }
123
+ if (id === `\0virtual:entry.tsx`) {
124
+ return transformWithEsbuild(entryContent, id);
125
+ }
126
+ return null;
127
+ },
128
+ },
129
+ ],
130
+ };
131
+
132
+ return { configuration, externalsArray };
133
+ }
134
+
135
+ /**
136
+ * Walks the dependency tree starting from a chunk and collects all dependencies
137
+ * @param {string} chunkKey - The key of the chunk to start from
138
+ * @param {Manifest} manifest - The Vite manifest
139
+ * @param {Set<string>} visited - Set of already visited chunks to avoid cycles
140
+ * @returns {Set<string>} - Set of all chunk keys in the dependency tree
141
+ */
142
+ function walkDependencyTree(chunkKey, manifest, visited = new Set()) {
143
+ if (visited.has(chunkKey)) {
144
+ return visited;
145
+ }
146
+
147
+ visited.add(chunkKey);
148
+ const chunk = manifest[chunkKey];
149
+
150
+ if (!chunk) {
151
+ throw new Error(`Chunk not found in manifest: ${chunkKey}`);
152
+ }
153
+
154
+ // Walk through static imports
155
+ if (chunk.imports) {
156
+ for (const importKey of chunk.imports) {
157
+ walkDependencyTree(importKey, manifest, visited);
158
+ }
159
+ }
160
+
161
+ // Walk through dynamic imports
162
+ if (chunk.dynamicImports) {
163
+ for (const dynamicImportKey of chunk.dynamicImports) {
164
+ walkDependencyTree(dynamicImportKey, manifest, visited);
165
+ }
166
+ }
167
+
168
+ return visited;
169
+ }
170
+
171
+ /**
172
+ * Process vite output to extract bundle sizes
173
+ * @param {string} outDir - The output directory
174
+ * @param {string} entryName - The entry name
175
+ * @returns {Promise<Map<string, { parsed: number, gzip: number }>>} - Map of bundle names to size information
176
+ */
177
+ async function processBundleSizes(outDir, entryName) {
178
+ // Read the manifest file to find the generated chunks
179
+ const manifestPath = path.join(outDir, '.vite/manifest.json');
180
+ const manifestContent = await fs.readFile(manifestPath, 'utf8');
181
+ /** @type {Manifest} */
182
+ const manifest = JSON.parse(manifestContent);
183
+
184
+ // Find the main entry point JS file in the manifest
185
+ const mainEntry = manifest['virtual:entry.tsx'];
186
+
187
+ if (!mainEntry) {
188
+ throw new Error(`No main entry found in manifest for ${entryName}`);
189
+ }
190
+
191
+ // Walk the dependency tree to get all chunks that are part of this entry
192
+ const allChunks = walkDependencyTree('virtual:entry.tsx', manifest);
193
+
194
+ // Process each chunk in the dependency tree in parallel
195
+ const chunkPromises = Array.from(allChunks, async (chunkKey) => {
196
+ const chunk = manifest[chunkKey];
197
+ const filePath = path.join(outDir, chunk.file);
198
+ const fileContent = await fs.readFile(filePath, 'utf8');
199
+
200
+ // Calculate sizes
201
+ const parsed = Buffer.byteLength(fileContent);
202
+ const gzip = Buffer.byteLength(gzipSync(fileContent));
203
+
204
+ // Use chunk key as the name, or fallback to entry name for main chunk
205
+ const chunkName = chunkKey === 'virtual:entry.tsx' ? entryName : chunkKey;
206
+ return /** @type {const} */ ([chunkName, { parsed, gzip }]);
207
+ });
208
+
209
+ const chunkEntries = await Promise.all(chunkPromises);
210
+ return new Map(chunkEntries);
211
+ }
212
+
213
+ /**
214
+ * Get sizes for a vite bundle
215
+ * @param {ObjectEntry} entry - The entry configuration
216
+ * @param {CommandLineArgs} args - Command line arguments
217
+ * @returns {Promise<Map<string, { parsed: number, gzip: number }>>}
218
+ */
219
+ export async function getViteSizes(entry, args) {
220
+ // Create vite configuration
221
+ const { configuration } = await createViteConfig(entry, args);
222
+ const outDir = path.join(rootDir, 'build', entry.id);
223
+
224
+ // Run vite build
225
+ await build(configuration);
226
+
227
+ // Process the output to get bundle sizes
228
+ return processBundleSizes(outDir, entry.id);
229
+ }
@@ -0,0 +1,267 @@
1
+ import { promisify } from 'util';
2
+ import path from 'path';
3
+ import webpackCallbackBased from 'webpack';
4
+ import CompressionPlugin from 'compression-webpack-plugin';
5
+ import TerserPlugin from 'terser-webpack-plugin';
6
+ import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
7
+ import { createRequire } from 'node:module';
8
+
9
+ /**
10
+ * @type {(options: webpackCallbackBased.Configuration) => Promise<webpackCallbackBased.Stats>}
11
+ */
12
+ // @ts-expect-error Can't select the right overload
13
+ const webpack = promisify(webpackCallbackBased);
14
+ const rootDir = process.cwd();
15
+ const require = createRequire(import.meta.url);
16
+
17
+ /**
18
+ * Creates webpack configuration for bundle size checking
19
+ * @param {ObjectEntry} entry - Entry point (string or object)
20
+ * @param {CommandLineArgs} args
21
+ * @returns {Promise<{configuration: import('webpack').Configuration, externalsArray: string[]}>}
22
+ */
23
+ async function createWebpackConfig(entry, args) {
24
+ const analyzerMode = args.analyze ? 'static' : 'disabled';
25
+ const concatenateModules = !args.accurateBundles;
26
+
27
+ const entryName = entry.id;
28
+ let entryContent;
29
+
30
+ if (entry.code && (entry.import || entry.importedNames)) {
31
+ entryContent = entry.code;
32
+ } else if (entry.code) {
33
+ entryContent = entry.code;
34
+ } else if (entry.import) {
35
+ if (entry.importedNames && entry.importedNames.length > 0) {
36
+ // Generate named imports for each name in the importedNames array
37
+ const imports = entry.importedNames
38
+ .map((name) => `import { ${name} } from '${entry.import}';`)
39
+ .join('\n');
40
+ const logs = entry.importedNames.map((name) => `console.log(${name});`).join('\n');
41
+ entryContent = `${imports}\n${logs}`;
42
+ } else {
43
+ // Default to import * as if importedNames is not defined
44
+ entryContent = `import * as _ from '${entry.import}';\nconsole.log(_);`;
45
+ }
46
+ } else {
47
+ throw new Error(`Entry "${entry.id}" must have either code or import property defined`);
48
+ }
49
+
50
+ /**
51
+ * Generate externals function from an array of package names
52
+ * @param {string[]} packages - Array of package names to exclude (defaults to react and react-dom)
53
+ * @returns {function} - Function to determine if a request should be treated as external
54
+ */
55
+ function createExternalsFunction(packages = ['react', 'react-dom']) {
56
+ /**
57
+ * Check if a request should be treated as external
58
+ * Uses the new recommended format to avoid deprecation warnings
59
+ * @param {{ context: string, request: string }} params - Object containing context and request
60
+ * @param {Function} callback - Callback to handle the result
61
+ */
62
+ return ({ request }, callback) => {
63
+ // Iterate through all packages and check if request is equal to or starts with package + '/'
64
+ for (const pkg of packages) {
65
+ if (request === pkg || request.startsWith(`${pkg}/`)) {
66
+ return callback(null, `commonjs ${request}`);
67
+ }
68
+ }
69
+
70
+ return callback();
71
+ };
72
+ }
73
+
74
+ // Use externals from the entry object
75
+ const externalsArray = entry.externals || ['react', 'react-dom'];
76
+
77
+ /**
78
+ * @type {import('webpack').Configuration}
79
+ */
80
+ const configuration = {
81
+ externals: [
82
+ // @ts-expect-error -- webpack types are not compatible with the current version
83
+ createExternalsFunction(externalsArray),
84
+ ],
85
+ mode: 'production',
86
+ optimization: {
87
+ concatenateModules,
88
+ minimizer: [
89
+ new TerserPlugin({
90
+ test: /\.m?js(\?.*)?$/i,
91
+ // Avoid creating LICENSE.txt files for each module
92
+ // See https://github.com/webpack-contrib/terser-webpack-plugin#remove-comments
93
+ terserOptions: {
94
+ format: {
95
+ comments: false,
96
+ },
97
+ },
98
+ extractComments: false,
99
+ }),
100
+ ],
101
+ },
102
+ module: {
103
+ rules: [
104
+ {
105
+ test: /\.[jt]sx?$/,
106
+ include: rootDir,
107
+ exclude: /node_modules/,
108
+ use: {
109
+ loader: require.resolve('babel-loader'),
110
+ options: {
111
+ presets: [
112
+ require.resolve('@babel/preset-react'),
113
+ require.resolve('@babel/preset-typescript'),
114
+ ],
115
+ },
116
+ },
117
+ },
118
+ {
119
+ test: /\.css$/,
120
+ use: [require.resolve('css-loader')],
121
+ },
122
+ {
123
+ test: /\.(png|svg|jpg|gif)$/,
124
+ use: [require.resolve('file-loader')],
125
+ },
126
+ ],
127
+ },
128
+ output: {
129
+ filename: '[name].js',
130
+ library: {
131
+ // TODO: Use `type: 'module'` once it is supported (currently incompatible with `externals`)
132
+ name: 'M',
133
+ type: 'var',
134
+ // type: 'module',
135
+ },
136
+ path: path.join(rootDir, 'build'),
137
+ },
138
+ plugins: [
139
+ new CompressionPlugin({
140
+ filename: '[path][base][fragment].gz',
141
+ }),
142
+ new BundleAnalyzerPlugin({
143
+ analyzerMode,
144
+ // We create a report for each bundle so around 120 reports.
145
+ // Opening them all is spam.
146
+ // If opened with `webpack --config . --analyze` it'll still open one new tab though.
147
+ openAnalyzer: false,
148
+ // '[name].html' not supported: https://github.com/webpack-contrib/webpack-bundle-analyzer/issues/12
149
+ reportFilename: `${entryName}.html`,
150
+ logLevel: 'warn',
151
+ }),
152
+ ],
153
+ // A context to the current dir, which has a node_modules folder with workspace dependencies
154
+ context: rootDir,
155
+ entry: {
156
+ // This format is a data: url combined with inline matchResource to obtain a virtual entry.
157
+ // See https://github.com/webpack/webpack/issues/6437#issuecomment-874466638
158
+ // See https://webpack.js.org/api/module-methods/#import
159
+ // See https://webpack.js.org/api/loaders/#inline-matchresource
160
+ [entryName]: `./index.js!=!data:text/javascript;charset=utf-8;base64,${Buffer.from(entryContent.trim()).toString('base64')}`,
161
+ },
162
+ // TODO: 'browserslist:modern'
163
+ // See https://github.com/webpack/webpack/issues/14203
164
+ target: 'web',
165
+ };
166
+
167
+ // Return both the configuration and the externals array
168
+ return { configuration, externalsArray };
169
+ }
170
+
171
+ /**
172
+ * Process webpack stats to extract bundle sizes
173
+ * @param {import('webpack').Stats} webpackStats - The webpack stats object
174
+ * @returns {Map<string, { parsed: number, gzip: number }>} - Map of bundle names to size information
175
+ */
176
+ function processBundleSizes(webpackStats) {
177
+ /** @type {Map<string, { parsed: number, gzip: number }>} */
178
+ const sizeMap = new Map();
179
+
180
+ if (!webpackStats) {
181
+ throw new Error('No webpack stats were returned');
182
+ }
183
+
184
+ if (webpackStats.hasErrors()) {
185
+ const statsJson = webpackStats.toJson({
186
+ all: false,
187
+ entrypoints: true,
188
+ errors: true,
189
+ });
190
+
191
+ const entrypointKeys = statsJson.entrypoints ? Object.keys(statsJson.entrypoints) : [];
192
+
193
+ throw new Error(
194
+ `ERROR: The following errors occurred during bundling of ${entrypointKeys.join(', ')} with webpack: \n${(
195
+ statsJson.errors || []
196
+ )
197
+ .map((error) => {
198
+ return `${JSON.stringify(error, null, 2)}`;
199
+ })
200
+ .join('\n')}`,
201
+ );
202
+ }
203
+
204
+ const stats = webpackStats.toJson({
205
+ all: false,
206
+ assets: true,
207
+ entrypoints: true,
208
+ relatedAssets: true,
209
+ });
210
+
211
+ if (!stats.assets) {
212
+ return sizeMap;
213
+ }
214
+
215
+ const assets = new Map(stats.assets.map((asset) => [asset.name, asset]));
216
+
217
+ if (stats.entrypoints) {
218
+ Object.values(stats.entrypoints).forEach((entrypoint) => {
219
+ let parsedSize = 0;
220
+ let gzipSize = 0;
221
+
222
+ if (entrypoint.assets) {
223
+ entrypoint.assets.forEach(({ name, size }) => {
224
+ const asset = assets.get(name);
225
+ if (asset && asset.related) {
226
+ const gzippedAsset = asset.related.find((relatedAsset) => {
227
+ return relatedAsset.type === 'gzipped';
228
+ });
229
+
230
+ if (size !== undefined) {
231
+ parsedSize += size;
232
+ }
233
+
234
+ if (gzippedAsset && gzippedAsset.size !== undefined) {
235
+ gzipSize += gzippedAsset.size;
236
+ }
237
+ }
238
+ });
239
+ }
240
+
241
+ if (!entrypoint.name) {
242
+ throw new Error('Entrypoint name is undefined');
243
+ }
244
+
245
+ sizeMap.set(entrypoint.name, { parsed: parsedSize, gzip: gzipSize });
246
+ });
247
+ }
248
+
249
+ return sizeMap;
250
+ }
251
+
252
+ /**
253
+ * Get sizes for a webpack bundle
254
+ * @param {ObjectEntry} entry - The entry configuration
255
+ * @param {CommandLineArgs} args - Command line arguments
256
+ * @returns {Promise<Map<string, { parsed: number, gzip: number }>>}
257
+ */
258
+ export async function getWebpackSizes(entry, args) {
259
+ // Create webpack configuration
260
+ const { configuration } = await createWebpackConfig(entry, args);
261
+
262
+ // Run webpack
263
+ const webpackStats = await webpack(configuration);
264
+
265
+ // Process the webpack stats to get bundle sizes
266
+ return processBundleSizes(webpackStats);
267
+ }