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
10use 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 pub bundles_dir: Option<PathBuf>,
25 pub minify: bool,
27 pub sourcemap: Option<SourceMapKind>,
29 pub skip_simulator_package: bool,
32 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 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 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 opts.external = Some(IsExternal::from(vec![
193 "/Images/*".to_string(),
194 "/Fonts/*".to_string(),
195 ]));
196
197 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 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 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 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
267impl 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
302fn 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}