Skip to content

Commit 9fc233a

Browse files
committed
Initial commit 🎉
0 parents  commit 9fc233a

25 files changed

Lines changed: 14972 additions & 0 deletions

.discoveryrc.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
const path = require("path");
2+
3+
module.exports = {
4+
name: "react-native-bundle-discovery",
5+
data: () => require("./tmp/before_metro-stats.json"),
6+
setup: path.resolve(__dirname, "setup.js"),
7+
view: {
8+
assets: [
9+
// Global styles
10+
path.resolve(__dirname, "views/global.css"),
11+
// Pages
12+
path.resolve(__dirname, "pages/default.js"),
13+
path.resolve(__dirname, "pages/module.js"),
14+
path.resolve(__dirname, "pages/package.js"),
15+
// Custom views
16+
path.resolve(__dirname, "views/highcharts.css"),
17+
path.resolve(__dirname, "views/highcharts.js"),
18+
path.resolve(__dirname, "views/foamtree.js"),
19+
],
20+
},
21+
};

.gitignore

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
node_modules/
2+
build/
3+
tmp/
4+
.DS_Store
5+
6+
# Yarn v4
7+
.yarn/*
8+
!.yarn/patches
9+
!.yarn/plugins
10+
!.yarn/releases
11+
!.yarn/sdks
12+
!.yarn/versions
13+
14+
# Ignore Yarn v4 cache
15+
.yarn/cache
16+
17+
# Ignore Yarn v4 build state
18+
.yarn/build-state.yml
19+
20+
# Ignore Yarn v4 install state
21+
.yarn/install-state.gz
22+
23+
# Ignore Yarn v4 log files
24+
.yarn/unplugged
25+
26+
# Ignore PnP files
27+
.pnp.*

.yarn/releases/yarn-4.6.0.cjs

Lines changed: 934 additions & 0 deletions
Large diffs are not rendered by default.

.yarnrc.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
nodeLinker: node-modules
2+
3+
yarnPath: .yarn/releases/yarn-4.6.0.cjs

README.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# react-native-bundle-discovery
2+
3+
> [!WARNING]
4+
> Currently, everything is in a very early stage. The project is not yet ready for use.
5+
6+
7+
A simple package that helps developers visualize and analyze the bundle size of React Native apps.
8+
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+
10+
<img width="800" alt="" src="./assets/img.png" />
11+
12+
### Setup:
13+
14+
#### 1. Install
15+
```bash
16+
yarn add -D react-native-bundle-discovery
17+
```
18+
19+
#### 2. Add to your metro.config.js
20+
21+
```diff
22+
// metro.config.js
23+
const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
24+
+const {createSerializer} = require('react-native-bundle-discovery');
25+
26+
+const mySerializer = createSerializer({
27+
+ includeCode: true, // Useful if you want to compare source/bundle code (but a report file will be larger)
28+
+ projectRoot: __dirname,
29+
+ //^^^ ⚠️ WARNING: In a monorepo setup, this should point to the monorepo root,
30+
+ // not the individual package directory.
31+
+});
32+
33+
-const config = {};
34+
+const config = {
35+
+ serializer: {
36+
+ customSerializer: mySerializer
37+
+ },
38+
+};
39+
40+
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
41+
```
42+
43+
#### 3. Build the app
44+
45+
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:
46+
47+
```bash
48+
npx react-native bundle \
49+
--entry-file index.js \
50+
--platform ios \
51+
--dev false \
52+
--bundle-output ios/main.jsbundle \
53+
--assets-dest ios/assets
54+
```
55+
56+
#### 4. View the report
57+
58+
Run webserver to view the report:
59+
60+
```bash
61+
npx react-native-bundle-discovery metro-stats.json
62+
```
63+
64+
---
65+
66+
**Similar projects:**
67+
68+
- https://github.com/expo/atlas
69+
- https://github.com/v3ron/expo-atlas-without-expo
70+
- https://github.com/callstack/react-native-bundle-visualizer
71+
- https://github.com/webpack-contrib/webpack-bundle-analyzer
72+
- https://github.com/statoscope/statoscope
73+
- https://github.com/relative-ci/bundle-stats/tree/master/packages/cli
74+
75+
---
76+
77+
**Built using Discovery.js:**
78+
- Build blocks for pages: https://discoveryjs.github.io/discovery/#views-showcase
79+
- Jora syntax: https://discoveryjs.github.io/jora/#article:jora-syntax-operators

TODO.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
2+
**TODO:**
3+
- [x] Search/filters
4+
- [x] bug: Code size is diff. treemap != badge info (BECAUSE no duplicated modules in treemap)
5+
- [ ] Diff page (to compare two versions bundle)
6+
- [ ] Map based on the coverage (https://x.com/chromedevtools/status/1095411723161354240?lang=en)
7+
- Static website (like https://statoscope.tech)
8+
- [ ] website
9+
- [ ] auto deploy
10+
- [ ] drag and drop `stats.json`
11+
- [x] Large tree maps https://www.highcharts.com/demo/highcharts/treemap-large-dataset or https://github.com/evmar/webtreemap
12+
- Reports:
13+
- [ ] top 10 largest modules
14+
- [ ] top 10 largest files
15+
- [ ] Dead code detection (as example ios only code in an android bundle, never used exports, etc ...)
16+
- [x] Duplicate modules detection
17+
- [ ] Custom reports
18+
- CI/CD reports
19+
- [ ] changes in bundle size (size, files, modules)

assets/img.png

571 KB
Loading

index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require("./lib/customSerializer");

lib/bin.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
#!/usr/bin/env node
2+
const fs = require("fs");
3+
const path = require("path");
4+
const chalk = require("chalk");
5+
const { createServer } = require("@discoveryjs/cli");
6+
const config = require("../.discoveryrc.js");
7+
8+
const filePath = process.argv[2];
9+
10+
if (!filePath) {
11+
console.error(
12+
`Usage: '${chalk.green("npx react-native-bundle-discovery <path-to-file>")}', Please provide a path to a JSON file.`,
13+
);
14+
process.exit(1);
15+
}
16+
17+
const jsonFilePath = path.resolve(process.cwd(), filePath);
18+
19+
let fullJsonPath;
20+
21+
try {
22+
fullJsonPath = require.resolve(jsonFilePath);
23+
} catch (err) {
24+
console.error(`Error loading file: ${chalk.red(jsonFilePath)}`);
25+
console.error(err.message);
26+
process.exit(1);
27+
}
28+
29+
const PORT = process.env.PORT || 8079;
30+
31+
const configFile = path.resolve(__dirname, "./.tmp.js");
32+
33+
fs.writeFileSync(
34+
configFile,
35+
`module.exports = ${JSON.stringify(
36+
{ ...config, data: "<tmp>" },
37+
null,
38+
1,
39+
).replace(`"<tmp>"`, `() => require("${fullJsonPath}")`)};`,
40+
);
41+
42+
createServer({
43+
cache: false,
44+
minify: true,
45+
dev: false,
46+
config: configFile,
47+
configFile,
48+
}).then((server) =>
49+
server.listen(PORT, () =>
50+
console.log(`Server listen on ${chalk.green(`http://localhost:${PORT}`)}`),
51+
),
52+
);

lib/customSerializer.js

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
const { writeFileSync, existsSync } = require("fs");
2+
const { resolve } = require("path");
3+
const { Buffer } = require("buffer");
4+
const chalk = require("chalk");
5+
6+
const NAME = require("../package.json").name;
7+
8+
function getDefaultSerializer() {
9+
const bundleToString = require("metro/src/lib/bundleToString");
10+
const baseJSBundle = require("metro/src/DeltaBundler/Serializers/baseJSBundle");
11+
12+
return (entryPoint, preModules, graph, options) =>
13+
bundleToString(baseJSBundle(entryPoint, preModules, graph, options)).code;
14+
}
15+
16+
function getStringSizeInBytes(str) {
17+
return Buffer.byteLength(str, "utf8");
18+
}
19+
20+
/**
21+
* `/Users/i/app/node_modules/metro/node_modules/@babel/runtime/helpers/createClass.js` -> `@babel/runtime`
22+
* `/Users/i/app/node_modules/react/jsx-runtime.js` -> `react`
23+
*/
24+
function getPackageNameFromPath(path) {
25+
const parts = path.split("node_modules/");
26+
const lastPart = parts[parts.length - 1];
27+
if (lastPart.startsWith("@")) {
28+
return lastPart.split("/").slice(0, 2).join("/");
29+
}
30+
return lastPart.split("/")[0];
31+
}
32+
33+
/**
34+
* `/Users/i/app/node_modules/metro/node_modules/@babel/runtime/helpers/createClass.js` -> `/Users/i/app/node_modules/metro/node_modules/@babel/runtime`
35+
* `/Users/i/app/node_modules/react/jsx-runtime.js` -> `/Users/i/app/node_modules/react`
36+
*/
37+
function getPackageAbsolutePath(path, pkgName) {
38+
const parts = path.split("node_modules/");
39+
parts[parts.length - 1] = pkgName;
40+
return parts.join("node_modules/");
41+
}
42+
43+
function toPackages(modules) {
44+
const packages = new Map();
45+
modules.forEach((module) => {
46+
if (!module.path.includes("node_modules/")) {
47+
return;
48+
}
49+
50+
const pkgName = getPackageNameFromPath(module.path);
51+
const absolutePkgPath = getPackageAbsolutePath(module.path, pkgName);
52+
53+
if (!packages.has(absolutePkgPath)) {
54+
packages.set(absolutePkgPath, {
55+
name: pkgName,
56+
absolutePath: absolutePkgPath,
57+
version: require(resolve(absolutePkgPath, "package.json")).version,
58+
});
59+
}
60+
});
61+
62+
return Array.from(packages.values()).sort((a, b) =>
63+
a.name.localeCompare(b.name),
64+
);
65+
}
66+
67+
function toModuleStruct(m, includeCode) {
68+
const sourceCode = m.getSource().toString("utf8");
69+
const outputCode = m.output[0].data.code;
70+
return {
71+
path: m.path,
72+
source: {
73+
code: includeCode ? sourceCode : "",
74+
lineCount: sourceCode.split("\n").length,
75+
sizeInBytes: getStringSizeInBytes(sourceCode),
76+
},
77+
output: {
78+
code: includeCode ? outputCode : "",
79+
lineCount: m.output[0].data.lineCount,
80+
sizeInBytes: getStringSizeInBytes(outputCode),
81+
},
82+
dependencies: Array.from(m?.dependencies?.values?.() ?? []).map((e) => ({
83+
absolutePath: e.absolutePath,
84+
name: e.data.name,
85+
})),
86+
};
87+
}
88+
89+
function createJsonReport({
90+
graph,
91+
entryPoint,
92+
includeEnvs,
93+
preModules,
94+
includeCode,
95+
outputJsonPath,
96+
rootFolder,
97+
}) {
98+
const dependencies = Array.from(graph.dependencies.values());
99+
100+
const stats = {
101+
date: Date.now(),
102+
entryPoint,
103+
transformOptions: graph.transformOptions,
104+
envs: includeEnvs.reduce((acc, envName) => {
105+
acc[envName] = process.env[envName];
106+
return acc;
107+
}, {}),
108+
rootFolder,
109+
packages: toPackages(dependencies),
110+
modules: preModules
111+
.map((m) => toModuleStruct(m, includeCode))
112+
.concat(dependencies.map((m) => toModuleStruct(m, includeCode))),
113+
};
114+
115+
writeFileSync(outputJsonPath, JSON.stringify(stats));
116+
117+
console.log(
118+
`${chalk.yellow(`[${NAME}]`)}: Saved stats to ${chalk.green(outputJsonPath)}`,
119+
);
120+
}
121+
122+
/**
123+
* Creates a custom serializer function for Metro bundler, which generates a JSON report
124+
* and optionally modifies the serialization process.
125+
*
126+
* @param {Object} options - Configuration options for the serializer.
127+
* @param {Function} [options.serializer] - A custom serializer function. If not provided, a default serializer is used.
128+
* @param {string} options.projectRoot - The root directory of the project. Must exist.
129+
* @param {string} [options.outputJsonPath] - The path where the JSON report will be saved. Defaults to "metro-stats.json" in the project root.
130+
* @param {boolean} [options.includeCode=true] - Whether to include the source and output code in the JSON report.
131+
* @param {string[]} [options.includeEnvs=[]] - A list of environment variable names to include in the JSON report.
132+
* @returns {Function} - A custom serializer function to be used by Metro.
133+
* @throws {Error} - Throws an error if the project root does not exist.
134+
*/
135+
function createSerializer({
136+
serializer,
137+
projectRoot,
138+
outputJsonPath,
139+
includeCode = true,
140+
includeEnvs = [],
141+
} = {}) {
142+
const mySerializer = serializer || getDefaultSerializer();
143+
144+
if (!existsSync(projectRoot)) {
145+
throw new Error(`[${NAME}]: Project root does not exist: ${projectRoot}`);
146+
}
147+
148+
const myOutputJsonPath =
149+
outputJsonPath ?? resolve(projectRoot, "metro-stats.json");
150+
151+
function customSerializer(entryPoint, preModules, graph, options) {
152+
const code = mySerializer(entryPoint, preModules, graph, options);
153+
154+
createJsonReport({
155+
graph,
156+
entryPoint,
157+
includeEnvs,
158+
preModules,
159+
includeCode,
160+
outputJsonPath: myOutputJsonPath,
161+
rootFolder: projectRoot,
162+
});
163+
164+
return code;
165+
}
166+
167+
return customSerializer;
168+
}
169+
170+
module.exports = { createSerializer };

0 commit comments

Comments
 (0)