Rolldown’s Rust-JS Bridge (napi-rs)
Rolldown is a Rust bundler that exposes a JavaScript API. The bridge between the two is napi-rs, a framework for building Node.js native addons in Rust.
Why This Matters
Understanding the bridge is important for:
- Contributing features that touch both Rust and JS (most Rolldown features do)
- Understanding performance characteristics (what crosses the bridge, what stays in Rust)
- Debugging issues where JS plugins interact with the Rust bundler
Architecture
JavaScript (Node.js) Rust
======================== ========================
packages/rolldown/ crates/rolldown/
src/ src/
rolldown.ts ----napi-rs----> lib.rs
options/ bundler.rs
plugin/ module_loader/
chunk_graph/
crates/rolldown_binding/
src/
bundler.rs <-- The bridge layer
options/ <-- Convert JS options to Rust types
worker/ <-- Thread management
How napi-rs Works
napi-rs generates Node.js bindings from Rust structs and functions using #[napi] attributes:
// crates/rolldown_binding/src/bundler.rs (simplified)
use napi_derive::napi;
#[napi]
pub struct Bundler {
inner: rolldown::Bundler,
}
#[napi]
impl Bundler {
#[napi(constructor)]
pub fn new(options: BindingInputOptions) -> napi::Result<Self> {
// Convert JS options to Rust options
let rust_options = options.into();
Ok(Self {
inner: rolldown::Bundler::new(rust_options),
})
}
#[napi]
pub async fn write(&self) -> napi::Result<BindingOutputs> {
let outputs = self.inner.write().await?;
Ok(outputs.into())
}
}
On the JS side, this becomes:
// packages/rolldown/src/rolldown.ts (simplified)
import { Bundler as NativeBundler } from "./binding";
export async function rolldown(options: InputOptions) {
const bundler = new NativeBundler(normalizeOptions(options));
return {
write: () => bundler.write(),
// ...
};
}
Data Flow Across the Bridge
User's JS Config
|
v
normalize options (JS) -- validate, set defaults
|
v
BindingInputOptions -- napi-rs struct (JS -> Rust conversion)
|
v
Rust InputOptions -- native Rust types
|
v
[Bundling happens entirely in Rust]
|
v
Rust BundleOutput -- chunks, assets, warnings
|
v
BindingOutputs -- napi-rs conversion (Rust -> JS)
|
v
JS OutputBundle -- user receives JS objects
Key insight: the expensive work (parsing, linking, tree shaking, code generation) all happens in Rust. The bridge only converts config in and results out.
Plugin Communication
Plugins are the trickiest part of the bridge. JS plugins need to be called from Rust:
Rust bundler encounters an import
|
v
Calls plugin.resolveId() -- crosses bridge to JS
|
v
JS plugin runs -- user's plugin logic
|
v
Returns result to Rust -- crosses bridge back
|
v
Rust continues bundling
Each bridge crossing has overhead. This is why Rolldown implements common plugins in Rust (the rolldown_plugin_vite_* crates) instead of JS: it avoids the bridge entirely.
The Vite Integration Plugins
Many Rust crates are Vite plugins reimplemented in Rust:
| Rust Crate | Replaces Vite Plugin | Purpose |
|---|---|---|
rolldown_plugin_vite_resolve | vite:resolve | Module resolution |
rolldown_plugin_vite_css | vite:css | CSS processing |
rolldown_plugin_vite_html | vite:html | HTML entry handling |
rolldown_plugin_vite_asset | vite:asset | Asset handling |
rolldown_plugin_vite_json | vite:json | JSON imports |
This is how Vite’s transition to Rolldown works: Vite’s TypeScript plugins are being rewritten in Rust as Rolldown plugins, eliminating the JS-Rust bridge for hot-path operations.
Contributing Implications
JS-only contributions (easier):
packages/rolldown/- API surface, option normalization, error messagespackages/debug/- debugging toolspackages/pluginutils/- plugin utility functions
Rust-only contributions (no bridge concerns):
crates/rolldown/- bundler internalscrates/rolldown_plugin_*- Rust-native pluginscrates/rolldown_resolver/- module resolution
Bridge contributions (need both):
- Adding a new option (add to Rust struct + binding struct + JS normalization)
- Adding a new plugin hook (define in Rust + expose via napi + JS wrapper)
For Your Projects
- ai-skills-sync uses tsup (esbuild). tsdown (the Rolldown-based replacement) uses this same JS API layer. When you switch to tsdown, your config stays JS, and the bridge handles the Rust bundling.
- markdown-task-planner uses Vite, which will call Rolldown through this bridge internally. You won’t interact with the bridge directly, but understanding it helps debug build issues.