use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::core::importmap::Importmap;
use crate::{Error, Result};
pub struct BundleOptions<'a> {
pub entry: &'a Path,
pub cwd: &'a Path,
pub out_dir: &'a Path,
pub production: bool,
}
pub fn bundle(opts: &BundleOptions<'_>) -> Result<()> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| Error::Bundle(format!("tokio runtime: {e}")))?
.block_on(bundle_async(opts))
}
async fn bundle_async(opts: &BundleOptions<'_>) -> Result<()> {
let cwd = opts
.cwd
.canonicalize()
.map_err(|e| Error::Bundle(format!("cwd {}: {e}", opts.cwd.display())))?;
let cwd_for_resolve = Arc::new(cwd);
let is_external = rolldown::IsExternal::Fn(Some(Arc::new(
move |specifier: &str, _importer: Option<&str>, resolved: bool| {
let cwd = Arc::clone(&cwd_for_resolve);
let specifier = specifier.to_string();
Box::pin(async move {
if resolved {
if let Ok(real) = Path::new(&specifier).canonicalize() {
if !real.starts_with(cwd.as_path()) {
return Err(Error::Bundle(format!(
"module {specifier} resolves outside the bundle cwd {}",
cwd.display()
))
.into());
}
}
}
Ok(false)
})
},
)));
let mut bundler = rolldown::Bundler::new(rolldown::BundlerOptions {
input: Some(vec![opts.entry.to_string_lossy().to_string().into()]),
cwd: Some(opts.cwd.to_path_buf()),
format: Some(rolldown::OutputFormat::Esm),
dir: Some(opts.out_dir.to_string_lossy().to_string()),
external: Some(is_external),
minify: Some(opts.production.into()),
define: opts.production.then(|| {
[(
"process.env.NODE_ENV".to_string(),
"\"production\"".to_string(),
)]
.into_iter()
.collect()
}),
..Default::default()
})
.map_err(|e| Error::Bundle(format!("{e:?}")))?;
bundler
.write()
.await
.map_err(|e| Error::Bundle(format!("{e:?}")))?;
Ok(())
}
pub struct SplitBundleOptions<'a> {
pub entries: &'a [PathBuf],
pub root: &'a Path,
pub out_dir: &'a Path,
pub importmap: Option<&'a Importmap>,
pub external: &'a [String],
pub chunk_filenames: &'a str,
pub minify: bool,
}
pub struct SplitBundleOutput {
pub bundled_modules: Vec<PathBuf>,
}
pub fn bundle_split(opts: &SplitBundleOptions<'_>) -> Result<SplitBundleOutput> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| Error::Bundle(format!("tokio runtime: {e}")))?
.block_on(bundle_split_async(opts))
}
fn matches_external(external: &[String], value: &str) -> bool {
external.iter().any(|e| {
if let Some(prefix) = e.strip_suffix('/') {
value == prefix
|| value
.strip_prefix(prefix)
.is_some_and(|r| r.starts_with('/'))
} else {
value == e
}
})
}
fn importmap_resolve(pairs: &[(String, String)], specifier: &str) -> Option<String> {
let mut exact = None;
let mut best_prefix: Option<(&str, &str)> = None;
for (spec, url) in pairs {
if spec == specifier {
exact = Some(url.clone());
} else if let Some(prefix) = spec.strip_suffix('/') {
if let Some(rest) = specifier.strip_prefix(prefix) {
if rest.starts_with('/')
&& best_prefix.is_none_or(|(best, _)| prefix.len() > best.len())
{
best_prefix = Some((prefix, url));
}
}
}
}
if let Some(url) = exact {
return Some(url);
}
best_prefix.map(|(prefix, url)| {
let rest = &specifier[prefix.len() + 1..];
format!("{}{}", url, rest)
})
}
async fn bundle_split_async(opts: &SplitBundleOptions<'_>) -> Result<SplitBundleOutput> {
let root = opts
.root
.canonicalize()
.map_err(|e| Error::Bundle(format!("root {}: {e}", opts.root.display())))?;
let input = opts
.entries
.iter()
.map(|entry| {
let rel = entry.to_string_lossy().replace('\\', "/");
let name = rel.strip_suffix(".js").unwrap_or(&rel).to_string();
rolldown::InputItem {
name: Some(name),
import: root.join(entry).to_string_lossy().to_string(),
}
})
.collect::<Vec<_>>();
let external_list: Arc<[String]> = opts.external.to_vec().into();
let external_paths: Arc<[(PathBuf, bool)]> = opts
.external
.iter()
.filter_map(|e| {
let (is_prefix, name) = match e.strip_suffix('/') {
Some(p) => (true, p),
None => (false, e.as_str()),
};
let path = root.join(name.trim_start_matches('/'));
Some((path.canonicalize().ok()?, is_prefix))
})
.collect::<Vec<_>>()
.into();
let map_pairs: Arc<[(String, String)]> = opts
.importmap
.map(|m| {
m.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect::<Vec<_>>()
})
.unwrap_or_default()
.into();
let root_for_resolve = Arc::new(root.clone());
let is_external = rolldown::IsExternal::Fn(Some(Arc::new(
move |specifier: &str, _importer: Option<&str>, resolved: bool| {
let external_list = Arc::clone(&external_list);
let external_paths = Arc::clone(&external_paths);
let map_pairs = Arc::clone(&map_pairs);
let root = Arc::clone(&root_for_resolve);
let specifier = specifier.to_string();
Box::pin(async move {
if resolved {
let path = Path::new(&specifier);
if external_paths.iter().any(|(external, is_prefix)| {
if *is_prefix {
path.starts_with(external)
} else {
path == external
}
}) {
return Ok(true);
}
if let Ok(real) = path.canonicalize() {
if !real.starts_with(root.as_path()) {
return Err(Error::Bundle(format!(
"module {specifier} resolves outside the bundle root {}",
root.display()
))
.into());
}
}
return Ok(false);
}
if specifier.starts_with('.') {
return Ok(false);
}
if specifier.starts_with('/') {
return Ok(matches_external(&external_list, &specifier)
|| !root.join(specifier.trim_start_matches('/')).exists());
}
if matches_external(&external_list, &specifier) {
return Ok(true);
}
if let Some(url) = importmap_resolve(&map_pairs, &specifier) {
return Ok(matches_external(&external_list, &url));
}
Ok(false)
})
},
)));
let alias = opts.importmap.map(|map| {
map.iter()
.filter(|(spec, url)| {
!matches_external(opts.external, spec) && !matches_external(opts.external, url)
})
.filter_map(|(spec, url)| {
let path = url.strip_prefix('/')?;
let target = root.join(path).to_string_lossy().to_string();
Some((
spec.strip_suffix('/').unwrap_or(spec).to_string(),
vec![Some(target)],
))
})
.collect::<Vec<_>>()
});
let mut bundler = rolldown::Bundler::new(rolldown::BundlerOptions {
input: Some(input),
cwd: Some(root.clone()),
format: Some(rolldown::OutputFormat::Esm),
dir: Some(opts.out_dir.to_string_lossy().to_string()),
entry_filenames: Some("[name].js".to_string().into()),
chunk_filenames: Some(opts.chunk_filenames.to_string().into()),
external: Some(is_external),
resolve: alias.map(|alias| rolldown::ResolveOptions {
alias: Some(alias),
..Default::default()
}),
minify: Some(opts.minify.into()),
..Default::default()
})
.map_err(|e| Error::Bundle(format!("{e:?}")))?;
let output = bundler
.write()
.await
.map_err(|e| Error::Bundle(format!("{e:?}")))?;
let mut bundled_modules = Vec::new();
for asset in &output.assets {
if let rolldown_common::Output::Chunk(chunk) = asset {
for id in &chunk.module_ids {
let path = PathBuf::from(id.to_string());
if path.is_absolute() && path.exists() {
bundled_modules.push(path);
}
}
}
}
bundled_modules.sort();
bundled_modules.dedup();
Ok(SplitBundleOutput { bundled_modules })
}