Skip to main content

rolldown/bundle/
bundle.rs

1use crate::bundle::bundle_handle::BundleHandle;
2
3use super::super::{
4  SharedOptions, SharedResolver,
5  module_loader::deferred_scan_data::defer_sync_scan_data,
6  stages::{
7    generate_stage::GenerateStage,
8    link_stage::LinkStage,
9    scan_stage::{NormalizedScanStageOutput, ScanStage, ScanStageOutput},
10  },
11  types::{bundle_output::BundleOutput, scan_stage_cache::ScanStageCache},
12  utils::fs_utils::clean_dir,
13};
14use anyhow::Context;
15use arcstr::ArcStr;
16use rolldown_common::{GetLocalDbMut, Module, ScanMode, SharedFileEmitter, SymbolRefDb};
17use rolldown_devtools::{action, trace_action, trace_action_enabled};
18use rolldown_error::{BuildDiagnostic, BuildResult, Severity};
19use rolldown_fs::{FileSystem, OsFileSystem};
20use rolldown_plugin::{
21  HookBuildEndArgs, HookCloseBundleArgs, HookRenderErrorArgs, SharedPluginDriver,
22};
23use rolldown_utils::dashmap::FxDashSet;
24use std::{path::Path, sync::Arc};
25use sugar_path::SugarPath;
26
27#[expect(
28  clippy::struct_field_names,
29  reason = "`bundle_span` emphasizes this's a span for this bundle, not a session level span"
30)]
31pub struct Bundle<Fs: FileSystem + Clone + 'static = OsFileSystem> {
32  pub(crate) fs: Fs,
33  pub(crate) options: SharedOptions,
34  pub(crate) resolver: SharedResolver<Fs>,
35  pub(crate) file_emitter: SharedFileEmitter,
36  pub(crate) plugin_driver: SharedPluginDriver,
37  pub(crate) warnings: Vec<BuildDiagnostic>,
38  pub(crate) cache: ScanStageCache,
39  pub(crate) bundle_span: Arc<tracing::Span>,
40}
41
42impl<Fs: FileSystem + Clone + 'static> Bundle<Fs> {
43  #[tracing::instrument(level = "debug", skip_all, parent = &*self.bundle_span)]
44  /// This method intentionally get the ownership of `self` to show that the method cannot be called multiple times.
45  pub async fn write(mut self) -> BuildResult<BundleOutput> {
46    let start = self.plugin_driver.start_timing();
47    let result = async {
48      self.trace_action_session_meta();
49      trace_action!(action::BuildStart { action: "BuildStart" });
50      let scan_stage_output = self.scan_modules(ScanMode::Full).await?;
51
52      let ret = self.bundle_write(scan_stage_output).await;
53      trace_action!(action::BuildEnd { action: "BuildEnd" });
54      ret
55    }
56    .await;
57    self.plugin_driver.set_total_build_time(start);
58    self.append_plugin_timings_warning(result)
59  }
60
61  #[tracing::instrument(level = "debug", skip_all, parent = &*self.bundle_span)]
62  /// This method intentionally get the ownership of `self` to show that the method cannot be called multiple times.
63  pub async fn generate(mut self) -> BuildResult<BundleOutput> {
64    let start = self.plugin_driver.start_timing();
65    let result = async {
66      self.trace_action_session_meta();
67      trace_action!(action::BuildStart { action: "BuildStart" });
68      let scan_stage_output = self.scan_modules(ScanMode::Full).await?;
69
70      let ret = self.bundle_generate(scan_stage_output).await.map(|mut output| {
71        output.warnings.append(&mut self.warnings);
72        output
73      });
74      trace_action!(action::BuildEnd { action: "BuildEnd" });
75      ret
76    }
77    .await;
78    self.plugin_driver.set_total_build_time(start);
79    self.append_plugin_timings_warning(result)
80  }
81
82  #[tracing::instrument(level = "debug", skip_all, parent = &*self.bundle_span)]
83  /// This method intentionally get the ownership of `self` to show that the method cannot be called multiple times.
84  pub async fn scan(mut self) -> BuildResult<()> {
85    self.scan_modules(ScanMode::Full).await?;
86
87    Ok(())
88  }
89
90  #[tracing::instrument(level = "debug", skip_all, parent = &*self.bundle_span)]
91  pub async fn scan_modules(
92    &mut self,
93    scan_mode: ScanMode<ArcStr>,
94  ) -> BuildResult<NormalizedScanStageOutput> {
95    trace_action!(action::BuildStart { action: "BuildStart" });
96    let is_full_scan_mode = scan_mode.is_full();
97
98    let scan_stage_output = match ScanStage::new(
99      Arc::clone(&self.options),
100      Arc::clone(&self.plugin_driver),
101      self.fs.clone(),
102      Arc::clone(&self.resolver),
103    )
104    .scan(scan_mode, &mut self.cache)
105    .await
106    {
107      Ok(v) => v,
108      Err(errs) => {
109        debug_assert!(errs.iter().all(|e| e.severity() == Severity::Error));
110        self
111          .plugin_driver
112          .build_end(Some(&HookBuildEndArgs { errors: &errs, cwd: &self.options.cwd }))
113          .await?;
114        self
115          .plugin_driver
116          .close_bundle(Some(&HookCloseBundleArgs { errors: &errs, cwd: &self.options.cwd }))
117          .await?;
118        return Err(errs);
119      }
120    };
121
122    let scan_stage_output = self
123      .normalize_scan_stage_output_and_update_cache(scan_stage_output, is_full_scan_mode)
124      .await?;
125
126    // Make sure the cache is reset if incremental build is not enabled.
127    if !self.options.experimental.is_incremental_build_enabled() {
128      std::mem::take(&mut self.cache);
129    }
130
131    Self::trace_action_module_graph_ready(&scan_stage_output);
132    self.plugin_driver.build_end(None).await?;
133    trace_action!(action::BuildEnd { action: "BuildEnd" });
134    Ok(scan_stage_output)
135  }
136
137  #[inline]
138  pub fn options(&self) -> &SharedOptions {
139    &self.options
140  }
141
142  pub fn get_watch_files(&self) -> &Arc<FxDashSet<ArcStr>> {
143    &self.plugin_driver.watch_files
144  }
145
146  pub fn context(&self) -> BundleHandle {
147    BundleHandle {
148      options: Arc::clone(&self.options),
149      plugin_driver: Arc::clone(&self.plugin_driver),
150      closed: Arc::default(),
151    }
152  }
153
154  #[tracing::instrument(level = "debug", skip_all, parent = &*self.bundle_span)]
155  pub async fn bundle_write(
156    &mut self,
157    scan_stage_output: NormalizedScanStageOutput,
158  ) -> BuildResult<BundleOutput> {
159    let dist_dir = self.options.cwd.join(&self.options.out_dir);
160
161    if self.options.clean_dir && self.options.file.is_none() {
162      if let Err(err) = clean_dir(&self.fs, &dist_dir) {
163        self.warnings.push(
164          BuildDiagnostic::could_not_clean_directory(
165            dist_dir.display().to_string(),
166            err.to_string(),
167          )
168          .with_severity_warning(),
169        );
170      }
171    }
172
173    let mut output = self.bundle_up(scan_stage_output, /* is_write */ true).await?;
174
175    self.fs.create_dir_all(&dist_dir).with_context(|| {
176      format!("Could not create directory for output chunks: {}", dist_dir.display())
177    })?;
178
179    for chunk in &output.assets {
180      let filename = chunk.filename();
181      if filename.contains('\0') {
182        let pattern_name = match chunk {
183          rolldown_common::Output::Chunk(c) => {
184            if c.is_entry {
185              "entryFileNames"
186            } else {
187              "chunkFileNames"
188            }
189          }
190          rolldown_common::Output::Asset(_) => "assetFileNames",
191        };
192        return Err(
193          BuildDiagnostic::invalid_option(rolldown_error::InvalidOptionType::NulByteInFilename {
194            pattern_name: pattern_name.to_string(),
195          })
196          .into(),
197        );
198      }
199      let dest = dist_dir.join(filename);
200      if let Some(p) = dest.parent() {
201        if !self.fs.exists(p) {
202          self.fs.create_dir_all(p).with_context(|| {
203            format!("Could not create directory for output chunks: {}", p.display())
204          })?;
205        }
206      }
207      self
208        .fs
209        .write(&dest, chunk.content_as_bytes())
210        .with_context(|| format!("Failed to write file in {}", dest.display()))?;
211    }
212
213    self
214      .plugin_driver
215      .write_bundle(&mut output.assets, &self.options, &mut output.warnings)
216      .await?;
217
218    output.warnings.append(&mut self.warnings);
219
220    Ok(output)
221  }
222
223  #[tracing::instrument(level = "debug", skip_all, parent = &*self.bundle_span)]
224  pub async fn bundle_generate(
225    &mut self,
226    scan_stage_output: NormalizedScanStageOutput,
227  ) -> BuildResult<BundleOutput> {
228    self.bundle_up(scan_stage_output, false).await
229  }
230
231  #[tracing::instrument(level = "debug", skip(self, output))]
232  async fn normalize_scan_stage_output_and_update_cache(
233    &mut self,
234    output: ScanStageOutput,
235    is_full_scan_mode: bool,
236  ) -> BuildResult<NormalizedScanStageOutput> {
237    let is_incremental = self.options.experimental.is_incremental_build_enabled();
238
239    if is_full_scan_mode {
240      let mut output: NormalizedScanStageOutput =
241        output.try_into().expect("Should be able to convert to NormalizedScanStageOutput");
242      // The scan produced a complete `output`; `defer_sync_scan_data` only
243      // mutates per-module `side_effects` best-effort, so its error doesn't
244      // invalidate `output`. Commit it rather than fall back to the stale prior
245      // snapshot.
246      // See meta/design/bundler-data-lifecycle.md ("Cache integrity on a failed build").
247      let result =
248        defer_sync_scan_data(&self.options, &self.cache.module_id_to_idx, &mut output).await;
249      if is_incremental {
250        self.cache.set_snapshot(output.make_copy());
251      }
252      result?;
253      return Ok(output);
254    }
255
256    self.cache.merge(output)?;
257    self.cache.update_defer_sync_data(&self.options).await?;
258    Ok(self.cache.create_output())
259  }
260
261  async fn bundle_up(
262    &mut self,
263    scan_stage_output: NormalizedScanStageOutput,
264    is_write: bool,
265  ) -> BuildResult<BundleOutput> {
266    let start = self.plugin_driver.start_timing();
267    let (mut link_stage_output, ast_table) =
268      LinkStage::new(scan_stage_output, &self.options).link();
269    self.plugin_driver.set_link_stage_time(start);
270
271    let bundle_output =
272      GenerateStage::new(&mut link_stage_output, ast_table, &self.options, &self.plugin_driver)
273        .generate()
274        .await; // Notice we don't use `?` to break the control flow here.
275
276    // `create_output`/`make_copy` strip symbol-table scoping from the cache for
277    // performance; reinstate it here, before the fallible steps below, so the
278    // cache stays whole on their `Err` paths.
279    // See meta/design/bundler-data-lifecycle.md ("Cache integrity on a failed build").
280    self.merge_immutable_fields_for_cache(link_stage_output.symbol_db);
281
282    if let Err(errors) = &bundle_output {
283      debug_assert!(errors.iter().all(|e| e.severity() == Severity::Error));
284      self
285        .plugin_driver
286        .render_error(&HookRenderErrorArgs { errors, cwd: &self.options.cwd })
287        .await?;
288    }
289
290    let mut output = bundle_output?;
291
292    // Add additional files from build plugins.
293    self.file_emitter.add_additional_files(&mut output.assets, &mut output.warnings);
294
295    self
296      .plugin_driver
297      .generate_bundle(&mut output.assets, is_write, &self.options, &mut output.warnings)
298      .await?;
299
300    for asset in &output.assets {
301      if is_filename_outside_output_dir(asset.filename()) {
302        return Err(
303          vec![BuildDiagnostic::filename_outside_output_directory(asset.filename().to_string())]
304            .into(),
305        );
306      }
307    }
308
309    if let Some(invalidate_js_side_cache) = &self.options.invalidate_js_side_cache {
310      invalidate_js_side_cache.call().await?;
311    }
312
313    Ok(output)
314  }
315
316  fn merge_immutable_fields_for_cache(&mut self, symbol_db: SymbolRefDb) {
317    if !self.options.experimental.is_incremental_build_enabled() {
318      return;
319    }
320    let snapshot = self.cache.get_snapshot_mut();
321    for (idx, symbol_ref_db) in symbol_db.into_inner().into_iter_enumerated() {
322      let Some(db_for_module) = symbol_ref_db else {
323        continue;
324      };
325      let cache_db = snapshot.symbol_ref_db.local_db_mut(idx);
326      cache_db.merge_from_build(db_for_module);
327    }
328  }
329
330  fn trace_action_module_graph_ready(scan_stage_output: &NormalizedScanStageOutput) {
331    if trace_action_enabled!() {
332      let modules = scan_stage_output
333        .module_table
334        .modules
335        .iter()
336        .map(|m| match m {
337          Module::Normal(module) => action::Module {
338            id: module.id.to_string(),
339            is_external: false,
340            imports: Some(
341              module
342                .import_records
343                .iter()
344                .filter_map(|r| {
345                  r.resolved_module.map(|module_idx| action::ModuleImport {
346                    module_id: scan_stage_output.module_table[module_idx].id().to_string(),
347                    kind: r.kind.to_string(),
348                    module_request: r.module_request.to_string(),
349                  })
350                })
351                .collect(),
352            ),
353            importers: Some(module.importers.iter().map(ToString::to_string).collect()),
354          },
355          Module::External(module) => action::Module {
356            id: module.id.to_string(),
357            is_external: true,
358            imports: None,
359            importers: None,
360          },
361        })
362        .collect();
363      trace_action!(action::ModuleGraphReady { action: "ModuleGraphReady", modules });
364    }
365  }
366
367  fn trace_action_session_meta(&self) {
368    if trace_action_enabled!() {
369      trace_action!(action::SessionMeta {
370        action: "SessionMeta",
371        inputs: self
372          .options
373          .input
374          .iter()
375          .map(|v| action::InputItem { name: v.name.clone(), filename: v.import.clone() })
376          .collect(),
377        plugins: self
378          .plugin_driver
379          .plugins()
380          .iter()
381          .enumerate()
382          .map(|(idx, p)| action::PluginItem {
383            name: p.call_name().into_owned(),
384            plugin_id: idx.try_into().unwrap()
385          })
386          .collect(),
387        cwd: self.options.cwd.to_string_lossy().to_string(),
388        platform: self.options.platform.to_string(),
389        format: self.options.format.to_string(),
390        dir: self.options.dir.clone(),
391        file: self.options.file.clone(),
392      });
393    }
394  }
395
396  /// Append plugin timings warning to result if applicable.
397  fn append_plugin_timings_warning(
398    &self,
399    result: BuildResult<BundleOutput>,
400  ) -> BuildResult<BundleOutput> {
401    result.map(|mut output| {
402      if let Some(plugins) = self.plugin_driver.get_plugin_timings_info() {
403        output.warnings.push(BuildDiagnostic::plugin_timings(plugins).with_severity_warning());
404      }
405      output
406    })
407  }
408}
409
410/// Check if a filename would escape the output directory.
411///
412/// Rejects absolute paths and paths that normalize to a location outside the
413/// output directory (e.g. via `..` traversal).
414fn is_filename_outside_output_dir(filename: &str) -> bool {
415  if Path::new(filename).is_absolute() {
416    return true;
417  }
418
419  let normalized = filename.normalize();
420  let normalized = normalized.to_string_lossy();
421
422  normalized == "."
423    || normalized == ".."
424    || normalized.starts_with("../")
425    || normalized.starts_with("..\\")
426}