use crate::config::{Instrument, ModuleAlias, PackageSpec};
use crate::package::{self, EmittedPackage};
use infinity_build_core::{
Artifact, BuildError, BuildResult, Builder, FileKind, GeneratedFile, SimpleArtifact,
pick_primary, stat_file,
};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use rolldown::{
Bundler, BundlerOptions, ChunkFilenamesOutputOption, InputItem, IsExternal, OutputFormat,
Platform, RawMinifyOptions, ResolveOptions, SourceMapType,
};
use rolldown_common::ModuleType;
use rolldown_utils::indexmap::FxIndexMap;
#[derive(Debug, Clone, Default)]
pub struct BundleOptions {
pub bundles_dir: Option<PathBuf>,
pub minify: bool,
pub sourcemap: Option<SourceMapKind>,
pub skip_simulator_package: bool,
pub env: HashMap<String, String>,
}
#[derive(Debug, Clone, Copy)]
pub enum SourceMapKind {
Inline,
External,
File,
}
#[derive(Debug, Clone)]
pub struct JsBuildInput {
pub instrument: Instrument,
pub package: PackageSpec,
}
#[derive(Debug, Clone)]
pub struct JsArtifact {
pub instrument_name: String,
pub bundle_dir: PathBuf,
pub generated: Vec<GeneratedFile>,
pub package: Option<EmittedPackage>,
}
impl Artifact for JsArtifact {
fn files(&self) -> &[GeneratedFile] {
&self.generated
}
fn name(&self) -> &str {
&self.instrument_name
}
fn primary(&self) -> Option<&GeneratedFile> {
self.generated
.iter()
.find(|f| matches!(f.kind, FileKind::Template))
.or_else(|| pick_primary(&self.generated))
}
}
impl From<JsArtifact> for SimpleArtifact {
fn from(value: JsArtifact) -> Self {
SimpleArtifact::new(value.instrument_name, value.generated)
}
}
pub struct JsBundler {
project_root: PathBuf,
options: BundleOptions,
}
impl JsBundler {
pub fn new(project_root: impl Into<PathBuf>, options: BundleOptions) -> Self {
Self {
project_root: project_root.into(),
options,
}
}
pub async fn build_async(&self, input: &JsBuildInput) -> BuildResult<JsArtifact> {
let bundles_dir = self
.options
.bundles_dir
.clone()
.unwrap_or_else(|| PathBuf::from("bundles"));
let abs_bundle_dir = self
.project_root
.join(&bundles_dir)
.join(&input.instrument.name);
std::fs::create_dir_all(&abs_bundle_dir).map_err(|e| BuildError::io(&abs_bundle_dir, e))?;
let entry = input.instrument.resolved_index(&self.project_root)?;
let bundler_options = self.bundler_options(&input.instrument, &abs_bundle_dir, &entry)?;
let mut bundler = Bundler::new(bundler_options)
.map_err(|e| BuildError::backend_failure("rolldown-init", format_rolldown_error(&e)))?;
bundler.write().await.map_err(|e| {
BuildError::backend_failure("rolldown-bundle", format_rolldown_error(&e))
})?;
let js_bundle_path = abs_bundle_dir.join("bundle.js");
let css_bundle_path = abs_bundle_dir.join("bundle.css");
let css_present = css_bundle_path.exists();
let mut generated: Vec<GeneratedFile> = Vec::new();
if let Ok(file) = stat_file(&js_bundle_path, FileKind::Script) {
generated.push(file);
}
if css_present {
if let Ok(file) = stat_file(&css_bundle_path, FileKind::Style) {
generated.push(file);
}
}
let package = if let Some(sim_pkg) = &input.instrument.simulator_package {
if self.options.skip_simulator_package {
None
} else {
let emitted = package::write_package(
&self.project_root,
&input.package,
&input.instrument,
sim_pkg,
&js_bundle_path,
if css_present {
Some(&css_bundle_path)
} else {
None
},
)?;
push_emitted_files(&emitted, &mut generated);
Some(emitted)
}
} else {
None
};
Ok(JsArtifact {
instrument_name: input.instrument.name.clone(),
bundle_dir: abs_bundle_dir,
generated,
package,
})
}
fn bundler_options(
&self,
instrument: &Instrument,
abs_bundle_dir: &Path,
entry: &Path,
) -> BuildResult<BundlerOptions> {
let mut opts = BundlerOptions::default();
opts.input = Some(vec![InputItem {
name: Some("bundle".to_string()),
import: entry.to_string_lossy().into_owned(),
}]);
opts.cwd = Some(self.project_root.clone());
opts.dir = Some(abs_bundle_dir.to_string_lossy().into_owned());
opts.platform = Some(Platform::Browser);
opts.format = Some(OutputFormat::Iife);
opts.entry_filenames = Some(ChunkFilenamesOutputOption::String("[name].js".to_string()));
opts.css_entry_filenames =
Some(ChunkFilenamesOutputOption::String("[name].css".to_string()));
opts.external = Some(IsExternal::from(vec![
"/Images/*".to_string(),
"/Fonts/*".to_string(),
]));
let mut module_types: rustc_hash::FxHashMap<String, ModuleType> = Default::default();
module_types.insert(".otf".to_string(), ModuleType::Asset);
module_types.insert(".ttf".to_string(), ModuleType::Asset);
opts.module_types = Some(module_types);
if !instrument.modules.is_empty() {
let mut resolve = ResolveOptions::default();
let alias_entries: Vec<(String, Vec<Option<String>>)> = instrument
.modules
.iter()
.map(|ModuleAlias { resolve, index }| {
let abs = self.project_root.join(index);
(
resolve.clone(),
vec![Some(abs.to_string_lossy().into_owned())],
)
})
.collect();
resolve.alias = Some(alias_entries);
opts.resolve = Some(resolve);
}
if !self.options.env.is_empty() {
let mut define: FxIndexMap<String, String> = Default::default();
for (key, value) in &self.options.env {
let json_value = serde_json::to_string(value).unwrap_or_else(|_| "null".into());
define.insert(format!("process.env.{key}"), json_value);
}
opts.define = Some(define);
}
if self.options.minify {
opts.minify = Some(RawMinifyOptions::Bool(true));
}
if let Some(kind) = self.options.sourcemap {
opts.sourcemap = Some(match kind {
SourceMapKind::Inline => SourceMapType::Inline,
SourceMapKind::External => SourceMapType::File,
SourceMapKind::File => SourceMapType::Hidden,
});
}
Ok(opts)
}
}
impl Builder for JsBundler {
type Input = JsBuildInput;
type Output = JsArtifact;
fn build(&self, input: &Self::Input) -> BuildResult<Self::Output> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| {
BuildError::backend_failure(
"tokio-runtime",
format!("could not start runtime: {e}"),
)
})?;
rt.block_on(self.build_async(input))
}
}
fn push_emitted_files(emitted: &EmittedPackage, into: &mut Vec<GeneratedFile>) {
for path in emitted.iter_paths() {
let kind = match path.extension().and_then(|e| e.to_str()) {
Some("html") => FileKind::Template,
Some("css") => FileKind::Style,
Some("js" | "mjs" | "cjs") => FileKind::Script,
Some("map") => FileKind::SourceMap,
_ => FileKind::Other,
};
if let Ok(file) = stat_file(path, kind) {
into.push(file);
}
}
}
fn format_rolldown_error<E: std::fmt::Debug + std::fmt::Display>(err: &E) -> String {
let display = format!("{err}");
if display.trim().is_empty() {
format!("{err:?}")
} else {
display
}
}