use std::{
path::{Path, PathBuf},
sync::Arc,
};
use path_clean::PathClean;
use rolldown::{BundlerBuilder as RolldownBundlerBuilder, BundlerOptions, InputItem};
use rolldown_plugin::__inner::SharedPluginable;
use rustc_hash::FxHashMap;
use crate::analysis::AnalyzedBundle;
use crate::builders::{asset_plugin::AssetDetectionPlugin, asset_registry::AssetRegistry};
use crate::diagnostics;
use crate::module_collection_plugin::ModuleCollectionPlugin;
use crate::plugins::{PluginPhase, PluginRegistry};
use crate::{Error, Result};
use fob_graph::analysis::stats::compute_stats;
use fob_graph::{AnalysisResult, CacheAnalysis, TransformationTrace};
pub(crate) fn normalize_entry_path(entry: impl AsRef<Path>) -> String {
let cleaned: PathBuf = entry.as_ref().to_path_buf().clean();
cleaned.to_string_lossy().into_owned()
}
#[derive(Clone, Debug)]
pub(crate) struct EntrySpec {
pub name: Option<String>,
pub import: String,
}
pub(crate) struct BundlePlan {
pub entries: Vec<EntrySpec>,
pub options: BundlerOptions,
pub plugins: Vec<SharedPluginable>,
pub cwd: Option<PathBuf>,
pub virtual_files: FxHashMap<String, String>,
pub runtime: Option<Arc<dyn crate::Runtime>>,
pub cache: Option<crate::cache::CacheConfig>,
pub incremental: Option<crate::builders::unified::primitives::IncrementalConfig>,
}
pub(crate) async fn execute_bundle(plan: BundlePlan) -> Result<AnalyzedBundle> {
let cache_plan_for_key = if plan.cache.is_some() {
Some(BundlePlan {
entries: plan.entries.clone(),
options: plan.options.clone(),
plugins: plan.plugins.clone(),
cwd: plan.cwd.clone(),
virtual_files: plan.virtual_files.clone(),
runtime: plan.runtime.clone(),
cache: None, incremental: None, })
} else {
None
};
let BundlePlan {
entries,
mut options,
plugins,
cwd,
virtual_files,
runtime,
cache: cache_config,
incremental: incremental_config,
} = plan;
if let Some(ref config) = cache_config {
if !config.should_force_rebuild() {
if let Some(ref key_plan) = cache_plan_for_key {
match try_load_from_cache(key_plan, config) {
Ok(cached) => {
return Ok(cached);
}
Err(e) => {
eprintln!("Cache miss or error: {}", e);
}
}
}
}
}
let scan_cwd = match cwd.clone() {
Some(path) => path,
None => {
if let Some(ref rt) = runtime {
match rt.get_cwd() {
Ok(path) => path,
Err(_e) => {
#[cfg(target_family = "wasm")]
{
return Err(Error::InvalidConfig(format!(
"Failed to get working directory from runtime: {}. \
On WASM, you must provide either an explicit cwd or a runtime with get_cwd() support.",
e
)));
}
#[cfg(not(target_family = "wasm"))]
{
std::env::current_dir()?
}
}
}
} else {
#[cfg(target_family = "wasm")]
{
return Err(Error::InvalidConfig(
"No working directory available on WASM. \
You must provide either an explicit cwd via BuildOptions or a Runtime implementation.".to_string()
));
}
#[cfg(not(target_family = "wasm"))]
{
std::env::current_dir()?
}
}
}
};
let collection_plugin = Arc::new(ModuleCollectionPlugin::new());
options.input = Some(
entries
.iter()
.map(|entry| InputItem {
name: entry.name.clone(),
import: entry.import.clone(),
})
.collect(),
);
if options.cwd.is_none() {
options.cwd = cwd.clone();
}
match runtime {
Some(_rt) => {
}
None => {
#[cfg(not(target_family = "wasm"))]
{
}
#[cfg(target_family = "wasm")]
{
return Err(Error::InvalidConfig(
"Runtime is required for asset detection plugin. \
On WASM, you must provide a Runtime implementation via BuildOptions::runtime()."
.to_string(),
));
}
}
}
let bundler_runtime = Arc::new(crate::runtime::BundlerRuntime::new(scan_cwd.clone()));
for (path, content) in &virtual_files {
if path.contains('\0') {
return Err(Error::InvalidOutputPath(
"Virtual file path contains null byte".to_string(),
));
}
if path.len() > 4096 {
return Err(Error::InvalidOutputPath(format!(
"Virtual file path too long: {} bytes (max 4096)",
path.len()
)));
}
const MAX_VIRTUAL_FILE_SIZE: usize = 1024 * 1024;
if content.len() > MAX_VIRTUAL_FILE_SIZE {
return Err(Error::WriteFailure(format!(
"Virtual file content too large: {} bytes (max {} bytes)",
content.len(),
MAX_VIRTUAL_FILE_SIZE
)));
}
bundler_runtime.add_virtual_file(path, content.as_bytes());
}
let runtime_file_plugin =
crate::builders::runtime_file_plugin::RuntimeFilePlugin::new(Arc::clone(&bundler_runtime));
let asset_registry = Arc::new(AssetRegistry::new());
let asset_extensions = vec![
".wasm".to_string(),
".png".to_string(),
".jpg".to_string(),
".jpeg".to_string(),
".gif".to_string(),
".svg".to_string(),
".webp".to_string(),
".ico".to_string(),
".ttf".to_string(),
".woff".to_string(),
".woff2".to_string(),
];
let runtime: Arc<dyn crate::Runtime> = bundler_runtime;
let asset_plugin = AssetDetectionPlugin::new(
Arc::clone(&asset_registry),
&scan_cwd,
asset_extensions,
Arc::clone(&runtime),
);
let mut registry = PluginRegistry::new();
registry.add(runtime_file_plugin); registry.add(asset_plugin); registry.add_with_phase(collection_plugin.clone(), PluginPhase::PostProcess);
for plugin in plugins {
registry.add_with_phase(plugin, PluginPhase::Transform);
}
let ordered_plugins = registry.into_rolldown_plugins();
let mut bundler = RolldownBundlerBuilder::default()
.with_options(options)
.with_plugins(ordered_plugins)
.build()
.map_err(|e| Error::from_rolldown_batch(&e))?;
let bundle = bundler
.generate()
.await
.map_err(|e| Error::from_rolldown_batch(&e))?;
let asset_registry_opt = if !asset_registry.is_empty() {
Some(asset_registry)
} else {
None
};
let collection_data = collection_plugin.take_data();
let graph = if let Some(ref inc_config) = incremental_config {
match try_load_incremental_graph(inc_config, &entries) {
Ok(Some(cached_graph)) => {
eprintln!("Incremental cache hit - reusing cached module graph");
cached_graph
}
Ok(None) => {
eprintln!("Incremental cache miss - building module graph from scratch");
let graph =
fob_graph::ModuleGraph::from_collected_data(collection_data).map_err(|e| {
Error::Bundler(vec![diagnostics::ExtractedDiagnostic {
kind: diagnostics::DiagnosticKind::Other("GraphBuildError".to_string()),
severity: diagnostics::DiagnosticSeverity::Error,
message: format!(
"Failed to build module graph from collected data: {}",
e
),
file: None,
line: None,
column: None,
help: None,
context: None,
error_chain: Vec::new(),
}])
})?;
if let Err(e) = try_save_incremental_graph(inc_config, &graph) {
eprintln!("Warning: Failed to save incremental cache: {}", e);
}
graph
}
Err(e) => {
eprintln!("Incremental cache error (non-fatal): {}", e);
fob_graph::ModuleGraph::from_collected_data(collection_data).map_err(|e| {
Error::Bundler(vec![diagnostics::ExtractedDiagnostic {
kind: diagnostics::DiagnosticKind::Other("GraphBuildError".to_string()),
severity: diagnostics::DiagnosticSeverity::Error,
message: format!("Failed to build module graph from collected data: {}", e),
file: None,
line: None,
column: None,
help: None,
context: None,
error_chain: Vec::new(),
}])
})?
}
}
} else {
fob_graph::ModuleGraph::from_collected_data(collection_data).map_err(|e| {
Error::Bundler(vec![diagnostics::ExtractedDiagnostic {
kind: diagnostics::DiagnosticKind::Other("GraphBuildError".to_string()),
severity: diagnostics::DiagnosticSeverity::Error,
message: format!("Failed to build module graph from collected data: {}", e),
file: None,
line: None,
column: None,
help: None,
context: None,
error_chain: Vec::new(),
}])
})?
};
let stats = compute_stats(&graph)?;
let entry_points = graph.entry_points()?;
let symbol_stats = graph.symbol_statistics()?;
let analysis = AnalysisResult {
graph,
entry_points,
warnings: Vec::new(),
errors: Vec::new(),
stats,
symbol_stats,
};
let cache = CacheAnalysis::default();
let trace = if std::env::var_os("JOY_TRACE").is_some() {
Some(TransformationTrace::default())
} else {
None
};
let result = AnalyzedBundle {
bundle,
analysis,
cache,
trace,
asset_registry: asset_registry_opt,
};
if let Some(ref config) = cache_config {
if let Some(ref key_plan) = cache_plan_for_key {
if let Err(e) = try_save_to_cache(key_plan, config, &result) {
eprintln!("Failed to save to cache: {}", e);
}
}
}
Ok(result)
}
fn try_load_from_cache(
plan: &BundlePlan,
config: &crate::cache::CacheConfig,
) -> Result<AnalyzedBundle> {
use crate::BundleOutput;
use crate::cache::{compute_cache_key, open_store, try_load};
let store = open_store(&config.dir)
.map_err(|e| Error::InvalidConfig(format!("Failed to open cache store: {}", e)))?;
let key = compute_cache_key(plan, config)
.map_err(|e| Error::InvalidConfig(format!("Failed to compute cache key: {}", e)))?;
let cached =
try_load(&store, &key).map_err(|e| Error::InvalidConfig(format!("Cache error: {}", e)))?;
if !cached.metadata.is_compatible() {
return Err(Error::InvalidConfig(format!(
"Cache version mismatch: expected {}, found {}",
crate::cache::serialize::CACHE_FORMAT_VERSION,
cached.metadata.format_version
)));
}
let bundle_output = BundleOutput {
assets: cached
.outputs
.into_iter()
.map(|o| o.into_rolldown())
.collect(),
warnings: vec![],
};
let graph = fob_graph::ModuleGraph::new()
.map_err(|e| Error::InvalidConfig(format!("Failed to create module graph: {}", e)))?;
let entry_points: Vec<fob_graph::ModuleId> = cached
.entry_points
.into_iter()
.map(|id| {
fob_graph::ModuleId::new(id)
.unwrap_or_else(|_| fob_graph::ModuleId::new("unknown").unwrap())
})
.collect();
let analysis = fob_graph::AnalysisResult {
graph,
entry_points,
warnings: cached.warnings,
errors: cached.errors,
stats: fob_graph::GraphStatistics::default(),
symbol_stats: fob_graph::SymbolStatistics::new(0, 0),
};
let asset_registry = if !cached.assets.is_empty() {
let registry = crate::builders::asset_registry::AssetRegistry::new();
for asset_info in cached.assets {
registry.register(
PathBuf::from(asset_info.source_path),
asset_info.referrer,
asset_info.specifier,
);
}
Some(Arc::new(registry))
} else {
None
};
Ok(AnalyzedBundle {
bundle: bundle_output,
analysis,
cache: cached.cache,
trace: cached.trace,
asset_registry,
})
}
fn try_save_to_cache(
plan: &BundlePlan,
config: &crate::cache::CacheConfig,
result: &AnalyzedBundle,
) -> Result<()> {
use crate::cache::{
compute_cache_key, open_store,
serialize::{BuildComponents, CachedBuild},
try_save,
};
let store = open_store(&config.dir)
.map_err(|e| Error::InvalidConfig(format!("Failed to open cache store: {}", e)))?;
let key = compute_cache_key(plan, config)
.map_err(|e| Error::InvalidConfig(format!("Failed to compute cache key: {}", e)))?;
let graph_json = "{}".to_string();
let entry_points_str: Vec<String> = result
.analysis
.entry_points
.iter()
.map(|id| id.to_string())
.collect();
let cached = CachedBuild::from_components(BuildComponents {
bundle: &result.bundle,
graph_json,
entry_points: entry_points_str,
warnings: result.analysis.warnings.clone(),
errors: result.analysis.errors.clone(),
cache: &result.cache,
trace: result.trace.as_ref(),
asset_registry: result.asset_registry.as_ref(),
});
try_save(&store, &key, &cached)
.map_err(|e| Error::InvalidConfig(format!("Failed to save to cache: {}", e)))?;
Ok(())
}
fn try_load_incremental_graph(
config: &crate::builders::unified::primitives::IncrementalConfig,
entries: &[EntrySpec],
) -> Result<Option<fob_graph::ModuleGraph>> {
use crate::cache::changes::ChangeDetector;
use crate::cache::incremental::IncrementalCache;
let cache = match IncrementalCache::load(&config.cache_dir) {
Ok(Some(cache)) => cache,
Ok(None) => {
return Ok(None);
}
Err(e) => {
eprintln!("Warning: Failed to load incremental cache: {}", e);
return Ok(None);
}
};
let entry_paths: Vec<PathBuf> = entries
.iter()
.map(|spec| PathBuf::from(&spec.import))
.collect();
if !cache.is_valid_for(&entry_paths) {
eprintln!("Incremental cache invalid: entry points or Rolldown version changed");
return Ok(None);
}
let Some(ref graph) = cache.graph else {
return Ok(None);
};
let detector = ChangeDetector::from_graph(graph)?;
let modules = graph.modules()?;
let mut current_hashes = Vec::new();
for module in modules {
if module.id.is_virtual() {
continue;
}
match std::fs::read(&module.path) {
Ok(contents) => {
let hash = blake3::hash(&contents);
current_hashes.push((module.id.clone(), *hash.as_bytes()));
}
Err(_) => {
eprintln!(
"Warning: Failed to read file {:?} for change detection. Invalidating cache.",
module.path
);
return Ok(None);
}
}
}
let changes = detector.detect_changes(¤t_hashes);
if changes.has_changes() {
eprintln!(
"Incremental cache invalid: {} files changed",
changes.modified.len() + changes.added.len() + changes.removed.len()
);
return Ok(None);
}
Ok(Some(graph.clone()))
}
fn try_save_incremental_graph(
config: &crate::builders::unified::primitives::IncrementalConfig,
graph: &fob_graph::ModuleGraph,
) -> Result<()> {
use crate::cache::changes::ChangeDetector;
use crate::cache::incremental::IncrementalCache;
let detector = ChangeDetector::from_graph(graph)?;
let cache = IncrementalCache {
graph: Some(graph.clone()),
module_hashes: detector.module_hashes.clone(),
rolldown_version: crate::cache::incremental::IncrementalCache::new().rolldown_version,
format_version: crate::cache::incremental::IncrementalCache::FORMAT_VERSION,
};
cache
.save(&config.cache_dir)
.map_err(|e| Error::InvalidConfig(format!("Failed to save incremental cache: {}", e)))?;
Ok(())
}