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
12use 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 pub bundles_dir: Option<PathBuf>,
27 pub minify: bool,
29 pub sourcemap: Option<SourceMapKind>,
31 pub skip_simulator_package: bool,
34 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 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 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 opts.external = Some(IsExternal::from(vec![
237 "/Images/*".to_string(),
238 "/Fonts/*".to_string(),
239 ]));
240
241 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 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 let mut transform = BundlerTransformOptions::default();
262 transform.target = Some(Either::Left("es2019".to_string()));
263 opts.transform = Some(transform);
264
265 opts.treeshake = TreeshakeOptions::Boolean(false);
270
271 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 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 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
329impl 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
364fn 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
397fn 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(¤t) {
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}