Architecture
browser-metro is a browser-based bundler that mirrors the architecture of Metro (React Native's bundler) in a simplified form. The system has two runtime components that work together.
System overview
Browser (Web Worker) esm.reactnative.run
+----------------------------+ +---------------------+
| VirtualFS | | GET /pkg/:specifier |
| In-memory file system | | npm install |
| | | esbuild bundle |
| Bundler | | cache to disk |
| 1. Walk dependency graph | +---------------------+
| 2. Transform (Sucrase) | ^
| 3. Rewrite requires | |
| 4. Fetch npm pkgs -------+---> fetch /pkg/lodash
| 5. Emit bundle + HMR |
| |
| Execute in iframe |
+----------------------------+Data flow
1. Project loading
The playground loads project files into memory as a FileMap - a flat object mapping absolute paths to source strings:
{
"/index.ts": "import { greet } from './utils';\n...",
"/utils.ts": "export function greet(name: string) {...}",
"/package.json": "{ \"dependencies\": {} }"
}2. Virtual filesystem
VirtualFS wraps a FileMap with filesystem operations (read, write, exists, list). The bundler never touches the real filesystem - everything operates on this in-memory map. This makes the system portable: the same bundler runs in the browser, in tests, or on a server.
3. Module resolution
The Resolver implements Node.js-style module resolution against the VirtualFS:
- Relative imports (
./utils,../lib/foo) - resolved against the importing file's directory - Extension resolution - tries each extension in
sourceExts(e.g..ts,.tsx,.js,.jsx) - Index files - tries
dir/index.{ext}for directory imports - npm packages - anything not starting with
.or/is treated as an npm package
4. Transformation pipeline
Each file passes through a three-stage pipeline:
Original source (.tsx/.ts/.jsx/.js)
│
▼
┌──────────────────────┐
│ Pre-transform hooks │ BundlerPlugin.transformSource()
│ (JSX still intact) │ e.g. data-bx-path injection
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Core transform │ Transformer.transform()
│ (Sucrase) │ TS types stripped, JSX → createElement
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Post-transform hooks│ BundlerPlugin.transformOutput()
│ (CommonJS output) │ e.g. React Refresh registration
└──────────────────────┘5. Bundle generation
The bundler walks the dependency graph starting from the entry file, then emits a single self-executing bundle:
(function(modules) {
var cache = {};
function require(id) {
if (cache[id]) return cache[id].exports;
var module = cache[id] = { exports: {} };
modules[id].call(module.exports, module, module.exports, require);
return module.exports;
}
require("/index.ts");
})({
"/index.ts": function(module, exports, require) { /* transformed */ },
"/utils.ts": function(module, exports, require) { /* transformed */ },
"react": function(module, exports, require) { /* from ESM server */ },
});6. Iframe execution
The bundle runs in a sandboxed iframe. Two blob URLs are created:
- JS Blob - the bundle code as
application/javascript - HTML Blob - an HTML document that loads the JS blob via
<script src>and includes console interception, a source map resolver, and error handlers
The key insight is that loading the bundle via <script src> (not inline) ensures the browser associates error positions with the JS blob URL, enabling source map resolution.
Multi-target architecture
Each target (web, expo) gets its own Bundler / IncrementalBundler instance with its own config, plugins, cache, and module map. The web worker orchestrator creates per-target instances, runs them in parallel, and merges results.