react-native-bundle-discovery 2.1.0 → 2.2.0-rc.2

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/README.md CHANGED
@@ -3,13 +3,11 @@
3
3
  [![react-native-bundle-discovery on npm](https://badgen.net/npm/v/react-native-bundle-discovery)](https://www.npmjs.com/package/react-native-bundle-discovery)
4
4
  [![react-native-bundle-discovery downloads](https://badgen.net/npm/dm/react-native-bundle-discovery)](https://www.npmtrends.com/react-native-bundle-discovery)
5
5
 
6
-
7
6
  A simple package that helps developers visualize and analyze the bundle size of React Native apps.
8
7
  With this tool, you can easily explore your app's codebase, identify large or heavy packages, and inspect the structure of modules and code within your project.
9
8
 
10
9
  <img width="800" alt="" src="./assets/img.png" />
11
10
 
12
-
13
11
  ### Packages:
14
12
 
15
13
  - `react-native-bundle-discovery` - simple JS library to generate a JSON report of the bundle.
@@ -17,13 +15,20 @@ With this tool, you can easily explore your app's codebase, identify large or he
17
15
  - `react-native-bundle-discovery-cli` - CLI to analyze the bundle report.
18
16
  - `react-native-bundle-discovery-rozenite-plugin` - Rozenite plugin to integrate UI tool to [React Native DevTools](https://reactnative.dev/docs/react-native-devtools).
19
17
 
18
+ ---
19
+
20
20
  ### Setup:
21
21
 
22
22
  There are two ways to install the package:
23
23
 
24
- 1. As in independent tool (UI + CLI)
24
+ 1. As in independent tool (UI + CLI)
25
25
  2. Or as a [Rozenite](_rozenite/README.md) plugin (see: [_rozenite/README.md](_rozenite/README.md))
26
26
 
27
+
28
+ > 🚀 If you've migrated to [Re.Pack](https://re-pack.dev/), you can still use this package to analyze 📊 JS bundles 📦 generated by Re.Pack.
29
+ >
30
+ > 💡 Learn more: 📖 [Re.Pack.md](./Re.Pack.md)
31
+
27
32
  #### 1. Install (independent tool)
28
33
 
29
34
  ```bash
@@ -62,7 +67,7 @@ See: [_rozenite/README.md](_rozenite/README.md)
62
67
 
63
68
  ### 3. Build the app
64
69
 
65
- As example, for iOS you can run the following command, and it will generate the `metro-stats.json` file in the root of your project:
70
+ For example, for iOS you can run the following command, and it will generate the `metro-stats.json` file in the root of your project:
66
71
 
67
72
  ```bash
68
73
  npx react-native bundle \
@@ -112,30 +117,34 @@ npx react-native-bundle-discovery-ui build metro-stats.json
112
117
  | outputJsonPath: string | `<root>/metro-stats.json` | The path where the JSON report will be saved. Defaults to `metro-stats.json` in project root. |
113
118
  | includeCode: boolean | `true` | Whether to include the source and output code in the JSON report. |
114
119
 
115
- ### `createProcessModuleFilter(options: ProcessModuleFilterOptions)`
120
+ **⚠️ Be cautious sharing reports with `includeCode` enabled**
121
+
122
+ Enabling this option significantly increases report size and **embeds the source code** of your assets.
116
123
 
124
+ If your code is proprietary 🔒, share these reports responsibly 🤝!
125
+
126
+ ---
117
127
 
118
- | Prop | Default value | Description |
119
- | ---------------------- | ------------------------- | ----------------------------- |
120
- | removePromisePolyfill: boolean | `false` | Remove the Promise polyfill as Hermes provide own impl. (issue: [#57702](https://github.com/react/react-native/issues/57702)) |
121
- | removeOldRenderer: boolean | `false` | Whether to remove the old renderer. Set to `true` when New Arch is enabled |
122
- | removeNewRenderer: boolean | `false` | Whether to remove the new renderer. Set to `true` when New Arch is disabled |
123
- | removeUTFSequence: boolean | `false` | Remove useless undocumented RN module. |
128
+ ### `createResolveRequest(options: ResolveRequestOptions)`
124
129
 
130
+ | Prop | Default value | Description |
131
+ | ------------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------- |
132
+ | removePromisePolyfill: boolean | `false` | Remove the Promise polyfill as Hermes provide own impl. (issue: [#57702](https://github.com/react/react-native/issues/57702)) |
133
+ | removeUTFSequence: boolean | `false` | Remove useless undocumented RN module. |
125
134
 
126
- Simple helper that devs can use to filter out unnecessary modules from the bundle report.
135
+ Helper that creates a custom resolve request function to filter out unnecessary modules from the bundle.
127
136
  You can get recommendations during the analysis of the bundle report using the CLI tool.
128
137
 
129
138
  ```js
130
139
  // metro.config.js
131
- const {createProcessModuleFilter} = require('react-native-bundle-discovery');
140
+ const { createResolveRequest } = require("react-native-bundle-discovery");
141
+ const resolveRequest = createResolveRequest({
142
+ removePromisePolyfill: true,
143
+ removeUTFSequence: true,
144
+ });
132
145
  const config = {
133
- serializer: {
134
- processModuleFilter: createProcessModuleFilter({
135
- removePromisePolyfill: true,
136
- removeOldRenderer: true,
137
- removeUTFSequence: true,
138
- }),
146
+ resolver: {
147
+ resolveRequest,
139
148
  },
140
149
  };
141
150
  ```
package/index.js CHANGED
@@ -2,7 +2,7 @@ const { parse, resolve } = require("path");
2
2
  const { writeFileSync, existsSync } = require("fs");
3
3
  const { Buffer } = require("buffer");
4
4
  const chalk = require("chalk");
5
-
5
+ const BundleDiscoveryPlugin = require("./webpack").BundleDiscoveryPlugin;
6
6
  const NAME = require("./package.json").name;
7
7
 
8
8
  function getDefault(module) {
@@ -215,8 +215,6 @@ function createSerializer({
215
215
  */
216
216
  const empty = { type: "empty" };
217
217
  const createResolveRequest = ({
218
- removePromisePolyfill = false, // Remove useless polyfill (Hermes already has Promise)
219
- removeOldRenderer = false, // Should be true when new ARCH is enabled
220
218
  removeNewRenderer = false, // Should be true when new ARCH is disabled
221
219
  removeUTFSequence = false, // Remove useless code
222
220
  } = {}) => {
@@ -231,20 +229,6 @@ const createResolveRequest = ({
231
229
  ) {
232
230
  return empty;
233
231
  }
234
- if (
235
- removePromisePolyfill &&
236
- result?.filePath?.endsWith("/react-native/Libraries/Promise.js")
237
- ) {
238
- return empty;
239
- }
240
- if (
241
- removeOldRenderer &&
242
- result?.filePath?.endsWith(
243
- "/react-native/Libraries/Renderer/shims/ReactNative.js",
244
- )
245
- ) {
246
- return empty;
247
- }
248
232
  if (
249
233
  removeNewRenderer &&
250
234
  result?.filePath?.endsWith(
@@ -264,8 +248,6 @@ const createResolveRequest = ({
264
248
  const createProcessModuleFilter =
265
249
  ({
266
250
  removePromisePolyfill = false, // Remove useless polyfill (Hermes already has Promise)
267
- removeOldRenderer = false, // Should be true when new ARCH is enabled
268
- removeNewRenderer = false, // Should be true when new ARCH is disabled
269
251
  removeUTFSequence = false, // Remove useless code
270
252
  } = {}) =>
271
253
  (module) => {
@@ -275,22 +257,6 @@ const createProcessModuleFilter =
275
257
  ) {
276
258
  return false;
277
259
  }
278
- if (
279
- removeOldRenderer &&
280
- module.path.endsWith(
281
- "/react-native/Libraries/Renderer/shims/ReactNative.js",
282
- )
283
- ) {
284
- return false;
285
- }
286
- if (
287
- removeNewRenderer &&
288
- module.path.endsWith(
289
- "/react-native/Libraries/Renderer/shims/ReactFabric.js",
290
- )
291
- ) {
292
- return false;
293
- }
294
260
  if (
295
261
  removeUTFSequence &&
296
262
  module.path.endsWith("/react-native/Libraries/UTFSequence.js")
@@ -302,7 +268,8 @@ const createProcessModuleFilter =
302
268
  };
303
269
 
304
270
  module.exports = {
305
- createResolveRequest,
306
271
  createSerializer,
272
+ createResolveRequest,
307
273
  createProcessModuleFilter,
274
+ BundleDiscoveryPlugin,
308
275
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-bundle-discovery",
3
- "version": "2.1.0",
3
+ "version": "2.2.0-rc.2",
4
4
  "main": "index.js",
5
5
  "repository": "git@github.com:retyui/react-native-bundle-discovery.git",
6
6
  "author": "David <4661784+retyui@users.noreply.github.com>",
@@ -9,7 +9,9 @@
9
9
  "prepublishOnly": "cp ../README.md README.md"
10
10
  },
11
11
  "dependencies": {
12
- "chalk": "^4.1.2"
12
+ "chalk": "^4.1.2",
13
+ "acorn": "^8.0.4",
14
+ "acorn-walk": "^8.0.0"
13
15
  },
14
16
  "peerDependencies": {
15
17
  "metro": "*"
@@ -20,6 +22,6 @@
20
22
  }
21
23
  },
22
24
  "files": [
23
- "index.js"
25
+ "*.js"
24
26
  ]
25
27
  }
package/parseUtils.js ADDED
@@ -0,0 +1,615 @@
1
+ //////////////////////////////////////////////////////////////////////////////////////////
2
+ // Copy of: https://github.com/webpack/webpack-bundle-analyzer/blob/main/src/parseUtils.js
3
+ // Thanks to the Webpack maintainers! 🙏
4
+ //////////////////////////////////////////////////////////////////////////////////////////
5
+ /** @typedef {import("acorn").Node} Node */
6
+ /** @typedef {import("acorn").CallExpression} CallExpression */
7
+ /** @typedef {import("acorn").ExpressionStatement} ExpressionStatement */
8
+ /** @typedef {import("acorn").Expression} Expression */
9
+ /** @typedef {import("acorn").SpreadElement} SpreadElement */
10
+
11
+ const fs = require("node:fs");
12
+ const acorn = require("acorn");
13
+ const walk = require("acorn-walk");
14
+
15
+ /**
16
+ * @param {Expression} node node
17
+ * @returns {boolean} true when id is numeric, otherwise false
18
+ */
19
+ function isNumericId(node) {
20
+ return (
21
+ node.type === "Literal" &&
22
+ node.value !== null &&
23
+ node.value !== undefined &&
24
+ Number.isInteger(node.value) &&
25
+ /** @type {number} */ (node.value) >= 0
26
+ );
27
+ }
28
+
29
+ /**
30
+ * @param {Expression | SpreadElement | null} node node
31
+ * @returns {boolean} true when module id, otherwise false
32
+ */
33
+ function isModuleId(node) {
34
+ return (
35
+ node !== null &&
36
+ node.type === "Literal" &&
37
+ (isNumericId(node) || typeof node.value === "string")
38
+ );
39
+ }
40
+
41
+ /**
42
+ * @param {Expression | SpreadElement} node node
43
+ * @returns {boolean} true when module wrapper, otherwise false
44
+ */
45
+ function isModuleWrapper(node) {
46
+ return (
47
+ // It's an anonymous function expression that wraps module
48
+ ((node.type === "FunctionExpression" ||
49
+ node.type === "ArrowFunctionExpression") &&
50
+ !node.id) ||
51
+ // If `DedupePlugin` is used it can be an ID of duplicated module...
52
+ isModuleId(node) ||
53
+ // or an array of shape [<module_id>, ...args]
54
+ (node.type === "ArrayExpression" &&
55
+ node.elements.length > 1 &&
56
+ isModuleId(node.elements[0]))
57
+ );
58
+ }
59
+
60
+ /**
61
+ * @param {Expression | SpreadElement | null} node node
62
+ * @returns {boolean} true when module hash, otherwise false
63
+ */
64
+ function isModulesHash(node) {
65
+ return (
66
+ node !== null &&
67
+ node.type === "ObjectExpression" &&
68
+ node.properties
69
+ .filter((property) => property.type !== "SpreadElement")
70
+ .map((node) => node.value)
71
+ .every(isModuleWrapper)
72
+ );
73
+ }
74
+
75
+ /**
76
+ * @param {Expression | SpreadElement | null} node node
77
+ * @returns {boolean} true when module array, otherwise false
78
+ */
79
+ function isModulesArray(node) {
80
+ return (
81
+ node !== null &&
82
+ node.type === "ArrayExpression" &&
83
+ node.elements.every(
84
+ (elem) =>
85
+ // Some of array items may be skipped because there is no module with such id
86
+ !elem || isModuleWrapper(elem),
87
+ )
88
+ );
89
+ }
90
+
91
+ /**
92
+ * @param {Expression | SpreadElement | null} node node
93
+ * @returns {boolean} true when simple modules list, otherwise false
94
+ */
95
+ function isSimpleModulesList(node) {
96
+ return (
97
+ // Modules are contained in hash. Keys are module ids.
98
+ isModulesHash(node) ||
99
+ // Modules are contained in array. Indexes are module ids.
100
+ isModulesArray(node)
101
+ );
102
+ }
103
+
104
+ /**
105
+ * @param {Expression | SpreadElement | null} node node
106
+ * @returns {boolean} true when optimized modules array, otherwise false
107
+ */
108
+ function isOptimizedModulesArray(node) {
109
+ // Checking whether modules are contained in `Array(<minimum ID>).concat(...modules)` array:
110
+ // https://github.com/webpack/webpack/blob/v1.14.0/lib/Template.js#L91
111
+ // The `<minimum ID>` + array indexes are module ids
112
+ return (
113
+ node !== null &&
114
+ node.type === "CallExpression" &&
115
+ node.callee.type === "MemberExpression" &&
116
+ // Make sure the object called is `Array(<some number>)`
117
+ node.callee.object.type === "CallExpression" &&
118
+ node.callee.object.callee.type === "Identifier" &&
119
+ node.callee.object.callee.name === "Array" &&
120
+ node.callee.object.arguments.length === 1 &&
121
+ node.callee.object.arguments[0].type !== "SpreadElement" &&
122
+ isNumericId(node.callee.object.arguments[0]) &&
123
+ // Make sure the property X called for `Array(<some number>).X` is `concat`
124
+ node.callee.property.type === "Identifier" &&
125
+ node.callee.property.name === "concat" &&
126
+ // Make sure exactly one array is passed in to `concat`
127
+ node.arguments.length === 1 &&
128
+ isModulesArray(node.arguments[0])
129
+ );
130
+ }
131
+
132
+ /**
133
+ * @param {Expression | SpreadElement | null} node node
134
+ * @returns {boolean} true when modules list, otherwise false
135
+ */
136
+ function isModulesList(node) {
137
+ return (
138
+ isSimpleModulesList(node) ||
139
+ // Modules are contained in expression `Array([minimum ID]).concat([<module>, <module>, ...])`
140
+ isOptimizedModulesArray(node)
141
+ );
142
+ }
143
+
144
+ /** @typedef {{ start: number, end: number }} Location */
145
+
146
+ /**
147
+ * @param {Node} node node
148
+ * @returns {Location} location
149
+ */
150
+ function getModuleLocation(node) {
151
+ return {
152
+ start: node.start,
153
+ end: node.end,
154
+ };
155
+ }
156
+
157
+ /** @typedef {Record<number, Location>} ModulesLocations */
158
+ /** @typedef {{ locations: ModulesLocations, hasWebpackRuntime: boolean, isTopLevel: boolean }} Webpack5IIFECandidate */
159
+
160
+ /**
161
+ * @param {Expression | SpreadElement} node node
162
+ * @returns {ModulesLocations} modules locations
163
+ */
164
+ function getModulesLocations(node) {
165
+ if (node.type === "ObjectExpression") {
166
+ // Modules hash
167
+ const modulesNodes = node.properties;
168
+
169
+ return modulesNodes.reduce((result, moduleNode) => {
170
+ if (moduleNode.type !== "Property") {
171
+ return result;
172
+ }
173
+
174
+ const moduleId =
175
+ moduleNode.key.type === "Identifier"
176
+ ? moduleNode.key.name
177
+ : // @ts-expect-error need verify why we need it, tests not cover it case
178
+ moduleNode.key.value;
179
+
180
+ if (moduleId === "undefined") {
181
+ return result;
182
+ }
183
+
184
+ result[moduleId] = getModuleLocation(moduleNode.value);
185
+
186
+ return result;
187
+ }, /** @type {ModulesLocations} */ ({}));
188
+ }
189
+
190
+ const isOptimizedArray = node.type === "CallExpression";
191
+
192
+ if (node.type === "ArrayExpression" || isOptimizedArray) {
193
+ // Modules array or optimized array
194
+ const minId =
195
+ isOptimizedArray &&
196
+ node.callee.type === "MemberExpression" &&
197
+ node.callee.object.type === "CallExpression" &&
198
+ node.callee.object.arguments[0].type === "Literal"
199
+ ? // Get the [minId] value from the Array() call first argument literal value
200
+ /** @type {number} */ (node.callee.object.arguments[0].value)
201
+ : // `0` for simple array
202
+ 0;
203
+ const modulesNodes = isOptimizedArray
204
+ ? // The modules reside in the `concat()` function call arguments
205
+ node.arguments[0].type === "ArrayExpression"
206
+ ? node.arguments[0].elements
207
+ : []
208
+ : node.elements;
209
+
210
+ return modulesNodes.reduce((result, moduleNode, i) => {
211
+ if (moduleNode) {
212
+ result[i + minId] = getModuleLocation(moduleNode);
213
+ }
214
+
215
+ return result;
216
+ }, /** @type {ModulesLocations} */ ({}));
217
+ }
218
+
219
+ return {};
220
+ }
221
+
222
+ /**
223
+ * @param {ExpressionStatement} node node
224
+ * @returns {CallExpression | null} IIFE call expression
225
+ */
226
+ function getIIFECallExpression(node) {
227
+ if (node.expression.type === "CallExpression") {
228
+ return node.expression;
229
+ }
230
+
231
+ if (
232
+ node.expression.type === "UnaryExpression" &&
233
+ node.expression.argument.type === "CallExpression"
234
+ ) {
235
+ return node.expression.argument;
236
+ }
237
+
238
+ return null;
239
+ }
240
+
241
+ /**
242
+ * @param {Node} node node
243
+ * @param {string} modulesVariableName modules variable name
244
+ * @returns {boolean} true when the node calls a module wrapper
245
+ */
246
+ function callsModulesMap(node, modulesVariableName) {
247
+ let callsModule = false;
248
+
249
+ walk.simple(node, {
250
+ CallExpression(callExpression) {
251
+ const { callee } = callExpression;
252
+
253
+ if (
254
+ callee.type === "MemberExpression" &&
255
+ ((callee.object.type === "Identifier" &&
256
+ callee.object.name === modulesVariableName) ||
257
+ (callee.object.type === "MemberExpression" &&
258
+ callee.object.object.type === "Identifier" &&
259
+ callee.object.object.name === modulesVariableName))
260
+ ) {
261
+ callsModule = true;
262
+ }
263
+ },
264
+ });
265
+
266
+ return callsModule;
267
+ }
268
+
269
+ /**
270
+ * @param {import("acorn").BlockStatement} body body
271
+ * @param {import("acorn").VariableDeclaration} modulesDeclaration modules declaration
272
+ * @param {string} modulesVariableName modules variable name
273
+ * @returns {boolean} true when the candidate contains Webpack module loading
274
+ */
275
+ function hasWebpackModulesRuntime(
276
+ body,
277
+ modulesDeclaration,
278
+ modulesVariableName,
279
+ ) {
280
+ const declarationIndex = body.body.indexOf(modulesDeclaration);
281
+
282
+ return body.body
283
+ .slice(declarationIndex + 1)
284
+ .some((statement) => callsModulesMap(statement, modulesVariableName));
285
+ }
286
+
287
+ /**
288
+ * @param {CallExpression} node node
289
+ * @param {boolean} isTopLevel is top-level IIFE
290
+ * @returns {Webpack5IIFECandidate | null} modules candidate
291
+ */
292
+ function getWebpack5IIFEModulesCandidate(node, isTopLevel) {
293
+ if (
294
+ node.arguments.length !== 0 ||
295
+ (node.callee.type !== "FunctionExpression" &&
296
+ node.callee.type !== "ArrowFunctionExpression") ||
297
+ node.callee.params.length !== 0 ||
298
+ node.callee.body.type !== "BlockStatement"
299
+ ) {
300
+ return null;
301
+ }
302
+
303
+ const firstVariableDeclaration = node.callee.body.body.find(
304
+ (node) => node.type === "VariableDeclaration",
305
+ );
306
+
307
+ if (firstVariableDeclaration) {
308
+ for (const declaration of firstVariableDeclaration.declarations) {
309
+ if (declaration.init && isModulesList(declaration.init)) {
310
+ const locations = getModulesLocations(declaration.init);
311
+
312
+ if (Object.keys(locations).length === 0) {
313
+ continue;
314
+ }
315
+
316
+ return {
317
+ locations,
318
+ hasWebpackRuntime:
319
+ declaration.id.type === "Identifier" &&
320
+ hasWebpackModulesRuntime(
321
+ node.callee.body,
322
+ firstVariableDeclaration,
323
+ declaration.id.name,
324
+ ),
325
+ isTopLevel,
326
+ };
327
+ }
328
+ }
329
+ }
330
+
331
+ return null;
332
+ }
333
+
334
+ /**
335
+ * @param {Webpack5IIFECandidate[]} candidates candidates
336
+ * @returns {Webpack5IIFECandidate | null} selected candidate
337
+ */
338
+ function selectWebpack5IIFECandidate(candidates) {
339
+ return (
340
+ candidates.find(
341
+ (candidate) => candidate.hasWebpackRuntime && candidate.isTopLevel,
342
+ ) ||
343
+ candidates.find((candidate) => candidate.hasWebpackRuntime) ||
344
+ candidates.find((candidate) => candidate.isTopLevel) ||
345
+ null
346
+ );
347
+ }
348
+
349
+ /**
350
+ * @param {Webpack5IIFECandidate[]} candidates candidates
351
+ * @param {(string | number)[] | undefined} expectedModuleIds expected module ids
352
+ * @returns {ModulesLocations | null} selected modules locations
353
+ */
354
+ function selectWebpack5IIFEModulesLocations(candidates, expectedModuleIds) {
355
+ if (candidates.length === 0) {
356
+ return null;
357
+ }
358
+
359
+ if (expectedModuleIds?.length) {
360
+ const expectedIds = new Set(expectedModuleIds.map(String));
361
+ const rankedCandidates = candidates
362
+ .map((candidate) => ({
363
+ candidate,
364
+ expectedIdsIntersection: Object.keys(candidate.locations).filter(
365
+ (moduleId) => expectedIds.has(moduleId),
366
+ ).length,
367
+ }))
368
+ .toSorted(
369
+ (candidateA, candidateB) =>
370
+ candidateB.expectedIdsIntersection -
371
+ candidateA.expectedIdsIntersection,
372
+ );
373
+
374
+ if (rankedCandidates[0].expectedIdsIntersection > 0) {
375
+ const bestCandidates = rankedCandidates
376
+ .filter(
377
+ (rankedCandidate) =>
378
+ rankedCandidate.expectedIdsIntersection ===
379
+ rankedCandidates[0].expectedIdsIntersection,
380
+ )
381
+ .map((rankedCandidate) => rankedCandidate.candidate);
382
+
383
+ return selectWebpack5IIFECandidate(bestCandidates)?.locations || null;
384
+ }
385
+ }
386
+
387
+ return selectWebpack5IIFECandidate(candidates)?.locations || null;
388
+ }
389
+
390
+ /**
391
+ * @param {Expression} node node
392
+ * @returns {boolean} true when chunks ids, otherwose false
393
+ */
394
+ function isChunkIds(node) {
395
+ // Array of numeric or string ids. Chunk IDs are strings when NamedChunksPlugin is used
396
+ return node.type === "ArrayExpression" && node.elements.every(isModuleId);
397
+ }
398
+
399
+ /**
400
+ * @param {(Expression | SpreadElement | null)[]} args arguments
401
+ * @returns {boolean} true when async chunk arguments, otherwise false
402
+ */
403
+ function mayBeAsyncChunkArguments(args) {
404
+ return (
405
+ args.length >= 2 &&
406
+ args[0] !== null &&
407
+ args[0].type !== "SpreadElement" &&
408
+ isChunkIds(args[0])
409
+ );
410
+ }
411
+
412
+ /**
413
+ * Returns bundle source except modules
414
+ * @param {string} content content
415
+ * @param {ModulesLocations | null} modulesLocations modules locations
416
+ * @returns {string} runtime code
417
+ */
418
+ function getBundleRuntime(content, modulesLocations) {
419
+ const sortedLocations = Object.values(modulesLocations || {}).toSorted(
420
+ (a, b) => a.start - b.start,
421
+ );
422
+
423
+ let result = "";
424
+ let lastIndex = 0;
425
+
426
+ for (const { start, end } of sortedLocations) {
427
+ result += content.slice(lastIndex, start);
428
+ lastIndex = end;
429
+ }
430
+
431
+ return result + content.slice(lastIndex);
432
+ }
433
+
434
+ /**
435
+ * @param {CallExpression} node node
436
+ * @returns {boolean} true when is async chunk push expression, otheriwse false
437
+ */
438
+ function isAsyncChunkPushExpression(node) {
439
+ const { callee, arguments: args } = node;
440
+
441
+ return (
442
+ callee.type === "MemberExpression" &&
443
+ callee.property.type === "Identifier" &&
444
+ callee.property.name === "push" &&
445
+ callee.object.type === "AssignmentExpression" &&
446
+ args.length === 1 &&
447
+ args[0].type === "ArrayExpression" &&
448
+ mayBeAsyncChunkArguments(args[0].elements) &&
449
+ isModulesList(args[0].elements[1])
450
+ );
451
+ }
452
+
453
+ /**
454
+ * @param {CallExpression} node node
455
+ * @returns {boolean} true when is async web worker, otherwise false
456
+ */
457
+ function isAsyncWebWorkerChunkExpression(node) {
458
+ const { callee, type, arguments: args } = node;
459
+
460
+ return (
461
+ type === "CallExpression" &&
462
+ callee.type === "MemberExpression" &&
463
+ args.length === 2 &&
464
+ args[0].type !== "SpreadElement" &&
465
+ isChunkIds(args[0]) &&
466
+ isModulesList(args[1])
467
+ );
468
+ }
469
+
470
+ /** @typedef {Record<string, string>} Modules */
471
+
472
+ /**
473
+ * @param {string} bundlePath bundle path
474
+ * @param {{ sourceType?: "script" | "module", expectedModuleIds?: (string | number)[] }} opts options
475
+ * @returns {{ modules: Modules, src: string, runtimeSrc: string }} parsed result
476
+ */
477
+ module.exports.parseBundle = function parseBundle(bundlePath, opts) {
478
+ const { sourceType = "script", expectedModuleIds } = opts || {};
479
+
480
+ const content = fs.readFileSync(bundlePath, "utf8");
481
+ const ast = acorn.parse(content, {
482
+ sourceType,
483
+ ecmaVersion: "latest",
484
+ });
485
+
486
+ /** @type {Set<CallExpression>} */
487
+ const topLevelIIFECalls = new Set();
488
+
489
+ for (const node of ast.body) {
490
+ if (node.type === "ExpressionStatement") {
491
+ const iifeCall = getIIFECallExpression(node);
492
+
493
+ if (iifeCall) {
494
+ topLevelIIFECalls.add(iifeCall);
495
+ }
496
+ }
497
+ }
498
+
499
+ /** @type {{ locations: ModulesLocations | null, webpack5IIFECandidates: Webpack5IIFECandidate[] }} */
500
+ const walkState = {
501
+ locations: null,
502
+ webpack5IIFECandidates: [],
503
+ };
504
+
505
+ walk.recursive(ast, walkState, {
506
+ AssignmentExpression(node, state) {
507
+ if (state.locations) return;
508
+
509
+ // Modules are stored in exports.modules:
510
+ // exports.modules = {};
511
+ const { left, right } = node;
512
+
513
+ if (
514
+ left &&
515
+ left.type === "MemberExpression" &&
516
+ left.object &&
517
+ left.object.type === "Identifier" &&
518
+ left.object.name === "exports" &&
519
+ left.property &&
520
+ left.property.type === "Identifier" &&
521
+ left.property.name === "modules" &&
522
+ isModulesHash(right)
523
+ ) {
524
+ state.locations = getModulesLocations(right);
525
+ }
526
+ },
527
+
528
+ CallExpression(node, state, callback) {
529
+ if (state.locations) return;
530
+
531
+ const args = node.arguments;
532
+ const webpack5IIFEModulesCandidate = getWebpack5IIFEModulesCandidate(
533
+ node,
534
+ topLevelIIFECalls.has(node),
535
+ );
536
+
537
+ if (webpack5IIFEModulesCandidate) {
538
+ state.webpack5IIFECandidates.push(webpack5IIFEModulesCandidate);
539
+ }
540
+
541
+ // Main chunk with webpack loader.
542
+ // Modules are stored in first argument:
543
+ // (function (...) {...})(<modules>)
544
+ if (
545
+ node.callee.type === "FunctionExpression" &&
546
+ !node.callee.id &&
547
+ args.length === 1 &&
548
+ isSimpleModulesList(args[0])
549
+ ) {
550
+ state.locations = getModulesLocations(args[0]);
551
+ return;
552
+ }
553
+
554
+ // Async Webpack < v4 chunk without webpack loader.
555
+ // webpackJsonp([<chunks>], <modules>, ...)
556
+ // As function name may be changed with `output.jsonpFunction` option we can't rely on it's default name.
557
+ if (
558
+ node.callee.type === "Identifier" &&
559
+ mayBeAsyncChunkArguments(args) &&
560
+ args[1].type !== "SpreadElement" &&
561
+ isModulesList(args[1])
562
+ ) {
563
+ state.locations = getModulesLocations(args[1]);
564
+ return;
565
+ }
566
+
567
+ // Async Webpack v4 chunk without webpack loader.
568
+ // (window.webpackJsonp=window.webpackJsonp||[]).push([[<chunks>], <modules>, ...]);
569
+ // As function name may be changed with `output.jsonpFunction` option we can't rely on it's default name.
570
+ if (
571
+ isAsyncChunkPushExpression(node) &&
572
+ args[0].type === "ArrayExpression" &&
573
+ args[0].elements[1]
574
+ ) {
575
+ state.locations = getModulesLocations(args[0].elements[1]);
576
+ return;
577
+ }
578
+
579
+ // Webpack v4 WebWorkerChunkTemplatePlugin
580
+ // globalObject.chunkCallbackName([<chunks>],<modules>, ...);
581
+ // Both globalObject and chunkCallbackName can be changed through the config, so we can't check them.
582
+ if (isAsyncWebWorkerChunkExpression(node)) {
583
+ state.locations = getModulesLocations(args[1]);
584
+ return;
585
+ }
586
+
587
+ // Walking into arguments because some of plugins (e.g. `DedupePlugin`) or some Webpack
588
+ // features (e.g. `umd` library output) can wrap modules list into additional IIFE.
589
+ for (const arg of args) {
590
+ callback(arg, state);
591
+ }
592
+ },
593
+ });
594
+
595
+ const modulesLocations =
596
+ walkState.locations ||
597
+ selectWebpack5IIFEModulesLocations(
598
+ walkState.webpack5IIFECandidates,
599
+ expectedModuleIds,
600
+ );
601
+ /** @type {Modules} */
602
+ const modules = {};
603
+
604
+ if (modulesLocations) {
605
+ for (const [id, loc] of Object.entries(modulesLocations)) {
606
+ modules[id] = content.slice(loc.start, loc.end);
607
+ }
608
+ }
609
+
610
+ return {
611
+ modules,
612
+ src: content,
613
+ runtimeSrc: getBundleRuntime(content, modulesLocations),
614
+ };
615
+ };
package/webpack.js ADDED
@@ -0,0 +1,221 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+
4
+ const chalk = require("chalk");
5
+
6
+ const { parseBundle } = require("./parseUtils.js");
7
+ const NAME = require("./package.json").name;
8
+
9
+ /**
10
+ * Simple Webpack/Rspack plugin
11
+ * to adapt stats object https://webpack.js.org/api/stats/
12
+ * to metro-stats.json format used by `react-native-bundle-discovery`
13
+ */
14
+ class BundleDiscoveryPlugin {
15
+ constructor({ filename, options, enabled } = {}) {
16
+ this.options = options;
17
+ this.filename = filename || "metro-stats.json";
18
+ this.enabled = enabled || true;
19
+ }
20
+
21
+ extractSizesFromJsBundle(statAsset, options) {
22
+ const assetFile = path.join(options.output.path, statAsset.name);
23
+ try {
24
+ return parseBundle(assetFile, {
25
+ sourceType: statAsset.info.javascriptModule ? "module" : "script",
26
+ });
27
+ } catch (e) {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ apply(compiler) {
33
+ if (!this.enabled) {
34
+ return;
35
+ }
36
+
37
+ compiler.hooks.done.tap("GenerateStatsJsonPlugin", (stats) => {
38
+ // Extract the relevant information from the stats object
39
+ const json = stats.toJson({
40
+ version: true,
41
+ builtAt: true,
42
+ modules: true,
43
+ reasons: true,
44
+ assets: true,
45
+ ids: true,
46
+ ...this.options,
47
+ all: false,
48
+ source: true,
49
+ });
50
+ // Format the stats into the desired structure
51
+ const statsJson = this.formatStat(
52
+ json,
53
+ // Parse JS bundle to extract modules sizes
54
+ this.extractSizesFromJsBundle(json.assets[0], compiler.options),
55
+ compiler.options,
56
+ );
57
+ // Write the formatted stats to the specified output file
58
+ const outputPath = path.resolve(compiler.options.context, this.filename);
59
+ fs.writeFileSync(outputPath, JSON.stringify(statsJson, null, 2));
60
+
61
+ console.log(
62
+ `${chalk.yellow(`[${NAME}]`)}: Saved stats to ${chalk.green(
63
+ outputPath,
64
+ )}`,
65
+ );
66
+ });
67
+ }
68
+
69
+ readSource(filePath) {
70
+ const fallback = { code: "", lineCount: 0, sizeInBytes: 0 };
71
+ try {
72
+ const code = fs.readFileSync(filePath, "utf-8");
73
+ const lineCount = code.split("\n").length;
74
+ const sizeInBytes = Buffer.byteLength(code, "utf-8");
75
+ return {
76
+ code: this.options?.source === false ? "" : code,
77
+ lineCount,
78
+ sizeInBytes,
79
+ };
80
+ } catch {
81
+ return fallback;
82
+ }
83
+ }
84
+
85
+ getPackageName(modulePath) {
86
+ if (!modulePath) {
87
+ return null;
88
+ }
89
+ const parts = modulePath.split("node_modules");
90
+ if (parts.length < 2) {
91
+ return null;
92
+ }
93
+ const packagePath = parts[1].split(path.sep).filter(Boolean);
94
+ if (packagePath.length === 0) {
95
+ return null;
96
+ }
97
+ if (packagePath[0].startsWith("@")) {
98
+ return `${packagePath[0]}/${packagePath[1]}`;
99
+ }
100
+ return packagePath[0];
101
+ }
102
+ getAbsolutePath(packageName, modulePath) {
103
+ if (!packageName) {
104
+ return null;
105
+ }
106
+ return modulePath.substring(
107
+ 0,
108
+ modulePath.indexOf(packageName) + packageName.length,
109
+ );
110
+ }
111
+ getPackages(statsJson) {
112
+ const packagesMap = statsJson.modules.reduce((allPackages, mdl) => {
113
+ const name = this.getPackageName(mdl.nameForCondition);
114
+ if (!name) {
115
+ return allPackages;
116
+ }
117
+ const absolutePath = this.getAbsolutePath(name, mdl.nameForCondition);
118
+ if (!allPackages.has(absolutePath)) {
119
+ allPackages.set(absolutePath, {
120
+ name,
121
+ absolutePath,
122
+ version: require(path.join(absolutePath, "package.json")).version,
123
+ });
124
+ }
125
+ return allPackages;
126
+ }, new Map());
127
+ return Array.from(packagesMap.values());
128
+ }
129
+ getEntryFile(statsJson, rootFolder) {
130
+ const fallbackValue = path.join(rootFolder, "index.js");
131
+ return (
132
+ statsJson.modules.find(
133
+ (mdl) =>
134
+ !mdl.nameForCondition?.includes("node_modules/") &&
135
+ mdl.reasons?.length === 1 &&
136
+ mdl.reasons?.[0].type === "entry",
137
+ )?.nameForCondition || fallbackValue
138
+ );
139
+ }
140
+ getModuleOutput(mdl, bundleModules) {
141
+ const jsCodeStrFromJSBundle = bundleModules?.modules?.[mdl.id];
142
+ if (jsCodeStrFromJSBundle) {
143
+ const formatted =
144
+ jsCodeStrFromJSBundle.startsWith("function(") &&
145
+ jsCodeStrFromJSBundle.endsWith("}")
146
+ ? jsCodeStrFromJSBundle.replace("function(", "function module(")
147
+ : jsCodeStrFromJSBundle;
148
+
149
+ return {
150
+ code: formatted,
151
+ lineCount: formatted.split("\n").length ?? 0,
152
+ sizeInBytes: Buffer.byteLength(jsCodeStrFromJSBundle, "utf-8"),
153
+ };
154
+ }
155
+ return {
156
+ code: mdl.source ?? "",
157
+ lineCount: mdl.source?.split("\n").length ?? 0,
158
+ sizeInBytes: mdl.size ?? 0,
159
+ };
160
+ }
161
+
162
+ formatStat(statsJson, bundleModules, compilerOptions) {
163
+ return {
164
+ date: statsJson.builtAt || Date.now(),
165
+ entryPoint: this.getEntryFile(statsJson, compilerOptions.context),
166
+ rootFolder: compilerOptions.context,
167
+ // envs: {},
168
+ transformOptions: {
169
+ // customTransformOptions: {},
170
+ dev: compilerOptions.mode !== "production",
171
+ minify: compilerOptions.optimization?.minimize ?? false,
172
+ platform: compilerOptions.name ?? "ios",
173
+ // type: "module",
174
+ // unstable_transformProfile: "default",
175
+ },
176
+ modules: statsJson.modules
177
+ .map((mdl) => {
178
+ if (mdl.type === "runtime modules") {
179
+ const sourceAndOutput = {
180
+ code: bundleModules.runtimeSrc ?? "",
181
+ lineCount: bundleModules.runtimeSrc?.split("\n").length ?? 0,
182
+ sizeInBytes: Buffer.byteLength(
183
+ bundleModules.runtimeSrc ?? "",
184
+ "utf-8",
185
+ ),
186
+ };
187
+ return {
188
+ path: "__prelude__",
189
+ source: sourceAndOutput,
190
+ output: sourceAndOutput,
191
+ dependencies: [],
192
+ };
193
+ }
194
+
195
+ return {
196
+ path: mdl.nameForCondition,
197
+ source: this.readSource(mdl.nameForCondition),
198
+ output: this.getModuleOutput(mdl, bundleModules),
199
+ dependencies:
200
+ mdl.reasons
201
+ ?.filter((r) => r.type !== "entry")
202
+ .map((reason) => {
203
+ return {
204
+ absolutePath: path.isAbsolute(reason.resolvedModule)
205
+ ? reason.resolvedModule
206
+ : path.resolve(
207
+ compilerOptions.context,
208
+ reason.resolvedModule,
209
+ ),
210
+ name: reason.userRequest,
211
+ };
212
+ }) ?? [],
213
+ };
214
+ })
215
+ .filter((e) => e !== null),
216
+ packages: this.getPackages(statsJson),
217
+ };
218
+ }
219
+ }
220
+
221
+ module.exports = { BundleDiscoveryPlugin };