react-native-bundle-discovery 2.3.0 → 2.5.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/README.md CHANGED
@@ -90,6 +90,10 @@ npx react-native-bundle-discovery-cli metro-stats.json
90
90
 
91
91
  # Display all packages in the bundle report
92
92
  npx react-native-bundle-discovery-cli packages metro-stats.json [--sort size|name] [--format json|table|default]
93
+
94
+ # Display the heaviest modules in the bundle report (default limit: 50, --limit 0 shows all)
95
+ # --filter accepts plain text (case-insensitive) or a regexp: --filter '/\.json/i'
96
+ npx react-native-bundle-discovery-cli modules metro-stats.json [--limit 50] [--filter <text|/regexp/>] [--sort size|name] [--format json|table|default]
93
97
  ```
94
98
 
95
99
  #### UI package
package/index.js CHANGED
@@ -3,6 +3,7 @@ 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
+ const { withPackagesMetadata } = require("./packageMetadata");
6
7
  const NAME = require("./package.json").name;
7
8
 
8
9
  function getDefault(module) {
@@ -106,7 +107,7 @@ function toModuleStruct(m, includeCode) {
106
107
  };
107
108
  }
108
109
 
109
- function createJsonReport({
110
+ async function createJsonReport({
110
111
  graph,
111
112
  entryPoint,
112
113
  includeEnvs,
@@ -116,12 +117,30 @@ function createJsonReport({
116
117
  rootFolder,
117
118
  silent,
118
119
  options,
120
+ fetchPackagesMetadata,
119
121
  }) {
120
122
  const { processModuleFilter = () => true } = options || {};
121
123
  const dependencies = Array.from(graph.dependencies.values()).filter(
122
124
  processModuleFilter,
123
125
  );
124
126
 
127
+ let packages = toPackages(preModules).concat(toPackages(dependencies));
128
+ const modules = preModules
129
+ .map((m) => toModuleStruct(m, includeCode))
130
+ .concat(dependencies.map((m) => toModuleStruct(m, includeCode)));
131
+
132
+ if (fetchPackagesMetadata) {
133
+ packages = await withPackagesMetadata(packages, {
134
+ onError: (error, pkg) => {
135
+ if (!silent) {
136
+ console.warn(
137
+ `${chalk.yellow(`[${NAME}]`)}: Failed to fetch metadata for ${pkg.name}@${pkg.version}: ${error.message}`,
138
+ );
139
+ }
140
+ },
141
+ });
142
+ }
143
+
125
144
  const stats = {
126
145
  date: Date.now(),
127
146
  entryPoint,
@@ -131,10 +150,8 @@ function createJsonReport({
131
150
  return acc;
132
151
  }, {}),
133
152
  rootFolder,
134
- packages: toPackages(preModules).concat(toPackages(dependencies)),
135
- modules: preModules
136
- .map((m) => toModuleStruct(m, includeCode))
137
- .concat(dependencies.map((m) => toModuleStruct(m, includeCode))),
153
+ packages,
154
+ modules,
138
155
  };
139
156
 
140
157
  writeFileSync(outputJsonPath, JSON.stringify(stats));
@@ -156,6 +173,7 @@ function createJsonReport({
156
173
  * @param {string} [options.outputJsonPath] - The path where the JSON report will be saved. Defaults to "metro-stats.json" in the project root.
157
174
  * @param {boolean} [options.includeCode=true] - Whether to include the source and output code in the JSON report.
158
175
  * @param {string[]} [options.includeEnvs=[]] - A list of environment variable names to include in the JSON report.
176
+ * @param {boolean} [options.fetchPackagesMetadata=true] - Whether to fetch packages metadata (publish date, deprecation, latest version) from the npm registry.
159
177
  * @returns {Function} - A custom serializer function to be used by Metro.
160
178
  * @throws {Error} - Throws an error if the project root does not exist.
161
179
  */
@@ -166,6 +184,7 @@ function createSerializer({
166
184
  includeCode = true,
167
185
  silent = false,
168
186
  includeEnvs = [],
187
+ fetchPackagesMetadata = true,
169
188
  } = {}) {
170
189
  const mySerializer = serializer || getDefaultSerializer();
171
190
 
@@ -179,6 +198,12 @@ function createSerializer({
179
198
  function customSerializer(entryPoint, preModules, graph, options) {
180
199
  const code = mySerializer(entryPoint, preModules, graph, options);
181
200
 
201
+ // Keeps the Node.js process alive until the report is written
202
+ // (otherwise it can exit while metadata requests are still pending)
203
+ const keepAlive = setInterval(() => {}, 1000);
204
+
205
+ // Graph/modules are read synchronously before the first `await`,
206
+ // so the report is not affected by later Metro graph mutations
182
207
  createJsonReport({
183
208
  graph,
184
209
  entryPoint,
@@ -189,7 +214,8 @@ function createSerializer({
189
214
  rootFolder: projectRoot,
190
215
  silent,
191
216
  options,
192
- });
217
+ fetchPackagesMetadata,
218
+ }).finally(() => clearInterval(keepAlive));
193
219
 
194
220
  return code;
195
221
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-bundle-discovery",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
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>",
@@ -0,0 +1,155 @@
1
+ const DEFAULT_REGISTRY = "https://registry.npmjs.org";
2
+ const DEFAULT_TIMEOUT_MS = 1_000;
3
+ const DEFAULT_CONCURRENCY = 8;
4
+
5
+ // Simple in-memory cache: `${registry}|${name}` -> Promise<packument summary | null>
6
+ // Keeps promises (not values) so concurrent lookups of the same package share one request,
7
+ // and different versions of the same package share one packument.
8
+ const cache = new Map();
9
+
10
+ /**
11
+ * `@babel/runtime` -> `@babel%2fruntime`
12
+ */
13
+ function encodePackageName(name) {
14
+ return name.startsWith("@") ? `@${encodeURIComponent(name.slice(1))}` : name;
15
+ }
16
+
17
+ async function fetchJson(url, timeoutMs) {
18
+ const res = await fetch(url, {
19
+ headers: { Accept: "application/json" },
20
+ signal: AbortSignal.timeout(timeoutMs),
21
+ });
22
+ if (res.status === 404) {
23
+ return null;
24
+ }
25
+ if (!res.ok) {
26
+ throw new Error(`${res.status} ${res.statusText} (${url})`);
27
+ }
28
+ return res.json();
29
+ }
30
+
31
+ /**
32
+ * The version manifest (`/<name>/<version>`) has no publish date,
33
+ * so the packument is fetched once and only the fields we need are kept.
34
+ */
35
+ async function fetchPackageSummary(name, { registry, timeoutMs }) {
36
+ const packument = await fetchJson(
37
+ `${registry}/${encodePackageName(name)}`,
38
+ timeoutMs,
39
+ );
40
+
41
+ if (!packument) {
42
+ return null;
43
+ }
44
+
45
+ const versions = {};
46
+ for (const [version, manifest] of Object.entries(packument.versions ?? {})) {
47
+ versions[version] = {
48
+ createdAt: packument.time?.[version] ?? null,
49
+ deprecated: manifest.deprecated || false,
50
+ };
51
+ }
52
+
53
+ return {
54
+ latestVersion: packument["dist-tags"]?.latest ?? null,
55
+ versions,
56
+ };
57
+ }
58
+
59
+ function getPackageSummary(name, { registry, timeoutMs }) {
60
+ const key = `${registry}|${name}`;
61
+
62
+ if (!cache.has(key)) {
63
+ const promise = fetchPackageSummary(name, { registry, timeoutMs }).catch(
64
+ (error) => {
65
+ // Don't cache failures (network issues, timeouts), retry on next report
66
+ cache.delete(key);
67
+ throw error;
68
+ },
69
+ );
70
+ cache.set(key, promise);
71
+ }
72
+
73
+ return cache.get(key);
74
+ }
75
+
76
+ /**
77
+ * Returns metadata of a specific (installed) package version, not the latest one.
78
+ *
79
+ * @returns {Promise<{createdAt: string|null, deprecated: string|false, isLatest: boolean, latestVersion: string|null} | null>}
80
+ * `null` when the package/version is not found in the registry.
81
+ */
82
+ async function getPackageMetadata(
83
+ name,
84
+ version,
85
+ { registry = DEFAULT_REGISTRY, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
86
+ ) {
87
+ const summary = await getPackageSummary(name, { registry, timeoutMs });
88
+ const info = summary?.versions[version];
89
+
90
+ if (!info) {
91
+ return null;
92
+ }
93
+
94
+ return {
95
+ createdAt: info.createdAt,
96
+ deprecated: info.deprecated,
97
+ isLatest: summary.latestVersion === version,
98
+ latestVersion: summary.latestVersion,
99
+ };
100
+ }
101
+
102
+ async function mapWithConcurrency(items, limit, fn) {
103
+ const results = new Array(items.length);
104
+ let index = 0;
105
+
106
+ async function worker() {
107
+ while (index < items.length) {
108
+ const i = index++;
109
+ results[i] = await fn(items[i], i);
110
+ }
111
+ }
112
+
113
+ await Promise.all(
114
+ Array.from({ length: Math.min(limit, items.length) }, worker),
115
+ );
116
+
117
+ return results;
118
+ }
119
+
120
+ /**
121
+ * Adds a `metadata` field to every package of the report.
122
+ * Failed lookups result in `metadata: null` and are passed to `onError`.
123
+ *
124
+ * @param {Array<{name: string, version: string}>} packages
125
+ */
126
+ async function withPackagesMetadata(
127
+ packages,
128
+ {
129
+ registry = DEFAULT_REGISTRY,
130
+ timeoutMs = DEFAULT_TIMEOUT_MS,
131
+ concurrency = DEFAULT_CONCURRENCY,
132
+ onError = () => {},
133
+ } = {},
134
+ ) {
135
+ return mapWithConcurrency(packages, concurrency, async (pkg) => {
136
+ if (!pkg.version) {
137
+ return { ...pkg, metadata: null };
138
+ }
139
+ try {
140
+ const metadata = await getPackageMetadata(pkg.name, pkg.version, {
141
+ registry,
142
+ timeoutMs,
143
+ });
144
+ return { ...pkg, metadata };
145
+ } catch (error) {
146
+ onError(error, pkg);
147
+ return { ...pkg, metadata: null };
148
+ }
149
+ });
150
+ }
151
+
152
+ module.exports = {
153
+ getPackageMetadata,
154
+ withPackagesMetadata,
155
+ };
package/webpack.js CHANGED
@@ -4,7 +4,9 @@ const path = require("node:path");
4
4
  const chalk = require("chalk");
5
5
 
6
6
  const { parseBundle } = require("./parseUtils.js");
7
+ const { withPackagesMetadata } = require("./packageMetadata.js");
7
8
  const NAME = require("./package.json").name;
9
+ const packageRegex = /(?:^|\/)node_modules\/((?:@[^\/]+\/)?[^\/]+)/g;
8
10
 
9
11
  /**
10
12
  * Simple Webpack/Rspack plugin
@@ -12,10 +14,11 @@ const NAME = require("./package.json").name;
12
14
  * to metro-stats.json format used by `react-native-bundle-discovery`
13
15
  */
14
16
  class BundleDiscoveryPlugin {
15
- constructor({ filename, options, enabled } = {}) {
17
+ constructor({ filename, options, enabled, fetchPackagesMetadata } = {}) {
16
18
  this.options = options;
17
19
  this.filename = filename || "metro-stats.json";
18
20
  this.enabled = enabled ?? true;
21
+ this.fetchPackagesMetadata = fetchPackagesMetadata ?? true;
19
22
  }
20
23
 
21
24
  extractSizesFromJsBundle(statAsset, options) {
@@ -47,6 +50,7 @@ class BundleDiscoveryPlugin {
47
50
  all: false,
48
51
  source: true,
49
52
  });
53
+
50
54
  // Format the stats into the desired structure
51
55
  const statsJson = this.formatStat(
52
56
  json,
@@ -57,16 +61,39 @@ class BundleDiscoveryPlugin {
57
61
  );
58
62
  // Write the formatted stats to the specified output file
59
63
  const outputPath = path.resolve(compiler.options.context, this.filename);
60
- fs.writeFileSync(outputPath, JSON.stringify(statsJson, null, 2));
61
64
 
62
- console.log(
63
- `${chalk.yellow(`[${NAME}]`)}: Saved stats to ${chalk.green(
64
- outputPath,
65
- )}`,
66
- );
65
+ // Keeps the Node.js process alive until the report is written
66
+ // (otherwise it can exit while metadata requests are still pending)
67
+ const keepAlive = setInterval(() => {}, 1000);
68
+
69
+ this.writeReport(statsJson, outputPath)
70
+ .catch((error) => {
71
+ console.error(
72
+ `${chalk.yellow(`[${NAME}]`)}: Failed to create JSON report: ${error.message}`,
73
+ );
74
+ })
75
+ .finally(() => clearInterval(keepAlive));
67
76
  });
68
77
  }
69
78
 
79
+ async writeReport(statsJson, outputPath) {
80
+ if (this.fetchPackagesMetadata) {
81
+ statsJson.packages = await withPackagesMetadata(statsJson.packages, {
82
+ onError: (error, pkg) => {
83
+ console.warn(
84
+ `${chalk.yellow(`[${NAME}]`)}: Failed to fetch metadata for ${pkg.name}@${pkg.version}: ${error.message}`,
85
+ );
86
+ },
87
+ });
88
+ }
89
+
90
+ fs.writeFileSync(outputPath, JSON.stringify(statsJson, null, 2));
91
+
92
+ console.log(
93
+ `${chalk.yellow(`[${NAME}]`)}: Saved stats to ${chalk.green(outputPath)}`,
94
+ );
95
+ }
96
+
70
97
  readSource(filePath) {
71
98
  const fallback = { code: "", lineCount: 0, sizeInBytes: 0 };
72
99
  try {
@@ -83,49 +110,83 @@ class BundleDiscoveryPlugin {
83
110
  }
84
111
  }
85
112
 
86
- getPackageName(modulePath) {
87
- if (!modulePath) {
88
- return null;
89
- }
90
- const parts = modulePath.split("node_modules");
91
- if (parts.length < 2) {
92
- return null;
93
- }
94
- // WARN: Won't work with `pnpm's nested layout`
95
- const packagePath = parts[1].split(path.sep).filter(Boolean);
96
- if (packagePath.length === 0) {
97
- return null;
98
- }
99
- if (packagePath[0].startsWith("@")) {
100
- return `${packagePath[0]}/${packagePath[1]}`;
101
- }
102
- return packagePath[0];
103
- }
104
- getAbsolutePath(packageName, modulePath) {
105
- if (!packageName) {
106
- return null;
113
+ getPkgVersionFromPath(path) {
114
+ try {
115
+ // Extract the segment right before node_modules in `.pnpm/.../node_modules/`
116
+ const matches = [...path.matchAll(/\.pnpm\/([^/]+)\/node_modules/g)];
117
+ if (!matches.length) return null;
118
+
119
+ const lastPnpmFolder = matches[matches.length - 1][1];
120
+
121
+ // Folder format is `<name>@<version>[_<peers>]`, where `<name>` may be scoped
122
+ // (`@scope+name`, `@scope_name`), so skip the leading `@` of the scope
123
+ const versionMatch = lastPnpmFolder.match(/^@?[^@]+@([^_]+)/);
124
+
125
+ return versionMatch ? versionMatch[1] : null;
126
+ } catch {
127
+ return "";
107
128
  }
108
- return modulePath.substring(
109
- 0,
110
- modulePath.indexOf(packageName) + packageName.length,
111
- );
112
129
  }
130
+
113
131
  getPackages(statsJson) {
114
- const packagesMap = statsJson.modules.reduce((allPackages, mdl) => {
115
- const name = this.getPackageName(mdl.nameForCondition);
116
- if (!name) {
117
- return allPackages;
132
+ const packagesMap = new Map();
133
+
134
+ for (const mdl of statsJson.modules) {
135
+ const filePath = mdl.nameForCondition;
136
+ if (!filePath) {
137
+ continue;
118
138
  }
119
- const absolutePath = this.getAbsolutePath(name, mdl.nameForCondition);
120
- if (!allPackages.has(absolutePath)) {
121
- allPackages.set(absolutePath, {
122
- name,
123
- absolutePath,
124
- version: require(path.join(absolutePath, "package.json")).version,
139
+
140
+ let match;
141
+ let lastMatch = null;
142
+
143
+ // Reset regex state for global matching
144
+ packageRegex.lastIndex = 0;
145
+
146
+ // Find the LAST valid node_modules/<pkg> pattern in the path
147
+ while ((match = packageRegex.exec(filePath)) !== null) {
148
+ lastMatch = match;
149
+ }
150
+
151
+ if (!lastMatch) continue;
152
+
153
+ const packageName = lastMatch[1];
154
+ const matchIndex = lastMatch.index;
155
+
156
+ // Account for potential leading slash in the regex match
157
+ const prefixOffset = filePath[matchIndex] === "/" ? 1 : 0;
158
+ const cutIndex =
159
+ matchIndex + prefixOffset + "node_modules/".length + packageName.length;
160
+
161
+ const absolutePath = filePath.slice(0, cutIndex);
162
+
163
+ let version = "";
164
+ try {
165
+ version = require(path.join(absolutePath, "package.json")).version;
166
+ } catch (e) {
167
+ const verFromPath = this.getPkgVersionFromPath(absolutePath);
168
+ if (verFromPath) {
169
+ version = verFromPath;
170
+ } else {
171
+ if (e?.code === "MODULE_NOT_FOUND") {
172
+ console.warn(`[${NAME}] getPackages: unexpected path resolution`, {
173
+ filePath,
174
+ absolutePath,
175
+ });
176
+ } else {
177
+ throw e;
178
+ }
179
+ }
180
+ }
181
+
182
+ if (!packagesMap.has(absolutePath)) {
183
+ packagesMap.set(absolutePath, {
184
+ name: packageName,
185
+ absolutePath: absolutePath,
186
+ version: version,
125
187
  });
126
188
  }
127
- return allPackages;
128
- }, new Map());
189
+ }
129
190
  return Array.from(packagesMap.values());
130
191
  }
131
192
  getEntryFile(statsJson, rootFolder) {
@@ -166,10 +227,15 @@ class BundleDiscoveryPlugin {
166
227
  statsJson,
167
228
  compilerOptions,
168
229
  );
230
+
169
231
  return {
232
+ kind: "webpack",
170
233
  date: statsJson.builtAt || Date.now(),
171
234
  entryPoint: this.getEntryFile(statsJson, compilerOptions.context),
172
- rootFolder: compilerOptions.context,
235
+ rootFolder:
236
+ this.findCommonRoot(
237
+ statsJson.modules.map((m) => m.nameForCondition).filter(Boolean),
238
+ ) || compilerOptions.context,
173
239
  // envs: {},
174
240
  transformOptions: {
175
241
  // customTransformOptions: {},
@@ -195,7 +261,7 @@ class BundleDiscoveryPlugin {
195
261
  };
196
262
 
197
263
  return {
198
- path: "runtime modules", // Can we rename it?
264
+ path: "__runtime__",
199
265
  source: sourceAndOutput,
200
266
  output: sourceAndOutput,
201
267
  dependencies: [],
@@ -209,7 +275,7 @@ class BundleDiscoveryPlugin {
209
275
  dependencies: modulesDeps.get(mdl.nameForCondition) ?? [],
210
276
  };
211
277
  })
212
- .filter((e) => e !== null),
278
+ .filter((e) => e !== null && e.path),
213
279
  packages: this.getPackages(statsJson),
214
280
  };
215
281
  }
@@ -264,6 +330,44 @@ class BundleDiscoveryPlugin {
264
330
 
265
331
  return importsMap;
266
332
  }
333
+
334
+ findCommonRoot(paths) {
335
+ if (!paths || paths.length === 0) return "";
336
+ if (paths.length === 1) {
337
+ const lastSlash = paths[0].lastIndexOf("/");
338
+ return lastSlash === -1 ? "" : paths[0].slice(0, lastSlash);
339
+ }
340
+
341
+ const first = paths[0];
342
+ let maxLen = first.length;
343
+
344
+ // Short-circuit scanning loop across all paths
345
+ for (let i = 1; i < paths.length; i++) {
346
+ const current = paths[i];
347
+ const limit = Math.min(maxLen, current.length);
348
+ let j = 0;
349
+
350
+ // Fast character match using engine string optimizations
351
+ while (j < limit && first.charCodeAt(j) === current.charCodeAt(j)) {
352
+ j++;
353
+ }
354
+
355
+ maxLen = j;
356
+ if (maxLen === 0) return "";
357
+ }
358
+
359
+ // Exact match down to the root or full path segment
360
+ if (maxLen === first.length) {
361
+ const lastSlash = first.lastIndexOf("/");
362
+ return lastSlash === -1 ? "" : first.slice(0, lastSlash);
363
+ }
364
+
365
+ // Truncate to the last valid '/' boundary within maxLen
366
+ const commonSlice = first.slice(0, maxLen);
367
+ const lastSlash = commonSlice.lastIndexOf("/");
368
+
369
+ return lastSlash === -1 ? "" : commonSlice.slice(0, lastSlash);
370
+ }
267
371
  }
268
372
 
269
373
  module.exports = { BundleDiscoveryPlugin };