Rolldown's Architecture: A Map to the Codebase
Prerequisites
- ›Basic familiarity with Rust ownership and traits
- ›Conceptual understanding of how JavaScript bundlers work (modules, tree shaking, code splitting)
- ›Familiarity with Rollup or esbuild at the user level
Rolldown's Architecture: A Map to the Codebase
Rolldown is a JavaScript/TypeScript bundler written in Rust, designed to be the future bundler for Vite. It aims for Rollup API compatibility with esbuild-class speed — a combination that requires careful architectural choices. Before diving into any implementation details in later articles, this first installment gives you the conceptual map you need to navigate the codebase confidently.
The project sits at roughly 55+ Rust crates and several TypeScript packages, organized in a hybrid monorepo. That sounds imposing, but the architecture follows a clean three-layer pattern that makes the whole system navigable once you understand the overall shape.
The Three-Layer Architecture
Rolldown separates concerns across three distinct layers, each with clear responsibilities:
flowchart TB
subgraph TS["TypeScript API Layer"]
direction LR
rolldown["rolldown()"]
build["build()"]
watch["watch()"]
end
subgraph NAPI["NAPI-RS Binding Layer"]
direction LR
BindingBundler["BindingBundler"]
ClassicBundler["ClassicBundler"]
end
subgraph Rust["Rust Core"]
direction LR
BundleFactory["BundleFactory"]
Bundle["Bundle"]
Pipeline["Scan → Link → Generate"]
end
TS -->|"Options normalization + FFI"| NAPI
NAPI -->|"Creates bundles"| Rust
Layer 1: TypeScript API (packages/rolldown/src/) — The public surface that developers interact with. It exports rolldown(), build(), watch(), and all TypeScript types. This layer handles plugin option hooks, normalizes user-supplied configuration, and converts everything into binding-compatible types before crossing the FFI boundary.
You can see the full public API surface in packages/rolldown/src/index.ts#L1-L225 — note the Rollup-compatible type aliases like RolldownError as RollupError on line 209.
Layer 2: NAPI-RS Binding (crates/rolldown_binding/) — The bridge between JavaScript and Rust. This layer initializes the Tokio async runtime with custom thread sizing, sets up mimalloc as the global allocator, installs a panic hook, and provides the BindingBundler NAPI class that wraps ClassicBundler.
The binding entry point at crates/rolldown_binding/src/lib.rs#L83-L117 reveals key performance decisions: the Tokio runtime uses num_cpus::get_physical() * 3/2 worker threads because rolldown puts heavy blocking tasks in worker threads rather than the default blocking thread pool.
Layer 3: Rust Core (crates/rolldown/) — The pure bundler implementation. It exports Bundler, BundleFactory, Bundle, and BundleHandle — the ownership-driven API that enforces single-use bundle semantics. The core knows nothing about NAPI or JavaScript.
The Rust core's public API is deliberately minimal, as seen in crates/rolldown/src/lib.rs#L21-L39:
pub use crate::{
bundle::{
bundle::Bundle,
bundle_factory::{BundleFactory, BundleFactoryOptions},
bundle_handle::BundleHandle,
},
bundler::Bundler,
bundler_builder::BundlerBuilder,
types::{bundle_output::BundleOutput, bundler_config::BundlerConfig},
};
Tip: This three-layer design means you can study the bundling algorithm in pure Rust without NAPI noise, and you can understand the JavaScript API without reading any Rust. Start with whichever layer matches your expertise.
Directory Structure Walkthrough
The repository is a hybrid monorepo managed with both pnpm (Node.js) and cargo (Rust). Here's a map of the key areas:
| Directory | Purpose |
|---|---|
crates/rolldown/ |
Rust core: bundler logic, pipeline stages, AST scanner, module loader |
crates/rolldown_binding/ |
NAPI-RS bindings exposing Rust to Node.js |
crates/rolldown_plugin/ |
Plugin trait definition and PluginDriver dispatch |
crates/rolldown_plugin_* |
~30 built-in plugins (Vite compatibility, replace, analyzer, etc.) |
crates/rolldown_common/ |
Shared types: modules, symbols, options, chunks |
crates/rolldown_watcher/ |
Watch mode coordinator (actor-pattern state machine) |
packages/rolldown/ |
Main npm package: TypeScript API, CLI, plugin utilities |
packages/rolldown-tests/ |
Vitest test suite for the Node.js API |
packages/rollup-tests/ |
Rollup compatibility test suite |
meta/design/ |
11 spec-driven design documents |
The Cargo workspace at the repository root declares all crates:
[workspace]
members = ["./crates/*", "tasks/*"]
resolver = "3"
Within crates/rolldown/src/, the core is organized by function:
| Module | Purpose |
|---|---|
stages/scan_stage.rs |
Module discovery and parsing |
stages/link_stage/ |
Symbol resolution, tree shaking, wrapping |
stages/generate_stage/ |
Code splitting, chunk rendering, output |
module_loader/ |
Async module loading with Tokio channels |
ast_scanner/ |
OXC AST visitor extracting imports/exports |
bundle/ |
BundleFactory, Bundle, BundleHandle ownership lifecycle |
bundler/ |
Long-lived Bundler for watch/HMR mode |
module_finalizers/ |
AST rewriting for scope hoisting |
ecmascript/format/ |
Output format renderers (ESM, CJS, IIFE, UMD) |
The Three-Stage Pipeline at a Glance
Every rolldown build passes through three sequential stages. Each stage produces a well-defined output that becomes the input to the next:
flowchart LR
Scan["**Scan Stage**<br/>Module discovery<br/>& parsing"]
Link["**Link Stage**<br/>Symbol resolution<br/>& tree shaking"]
Gen["**Generate Stage**<br/>Code splitting<br/>& rendering"]
Scan -->|"NormalizedScanStageOutput<br/>(ModuleTable, SymbolRefDb,<br/>EntryPoints, Runtime)"| Link
Link -->|"LinkStageOutput<br/>(sorted modules, metas,<br/>used symbols)"| Gen
Gen -->|"BundleOutput<br/>(assets, warnings)"| Output["Final files"]
Scan Stage (scan_stage.rs) discovers the entire module graph by resolving entry points and recursively following imports. It runs plugin hooks (resolveId, load, transform) for each module, parses them with OXC, and extracts metadata through the AstScanner. The output is NormalizedScanStageOutput, containing the complete ModuleTable, SymbolRefDb, entry points, and runtime module information.
You can see the output structure defined at crates/rolldown/src/stages/scan_stage.rs#L40-L54:
pub struct NormalizedScanStageOutput {
pub module_table: ModuleTable,
pub index_ecma_ast: IndexEcmaAst,
pub entry_points: Vec<EntryPoint>,
pub symbol_ref_db: SymbolRefDb,
pub runtime: RuntimeModuleBrief,
pub warnings: Vec<BuildDiagnostic>,
// ... additional fields
}
Link Stage (link_stage/mod.rs) runs 13 ordered analysis passes over the module graph: sorting, CJS/ESM classification, module wrapping, symbol binding, tree shaking, and cross-module optimization. The output is LinkStageOutput, a fully annotated module graph ready for code generation.
The 13-pass sequence is visible in the link() method at crates/rolldown/src/stages/link_stage/mod.rs#L197-L211:
pub fn link(mut self) -> LinkStageOutput {
self.sort_modules();
self.compute_tla();
self.determine_module_exports_kind();
self.determine_safely_merge_cjs_ns();
self.wrap_modules();
// ... 6 more passes
self.include_statements(&unreachable_import_expression_addrs);
self.patch_module_dependencies();
Generate Stage (generate_stage/mod.rs) transforms the linked module graph into final output assets. It splits code into chunks using a BitSet reachability algorithm, computes cross-chunk imports/exports, deconflicts symbol names, finalizes ASTs for scope hoisting, and renders chunks in the requested output format.
Design Docs and Project Navigation
Rolldown follows a spec-driven development practice. The meta/design/ directory contains 11 design documents that capture architectural decisions and design intent:
| Design Doc | Topic |
|---|---|
rust-bundler.md |
Core Bundler vs. ClassicBundler design rationale |
rust-classic-bundler.md |
ClassicBundler API compatibility approach |
watch-mode.md |
Watch mode actor pattern, state machine, debounce rules |
code-splitting.md |
Code splitting algorithm and chunk optimization |
manual-code-splitting.md |
User-configurable chunk grouping |
module-id.md |
Module identification and stable ID generation |
cli.md |
CLI argument parsing and configuration |
devtools.md |
Debug tracing and devtools integration |
The project's AGENTS.md (also symlinked as CLAUDE.md) serves as the canonical project bible. It defines the three-layer architecture, repository structure, bash commands, and contributor conventions. If you're ever lost, start there.
Tip: When code implements something described in a design doc, the codebase convention is to add a comment referencing it (e.g.,
// See meta/design/watch-mode.md). Grep for these references to find where design decisions are implemented.
Hybrid Monorepo Tooling
The hybrid pnpm/Cargo monorepo is orchestrated by just, a command runner. The justfile at the repository root provides unified commands across both ecosystems:
just roll # Build, lint, and test everything (Rust + Node.js + repo)
just build-rolldown # Build the rolldown node package + binding binary
just test-rust # Test Rust crates with auto-snapshot updates
just test-node # Test Node.js packages with Vitest
just lint # Lint everything
The Rust workspace uses Cargo with strict clippy lints — pedantic is enabled by default with specific exceptions. The Node.js side uses pnpm workspaces with vite-plus (vp) for package management.
One subtle but important detail: the NAPI binding binary (packages/rolldown/src/binding.js and binding.d.ts) is auto-generated by just build-rolldown. These files should never be edited manually — the source of truth is the Rust code in crates/rolldown_binding/.
What's Next
With this map in hand, you're ready to dive deeper. In the next article, we'll trace the complete journey of a JavaScript API call through the NAPI boundary into Rust, exploring the options normalization pipeline and the dual bundler design that makes both Rollup compatibility and incremental rebuilds possible.