use blake3::Hasher;
use std::path::Path;
use super::{CacheConfig, CacheResult};
use crate::builders::common::BundlePlan;
const CACHE_FORMAT_VERSION: u32 = 1;
const ROLLDOWN_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CacheKey(String);
impl CacheKey {
pub fn from_hex(hex: impl Into<String>) -> Self {
Self(hex.into())
}
pub fn as_hex(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for CacheKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
pub fn compute_cache_key(plan: &BundlePlan, config: &CacheConfig) -> CacheResult<CacheKey> {
let mut hasher = Hasher::new();
hasher.update(&CACHE_FORMAT_VERSION.to_le_bytes());
hasher.update(ROLLDOWN_VERSION.as_bytes());
hash_entries(&mut hasher, plan)?;
hash_bundler_options(&mut hasher, &plan.options);
hash_virtual_files(&mut hasher, &plan.virtual_files);
hash_env_vars(&mut hasher, &config.env_vars);
let hash = hasher.finalize();
Ok(CacheKey(hash.to_hex().to_string()))
}
fn hash_entries(hasher: &mut Hasher, plan: &BundlePlan) -> CacheResult<()> {
let mut entries: Vec<&str> = plan.entries.iter().map(|e| e.import.as_str()).collect();
entries.sort();
for entry in entries {
hasher.update(entry.as_bytes());
hasher.update(b"\0");
if !entry.starts_with("virtual:") {
if let Ok(content) = read_file_content(entry, plan) {
let content_hash = blake3::hash(&content);
hasher.update(content_hash.as_bytes());
}
}
}
Ok(())
}
fn read_file_content(path: &str, plan: &BundlePlan) -> std::io::Result<Vec<u8>> {
if let Some(content) = plan.virtual_files.get(path) {
return Ok(content.as_bytes().to_vec());
}
let resolved_path = if Path::new(path).is_absolute() {
Path::new(path).to_path_buf()
} else if let Some(cwd) = &plan.cwd {
cwd.join(path)
} else {
Path::new(path).to_path_buf()
};
std::fs::read(&resolved_path)
}
fn hash_bundler_options(hasher: &mut Hasher, options: &rolldown::BundlerOptions) {
if let Some(format) = &options.format {
hasher.update(format!("{:?}", format).as_bytes());
}
if let Some(platform) = &options.platform {
hasher.update(format!("{:?}", platform).as_bytes());
}
if let Some(sourcemap) = &options.sourcemap {
hasher.update(format!("{:?}", sourcemap).as_bytes());
}
if let Some(minify) = &options.minify {
hasher.update(format!("{:?}", minify).as_bytes());
}
if let Some(external) = &options.external {
hasher.update(format!("{:?}", external).as_bytes());
}
if let Some(globals) = &options.globals {
hasher.update(format!("{:?}", globals).as_bytes());
}
if let Some(resolve) = &options.resolve {
if let Some(alias) = &resolve.alias {
let mut aliases: Vec<_> = alias.iter().collect();
aliases.sort_by_key(|(k, _)| k.clone());
for (key, values) in aliases {
hasher.update(key.as_bytes());
hasher.update(format!("{:?}", values).as_bytes());
}
}
if let Some(conditions) = &resolve.condition_names {
let mut conds: Vec<_> = conditions.iter().collect();
conds.sort();
for cond in conds {
hasher.update(cond.as_bytes());
}
}
}
if let Some(advanced_chunks) = &options.advanced_chunks {
hasher.update(format!("{:?}", advanced_chunks).as_bytes());
}
if let Some(transform) = &options.transform {
hasher.update(format!("{:?}", transform).as_bytes());
}
}
fn hash_virtual_files(hasher: &mut Hasher, virtual_files: &rustc_hash::FxHashMap<String, String>) {
let mut paths: Vec<_> = virtual_files.keys().collect();
paths.sort();
for path in paths {
let content = &virtual_files[path];
hasher.update(path.as_bytes());
hasher.update(b"\0");
let content_hash = blake3::hash(content.as_bytes());
hasher.update(content_hash.as_bytes());
}
}
fn hash_env_vars(hasher: &mut Hasher, env_vars: &[String]) {
let mut vars: Vec<_> = env_vars.iter().collect();
vars.sort();
for var in vars {
hasher.update(var.as_bytes());
hasher.update(b"=");
if let Ok(value) = std::env::var(var) {
hasher.update(value.as_bytes());
}
hasher.update(b"\0");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_plan(entries: Vec<&str>, vfiles: Vec<(&str, &str)>) -> BundlePlan {
BundlePlan {
entries: entries
.into_iter()
.map(|e| crate::builders::common::EntrySpec {
name: None,
import: e.to_string(),
})
.collect(),
options: rolldown::BundlerOptions::default(),
plugins: vec![],
cwd: None,
virtual_files: vfiles
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
runtime: None,
cache: None,
incremental: None,
}
}
#[test]
fn test_cache_key_deterministic() {
let plan1 = make_test_plan(
vec!["virtual:a.js", "virtual:b.js"],
vec![
("virtual:a.js", "export const a = 1;"),
("virtual:b.js", "export const b = 2;"),
],
);
let plan2 = make_test_plan(
vec!["virtual:b.js", "virtual:a.js"],
vec![
("virtual:b.js", "export const b = 2;"),
("virtual:a.js", "export const a = 1;"),
],
);
let config = CacheConfig::default();
let key1 = compute_cache_key(&plan1, &config).unwrap();
let key2 = compute_cache_key(&plan2, &config).unwrap();
assert_eq!(key1, key2);
}
#[test]
fn test_cache_key_changes_on_content_change() {
let plan1 = make_test_plan(
vec!["virtual:a.js"],
vec![("virtual:a.js", "export const a = 1;")],
);
let plan2 = make_test_plan(
vec!["virtual:a.js"],
vec![("virtual:a.js", "export const a = 2;")],
);
let config = CacheConfig::default();
let key1 = compute_cache_key(&plan1, &config).unwrap();
let key2 = compute_cache_key(&plan2, &config).unwrap();
assert_ne!(key1, key2);
}
#[test]
fn test_cache_key_changes_on_entry_change() {
let plan1 = make_test_plan(
vec!["virtual:a.js"],
vec![("virtual:a.js", "export const a = 1;")],
);
let plan2 = make_test_plan(
vec!["virtual:b.js"],
vec![("virtual:b.js", "export const a = 1;")],
);
let config = CacheConfig::default();
let key1 = compute_cache_key(&plan1, &config).unwrap();
let key2 = compute_cache_key(&plan2, &config).unwrap();
assert_ne!(key1, key2);
}
}