Skip to main content

infinity_build_js/
bundler.rs

1use crate::config::{Instrument, ModuleAlias, PackageSpec};
2use crate::package::{self, EmittedPackage};
3use infinity_build_core::{
4    Artifact, BuildError, BuildResult, Builder, FileKind, GeneratedFile, SimpleArtifact,
5    pick_primary, stat_file,
6};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10// Re-imports: keep all rolldown types behind one use group so future
11// API drift is a single search/replace away.
12use rolldown::{
13    Bundler, BundlerOptions, ChunkFilenamesOutputOption, InputItem, IsExternal, OutputFormat,
14    Platform, RawMinifyOptions, ResolveOptions, SourceMapType,
15};
16use rolldown_common::ModuleType;
17use rolldown_utils::indexmap::FxIndexMap;
18
19#[derive(Debug, Clone, Default)]
20pub struct BundleOptions {
21    /// Output directory for raw bundles, relative to `project_root`.
22    /// Defaults to `bundles`. Each instrument gets its own
23    /// `<bundles_dir>/<instrument.name>/` subdirectory.
24    pub bundles_dir: Option<PathBuf>,
25    /// Minify the JS output.
26    pub minify: bool,
27    /// Emit sourcemaps. None = no sourcemaps (default).
28    pub sourcemap: Option<SourceMapKind>,
29    /// Skip the simulator-package emission step. Useful for CI smoke
30    /// tests that just want to know whether the bundle compiles.
31    pub skip_simulator_package: bool,
32    /// Extra `process.env.<name> = <value>` substitutions. Values are
33    /// JSON-encoded automatically — pass strings as plain strings, no
34    /// quoting needed.
35    pub env: HashMap<String, String>,
36}
37
38#[derive(Debug, Clone, Copy)]
39pub enum SourceMapKind {
40    Inline,
41    External,
42    File,
43}
44
45#[derive(Debug, Clone)]
46pub struct JsBuildInput {
47    pub instrument: Instrument,
48    pub package: PackageSpec,
49}
50
51#[derive(Debug, Clone)]
52pub struct JsArtifact {
53    pub instrument_name: String,
54    pub bundle_dir: PathBuf,
55    pub generated: Vec<GeneratedFile>,
56    pub package: Option<EmittedPackage>,
57}
58
59impl Artifact for JsArtifact {
60    fn files(&self) -> &[GeneratedFile] {
61        &self.generated
62    }
63
64    fn name(&self) -> &str {
65        &self.instrument_name
66    }
67
68    fn primary(&self) -> Option<&GeneratedFile> {
69        self.generated
70            .iter()
71            .find(|f| matches!(f.kind, FileKind::Template))
72            .or_else(|| pick_primary(&self.generated))
73    }
74}
75
76impl From<JsArtifact> for SimpleArtifact {
77    fn from(value: JsArtifact) -> Self {
78        SimpleArtifact::new(value.instrument_name, value.generated)
79    }
80}
81
82pub struct JsBundler {
83    project_root: PathBuf,
84    options: BundleOptions,
85}
86
87impl JsBundler {
88    pub fn new(project_root: impl Into<PathBuf>, options: BundleOptions) -> Self {
89        Self {
90            project_root: project_root.into(),
91            options,
92        }
93    }
94
95    /// Async entry point. Use this when you're already inside a tokio
96    /// runtime; calling [`Builder::build`] from inside one will panic
97    /// (`cannot start a runtime from within a runtime`).
98    pub async fn build_async(&self, input: &JsBuildInput) -> BuildResult<JsArtifact> {
99        let bundles_dir = self
100            .options
101            .bundles_dir
102            .clone()
103            .unwrap_or_else(|| PathBuf::from("bundles"));
104        let abs_bundle_dir = self
105            .project_root
106            .join(&bundles_dir)
107            .join(&input.instrument.name);
108        std::fs::create_dir_all(&abs_bundle_dir).map_err(|e| BuildError::io(&abs_bundle_dir, e))?;
109
110        let entry = input.instrument.resolved_index(&self.project_root)?;
111        let bundler_options = self.bundler_options(&input.instrument, &abs_bundle_dir, &entry)?;
112
113        let mut bundler = Bundler::new(bundler_options)
114            .map_err(|e| BuildError::backend_failure("rolldown-init", format_rolldown_error(&e)))?;
115        bundler.write().await.map_err(|e| {
116            BuildError::backend_failure("rolldown-bundle", format_rolldown_error(&e))
117        })?;
118
119        let js_bundle_path = abs_bundle_dir.join("bundle.js");
120        let css_bundle_path = abs_bundle_dir.join("bundle.css");
121        let css_present = css_bundle_path.exists();
122
123        let mut generated: Vec<GeneratedFile> = Vec::new();
124        if let Ok(file) = stat_file(&js_bundle_path, FileKind::Script) {
125            generated.push(file);
126        }
127        if css_present {
128            if let Ok(file) = stat_file(&css_bundle_path, FileKind::Style) {
129                generated.push(file);
130            }
131        }
132
133        let package = if let Some(sim_pkg) = &input.instrument.simulator_package {
134            if self.options.skip_simulator_package {
135                None
136            } else {
137                let emitted = package::write_package(
138                    &self.project_root,
139                    &input.package,
140                    &input.instrument,
141                    sim_pkg,
142                    &js_bundle_path,
143                    if css_present {
144                        Some(&css_bundle_path)
145                    } else {
146                        None
147                    },
148                )?;
149                push_emitted_files(&emitted, &mut generated);
150                Some(emitted)
151            }
152        } else {
153            None
154        };
155
156        Ok(JsArtifact {
157            instrument_name: input.instrument.name.clone(),
158            bundle_dir: abs_bundle_dir,
159            generated,
160            package,
161        })
162    }
163
164    fn bundler_options(
165        &self,
166        instrument: &Instrument,
167        abs_bundle_dir: &Path,
168        entry: &Path,
169    ) -> BuildResult<BundlerOptions> {
170        let mut opts = BundlerOptions::default();
171
172        opts.input = Some(vec![InputItem {
173            name: Some("bundle".to_string()),
174            import: entry.to_string_lossy().into_owned(),
175        }]);
176        opts.cwd = Some(self.project_root.clone());
177        opts.dir = Some(abs_bundle_dir.to_string_lossy().into_owned());
178        opts.platform = Some(Platform::Browser);
179        opts.format = Some(OutputFormat::Iife);
180
181        // Force literal `bundle.js` / `bundle.css` filenames (no hash).
182        // The "[name]" placeholder becomes our InputItem.name = "bundle".
183        opts.entry_filenames = Some(ChunkFilenamesOutputOption::String("[name].js".to_string()));
184        opts.css_entry_filenames =
185            Some(ChunkFilenamesOutputOption::String("[name].css".to_string()));
186
187        // Externals matching mach. Note IsExternal in 0.1.0 has a
188        // `From<Vec<String>>` impl on the deserializer side; the
189        // public ctor used to be `from_vec`. If the next compile says
190        // otherwise, switch to whatever ctor is exposed.
191        // FIXME(rolldown-0.1): confirm IsExternal constructor name.
192        opts.external = Some(IsExternal::from(vec![
193            "/Images/*".to_string(),
194            "/Fonts/*".to_string(),
195        ]));
196
197        // Treat .otf/.ttf as assets: rolldown copies them next to the
198        // bundle and rewrites imports to relative URLs. Closest match
199        // to mach's `loader: { ".otf": "file" }`.
200        let mut module_types: rustc_hash::FxHashMap<String, ModuleType> = Default::default();
201        module_types.insert(".otf".to_string(), ModuleType::Asset);
202        module_types.insert(".ttf".to_string(), ModuleType::Asset);
203        opts.module_types = Some(module_types);
204
205        // JSX classic React runtime is the default in rolldown 0.1.0
206        // (JsxPreset::Enable). Leaving `opts.transform` unset gives us
207        // that out of the box, matching the mach default.
208
209        // Module aliases for nested instruments (mach's `modules`
210        // feature). Rolldown's ResolveOptions.alias takes a list of
211        // (specifier, replacement-paths) tuples.
212        // FIXME(rolldown-0.1): confirm whether alias is `Vec<(String, Vec<String>)>`
213        // or `IndexMap<String, Vec<String>>`. Both shapes appear in
214        // the wider rolldown ecosystem.
215        if !instrument.modules.is_empty() {
216            let mut resolve = ResolveOptions::default();
217            let alias_entries: Vec<(String, Vec<Option<String>>)> = instrument
218                .modules
219                .iter()
220                .map(|ModuleAlias { resolve, index }| {
221                    let abs = self.project_root.join(index);
222                    (
223                        resolve.clone(),
224                        vec![Some(abs.to_string_lossy().into_owned())],
225                    )
226                })
227                .collect();
228            resolve.alias = Some(alias_entries);
229            opts.resolve = Some(resolve);
230        }
231
232        // process.env.* substitutions via `define`. Values are JSON-
233        // encoded so quoting is preserved — much cleaner than mach's
234        // regex hack. In 0.1.0 `define` is `Option<FxIndexMap<String,
235        // String>>`. We use the rustc_hash + indexmap re-export
236        // surface that rolldown re-exports.
237        if !self.options.env.is_empty() {
238            let mut define: FxIndexMap<String, String> = Default::default();
239            for (key, value) in &self.options.env {
240                let json_value = serde_json::to_string(value).unwrap_or_else(|_| "null".into());
241                define.insert(format!("process.env.{key}"), json_value);
242            }
243            opts.define = Some(define);
244        }
245
246        if self.options.minify {
247            opts.minify = Some(RawMinifyOptions::Bool(true));
248        }
249
250        if let Some(kind) = self.options.sourcemap {
251            // rolldown 0.1.0's SourceMapType is {File, Inline, Hidden}.
252            // We map "external" (linked) → File (writes .map + adds the
253            // sourceMappingURL comment) and "file" → Hidden (writes
254            // .map without the comment, for shipping side-by-side
255            // without exposing a link).
256            opts.sourcemap = Some(match kind {
257                SourceMapKind::Inline => SourceMapType::Inline,
258                SourceMapKind::External => SourceMapType::File,
259                SourceMapKind::File => SourceMapType::Hidden,
260            });
261        }
262
263        Ok(opts)
264    }
265}
266
267/// `Builder` impl. Boots a current-thread tokio runtime per call. If
268/// you already have a runtime, prefer [`JsBundler::build_async`].
269impl Builder for JsBundler {
270    type Input = JsBuildInput;
271    type Output = JsArtifact;
272
273    fn build(&self, input: &Self::Input) -> BuildResult<Self::Output> {
274        let rt = tokio::runtime::Builder::new_current_thread()
275            .enable_all()
276            .build()
277            .map_err(|e| {
278                BuildError::backend_failure(
279                    "tokio-runtime",
280                    format!("could not start runtime: {e}"),
281                )
282            })?;
283        rt.block_on(self.build_async(input))
284    }
285}
286
287fn push_emitted_files(emitted: &EmittedPackage, into: &mut Vec<GeneratedFile>) {
288    for path in emitted.iter_paths() {
289        let kind = match path.extension().and_then(|e| e.to_str()) {
290            Some("html") => FileKind::Template,
291            Some("css") => FileKind::Style,
292            Some("js" | "mjs" | "cjs") => FileKind::Script,
293            Some("map") => FileKind::SourceMap,
294            _ => FileKind::Other,
295        };
296        if let Ok(file) = stat_file(path, kind) {
297            into.push(file);
298        }
299    }
300}
301
302/// Render a rolldown error into a single human-readable string. The
303/// rolldown error type's `Display` is often empty for diagnostic
304/// containers, so we fall back to `Debug` if Display gives us nothing.
305fn format_rolldown_error<E: std::fmt::Debug + std::fmt::Display>(err: &E) -> String {
306    let display = format!("{err}");
307    if display.trim().is_empty() {
308        format!("{err:?}")
309    } else {
310        display
311    }
312}