use std::collections::HashSet; use std::path::{Path, PathBuf}; use avril_core::Error; use avril_runtime::bundle; use avril_runtime::caps::PathTarget; use avril_runtime::modules::{ModuleGraph, http_url}; use crate::cli::ParseError; use crate::fragments::{Arg, Parser, flag_value, reject}; pub struct BundleArgs { pub entry: crate::fragments::Entry, pub entries: Vec, pub output: Output, pub bundle: bundle::Options, } /// `avril bundle` argument parsing failed. #[derive(Debug, thiserror::Error)] pub enum BundleParseError { #[error(transparent)] Generic(#[from] ParseError), #[error("Unsupported --target {text}: expected es2015 to es2025 or esnext")] BadTarget { text: String }, #[error("--out and --outdir cannot be combined")] OutAndOutdir, #[error("--out requires exactly one entry module; use --outdir for multiple entries")] OutRequiresSingle, #[error("--no-splitting requires exactly one entry module")] NoSplittingSingle, #[error( "Expected --define KEY=VALUE, where KEY is a dotted identifier and VALUE an expression" )] BadDefine, } pub(crate) fn target(text: &str) -> Result, BundleParseError> { if text == "esnext" { return Ok(None); } text.strip_prefix("es") .and_then(|year| year.parse().ok()) .filter(|year| (2015..=2025).contains(year)) .map(Some) .ok_or_else(|| BundleParseError::BadTarget { text: text.to_owned() }) } pub fn parse(argv: Vec, cwd: PathBuf) -> Result { let mut parser = Parser::new("bundle", argv, &cwd)?; let mut bundle = bundle::Options::default(); let (mut output, mut outdir, mut raw) = (None, None, Vec::new()); while let Some(arg) = parser.next()? { let arg = match arg { Arg::Positional(arg) => { raw.push(arg); continue; } Arg::Flag(arg) if matches!( arg.as_str(), "-o" | "--out" | "--outdir" | "--target" | "--define" ) => { let value = parser.value(ParseError::SpacedValue { flag: arg.clone() })?; format!("{arg}={value}") } Arg::Flag(arg) => arg, }; match arg.as_str() { "--minify" => bundle.minify = true, "--sourcemap" => bundle.sourcemap = true, "--no-splitting" => bundle.no_splitting = true, _ => match flag_value(&arg)? { ("-o" | "--out", value) => output = Some(cwd.join(value)), ("--outdir", value) => outdir = Some(cwd.join(value)), ("--target", value) => bundle.target = target(value)?, ("--define", value) => { let (key, value) = value .split_once('=') .filter(|(key, value)| { !value.is_empty() && key .split('.') .all(oxc::syntax::identifier::is_identifier_name) }) .ok_or(BundleParseError::BadDefine)?; bundle.defines.push((key.to_owned(), value.to_owned())); } _ => { return Err(reject(&arg, &["--minify", "--sourcemap", "--no-splitting"]).into()); } }, } } let output = match (output, outdir) { (Some(_), Some(_)) => return Err(BundleParseError::OutAndOutdir), (Some(path), None) => { if raw.len() > 1 { return Err(BundleParseError::OutRequiresSingle); } Output::File(path) } (None, Some(path)) => Output::Directory(path), (None, None) => return Err(parser.usage().into()), }; if bundle.no_splitting && raw.len() > 1 { return Err(BundleParseError::NoSplittingSingle); } let first = raw.first().ok_or_else(|| parser.usage())?; let resolved = parser.resolve(first)?; let mut entries = vec![resolved.module.clone()]; for extra in raw.iter().skip(1) { entries.push(parser.resolve(extra)?.module); } let entry = parser .common .finish(resolved.path, resolved.module, resolved.lock)?; Ok(BundleArgs { entry, entries, output, bundle }) } pub enum Output { File(PathBuf), Directory(PathBuf), } pub(crate) fn run( graph: ModuleGraph, entries: &[String], output: &Output, options: &bundle::Options, ) -> Result<(), Error> { let (parent, file_name) = match output { Output::File(out) => { let name = out .file_name() .and_then(|name| name.to_str()) .filter(|name| name.ends_with(".js") || name.ends_with(".mjs")) .ok_or_else(|| Error::Module("Output must be a .js or .mjs file".into()))?; url_safe(name)?; (out.parent().unwrap(), Some(name)) } Output::Directory(out) => (out.as_path(), None), }; let mut entry_files = HashSet::new(); let mut input = Vec::new(); for entry in entries { let name = if let Some(file_name) = file_name { entry_files.insert(file_name.to_owned()); None } else if avril_runtime::modules::data_url(entry).is_some() { return Err(Error::Module( "Data URL entries require an explicit -o output filename".into(), )); } else { let url = http_url(entry); let path = url.as_ref().map_or(entry.as_str(), |url| url.path()); let name = Path::new(path) .file_stem() .and_then(|name| name.to_str()) .ok_or_else(|| Error::Module(format!("Cannot name entry output: {entry}")))?; let file_name = format!("{name}.js"); url_safe(&file_name)?; if !entry_files.insert(file_name.clone()) { return Err(Error::Module(format!( "Duplicate entry output filename: {file_name}" ))); } Some(name.to_owned()) }; input.push(bundle::Entry { name, import: entry.to_owned() }); } // An ambiguous destination, like `..` below a missing directory, fails here. let directory = PathTarget::for_creation(parent) .map_err(|_| Error::Module(format!("Invalid output path: {}", parent.display())))? .into_path(); let generated = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .map_err(|error| Error::Module(error.to_string()))? .block_on(bundle::generate( &graph, &input, file_name.unwrap_or("[name].js"), &directory, options, ))?; for warning in &generated.warnings { eprintln!("{warning}"); } graph .store() .save_graph(&generated.lock_keys, &generated.modules) .map_err(|error| Error::Module(error.to_string()))?; let mut inputs: HashSet = generated .modules .iter() .map(Path::new) .filter(|path| path.is_absolute()) .map(physical) .collect(); inputs.extend(graph.config_inputs().iter().map(|path| physical(path))); inputs.insert(physical(&graph.store().options().lock)); publish(&inputs, parent, &directory, &entry_files, generated.files) } /// Output files become relative URLs in `import()` calls and map references. fn url_safe(file_name: &str) -> Result<(), Error> { if file_name.is_empty() || file_name.starts_with('.') || file_name.contains(['/', '\\', '?', '#', '%', '[', ']']) || file_name .chars() .any(|c| c.is_control() || c.is_whitespace()) { return Err(Error::Module(format!( "Unsupported output filename: {file_name:?}" ))); } Ok(()) } fn physical(path: &Path) -> PathBuf { path.canonicalize() .or_else(|_| PathTarget::for_entry(path).map(PathTarget::into_path)) .unwrap_or_else(|_| path.to_owned()) } /// Checks every destination before writing any, stages every file, then /// renames chunks and maps into place before the entry that loads them. fn publish( inputs: &HashSet, parent: &Path, directory: &Path, entries: &HashSet, mut files: Vec<(String, Vec)>, ) -> Result<(), Error> { let failed = |path: &Path, error: std::io::Error| { Error::Module(format!("Cannot write {}: {error}", path.display())) }; for (name, _) in &files { url_safe(name)?; let path = directory.join(name); if std::fs::symlink_metadata(&path).is_ok_and(|metadata| !metadata.is_file()) { return Err(Error::Module(format!( "Output {} is a symlink or not a regular file", path.display() ))); } if inputs.contains(&physical(&path)) { return Err(Error::Module(format!( "Output {} would overwrite a bundle input", path.display() ))); } } // Creating through the given spelling refuses dangling symlinked parents. std::fs::create_dir_all(parent).map_err(|error| failed(parent, error))?; if PathTarget::resolve(parent) .ok() .as_ref() .map(PathTarget::as_path) != Some(directory) { return Err(Error::Module(format!( "Output directory {} changed while bundling", parent.display() ))); } files.sort_by_key(|(name, _)| entries.contains(name)); let mut staged = Vec::new(); for (name, bytes) in files { let path = directory.join(name); let mut builder = tempfile::Builder::new(); builder.prefix(".avril-bundle-"); #[cfg(unix)] builder.permissions(std::os::unix::fs::PermissionsExt::from_mode(0o666)); let mut file = builder .tempfile_in(directory) .map_err(|error| failed(&path, error))?; std::io::Write::write_all(&mut file, &bytes).map_err(|error| failed(&path, error))?; if let Ok(previous) = std::fs::symlink_metadata(&path) && previous.is_file() { file.as_file() .set_permissions(previous.permissions()) .map_err(|error| failed(&path, error))?; } staged.push((file, path)); } for (file, path) in staged { file.persist(&path) .map_err(|error| failed(&path, error.error))?; } Ok(()) }