meta/design/rust-bundler.md
Bundler is the long-lived, cache-preserving bundler used by watch mode, dev mode, and HMR. It creates Bundle instances for each build while persisting scan-stage caches and resolver state across builds. This is distinct from ClassicBundler, which creates a fresh factory for each build with no shared state — see rust-classic-bundler.md.
// crates/rolldown/src/bundler/bundler.rs
pub struct Bundler {
session: rolldown_devtools::Session,
bundle_factory: BundleFactory,
cache: ScanStageCache,
closed: bool,
}
BundleFactory — Reused across builds. Holds the shared resolver, plugin driver factory, file emitter, and options. Each build calls factory.create_bundle() to produce a fresh Bundle without discarding the factory.ScanStageCache — Persists the module graph, barrel state, and module index maps across builds. Swapped in/out of Bundle via with_cached_bundle() so incremental builds only re-scan changed modules.SharedResolver — Owned by the factory, shared across builds. The resolution cache survives between builds.closed — Legacy flag, see "Close Mechanism" below.Bundler derefs to BundleFactory, so callers can access factory fields directly (e.g. bundler.options, bundler.resolver).
Each build goes through with_cached_bundle_experimental:
pub async fn with_cached_bundle_experimental<T>(
&mut self,
bundle_mode: BundleMode,
with_fn: impl AsyncFnOnce(&mut Bundle) -> BuildResult<T>,
) -> BuildResult<T>
ScanStageCache out of selfbundle_factory.create_bundle(bundle_mode, Some(cache)) to produce a Bundle&mut Bundle to the closure — the caller orchestrates scan/render/write phasesself when the closure returnsThe watch mode closure typically does:
bundler.with_cached_bundle_experimental(FullBuild, |bundle| async {
let scan_output = bundle.scan_modules(scan_mode).await?;
// register FS watches from bundle.get_watch_files() BEFORE render
let output = bundle.bundle_write(scan_output).await?;
Ok(output)
}).await
// crates/rolldown/src/bundle/bundle.rs
pub struct Bundle {
fs: OsFileSystem,
options: SharedOptions,
resolver: SharedResolver,
file_emitter: SharedFileEmitter,
plugin_driver: SharedPluginDriver,
warnings: Vec<BuildDiagnostic>,
cache: ScanStageCache,
bundle_span: Arc<tracing::Span>,
}
A Bundle represents a single build. Its consuming methods (write(), generate(), scan()) take ownership of self to enforce single-use semantics.
For watch mode, the non-consuming methods (scan_modules(), bundle_write(), bundle_generate(), get_watch_files()) allow manual phase orchestration via with_cached_bundle_experimental.
Bundler doesn't need close()Bundler is a long-lived Rust struct. Resources clean up on drop — there's nothing to "close." The only meaningful work Bundler::close() does today is call the closeBundle plugin hook, but that's a per-build lifecycle concern, not a per-bundler concern. It belongs on the build artifact (BundleHandle), not on the bundler.
The current Bundler::close() also resets the scan stage cache and clears the resolver cache — but these are rebuild concerns, not close concerns. In watch mode, destroying caches on result.close() is actively harmful (forces a cold rebuild).
The following should be removed from Bundler:
closed flag — No purpose once closeBundle moves to BundleHandle.inner_close() — Its only real job (calling closeBundle) moves to BundleHandle.close(). Cache/resolver cleanup happens on drop.reset_closed_for_watch_mode() — This hack exists because BindingWatcherBundler.close() calls bundler.close() which sets closed = true, requiring a reset before each rebuild. With closeBundle on BundleHandle, no reset is needed.create_error_if_closed() — Callers that need a closed guard (ClassicBundler, DevEngine) have their own closed flags.close() — Removed entirely.BundleHandle.close() — Design DecisionBundleHandle should own a close() method that:
closeBundle plugin hookArc<AtomicBool>)This is the correct place because closeBundle signals that no more output processing will happen for a specific build. The watcher's BUNDLE_END/ERROR event data carries a BundleHandle (not the full bundler), and JS result.close() calls handle.close() directly — no bundler lock needed.
rolldown_watcher owns the build lifecycle:
WatchTask holds an Arc<TokioMutex<Bundler>>with_cached_bundle_experimental, and orchestrates scan/write phasesrolldown_watcher should call Bundle.close() (or BundleHandle.close()) to fire closeBundle — this is the watcher's responsibility, not something JS reaches in to doThis means BindingWatcherBundler should NOT call bundler.close() — the closeBundle hook is the contract of rolldown_watcher, triggered at the right point in the build lifecycle.
crates/rolldown/src/bundler/ — Bundler implementationcrates/rolldown/src/bundle/ — Bundle and BundleFactory implementation