//! `avril bundle`: Rolldown discovers, links, lowers, and chunks the graph, //! asking avril to resolve and load every module exactly as `run` would, with //! npm packages in browser mode. No application code runs. use std::cell::RefCell; use std::collections::{BTreeSet, HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::{Arc, Mutex}; use crate::registry::npm::resolution::BROWSER_EXCLUDED; use avril_core::{Error, ModuleFormat, ModuleLoader}; use futures::StreamExt; use futures::future::LocalBoxFuture; use oxc::ast::AstKind; use oxc::ast::ast::{Argument, Expression, UnaryOperator, VariableDeclarationKind}; use oxc::span::GetSpan; use rolldown::plugin::{ HookLoadArgs, HookLoadOutput, HookLoadReturn, HookResolveIdArgs, HookResolveIdOutput, HookResolveIdReturn, HookTransformArgs, HookTransformOutput, HookTransformOutputMap, HookTransformReturn, HookUsage, Plugin, PluginContext, SharedLoadPluginContext, SharedTransformPluginContext, }; use rolldown_common::side_effects::HookSideEffects; use rolldown_common::{ImportKind, ModuleType}; use tokio::sync::{mpsc, oneshot}; #[derive(Default)] pub struct Options { pub minify: bool, pub sourcemap: bool, pub no_splitting: bool, /// The ECMAScript year to lower syntax to; `None` is esnext. pub target: Option, pub defines: Vec<(String, String)>, } /// Marks CommonJS that Rolldown would otherwise infer to be ESM from its name. const COMMONJS: &str = "\0.cjs"; /// One bundler input: an entry module with an optional chunk name. pub struct Entry { pub name: Option, pub import: String, } /// An in-memory bundle plus the tracking its caller persists. pub struct Generated { pub files: Vec<(String, Vec)>, pub warnings: Vec, pub lock_keys: BTreeSet, pub modules: HashSet, } /// Generate using a fresh graph from [`crate::modules::ModuleGraph::open_browser`]. /// `file_name` is the entry filename template (`[name].js` for named entries); /// `directory` is its absolute output location for source maps and need not exist. /// Outputs and lockfile changes are returned, not published; package acquisition /// may still populate caches. Diagnostics are returned without printing. /// /// Poll within a Tokio runtime on the thread owning `graph`; the future is `!Send`. pub async fn generate( graph: &crate::modules::ModuleGraph, entries: &[Entry], file_name: &str, directory: &Path, options: &Options, ) -> Result { let input = entries .iter() .map(|entry| rolldown::InputItem { name: entry.name.clone(), import: entry.import.clone(), }) .collect(); let host = Rc::new(Host { project_root: graph.store.options().project_root.clone(), target: options.target, modules: Arc::default(), lock_keys: RefCell::default(), warnings: RefCell::default(), error: RefCell::default(), graph: graph.clone(), }); let files = generate_bundle(&host, input, directory, file_name, options).await?; let warnings = std::mem::take(&mut *host.warnings.borrow_mut()); Ok(Generated { files, warnings, lock_keys: host.lock_keys.borrow().clone(), modules: std::mem::take(&mut *host.modules.lock().unwrap()), }) } async fn generate_bundle( host: &Rc, input: Vec, directory: &Path, file_name: &str, options: &Options, ) -> Result)>, Error> { use rolldown::*; let entry_names: HashSet = input .iter() .filter_map(|entry| entry.name.clone()) .collect(); let target = options .target .map_or("esnext".to_owned(), |year| format!("es{year}")); let options = BundlerOptions { input: Some(input), // Stable ids, and so chunk hashes, depend only on the project layout. cwd: Some(host.project_root.clone()), dir: Some(directory.to_string_lossy().into_owned()), entry_filenames: Some(file_name.to_owned().into()), chunk_filenames: Some("[name]-[hash].js".to_owned().into()), // Explicit entry names must not be silently sanitized or deconflicted. sanitize_filename: Some(SanitizeFilename::Fn(Arc::new(move |name| { let sanitize = !entry_names.contains(name); let name = name.to_owned(); Box::pin(async move { Ok(SanitizeFilename::Boolean(sanitize) .call(&name) .await? .to_string()) }) }))), format: Some(OutputFormat::Esm), // `Browser` would implicitly define `process.env.NODE_ENV`. platform: Some(Platform::Neutral), code_splitting: Some(CodeSplittingMode::Bool(!options.no_splitting)), minify: options.minify.then_some(RawMinifyOptions::Bool(true)), sourcemap: options.sourcemap.then_some(SourceMapType::File), sourcemap_path_transform: Some(SourceMapPathTransform::new(Arc::new({ let modules = host.modules.clone(); move |sources, map| { // Rolldown relativizes sources as paths, mangling remote URLs. use sugar_path::SugarPath; let directory = Path::new(map).parent().unwrap(); let remote: HashMap = modules .lock() .unwrap() .iter() .filter(|name| { crate::modules::http_url(name).is_some() || crate::modules::data_url(name).is_some() }) .map(|url| { ( url.as_path() .relative(directory) .to_string_lossy() .into_owned(), url.clone(), ) }) .collect(); let sources = sources .into_iter() .map(|source| match remote.get(&source) { Some(url) => url.clone(), None => canonical(&source).to_owned(), }) .collect(); Box::pin(async move { Ok(sources) }) } }))), define: Some(options.defines.iter().cloned().collect()), // Rolldown's transformer, minifier, and helpers all lower to this. transform: Some(BundlerTransformOptions { target: Some(Either::Left(target.clone())), ..Default::default() }), // Modules arrive compiled with their own options; never read other configs. tsconfig: Some(TsConfig::Auto(false)), experimental: Some(ExperimentalOptions { // Region comments would carry module ids into the output. attach_debug_info: Some(AttachDebugInfo::None), // Surfaces asset references to `resolve` so they are rejected. resolve_new_url_to_asset: Some(true), ..Default::default() }), ..Default::default() }; let (sender, mut receiver) = mpsc::unbounded_channel::(); let bundle = async move { let mut bundler = Bundler::with_plugins(options, vec![Plugin::new_shared(Bridge(sender))])?; bundler.generate().await }; let serve = futures::stream::poll_fn(|context| receiver.poll_recv(context)) .for_each_concurrent(None, |job| job(host.clone())); let output = match futures::future::select(std::pin::pin!(bundle), std::pin::pin!(serve)).await { futures::future::Either::Left((output, _)) => output, futures::future::Either::Right(_) => unreachable!("the bundler holds the sender"), } .map_err(|errors| { host.error .take() .unwrap_or_else(|| Error::Module(format!("Bundling failed:\n{errors}"))) })?; for warning in &output.warnings { // Oxc tolerates syntax it cannot lower and would leave it in place. if matches!( warning.kind(), rolldown_error::EventKind::ToleratedTransform ) { return Err(Error::Module(format!( "Cannot bundle for {target}:\n{warning}" ))); } host.warnings.borrow_mut().push(warning.to_string()); } output .assets .iter() .map(|asset| match asset { rolldown_common::Output::Asset(map) if !map.filename.ends_with(".map") => Err( Error::Module(format!("Unexpected bundle asset: {}", map.filename)), ), asset => Ok(( asset.filename().to_owned(), asset.content_as_bytes().to_vec(), )), }) .collect() } /// Rolldown's hooks must be `Send`, but the module graph is not: each hook /// call becomes a job serviced by the thread that owns the graph. type Job = Box) -> LocalBoxFuture<'static, ()> + Send>; #[derive(Debug)] struct Bridge(mpsc::UnboundedSender); impl Bridge { fn call( &self, task: impl FnOnce(Rc) -> LocalBoxFuture<'static, Result> + Send + 'static, ) -> impl Future> + Send + 'static { let (reply, response) = oneshot::channel(); // A stopped host drops the job, and with it the reply sender. let _ = self.0.send(Box::new(move |host| { Box::pin(async move { let result = task(host.clone()).await.map_err(|error| { let message = error.to_string(); // Rolldown reports plugin errors without their message. host.error.borrow_mut().get_or_insert(error); message }); let _ = reply.send(result); }) })); async move { response.await?.map_err(anyhow::Error::msg) } } } impl Plugin for Bridge { fn name(&self) -> std::borrow::Cow<'static, str> { "avril".into() } fn register_hook_usage(&self) -> HookUsage { HookUsage::ResolveId | HookUsage::Load | HookUsage::Transform } fn resolve_id( &self, _: &PluginContext, args: &HookResolveIdArgs<'_>, ) -> impl Future + Send { let specifier = args.specifier.to_owned(); let importer = args.importer.map(str::to_owned); let kind = args.kind; let resolved = self.call(move |host| { Box::pin(async move { host.resolve(&specifier, importer.as_deref(), kind).await }) }); async move { resolved.await.map(Some) } } fn load( &self, _: SharedLoadPluginContext, args: &HookLoadArgs<'_>, ) -> impl Future + Send { let id = args.id.to_owned(); let loaded = self.call(move |host| Box::pin(async move { host.load(&id).await })); async move { loaded.await.map(Some) } } /// Rolldown treats modules using a free `module` or `exports` as CommonJS /// whatever their name says, unless they have an export, so avril's ES /// modules get an empty one. It moves no code, so maps stay as they were. fn transform( &self, _: SharedTransformPluginContext, args: &HookTransformArgs<'_>, ) -> impl Future + Send { let code = format!("{}\nexport {{}};\n", args.code); let id = args.id.to_owned(); let format = (matches!(args.module_type, ModuleType::Js) && !id.starts_with('\0') && !id.ends_with(BROWSER_EXCLUDED)) .then(|| { self.call(move |host| Box::pin(async move { host.graph.module_format(canonical(&id)) })) }); async move { let Some(format) = format else { return Ok(None); }; Ok( (format.await? == ModuleFormat::Esm).then(|| HookTransformOutput { code: Some(code), map: HookTransformOutputMap::Null, ..Default::default() }), ) } } } struct Host { graph: crate::modules::ModuleGraph, project_root: PathBuf, target: Option, /// Shared with the source map path transform. modules: Arc>>, lock_keys: RefCell>, warnings: RefCell>, error: RefCell>, } impl Host { async fn resolve( &self, specifier: &str, importer: Option<&str>, kind: ImportKind, ) -> Result { let name = match importer { None => crate::modules::data_url(specifier) .map_or_else(|| specifier.to_owned(), String::from), Some(importer) => self .resolve_from(specifier, canonical(importer), kind) .await .map_err(|error| { Error::Module(format!("{error} (imported from {})", canonical(importer))) })?, }; let id = if name.ends_with(BROWSER_EXCLUDED) { name.clone() } else if self.graph.module_format(&name)? == ModuleFormat::CommonJs && !name.ends_with(".cjs") && !name.ends_with(".cts") { format!("{name}{COMMONJS}") } else { name.clone() }; Ok(HookResolveIdOutput { id: id.into(), side_effects: self.graph.side_effects(&name).map(|declared| { if declared { HookSideEffects::True } else { HookSideEffects::False } }), ..Default::default() }) } async fn resolve_from( &self, specifier: &str, importer: &str, kind: ImportKind, ) -> Result { let name = match kind { ImportKind::Import | ImportKind::DynamicImport => { let (name, key) = self.graph.resolve_tracked(specifier, importer).await?; self.lock_keys.borrow_mut().extend(key); name } ImportKind::Require if self.graph.module_format(importer)? == ModuleFormat::Esm => { return Err(Error::Module(format!( "require({specifier:?}) is unavailable in ES modules; import it instead" ))); } ImportKind::Require => self.graph.require_resolve(specifier, importer)?, ImportKind::NewUrl => { return Err(Error::Module(format!( "Asset references are not supported: new URL({specifier:?}, import.meta.url)" ))); } kind => { return Err(Error::Module(format!( "Unsupported {kind:?} reference: {specifier}" ))); } }; if name.starts_with("avril:") { return Err(Error::Module(format!( "{name} has no browser implementation" ))); } Ok(name) } async fn load(&self, id: &str) -> Result { if id.ends_with(BROWSER_EXCLUDED) { return Ok(HookLoadOutput { code: "module.exports = {};".into(), module_type: Some(ModuleType::Js), ..Default::default() }); } let name = canonical(id); self.graph.check_access(name)?; self.graph.initialize_package(name).await?; let format = self.graph.module_format(name)?; let source = self.graph.original_source(name).await?; self.modules.lock().unwrap().insert(name.to_owned()); if format == ModuleFormat::Json { return Ok(HookLoadOutput { code: source.into(), module_type: Some(ModuleType::Json), ..Default::default() }); } let source_type = self.graph.source_type(name)?; let (code, map) = if source_type.is_typescript() || source_type.is_jsx() { let options = if source_type.is_jsx() { self.graph .jsx_config(name)? .transform_options() .map_err(|error| Error::Module(format!("{name}: {error}")))? } else { Default::default() }; // JSX development metadata names local files relative to the project. let path = match Path::new(name) { path if path.is_absolute() => { sugar_path::SugarPath::relative(path, &self.project_root) } path => path.into(), }; let (code, mut map) = crate::modules::compile(name, &path, &source, source_type, options)?; map.set_source_contents(vec![Some(&source)]); (code, Some(map)) } else { (source, None) }; let specifiers = analyze(name, &code, format == ModuleFormat::CommonJs, self.target)?; let keys = self .graph .prepare_npm_roots(name, format, &specifiers) .await?; self.lock_keys.borrow_mut().extend(keys); Ok(HookLoadOutput { code: code.into(), map, module_type: Some(ModuleType::Js), ..Default::default() }) } } fn canonical(id: &str) -> &str { id.split_once('\0').map_or(id, |(name, _)| name) } /// Rejects what Rolldown would otherwise turn into broken browser code: a /// free `require` that is not a literal call (left behind as a throwing /// shim), computed `import()`, and top-level await below ES2022. Returns /// the static import specifiers. fn analyze( name: &str, code: &str, commonjs: bool, target: Option, ) -> Result, Error> { use oxc::allocator::Allocator; use oxc::span::SourceType; let allocator = Allocator::default(); let source_type = if commonjs { SourceType::cjs() } else { SourceType::mjs() }; let parsed = oxc::parser::Parser::new(&allocator, code, source_type).parse(); if let Some(error) = parsed.diagnostics.into_iter().next() { let error = error.render_with_source_code(oxc::diagnostics::NamedSource::new(name, code)); return Err(Error::Syntax(format!("{name}\n{error}"))); } let semantic = oxc::semantic::SemanticBuilder::new() .with_build_nodes(true) .build(&parsed.program) .semantic; let (scoping, nodes) = (semantic.scoping(), semantic.nodes()); let fail = |message: &str| Err(Error::Module(format!("{name}: {message}"))); let literal = |argument: Option<&Argument>| match argument { Some(Argument::StringLiteral(_)) => true, Some(Argument::TemplateLiteral(template)) => template.is_no_substitution_template(), _ => false, }; let unresolved = scoping.root_unresolved_references(); for &reference in unresolved.get("require").into_iter().flatten() { let node = scoping.get_reference(reference).node_id(); let span = nodes.get_node(node).span(); let bundleable = match nodes.parent_kind(node) { AstKind::CallExpression(call) => { call.callee.span() == span && !call.optional && call.arguments.len() == 1 && literal(call.arguments.first()) } AstKind::UnaryExpression(unary) => unary.operator == UnaryOperator::Typeof, _ => false, }; if !bundleable { return fail("only direct require('literal') calls can be bundled"); } } for node in nodes.iter() { let awaits = match node.kind() { AstKind::ImportExpression(import) => { if !matches!(&import.source, Expression::StringLiteral(_)) && !matches!(&import.source, Expression::TemplateLiteral(template) if template.is_no_substitution_template()) { return fail("computed import() cannot be bundled; use a string literal"); } false } AstKind::AwaitExpression(_) => true, AstKind::ForOfStatement(statement) => statement.r#await, AstKind::VariableDeclaration(declaration) => { declaration.kind == VariableDeclarationKind::AwaitUsing } _ => false, }; if awaits && target.is_some_and(|year| year < 2022) && !scoping .scope_ancestors(node.scope_id()) .any(|scope| scoping.scope_flags(scope).is_function()) { return fail(&format!( "top-level await requires es2022 or later, not es{}", target.unwrap() )); } } Ok(parsed .module_record .requested_modules .keys() .map(ToString::to_string) .collect()) }