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 CrateReplaces Vite PluginPurpose
rolldown_plugin_vite_resolvevite:resolveModule resolution
rolldown_plugin_vite_cssvite:cssCSS processing
rolldown_plugin_vite_htmlvite:htmlHTML entry handling
rolldown_plugin_vite_assetvite:assetAsset handling
rolldown_plugin_vite_jsonvite:jsonJSON 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 messages
  • packages/debug/ - debugging tools
  • packages/pluginutils/ - plugin utility functions

Rust-only contributions (no bridge concerns):

  • crates/rolldown/ - bundler internals
  • crates/rolldown_plugin_* - Rust-native plugins
  • crates/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.