react-native-bundle-discovery 2.2.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 +12 -1
- package/index.js +35 -8
- package/package.json +5 -3
- package/packageMetadata.js +155 -0
- package/parseUtils.js +615 -0
- package/webpack.js +373 -0
package/README.md
CHANGED
|
@@ -15,6 +15,8 @@ With this tool, you can easily explore your app's codebase, identify large or he
|
|
|
15
15
|
- `react-native-bundle-discovery-cli` - CLI to analyze the bundle report.
|
|
16
16
|
- `react-native-bundle-discovery-rozenite-plugin` - Rozenite plugin to integrate UI tool to [React Native DevTools](https://reactnative.dev/docs/react-native-devtools).
|
|
17
17
|
|
|
18
|
+
---
|
|
19
|
+
|
|
18
20
|
### Setup:
|
|
19
21
|
|
|
20
22
|
There are two ways to install the package:
|
|
@@ -22,6 +24,11 @@ There are two ways to install the package:
|
|
|
22
24
|
1. As in independent tool (UI + CLI)
|
|
23
25
|
2. Or as a [Rozenite](_rozenite/README.md) plugin (see: [_rozenite/README.md](_rozenite/README.md))
|
|
24
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
|
+
|
|
25
32
|
#### 1. Install (independent tool)
|
|
26
33
|
|
|
27
34
|
```bash
|
|
@@ -60,7 +67,7 @@ See: [_rozenite/README.md](_rozenite/README.md)
|
|
|
60
67
|
|
|
61
68
|
### 3. Build the app
|
|
62
69
|
|
|
63
|
-
|
|
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:
|
|
64
71
|
|
|
65
72
|
```bash
|
|
66
73
|
npx react-native bundle \
|
|
@@ -83,6 +90,10 @@ npx react-native-bundle-discovery-cli metro-stats.json
|
|
|
83
90
|
|
|
84
91
|
# Display all packages in the bundle report
|
|
85
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]
|
|
86
97
|
```
|
|
87
98
|
|
|
88
99
|
#### UI package
|
package/index.js
CHANGED
|
@@ -2,7 +2,8 @@ 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
|
+
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
|
|
135
|
-
modules
|
|
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
|
}
|
|
@@ -268,7 +294,8 @@ const createProcessModuleFilter =
|
|
|
268
294
|
};
|
|
269
295
|
|
|
270
296
|
module.exports = {
|
|
271
|
-
createResolveRequest,
|
|
272
297
|
createSerializer,
|
|
298
|
+
createResolveRequest,
|
|
273
299
|
createProcessModuleFilter,
|
|
300
|
+
BundleDiscoveryPlugin,
|
|
274
301
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-bundle-discovery",
|
|
3
|
-
"version": "2.
|
|
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>",
|
|
@@ -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
|
-
"
|
|
25
|
+
"*.js"
|
|
24
26
|
]
|
|
25
27
|
}
|
|
@@ -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/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,373 @@
|
|
|
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 { withPackagesMetadata } = require("./packageMetadata.js");
|
|
8
|
+
const NAME = require("./package.json").name;
|
|
9
|
+
const packageRegex = /(?:^|\/)node_modules\/((?:@[^\/]+\/)?[^\/]+)/g;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Simple Webpack/Rspack plugin
|
|
13
|
+
* to adapt stats object https://webpack.js.org/api/stats/
|
|
14
|
+
* to metro-stats.json format used by `react-native-bundle-discovery`
|
|
15
|
+
*/
|
|
16
|
+
class BundleDiscoveryPlugin {
|
|
17
|
+
constructor({ filename, options, enabled, fetchPackagesMetadata } = {}) {
|
|
18
|
+
this.options = options;
|
|
19
|
+
this.filename = filename || "metro-stats.json";
|
|
20
|
+
this.enabled = enabled ?? true;
|
|
21
|
+
this.fetchPackagesMetadata = fetchPackagesMetadata ?? true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
extractSizesFromJsBundle(statAsset, options) {
|
|
25
|
+
const assetFile = path.join(options.output.path, statAsset.name);
|
|
26
|
+
try {
|
|
27
|
+
return parseBundle(assetFile, {
|
|
28
|
+
sourceType: statAsset.info.javascriptModule ? "module" : "script",
|
|
29
|
+
});
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
apply(compiler) {
|
|
36
|
+
if (!this.enabled) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
compiler.hooks.done.tap("GenerateStatsJsonPlugin", (stats) => {
|
|
41
|
+
// Extract the relevant information from the stats object
|
|
42
|
+
const json = stats.toJson({
|
|
43
|
+
version: true,
|
|
44
|
+
builtAt: true,
|
|
45
|
+
modules: true,
|
|
46
|
+
reasons: true,
|
|
47
|
+
assets: true,
|
|
48
|
+
ids: true,
|
|
49
|
+
...this.options,
|
|
50
|
+
all: false,
|
|
51
|
+
source: true,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Format the stats into the desired structure
|
|
55
|
+
const statsJson = this.formatStat(
|
|
56
|
+
json,
|
|
57
|
+
// Parse JS bundle to extract modules sizes
|
|
58
|
+
// WANR: The stats asset list can contain multiple emitted files and is not guaranteed to put the JavaScript bundle first (and can be empty after a failed compilation)
|
|
59
|
+
this.extractSizesFromJsBundle(json.assets[0], compiler.options),
|
|
60
|
+
compiler.options,
|
|
61
|
+
);
|
|
62
|
+
// Write the formatted stats to the specified output file
|
|
63
|
+
const outputPath = path.resolve(compiler.options.context, this.filename);
|
|
64
|
+
|
|
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));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
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
|
+
|
|
97
|
+
readSource(filePath) {
|
|
98
|
+
const fallback = { code: "", lineCount: 0, sizeInBytes: 0 };
|
|
99
|
+
try {
|
|
100
|
+
const code = fs.readFileSync(filePath, "utf-8");
|
|
101
|
+
const lineCount = code.split("\n").length;
|
|
102
|
+
const sizeInBytes = Buffer.byteLength(code, "utf-8");
|
|
103
|
+
return {
|
|
104
|
+
code: this.options?.source === false ? "" : code,
|
|
105
|
+
lineCount,
|
|
106
|
+
sizeInBytes,
|
|
107
|
+
};
|
|
108
|
+
} catch {
|
|
109
|
+
return fallback;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
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 "";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
getPackages(statsJson) {
|
|
132
|
+
const packagesMap = new Map();
|
|
133
|
+
|
|
134
|
+
for (const mdl of statsJson.modules) {
|
|
135
|
+
const filePath = mdl.nameForCondition;
|
|
136
|
+
if (!filePath) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
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,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return Array.from(packagesMap.values());
|
|
191
|
+
}
|
|
192
|
+
getEntryFile(statsJson, rootFolder) {
|
|
193
|
+
const fallbackValue = path.join(rootFolder, "index.js");
|
|
194
|
+
return (
|
|
195
|
+
statsJson.modules.find(
|
|
196
|
+
(mdl) =>
|
|
197
|
+
!mdl.nameForCondition?.includes("node_modules/") &&
|
|
198
|
+
mdl.reasons?.length === 1 &&
|
|
199
|
+
mdl.reasons?.[0].type === "entry",
|
|
200
|
+
)?.nameForCondition || fallbackValue
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
getModuleOutput(mdl, bundleModules) {
|
|
204
|
+
const jsCodeStrFromJSBundle = bundleModules?.modules?.[mdl.id];
|
|
205
|
+
if (jsCodeStrFromJSBundle) {
|
|
206
|
+
const formatted =
|
|
207
|
+
jsCodeStrFromJSBundle.startsWith("function(") &&
|
|
208
|
+
jsCodeStrFromJSBundle.endsWith("}")
|
|
209
|
+
? jsCodeStrFromJSBundle.replace("function(", "function module(")
|
|
210
|
+
: jsCodeStrFromJSBundle;
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
code: formatted,
|
|
214
|
+
lineCount: formatted.split("\n").length ?? 0,
|
|
215
|
+
sizeInBytes: Buffer.byteLength(jsCodeStrFromJSBundle, "utf-8"),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
code: mdl.source ?? "",
|
|
220
|
+
lineCount: mdl.source?.split("\n").length ?? 0,
|
|
221
|
+
sizeInBytes: mdl.size ?? 0,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
formatStat(statsJson, bundleModules, compilerOptions) {
|
|
226
|
+
const modulesDeps = this.buildImportsMapFromReasons(
|
|
227
|
+
statsJson,
|
|
228
|
+
compilerOptions,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
kind: "webpack",
|
|
233
|
+
date: statsJson.builtAt || Date.now(),
|
|
234
|
+
entryPoint: this.getEntryFile(statsJson, compilerOptions.context),
|
|
235
|
+
rootFolder:
|
|
236
|
+
this.findCommonRoot(
|
|
237
|
+
statsJson.modules.map((m) => m.nameForCondition).filter(Boolean),
|
|
238
|
+
) || compilerOptions.context,
|
|
239
|
+
// envs: {},
|
|
240
|
+
transformOptions: {
|
|
241
|
+
// customTransformOptions: {},
|
|
242
|
+
dev: compilerOptions.mode !== "production",
|
|
243
|
+
minify: compilerOptions.optimization?.minimize ?? false,
|
|
244
|
+
platform: compilerOptions.name ?? "ios",
|
|
245
|
+
// type: "module",
|
|
246
|
+
// unstable_transformProfile: "default",
|
|
247
|
+
},
|
|
248
|
+
modules: statsJson.modules
|
|
249
|
+
.map((mdl) => {
|
|
250
|
+
if (mdl.type === "runtime modules") {
|
|
251
|
+
if (!bundleModules) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
const sourceAndOutput = {
|
|
255
|
+
code: bundleModules.runtimeSrc ?? "",
|
|
256
|
+
lineCount: bundleModules.runtimeSrc?.split("\n").length ?? 0,
|
|
257
|
+
sizeInBytes: Buffer.byteLength(
|
|
258
|
+
bundleModules.runtimeSrc ?? "",
|
|
259
|
+
"utf-8",
|
|
260
|
+
),
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
path: "__runtime__",
|
|
265
|
+
source: sourceAndOutput,
|
|
266
|
+
output: sourceAndOutput,
|
|
267
|
+
dependencies: [],
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
path: mdl.nameForCondition,
|
|
273
|
+
source: this.readSource(mdl.nameForCondition),
|
|
274
|
+
output: this.getModuleOutput(mdl, bundleModules),
|
|
275
|
+
dependencies: modulesDeps.get(mdl.nameForCondition) ?? [],
|
|
276
|
+
};
|
|
277
|
+
})
|
|
278
|
+
.filter((e) => e !== null && e.path),
|
|
279
|
+
packages: this.getPackages(statsJson),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
buildImportsMapFromReasons(statsJson, compilerOptions) {
|
|
284
|
+
const modules = Array.isArray(statsJson?.modules) ? statsJson.modules : [];
|
|
285
|
+
const importsMap = new Map();
|
|
286
|
+
|
|
287
|
+
const getAbs = (p) => {
|
|
288
|
+
if (!p || typeof p !== "string") return null;
|
|
289
|
+
if (path.isAbsolute(p)) return p;
|
|
290
|
+
return compilerOptions?.context
|
|
291
|
+
? path.resolve(compilerOptions?.context, p)
|
|
292
|
+
: p;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const getModulePath = (m) =>
|
|
296
|
+
m?.path || m?.nameForCondition || m?.identifier || null;
|
|
297
|
+
|
|
298
|
+
for (const depModule of modules) {
|
|
299
|
+
const depAbsolutePath = getAbs(getModulePath(depModule));
|
|
300
|
+
if (!depAbsolutePath) continue;
|
|
301
|
+
|
|
302
|
+
const reasons = Array.isArray(depModule.reasons) ? depModule.reasons : [];
|
|
303
|
+
for (const reason of reasons) {
|
|
304
|
+
if (reason?.type === "entry") continue;
|
|
305
|
+
|
|
306
|
+
// importer (who imports current dep module)
|
|
307
|
+
const importerPath = getAbs(
|
|
308
|
+
reason?.modulePath ||
|
|
309
|
+
reason?.moduleNameForCondition ||
|
|
310
|
+
reason?.resolvedModule ||
|
|
311
|
+
reason?.moduleIdentifier,
|
|
312
|
+
);
|
|
313
|
+
if (!importerPath) continue;
|
|
314
|
+
|
|
315
|
+
if (!importsMap.has(importerPath)) {
|
|
316
|
+
importsMap.set(importerPath, []);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const deps = importsMap.get(importerPath);
|
|
320
|
+
|
|
321
|
+
// uniq by absolutePath
|
|
322
|
+
if (!deps.some((d) => d.absolutePath === depAbsolutePath)) {
|
|
323
|
+
deps.push({
|
|
324
|
+
absolutePath: depAbsolutePath,
|
|
325
|
+
name: reason?.userRequest || "",
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return importsMap;
|
|
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
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
module.exports = { BundleDiscoveryPlugin };
|