Skip to content

Commit 7b93de3

Browse files
Copilottido64
andcommitted
feat: add @rnx-kit/esbuild-service - Metro-independent esbuild bundler for React Native
Co-authored-by: tido64 <4123478+tido64@users.noreply.github.com>
1 parent 33a9573 commit 7b93de3

18 files changed

Lines changed: 1066 additions & 0 deletions
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# @rnx-kit/esbuild-service
2+
3+
[![Build](https://github.com/microsoft/rnx-kit/actions/workflows/build.yml/badge.svg)](https://github.com/microsoft/rnx-kit/actions/workflows/build.yml)
4+
5+
🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧
6+
7+
### This tool is EXPERIMENTAL - USE WITH CAUTION
8+
9+
🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧🚧
10+
11+
A Metro-independent, esbuild-based bundler for React Native.
12+
13+
## Motivation: Metro vs. esbuild
14+
15+
[Metro](https://facebook.github.io/metro/) is the standard bundler for React
16+
Native. It is reliable, battle-tested, and deeply integrated into the React
17+
Native toolchain. However, Metro was designed around CommonJS semantics and
18+
Babel transformations. This makes it slower at scale and harder to integrate
19+
with modern tooling.
20+
21+
[esbuild](https://esbuild.github.io/) is an extremely fast JavaScript bundler
22+
written in Go. It handles TypeScript and JSX natively, provides excellent tree-
23+
shaking, and produces source maps with minimal overhead.
24+
25+
This package explores using esbuild as a **complete replacement for Metro**
26+
rather than just its serialization step (which is what
27+
[`@rnx-kit/metro-serializer-esbuild`](../packages/metro-serializer-esbuild)
28+
does).
29+
30+
---
31+
32+
## Metro component analysis
33+
34+
The table below maps each Metro component to its esbuild equivalent and
35+
explains how much code must be reimplemented.
36+
37+
| Metro component | Can esbuild replace it? | Notes |
38+
|---|---|---|
39+
| **Transformer** (Babel / Flow) | ✅ Yes — natively | esbuild supports TypeScript and JSX out of the box. Flow types can be stripped with a simple plugin. Babel is no longer needed for the common case. |
40+
| **Dependency graph** | ✅ Yes — natively | esbuild builds its own dependency graph as part of bundling. |
41+
| **Tree-shaking** | ✅ Yes — natively | esbuild performs dead code elimination (DCE) automatically for ESM code. |
42+
| **Minifier** | ✅ Yes — natively | esbuild has a built-in, high-performance minifier. |
43+
| **Source maps** | ✅ Yes — natively | esbuild generates linked or inline source maps. |
44+
| **Serializer** | ✅ Yes — natively | esbuild produces the final bundle; this is the role of `metro-serializer-esbuild`. |
45+
| **Resolver** (platform extensions, `react-native` field) | ⚠️ Plugin required | The `reactNativeResolver` plugin in this package reimplements Metro's platform-extension resolution (`.ios.js`, `.android.js`, `.native.js`) and the `react-native``module``browser``main` field priority from `package.json`. ~250 lines of code. |
46+
| **Pre-modules / polyfills** | ⚠️ Plugin required | The `reactNativePolyfills` plugin reimplements Metro's `preModules` mechanism by injecting a virtual entry-point that sets up `global`, `__DEV__`, and any user-provided polyfills. ~110 lines of code. |
47+
| **Asset handling** | ⚠️ Plugin required | Metro's asset system resolves image/font imports to an asset registry lookup. An esbuild plugin can replicate this, but it is not yet included in this package. |
48+
| **Dev server + HMR** | ❌ Cannot replace | Metro's development server implements React Native's fast-refresh / HMR protocol. esbuild has a basic HTTP server mode but no HMR support. |
49+
| **RAM bundles** | ❌ Cannot replace | Metro's indexed RAM bundle format has no esbuild equivalent. |
50+
| **Lazy module loading** | ❌ Cannot replace | Metro's async require / lazy-loading mechanism requires a custom module loader runtime that esbuild does not provide. |
51+
52+
### Code reuse from `@rnx-kit/metro-serializer-esbuild`
53+
54+
| Component | Reuse? | Notes |
55+
|---|---|---|
56+
| `targets.ts` — Hermes target inference | ✅ Copied | Identical logic; infers the right `hermesX.Y` esbuild target from the installed `react-native` version. |
57+
| `getSideEffects` from `module.ts` | ✅ Concept reused | The `sideEffects` package.json field logic applies equally to a standalone esbuild bundler; esbuild respects it natively through its own side-effects handling. |
58+
| `esbuildTransformerConfig` | ❌ Not applicable | That export configures Metro's Babel transformer to be esbuild-friendly. It is not relevant when Metro is removed entirely. |
59+
| `index.ts` — the custom serializer | ❌ Not applicable | The serializer depends on Metro's dependency graph API and cannot be reused. |
60+
| `sourceMap.ts` — Metro source map helpers | ❌ Not applicable | These helpers wrap Metro's source-map utilities; not needed without Metro. |
61+
62+
---
63+
64+
## Installation
65+
66+
```sh
67+
yarn add --dev @rnx-kit/esbuild-service
68+
```
69+
70+
## Usage
71+
72+
```typescript
73+
import { bundle } from "@rnx-kit/esbuild-service";
74+
75+
await bundle({
76+
entryFile: "index.js",
77+
platform: "ios",
78+
dev: false,
79+
bundleOutput: "dist/main.ios.jsbundle",
80+
sourcemapOutput: "dist/main.ios.jsbundle.map",
81+
});
82+
```
83+
84+
## API
85+
86+
### `bundle(options)`
87+
88+
Bundles a React Native application using esbuild, without Metro.
89+
90+
#### Options
91+
92+
| Option | Type | Default | Description |
93+
|---|---|---|---|
94+
| `entryFile` | `string` | required | Path to the entry file. |
95+
| `platform` | `AllPlatforms` | required | Target platform (`android`, `ios`, `macos`, `windows`, …). |
96+
| `dev` | `boolean` | `false` | Bundle in development mode. |
97+
| `minify` | `boolean` | `!dev` | Minify the output. |
98+
| `bundleOutput` | `string` | required | Path to write the bundle to. |
99+
| `sourcemapOutput` | `string` || Path to write the source map to. |
100+
| `target` | `string \| string[]` | Auto-detected | esbuild target (e.g. `"hermes0.12"`). |
101+
| `plugins` | `Plugin[]` | `[]` | Extra esbuild plugins. |
102+
| `projectRoot` | `string` | `process.cwd()` | Project root directory. |
103+
| `logLevel` | esbuild log level | `"warning"` | esbuild log level. |
104+
| `drop` | esbuild drop || Drop `debugger` or `console` calls. |
105+
| `pure` | `string[]` || Mark calls as side-effect free. |
106+
107+
### `reactNativeResolver(platform, mainFields?)`
108+
109+
An esbuild plugin that adds React Native–specific module resolution:
110+
111+
- Platform-specific file extensions (`.ios.ts`, `.android.ts`, `.native.ts`, …)
112+
- `react-native``module``browser``main` field priority in `package.json`
113+
114+
```typescript
115+
import { reactNativeResolver } from "@rnx-kit/esbuild-service";
116+
import * as esbuild from "esbuild";
117+
118+
await esbuild.build({
119+
entryPoints: ["index.ts"],
120+
bundle: true,
121+
plugins: [reactNativeResolver("ios")],
122+
outfile: "dist/bundle.js",
123+
});
124+
```
125+
126+
### `reactNativePolyfills(options)`
127+
128+
An esbuild plugin that injects React Native globals (`global`, `__DEV__`) and
129+
optional polyfills as a virtual entry-point before your application code.
130+
131+
```typescript
132+
import { reactNativePolyfills } from "@rnx-kit/esbuild-service";
133+
import * as esbuild from "esbuild";
134+
135+
await esbuild.build({
136+
entryPoints: ["index.ts"],
137+
bundle: true,
138+
plugins: [
139+
reactNativePolyfills({
140+
entryFile: "index.ts",
141+
dev: false,
142+
polyfills: ["./polyfills/myPolyfill.js"],
143+
}),
144+
],
145+
outfile: "dist/bundle.js",
146+
});
147+
```
148+
149+
### `inferBuildTarget(projectRoot?)`
150+
151+
Infers the appropriate esbuild target string for the installed version of
152+
`react-native` / Hermes.
153+
154+
```typescript
155+
import { inferBuildTarget } from "@rnx-kit/esbuild-service";
156+
157+
const target = inferBuildTarget(); // e.g. "hermes0.12"
158+
```
159+
160+
## Known Limitations
161+
162+
- **Dev server / HMR** — use Metro for development; this package targets
163+
production bundling only.
164+
- **RAM bundles** — not supported. Use Metro if you need indexed RAM bundles.
165+
- **Asset handling** — image and font imports are not yet handled. Contributions
166+
welcome.
167+
- **Flow types** — esbuild cannot strip Flow types natively. You'll need a Flow-
168+
stripping Babel transform or a third-party esbuild plugin if your code uses
169+
Flow.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
{
2+
"name": "@rnx-kit/esbuild-service",
3+
"version": "0.0.1",
4+
"description": "EXPERIMENTAL - USE WITH CAUTION - esbuild-based bundler for React Native (Metro-independent)",
5+
"homepage": "https://github.com/microsoft/rnx-kit/tree/main/incubator/esbuild-service#readme",
6+
"license": "MIT",
7+
"author": {
8+
"name": "Microsoft Open Source",
9+
"email": "microsoftopensource@users.noreply.github.com"
10+
},
11+
"repository": {
12+
"type": "git",
13+
"url": "https://github.com/microsoft/rnx-kit",
14+
"directory": "incubator/esbuild-service"
15+
},
16+
"files": [
17+
"lib/**/*.d.ts",
18+
"lib/**/*.js"
19+
],
20+
"main": "lib/index.js",
21+
"types": "lib/index.d.ts",
22+
"type": "module",
23+
"exports": {
24+
".": {
25+
"types": "./lib/index.d.ts",
26+
"typescript": "./src/index.ts",
27+
"default": "./lib/index.js"
28+
},
29+
"./package.json": "./package.json"
30+
},
31+
"scripts": {
32+
"build": "rnx-kit-scripts build",
33+
"format": "rnx-kit-scripts format",
34+
"lint": "rnx-kit-scripts lint",
35+
"test": "rnx-kit-scripts test"
36+
},
37+
"dependencies": {
38+
"@rnx-kit/tools-node": "^3.0.4",
39+
"@rnx-kit/tools-react-native": "^2.3.3",
40+
"@rnx-kit/types-bundle-config": "^1.0.0",
41+
"esbuild": "^0.27.1"
42+
},
43+
"devDependencies": {
44+
"@rnx-kit/scripts": "*",
45+
"@rnx-kit/tsconfig": "*",
46+
"@types/node": "^24.0.0",
47+
"react-native": "^0.83.0"
48+
},
49+
"engines": {
50+
"node": ">=18.12"
51+
},
52+
"experimental": true
53+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import * as esbuild from "esbuild";
2+
import * as fs from "node:fs";
3+
import * as path from "node:path";
4+
import { reactNativePolyfills } from "./plugins/polyfills.ts";
5+
import { reactNativeResolver } from "./plugins/resolver.ts";
6+
import { inferBuildTarget } from "./targets.ts";
7+
import type { BundleServiceOptions } from "./types.ts";
8+
9+
/**
10+
* Bundles a React Native application using esbuild, without requiring Metro.
11+
*
12+
* ## Metro → esbuild mapping
13+
*
14+
* | Metro component | esbuild replacement |
15+
* |--------------------------|--------------------------------------------------|
16+
* | Transformer (Babel/Flow) | esbuild native TypeScript + JSX loader |
17+
* | Dependency graph | esbuild native bundler |
18+
* | Tree-shaking | esbuild native tree-shaking |
19+
* | Minifier | esbuild native minifier |
20+
* | Source maps | esbuild native source-map generation |
21+
* | Resolver | `reactNativeResolver` plugin (reimplemented) |
22+
* | Pre-modules / polyfills | `reactNativePolyfills` plugin (reimplemented) |
23+
*
24+
* ## What cannot be replaced by esbuild
25+
*
26+
* - **Dev server with HMR** – esbuild has a basic `serve` mode but does not
27+
* implement React Native's fast-refresh / HMR protocol.
28+
* - **RAM bundles** – Metro's indexed RAM bundle format has no esbuild
29+
* equivalent.
30+
* - **Lazy module loading** – Metro's built-in lazy-loading mechanism requires
31+
* a reimplementation of the module loader runtime.
32+
*
33+
* @param options Bundle options.
34+
* @returns A promise that resolves when the bundle has been written to disk.
35+
*/
36+
export async function bundle(options: BundleServiceOptions): Promise<void> {
37+
const {
38+
entryFile,
39+
platform,
40+
dev = false,
41+
minify = !dev,
42+
bundleOutput,
43+
sourcemapOutput,
44+
target,
45+
plugins: extraPlugins = [],
46+
projectRoot = process.cwd(),
47+
logLevel = "warning",
48+
drop,
49+
pure,
50+
} = options;
51+
52+
const resolvedEntry = path.resolve(projectRoot, entryFile);
53+
const resolvedOutput = path.resolve(projectRoot, bundleOutput);
54+
const resolvedSourcemap = sourcemapOutput
55+
? path.resolve(projectRoot, sourcemapOutput)
56+
: undefined;
57+
58+
// Ensure the output directory exists.
59+
fs.mkdirSync(path.dirname(resolvedOutput), { recursive: true });
60+
61+
const buildTarget = target ?? inferBuildTarget(projectRoot);
62+
63+
await esbuild.build({
64+
bundle: true,
65+
define: {
66+
__DEV__: JSON.stringify(dev),
67+
__METRO_GLOBAL_PREFIX__: "''",
68+
global: "global",
69+
"process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production"),
70+
},
71+
drop,
72+
entryPoints: [resolvedEntry],
73+
legalComments: "none",
74+
logLevel,
75+
metafile: false,
76+
minify,
77+
outfile: resolvedOutput,
78+
platform: "node",
79+
plugins: [
80+
reactNativePolyfills({
81+
entryFile: resolvedEntry,
82+
dev,
83+
}),
84+
reactNativeResolver(platform),
85+
...extraPlugins,
86+
],
87+
pure,
88+
sourcemap: resolvedSourcemap ? "external" : false,
89+
target: buildTarget,
90+
supported: (() => {
91+
if (
92+
typeof buildTarget !== "string" ||
93+
!buildTarget.startsWith("hermes")
94+
) {
95+
return undefined;
96+
}
97+
98+
// Hermes supports these ES6+ features even though the compatibility
99+
// table may not list them. See the metro-serializer-esbuild package for
100+
// the original rationale.
101+
//
102+
// Note: unlike metro-serializer-esbuild (which receives Babel-pre-
103+
// processed code), this bundler passes raw TypeScript/ES6 source to
104+
// esbuild. We therefore also mark const-and-let as supported because
105+
// Hermes has supported block scoping since its earliest versions.
106+
return {
107+
arrow: true,
108+
"const-and-let": true,
109+
"default-argument": true,
110+
destructuring: true,
111+
generator: true,
112+
"rest-argument": true,
113+
"template-literal": true,
114+
};
115+
})(),
116+
write: true,
117+
});
118+
119+
// Move the source map to the requested location when it differs from the
120+
// default `.js.map` path that esbuild writes.
121+
if (resolvedSourcemap) {
122+
const defaultMapPath = resolvedOutput + ".map";
123+
if (
124+
defaultMapPath !== resolvedSourcemap &&
125+
fs.existsSync(defaultMapPath)
126+
) {
127+
fs.renameSync(defaultMapPath, resolvedSourcemap);
128+
}
129+
}
130+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export { bundle } from "./bundle.ts";
2+
export { reactNativePolyfills } from "./plugins/polyfills.ts";
3+
export type { PolyfillsPluginOptions } from "./plugins/polyfills.ts";
4+
export { reactNativeResolver } from "./plugins/resolver.ts";
5+
export { inferBuildTarget } from "./targets.ts";
6+
export type { BundleServiceOptions } from "./types.ts";
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { reactNativePolyfills } from "./polyfills.ts";
2+
export type { PolyfillsPluginOptions } from "./polyfills.ts";
3+
export { reactNativeResolver } from "./resolver.ts";

0 commit comments

Comments
 (0)