Skip to main content

infinity_build_js/
bundler.rs

1use crate::config::{Instrument, ModuleAlias, PackageSpec, SimulatorPackage};
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, HashSet};
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::sync::Mutex;
11
12// Re-imports: keep all rolldown types behind one use group so future
13// API drift is a single search/replace away.
14use rolldown::{
15    Bundler, BundlerOptions, ChunkFilenamesOutputOption, InputItem, IsExternal, OutputFormat,
16    Platform, RawMinifyOptions, ResolveOptions, SourceMapType,
17};
18use rolldown_common::{BundlerTransformOptions, Either, ModuleType, TreeshakeOptions};
19use rolldown_utils::indexmap::FxIndexMap;
20
21#[derive(Debug, Clone, Default)]
22pub struct BundleOptions {
23    /// Output directory for raw bundles, relative to `project_root`.
24    /// Defaults to `bundles`. Each instrument gets its own
25    /// `<bundles_dir>/<instrument.name>/` subdirectory.
26    pub bundles_dir: Option<PathBuf>,
27    /// Minify the JS output.
28    pub minify: bool,
29    /// Emit sourcemaps. None = no sourcemaps (default).
30    pub sourcemap: Option<SourceMapKind>,
31    /// Skip the simulator-package emission step. Useful for CI smoke
32    /// tests that just want to know whether the bundle compiles.
33    pub skip_simulator_package: bool,
34    /// Extra `process.env.<name> = <value>` substitutions. Values are
35    /// JSON-encoded automatically — pass strings as plain strings, no
36    /// quoting needed.
37    pub env: HashMap<String, String>,
38}
39
40#[derive(Debug, Clone, Copy)]
41pub enum SourceMapKind {
42    Inline,
43    External,
44    File,
45}
46
47#[derive(Debug, Clone)]
48pub struct JsBuildInput {
49    pub instrument: Instrument,
50    pub package: PackageSpec,
51}
52
53#[derive(Debug, Clone)]
54pub struct JsArtifact {
55    pub instrument_name: String,
56    pub bundle_dir: PathBuf,
57    pub generated: Vec<GeneratedFile>,
58    pub package: Option<EmittedPackage>,
59}
60
61impl Artifact for JsArtifact {
62    fn files(&self) -> &[GeneratedFile] {
63        &self.generated
64    }
65
66    fn name(&self) -> &str {
67        &self.instrument_name
68    }
69
70    fn primary(&self) -> Option<&GeneratedFile> {
71        self.generated
72            .iter()
73            .find(|f| matches!(f.kind, FileKind::Template))
74            .or_else(|| pick_primary(&self.generated))
75    }
76}
77
78impl From<JsArtifact> for SimpleArtifact {
79    fn from(value: JsArtifact) -> Self {
80        SimpleArtifact::new(value.instrument_name, value.generated)
81    }
82}
83
84pub struct JsBundler {
85    project_root: PathBuf,
86    options: BundleOptions,
87    prepared_rescript_dirs: Mutex<HashSet<PathBuf>>,
88}
89
90impl JsBundler {
91    pub fn new(project_root: impl Into<PathBuf>, options: BundleOptions) -> Self {
92        Self {
93            project_root: project_root.into(),
94            options,
95            prepared_rescript_dirs: Mutex::new(HashSet::new()),
96        }
97    }
98
99    /// Async entry point. Use this when you're already inside a tokio
100    /// runtime; calling [`Builder::build`] from inside one will panic
101    /// (`cannot start a runtime from within a runtime`).
102    pub async fn build_async(&self, input: &JsBuildInput) -> BuildResult<JsArtifact> {
103        let bundles_dir = self
104            .options
105            .bundles_dir
106            .clone()
107            .unwrap_or_else(|| PathBuf::from("bundles"));
108        let abs_bundle_dir = self
109            .project_root
110            .join(&bundles_dir)
111            .join(&input.instrument.name);
112        std::fs::create_dir_all(&abs_bundle_dir).map_err(|e| BuildError::io(&abs_bundle_dir, e))?;
113
114        self.prepare_entry(input)?;
115        let entry = input.instrument.resolved_index(&self.project_root)?;
116        let bundler_options = self.bundler_options(&input.instrument, &abs_bundle_dir, &entry)?;
117
118        let mut bundler = Bundler::new(bundler_options)
119            .map_err(|e| BuildError::backend_failure("rolldown-init", format_rolldown_error(&e)))?;
120        bundler.write().await.map_err(|e| {
121            BuildError::backend_failure("rolldown-bundle", format_rolldown_error(&e))
122        })?;
123
124        let js_bundle_path = abs_bundle_dir.join("bundle.js");
125        let css_bundle_path = abs_bundle_dir.join("bundle.css");
126        let css_present = css_bundle_path.exists();
127
128        if matches!(
129            input.instrument.simulator_package,
130            Some(SimulatorPackage::RescriptReact { .. })
131        ) {
132            inject_rescript_react_automount(&js_bundle_path)?;
133        }
134
135        let mut generated: Vec<GeneratedFile> = Vec::new();
136        if let Ok(file) = stat_file(&js_bundle_path, FileKind::Script) {
137            generated.push(file);
138        }
139        if css_present {
140            if let Ok(file) = stat_file(&css_bundle_path, FileKind::Style) {
141                generated.push(file);
142            }
143        }
144
145        let package = if let Some(sim_pkg) = &input.instrument.simulator_package {
146            if self.options.skip_simulator_package {
147                None
148            } else {
149                let emitted = package::write_package(
150                    &self.project_root,
151                    &input.package,
152                    &input.instrument,
153                    sim_pkg,
154                    &js_bundle_path,
155                    if css_present {
156                        Some(&css_bundle_path)
157                    } else {
158                        None
159                    },
160                )?;
161                push_emitted_files(&emitted, &mut generated);
162                Some(emitted)
163            }
164        } else {
165            None
166        };
167
168        Ok(JsArtifact {
169            instrument_name: input.instrument.name.clone(),
170            bundle_dir: abs_bundle_dir,
171            generated,
172            package,
173        })
174    }
175
176    fn prepare_entry(&self, input: &JsBuildInput) -> BuildResult<()> {
177        let Some(SimulatorPackage::RescriptReact {
178            build_command,
179            build_dir,
180            ..
181        }) = &input.instrument.simulator_package
182        else {
183            return Ok(());
184        };
185
186        let build_dir =
187            resolve_rescript_build_dir(&self.project_root, &input.instrument, build_dir.as_ref())?;
188        let mut prepared = self.prepared_rescript_dirs.lock().map_err(|_| {
189            BuildError::backend_failure(
190                "rescript-build",
191                format!(
192                    "failed to acquire ReScript build lock for {}",
193                    build_dir.display()
194                ),
195            )
196        })?;
197        if !prepared.insert(build_dir.clone()) {
198            return Ok(());
199        }
200        drop(prepared);
201
202        run_rescript_build_command(
203            build_command.as_deref().unwrap_or("bun run build"),
204            &build_dir,
205        )
206    }
207
208    fn bundler_options(
209        &self,
210        instrument: &Instrument,
211        abs_bundle_dir: &Path,
212        entry: &Path,
213    ) -> BuildResult<BundlerOptions> {
214        let mut opts = BundlerOptions::default();
215
216        opts.input = Some(vec![InputItem {
217            name: Some("bundle".to_string()),
218            import: entry.to_string_lossy().into_owned(),
219        }]);
220        opts.cwd = Some(self.project_root.clone());
221        opts.dir = Some(abs_bundle_dir.to_string_lossy().into_owned());
222        opts.platform = Some(Platform::Browser);
223        opts.format = Some(OutputFormat::Iife);
224
225        // Force literal `bundle.js` / `bundle.css` filenames (no hash).
226        // The "[name]" placeholder becomes our InputItem.name = "bundle".
227        opts.entry_filenames = Some(ChunkFilenamesOutputOption::String("[name].js".to_string()));
228        opts.css_entry_filenames =
229            Some(ChunkFilenamesOutputOption::String("[name].css".to_string()));
230
231        // Externals matching mach. Note IsExternal in 0.1.0 has a
232        // `From<Vec<String>>` impl on the deserializer side; the
233        // public ctor used to be `from_vec`. If the next compile says
234        // otherwise, switch to whatever ctor is exposed.
235        // FIXME(rolldown-0.1): confirm IsExternal constructor name.
236        opts.external = Some(IsExternal::from(vec![
237            "/Images/*".to_string(),
238            "/Fonts/*".to_string(),
239        ]));
240
241        // Treat .otf/.ttf as assets: rolldown copies them next to the
242        // bundle and rewrites imports to relative URLs. Closest match
243        // to mach's `loader: { ".otf": "file" }`.
244        let mut module_types: rustc_hash::FxHashMap<String, ModuleType> = Default::default();
245        module_types.insert(".otf".to_string(), ModuleType::Asset);
246        module_types.insert(".ttf".to_string(), ModuleType::Asset);
247        // Force .js through the Jsx parse path so rolldown 0.1.0's
248        // transformer actually runs on plain JS — without this, the
249        // transform.target setting below is silently ignored for .js
250        // files (see rolldown pre_process_ecma_ast.rs: only !Js types
251        // hit the Transformer). JSX is a superset of JS, so non-JSX
252        // sources still parse cleanly.
253        module_types.insert(".js".to_string(), ModuleType::Jsx);
254        module_types.insert(".mjs".to_string(), ModuleType::Jsx);
255        module_types.insert(".cjs".to_string(), ModuleType::Jsx);
256        opts.module_types = Some(module_types);
257
258        // Lower modern syntax for Coherent GT (MSFS HTML UI). Coherent
259        // chokes on optional chaining / nullish coalescing, so target
260        // es2019. JSX classic runtime is still the rolldown default.
261        let mut transform = BundlerTransformOptions::default();
262        transform.target = Some(Either::Left("es2019".to_string()));
263        opts.transform = Some(transform);
264
265        // Disable rolldown's DCE pass. Its oxc compressor runs after
266        // the transformer with `CompressOptions::dce()` (no target),
267        // and re-introduces ES2020 `?.` / `??` on patterns it folds —
268        // undoing the lowering and tripping Coherent GT.
269        opts.treeshake = TreeshakeOptions::Boolean(false);
270
271        // Module aliases for nested instruments (mach's `modules`
272        // feature). Rolldown's ResolveOptions.alias takes a list of
273        // (specifier, replacement-paths) tuples.
274        // FIXME(rolldown-0.1): confirm whether alias is `Vec<(String, Vec<String>)>`
275        // or `IndexMap<String, Vec<String>>`. Both shapes appear in
276        // the wider rolldown ecosystem.
277        if !instrument.modules.is_empty() {
278            let mut resolve = ResolveOptions::default();
279            let alias_entries: Vec<(String, Vec<Option<String>>)> = instrument
280                .modules
281                .iter()
282                .map(|ModuleAlias { resolve, index }| {
283                    let abs = self.project_root.join(index);
284                    (
285                        resolve.clone(),
286                        vec![Some(abs.to_string_lossy().into_owned())],
287                    )
288                })
289                .collect();
290            resolve.alias = Some(alias_entries);
291            opts.resolve = Some(resolve);
292        }
293
294        // process.env.* substitutions via `define`. Values are JSON-
295        // encoded so quoting is preserved — much cleaner than mach's
296        // regex hack. In 0.1.0 `define` is `Option<FxIndexMap<String,
297        // String>>`. We use the rustc_hash + indexmap re-export
298        // surface that rolldown re-exports.
299        if !self.options.env.is_empty() {
300            let mut define: FxIndexMap<String, String> = Default::default();
301            for (key, value) in &self.options.env {
302                let json_value = serde_json::to_string(value).unwrap_or_else(|_| "null".into());
303                define.insert(format!("process.env.{key}"), json_value);
304            }
305            opts.define = Some(define);
306        }
307
308        if self.options.minify {
309            opts.minify = Some(RawMinifyOptions::Bool(true));
310        }
311
312        if let Some(kind) = self.options.sourcemap {
313            // rolldown 0.1.0's SourceMapType is {File, Inline, Hidden}.
314            // We map "external" (linked) → File (writes .map + adds the
315            // sourceMappingURL comment) and "file" → Hidden (writes
316            // .map without the comment, for shipping side-by-side
317            // without exposing a link).
318            opts.sourcemap = Some(match kind {
319                SourceMapKind::Inline => SourceMapType::Inline,
320                SourceMapKind::External => SourceMapType::File,
321                SourceMapKind::File => SourceMapType::Hidden,
322            });
323        }
324
325        Ok(opts)
326    }
327}
328
329/// `Builder` impl. Boots a current-thread tokio runtime per call. If
330/// you already have a runtime, prefer [`JsBundler::build_async`].
331impl Builder for JsBundler {
332    type Input = JsBuildInput;
333    type Output = JsArtifact;
334
335    fn build(&self, input: &Self::Input) -> BuildResult<Self::Output> {
336        let rt = tokio::runtime::Builder::new_current_thread()
337            .enable_all()
338            .build()
339            .map_err(|e| {
340                BuildError::backend_failure(
341                    "tokio-runtime",
342                    format!("could not start runtime: {e}"),
343                )
344            })?;
345        rt.block_on(self.build_async(input))
346    }
347}
348
349fn push_emitted_files(emitted: &EmittedPackage, into: &mut Vec<GeneratedFile>) {
350    for path in emitted.iter_paths() {
351        let kind = match path.extension().and_then(|e| e.to_str()) {
352            Some("html") => FileKind::Template,
353            Some("css") => FileKind::Style,
354            Some("js" | "mjs" | "cjs") => FileKind::Script,
355            Some("map") => FileKind::SourceMap,
356            _ => FileKind::Other,
357        };
358        if let Ok(file) = stat_file(path, kind) {
359            into.push(file);
360        }
361    }
362}
363
364/// Render a rolldown error into a single human-readable string. The
365/// rolldown error type's `Display` is often empty for diagnostic
366/// containers, so we fall back to `Debug` if Display gives us nothing.
367fn format_rolldown_error<E: std::fmt::Debug + std::fmt::Display>(err: &E) -> String {
368    let display = format!("{err}");
369    if display.trim().is_empty() {
370        format!("{err:?}")
371    } else {
372        display
373    }
374}
375
376fn resolve_rescript_build_dir(
377    project_root: &Path,
378    instrument: &Instrument,
379    configured: Option<&PathBuf>,
380) -> BuildResult<PathBuf> {
381    let dir = if let Some(configured) = configured {
382        resolve_path(project_root, configured)
383    } else {
384        discover_rescript_project_dir(project_root, instrument)
385    };
386
387    if !dir.is_dir() {
388        return Err(BuildError::invalid_path(
389            &dir,
390            "ReScript build directory does not exist or is not a directory",
391        ));
392    }
393
394    Ok(dir)
395}
396
397/// Inject an auto-invocation of the bundle's `mount` export into the
398/// IIFE produced by rolldown for ReScript-React instruments.
399///
400/// rolldown emits `(function(exports){ ...; exports.mount = mount; return exports; })({});`
401/// and discards the returned object, so the user's `mount` is defined
402/// but never called. We splice a call inside the closure, just before
403/// `return exports;`, so it runs in the IIFE scope when the script
404/// loads.
405fn inject_rescript_react_automount(bundle_path: &Path) -> BuildResult<()> {
406    let source = std::fs::read_to_string(bundle_path).map_err(|e| BuildError::io(bundle_path, e))?;
407
408    let marker = "return exports;";
409    let Some(idx) = source.rfind(marker) else {
410        return Err(BuildError::backend_failure(
411            "rescript-automount",
412            format!(
413                "expected `return exports;` in IIFE bundle at {}",
414                bundle_path.display()
415            ),
416        ));
417    };
418
419    let injected = "if (typeof exports.mount === 'function') { exports.mount(); }\n";
420    let mut patched = String::with_capacity(source.len() + injected.len());
421    patched.push_str(&source[..idx]);
422    patched.push_str(injected);
423    patched.push_str(&source[idx..]);
424
425    std::fs::write(bundle_path, patched).map_err(|e| BuildError::io(bundle_path, e))
426}
427
428fn discover_rescript_project_dir(project_root: &Path, instrument: &Instrument) -> PathBuf {
429    let entry_path = resolve_path(project_root, &instrument.index);
430    let mut current = entry_path.parent().unwrap_or(project_root).to_path_buf();
431
432    loop {
433        if contains_rescript_marker(&current) {
434            return current;
435        }
436
437        if current == project_root {
438            break;
439        }
440
441        let Some(parent) = current.parent() else {
442            break;
443        };
444        if !parent.starts_with(project_root) {
445            break;
446        }
447        current = parent.to_path_buf();
448    }
449
450    project_root.to_path_buf()
451}
452
453fn contains_rescript_marker(dir: &Path) -> bool {
454    ["rescript.json", "bsconfig.json", "package.json"]
455        .into_iter()
456        .any(|name| dir.join(name).exists())
457}
458
459fn resolve_path(project_root: &Path, path: &Path) -> PathBuf {
460    if path.is_absolute() {
461        path.to_path_buf()
462    } else {
463        project_root.join(path)
464    }
465}
466
467fn run_rescript_build_command(command: &str, cwd: &Path) -> BuildResult<()> {
468    let output = shell_command(command)
469        .current_dir(cwd)
470        .output()
471        .map_err(|e| {
472            BuildError::backend_failure(
473                "rescript-build",
474                format!("failed to start `{command}` in {}: {e}", cwd.display()),
475            )
476        })?;
477
478    if output.status.success() {
479        return Ok(());
480    }
481
482    let stdout = String::from_utf8_lossy(&output.stdout).replace("\r\n", "\n");
483    let stderr = String::from_utf8_lossy(&output.stderr).replace("\r\n", "\n");
484    let detail = if !stderr.trim().is_empty() {
485        stderr.trim().to_string()
486    } else if !stdout.trim().is_empty() {
487        stdout.trim().to_string()
488    } else {
489        "no output captured".to_string()
490    };
491
492    Err(BuildError::backend_failure(
493        "rescript-build",
494        format!(
495            "`{command}` failed in {} with status {}:\n{}",
496            cwd.display(),
497            output.status,
498            detail
499        ),
500    ))
501}
502
503#[cfg(windows)]
504fn shell_command(script: &str) -> Command {
505    let mut cmd = Command::new("cmd");
506    cmd.arg("/C").arg(script);
507    cmd
508}
509
510#[cfg(not(windows))]
511fn shell_command(script: &str) -> Command {
512    let mut cmd = Command::new("sh");
513    cmd.arg("-c").arg(script);
514    cmd
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use crate::config::SimulatorPackageKind;
521    use std::time::{SystemTime, UNIX_EPOCH};
522
523    struct TempDir {
524        path: PathBuf,
525    }
526
527    impl TempDir {
528        fn new(prefix: &str) -> Self {
529            let unique = SystemTime::now()
530                .duration_since(UNIX_EPOCH)
531                .unwrap()
532                .as_nanos();
533            let path = std::env::temp_dir().join(format!("infinity-build-js-{prefix}-{unique}"));
534            std::fs::create_dir_all(&path).unwrap();
535            Self { path }
536        }
537    }
538
539    impl Drop for TempDir {
540        fn drop(&mut self) {
541            let _ = std::fs::remove_dir_all(&self.path);
542        }
543    }
544
545    #[test]
546    fn discovers_nearest_rescript_project_dir() {
547        let temp = TempDir::new("discover");
548        let ui_dir = temp.path.join("ui");
549        std::fs::create_dir_all(ui_dir.join("src")).unwrap();
550        std::fs::write(ui_dir.join("rescript.json"), "{}").unwrap();
551
552        let instrument = Instrument {
553            name: "PFD".into(),
554            index: PathBuf::from("ui/src/Main.res.mjs"),
555            simulator_package: Some(SimulatorPackage::RescriptReact {
556                file_name: None,
557                template_id: None,
558                is_interactive: true,
559                imports: Vec::new(),
560                html_template: None,
561                js_template: None,
562                build_command: None,
563                build_dir: None,
564            }),
565            modules: Vec::new(),
566        };
567
568        let resolved = resolve_rescript_build_dir(&temp.path, &instrument, None).unwrap();
569        assert_eq!(resolved, ui_dir);
570        assert_eq!(
571            instrument.simulator_package.unwrap().kind(),
572            SimulatorPackageKind::RescriptReact
573        );
574    }
575
576    #[test]
577    fn rescript_react_runs_build_before_bundling() {
578        let temp = TempDir::new("bundle");
579        std::fs::create_dir_all(temp.path.join("src")).unwrap();
580        std::fs::write(temp.path.join("package.json"), "{}").unwrap();
581        let build_command = create_entry_build_script(&temp.path, "src/Main.res.mjs");
582
583        let instrument = Instrument {
584            name: "PFD".into(),
585            index: PathBuf::from("src/Main.res.mjs"),
586            simulator_package: Some(SimulatorPackage::RescriptReact {
587                file_name: None,
588                template_id: Some("PFD".into()),
589                is_interactive: true,
590                imports: Vec::new(),
591                html_template: None,
592                js_template: None,
593                build_command: Some(build_command),
594                build_dir: None,
595            }),
596            modules: Vec::new(),
597        };
598        let input = JsBuildInput {
599            instrument,
600            package: PackageSpec {
601                package_name: "pkg".into(),
602                package_dir: PathBuf::from("PackageSources"),
603            },
604        };
605
606        let bundler = JsBundler::new(
607            temp.path.clone(),
608            BundleOptions {
609                skip_simulator_package: true,
610                ..BundleOptions::default()
611            },
612        );
613
614        let artifact = bundler.build(&input).unwrap();
615        assert!(temp.path.join("src/Main.res.mjs").exists());
616        assert!(artifact.bundle_dir.join("bundle.js").exists());
617        assert!(!artifact.files().is_empty());
618    }
619
620    #[cfg(windows)]
621    fn create_entry_build_script(root: &Path, path: &str) -> String {
622        let script_path = root.join("build-entry.ps1");
623        let path = path.replace('/', "\\");
624        std::fs::write(
625            &script_path,
626            format!(
627                "$null = New-Item -ItemType Directory -Force -Path 'src'\n$null = New-Item -ItemType File -Force -Path '{path}'\n"
628            ),
629        )
630        .unwrap();
631        format!(
632            "powershell -NoProfile -ExecutionPolicy Bypass -File {}",
633            script_path.display()
634        )
635    }
636
637    #[cfg(not(windows))]
638    fn create_entry_build_script(root: &Path, path: &str) -> String {
639        let script_path = root.join("build-entry.sh");
640        std::fs::write(&script_path, format!("mkdir -p src\n: > {path}\n")).unwrap();
641        format!("sh {}", script_path.display())
642    }
643}