use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use ferrijs::ScriptError;
use ferrijs::modules::ModuleRegistry;
use ferrijs::source_map::{CompiledModule, LazyMap};
use rolldown::{
Bundler as Rolldown, BundlerOptions as RolldownOptions, InputItem, OutputFormat, Platform, SourceMapType,
};
use rolldown_common::{CodeSplittingMode, ModuleType, Output, ResolveOptions, TsConfig};
use rolldown_plugin::{
HookLoadArgs, HookLoadOutput, HookLoadReturn, HookResolveIdArgs, HookResolveIdOutput, HookResolveIdReturn, HookUsage,
Plugin, PluginContext, SharedLoadPluginContext,
};
use rquickjs::{AsyncContext, AsyncRuntime, CatchResultExt, Module, WriteOptions, WriteOptionsEndianness};
use crate::cache::BytecodeCache;
const VIRTUAL_USER_PREFIX: &str = "\0ferrijs-virtual:";
fn render_bundle_diagnostics(err: &rolldown_error::BatchedBuildDiagnostic) -> String {
let rendered: Vec<String> = err
.iter()
.map(|d| {
let diagnostic = d.to_diagnostic();
let kind = diagnostic.kind();
match diagnostic.get_primary_location() {
Some((file, line, column, _)) => format!("{kind} at {file}:{line}:{column}: {d}"),
None => format!("{kind}: {d}"),
}
})
.collect();
if rendered.is_empty() {
return format!("rolldown bundle: {err}");
}
format!("rolldown bundle: {}", rendered.join("; "))
}
fn asset_module_types() -> rustc_hash::FxHashMap<String, ModuleType> {
let mut m = rustc_hash::FxHashMap::default();
for ext in ["css", "scss", "sass", "less", "styl", "stylus"] {
m.insert(ext.to_string(), ModuleType::Empty);
}
for ext in [
"png", "jpg", "jpeg", "gif", "webp", "avif", "ico", "woff", "woff2", "ttf", "eot", "mp4", "webm",
] {
m.insert(ext.to_string(), ModuleType::Empty);
}
for ext in ["svg", "txt", "md", "graphql", "gql", "html"] {
m.insert(ext.to_string(), ModuleType::Text);
}
m
}
#[derive(Debug, Clone)]
pub struct BundlerOptions {
pub alias: Vec<(String, PathBuf)>,
pub virtual_modules: Vec<(String, String)>,
pub conditions: Vec<String>,
pub main_fields: Vec<String>,
pub alias_fields: Vec<Vec<String>>,
pub tsconfig: Option<PathBuf>,
pub externals: Vec<String>,
}
impl Default for BundlerOptions {
fn default() -> Self {
Self {
alias: Vec::new(),
virtual_modules: Vec::new(),
conditions: Vec::new(),
main_fields: vec!["module".to_string(), "main".to_string()],
alias_fields: Vec::new(),
tsconfig: None,
externals: Vec::new(),
}
}
}
impl BundlerOptions {
#[must_use]
pub fn alias(mut self, specifier: impl Into<String>, target: impl AsRef<Path>, base: &Path) -> Self {
let p = target.as_ref();
let abs = if p.is_absolute() { p.to_path_buf() } else { base.join(p) };
self.alias.push((specifier.into(), abs));
self
}
#[must_use]
pub fn virtual_module(mut self, specifier: impl Into<String>, source: impl Into<String>) -> Self {
self.virtual_modules.push((specifier.into(), source.into()));
self
}
#[must_use]
pub fn with_tsconfig(mut self, tsconfig: Option<&str>, base: &Path) -> Self {
self.tsconfig = tsconfig.map(|t| {
let p = Path::new(t);
if p.is_absolute() { p.to_path_buf() } else { base.join(p) }
});
self
}
#[must_use]
pub fn fingerprint(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
for (spec, path) in &self.alias {
spec.hash(&mut h);
path.hash(&mut h);
}
for (spec, src) in &self.virtual_modules {
spec.hash(&mut h);
src.hash(&mut h);
}
self.conditions.hash(&mut h);
self.main_fields.hash(&mut h);
self.alias_fields.hash(&mut h);
self.tsconfig.hash(&mut h);
self.externals.hash(&mut h);
h.finish()
}
}
const MULTI_ENTRY_ID: &str = "\0ferrijs-multi-entry.js";
#[derive(Debug)]
struct RuntimePlugin {
env: Arc<BundlerOptions>,
registry: Arc<ModuleRegistry>,
multi_entry: Option<String>,
}
impl Plugin for RuntimePlugin {
fn name(&self) -> Cow<'static, str> {
"ferrijs-runtime".into()
}
#[allow(unknown_lints, clippy::unused_async_trait_impl)]
async fn resolve_id(&self, _ctx: &PluginContext, args: &HookResolveIdArgs<'_>) -> HookResolveIdReturn {
if args.specifier == MULTI_ENTRY_ID && self.multi_entry.is_some() {
return Ok(Some(HookResolveIdOutput::from_id(MULTI_ENTRY_ID)));
}
if self.registry.serves(args.specifier) || self.env.externals.iter().any(|e| e == args.specifier) {
return Ok(Some(HookResolveIdOutput {
id: args.specifier.into(),
external: Some(rolldown_common::ResolvedExternal::Bool(true)),
..Default::default()
}));
}
if self.env.virtual_modules.iter().any(|(spec, _)| spec == args.specifier) {
return Ok(Some(HookResolveIdOutput::from_id(format!(
"{VIRTUAL_USER_PREFIX}{}",
args.specifier
))));
}
if let Some((_, target)) = self.env.alias.iter().find(|(spec, _)| spec == args.specifier) {
return Ok(Some(HookResolveIdOutput::from_id(
target.to_string_lossy().into_owned(),
)));
}
Ok(None)
}
#[allow(unknown_lints, clippy::unused_async_trait_impl)]
async fn load(&self, _ctx: SharedLoadPluginContext, args: &HookLoadArgs<'_>) -> HookLoadReturn {
if args.id == MULTI_ENTRY_ID
&& let Some(src) = &self.multi_entry
{
return Ok(Some(HookLoadOutput {
code: src.clone().into(),
module_type: Some(ModuleType::Js),
..Default::default()
}));
}
let code: Option<Cow<'_, str>> = args.id.strip_prefix(VIRTUAL_USER_PREFIX).and_then(|spec| {
self
.env
.virtual_modules
.iter()
.find(|(s, _)| s == spec)
.map(|(_, src)| Cow::Owned(src.clone()))
});
Ok(code.map(|code| HookLoadOutput {
code: code.into_owned().into(),
module_type: Some(ModuleType::Js),
..Default::default()
}))
}
fn register_hook_usage(&self) -> HookUsage {
HookUsage::ResolveId | HookUsage::Load
}
}
pub struct BundledSource {
pub code: String,
pub source_map_json: Option<String>,
pub modules: Vec<PathBuf>,
pub config_inputs: Vec<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct Bundler {
options: Arc<BundlerOptions>,
registry: Arc<ModuleRegistry>,
cache: BytecodeCache,
}
impl Bundler {
#[must_use]
pub fn new(options: BundlerOptions, registry: Arc<ModuleRegistry>, cache: BytecodeCache) -> Self {
Self {
options: Arc::new(options),
registry,
cache,
}
}
#[must_use]
pub fn options(&self) -> &BundlerOptions {
&self.options
}
#[must_use]
pub fn registry(&self) -> &Arc<ModuleRegistry> {
&self.registry
}
#[must_use]
pub fn cache(&self) -> &BytecodeCache {
&self.cache
}
#[must_use]
pub fn env_fingerprint(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
self.options.fingerprint().hash(&mut h);
self.registry.fingerprint().hash(&mut h);
h.finish()
}
#[must_use]
pub fn cache_key(&self, kind: &str, entry_paths: &[PathBuf], cwd: &Path) -> u64 {
crate::cache::entry_key(kind, entry_paths, cwd, self.env_fingerprint())
}
pub async fn bundle(&self, entry_paths: &[PathBuf], cwd: &Path) -> Result<BundledSource, ScriptError> {
if entry_paths.is_empty() {
return Err(ScriptError::internal("no entry files".to_string()));
}
let env = Arc::clone(&self.options);
if let Some(ts) = &env.tsconfig
&& !ts.is_file()
{
return Err(ScriptError::internal(format!(
"tsconfig points at {}, which is not a file",
ts.display()
)));
}
let multi_entry = (entry_paths.len() > 1).then(|| {
use std::fmt::Write as _;
entry_paths.iter().fold(String::new(), |mut acc, p| {
let _ = writeln!(
acc,
"import {};",
serde_json::to_string(&p.to_string_lossy()).unwrap_or_else(|_| String::from("\"\""))
);
acc
})
});
let input: Vec<InputItem> = vec![InputItem {
name: None,
import: if multi_entry.is_some() {
MULTI_ENTRY_ID.to_string()
} else {
entry_paths[0].to_string_lossy().into_owned()
},
}];
let options = RolldownOptions {
input: Some(input),
cwd: Some(cwd.to_path_buf()),
platform: Some(Platform::Neutral),
format: Some(OutputFormat::Esm),
sourcemap: Some(SourceMapType::Hidden),
sourcemap_exclude_sources: Some(true),
code_splitting: Some(CodeSplittingMode::Bool(false)),
resolve: Some(ResolveOptions {
main_fields: Some(env.main_fields.clone()),
condition_names: (!env.conditions.is_empty()).then(|| env.conditions.clone()),
alias_fields: (!env.alias_fields.is_empty()).then(|| env.alias_fields.clone()),
..Default::default()
}),
tsconfig: env.tsconfig.clone().map(TsConfig::Manual),
module_types: Some(asset_module_types()),
..Default::default()
};
let build_started = Instant::now();
let mut bundler = Rolldown::with_plugins(
options,
vec![Arc::new(RuntimePlugin {
env: Arc::clone(&env),
registry: Arc::clone(&self.registry),
multi_entry,
})],
)
.map_err(|e| ScriptError::internal(format!("rolldown init: {e:?}")))?;
let ctor_ms = build_started.elapsed().as_secs_f64() * 1000.0;
let gen_started = Instant::now();
let out = Box::pin(bundler.generate())
.await
.map_err(|e| ScriptError::internal(render_bundle_diagnostics(&e)))?;
tracing::debug!(
target: "ferrijs::bundle",
entries = entry_paths.len(),
ctor_ms,
generate_ms = gen_started.elapsed().as_secs_f64() * 1000.0,
"rolldown build"
);
let config_inputs: Vec<PathBuf> = bundler
.watch_files()
.iter()
.map(|f| PathBuf::from(f.as_str()))
.filter(|p| {
let named_tsconfig = p
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("tsconfig"));
named_tsconfig && p.extension().is_some_and(|e| e.eq_ignore_ascii_case("json"))
})
.collect();
for asset in &out.assets {
if let Output::Chunk(chunk) = asset
&& chunk.is_entry
{
let modules = chunk
.module_ids
.iter()
.map(|id| PathBuf::from(id.to_string()))
.filter(|p| p.is_file())
.collect();
let mut source_map_json = None;
if let Some(m) = chunk.map.as_ref() {
source_map_json = Some(m.to_json_string());
}
return Ok(BundledSource {
code: chunk.code.clone(),
source_map_json,
modules,
config_inputs,
});
}
}
Err(ScriptError::internal("rolldown produced no entry chunk".to_string()))
}
pub async fn compile(
&self,
entry_paths: &[PathBuf],
cwd: &Path,
module_name: &str,
) -> Result<CompiledModule, ScriptError> {
let module_name = module_name.to_string();
let cache_key = self.cache_key(&format!("bundle:{module_name}"), entry_paths, cwd);
let probe_started = Instant::now();
let hit = self.cache.load(cache_key);
let probe_elapsed = probe_started.elapsed();
if let Some(hit) = hit {
let map_bytes = hit.source_map_json.as_ref().map_or(0, String::len);
let source_map = LazyMap::from_json(hit.source_map_json.as_deref());
tracing::debug!(
target: "ferrijs::bundle",
module = %module_name,
entries = entry_paths.len(),
map_bytes,
probe_ms = probe_elapsed.as_secs_f64() * 1000.0,
"bundle warm path"
);
return Ok(CompiledModule {
module_name,
bytecode: Arc::from(hit.bytecode.into_boxed_slice()),
source_map,
cwd: Some(cwd.to_path_buf()),
});
}
let bundle_started = Instant::now();
let bundled = Box::pin(self.bundle(entry_paths, cwd)).await?;
let bundle_elapsed = bundle_started.elapsed();
let (code, map_json, mut modules) = (bundled.code, bundled.source_map_json, bundled.modules);
modules.extend(bundled.config_inputs);
let compile_started = Instant::now();
let compiled = self.compile_source(&code, &module_name, map_json.as_deref()).await?;
let compile_elapsed = compile_started.elapsed();
let store_started = Instant::now();
let inputs = crate::cache::input_set(entry_paths, &modules);
self.cache.store(
cache_key,
&compiled.bytecode,
&module_name,
map_json.as_deref(),
None,
&inputs,
);
let store_elapsed = store_started.elapsed();
tracing::debug!(
target: "ferrijs::bundle",
module = %module_name,
entries = entry_paths.len(),
modules = modules.len(),
code_bytes = code.len(),
map_bytes = map_json.as_ref().map_or(0, String::len),
bytecode_bytes = compiled.bytecode.len(),
probe_ms = probe_elapsed.as_secs_f64() * 1000.0,
bundle_ms = bundle_elapsed.as_secs_f64() * 1000.0,
compile_ms = compile_elapsed.as_secs_f64() * 1000.0,
store_ms = store_elapsed.as_secs_f64() * 1000.0,
"bundle cold path"
);
Ok(compiled)
}
pub async fn compile_source(
&self,
code: &str,
module_name: &str,
source_map_json: Option<&str>,
) -> Result<CompiledModule, ScriptError> {
let name = module_name.to_string();
let code = code.to_string();
let rt_started = Instant::now();
let runtime = AsyncRuntime::new().map_err(|e| ScriptError::internal(format!("bytecode runtime: {e}")))?;
let (resolver, loader) = self.registry.loader();
let externals = ExternalStubs(self.options.externals.clone());
runtime
.set_loader((resolver, externals.clone()), (loader, externals))
.await;
let ctx = AsyncContext::full(&runtime)
.await
.map_err(|e| ScriptError::internal(format!("bytecode context: {e}")))?;
let rt_elapsed = rt_started.elapsed();
let bytecode: Vec<u8> = ctx
.async_with(async |ctx| {
let declare_started = Instant::now();
let module = Module::declare(ctx.clone(), name.into_bytes(), code.into_bytes())
.catch(&ctx)
.map_err(|e| ScriptError::from_caught_unmapped(e, "", 0))?;
let declare_elapsed = declare_started.elapsed();
let write_started = Instant::now();
let out = module
.write(WriteOptions {
endianness: WriteOptionsEndianness::Native,
..Default::default()
})
.map_err(|e| ScriptError::internal(format!("module write: {e}")));
tracing::debug!(
target: "ferrijs::bundle",
runtime_ms = rt_elapsed.as_secs_f64() * 1000.0,
declare_ms = declare_elapsed.as_secs_f64() * 1000.0,
write_ms = write_started.elapsed().as_secs_f64() * 1000.0,
"bundle compile split"
);
out
})
.await?;
Ok(CompiledModule {
module_name: module_name.to_string(),
bytecode: Arc::from(bytecode.into_boxed_slice()),
source_map: LazyMap::from_json(source_map_json),
cwd: None,
})
}
}
#[derive(Clone)]
struct ExternalStubs(Vec<String>);
impl rquickjs::loader::Resolver for ExternalStubs {
fn resolve<'js>(
&mut self,
_ctx: &rquickjs::Ctx<'js>,
base: &str,
name: &str,
_attributes: Option<rquickjs::loader::ImportAttributes<'js>>,
) -> rquickjs::Result<String> {
if self.0.iter().any(|e| e == name) {
Ok(name.to_string())
} else {
Err(rquickjs::Error::new_resolving(base, name))
}
}
}
impl rquickjs::loader::Loader for ExternalStubs {
fn load<'js>(
&mut self,
ctx: &rquickjs::Ctx<'js>,
name: &str,
_attributes: Option<rquickjs::loader::ImportAttributes<'js>>,
) -> rquickjs::Result<Module<'js>> {
if self.0.iter().any(|e| e == name) {
Module::declare(ctx.clone(), name, "export {};\n")
} else {
Err(rquickjs::Error::new_loading(name))
}
}
}
#[must_use]
pub fn is_typescript_path(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("ts" | "tsx" | "mts" | "cts")
)
}
#[must_use]
pub fn source_is_es_module(source: &str) -> bool {
source.lines().any(|line| {
let t = line.trim_start();
let static_import = t
.strip_prefix("import")
.is_some_and(|rest| matches!(rest.as_bytes().first(), Some(b' ' | b'\t' | b'{' | b'\'' | b'"')));
static_import
|| t.starts_with("export ")
|| t.starts_with("export\t")
|| t.starts_with("export{")
|| t.starts_with("export*")
})
}