use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use bytes::Bytes;
use crate::minify::MinifyError;
use crate::reload::ChangeType;
use crate::watcher::Broadcaster;
pub(crate) const DEFAULT_MINIFY_CACHE_CAPACITY: usize = 256;
struct CacheEntry {
dependencies: Vec<(PathBuf, SystemTime)>,
bytes: Bytes,
}
pub(crate) struct MinifyCache {
entries: Mutex<HashMap<PathBuf, CacheEntry>>,
reverse_deps: Mutex<HashMap<PathBuf, HashSet<PathBuf>>>,
capacity: usize,
}
impl MinifyCache {
pub(crate) fn new(capacity: usize) -> Self {
MinifyCache {
entries: Mutex::new(HashMap::new()),
reverse_deps: Mutex::new(HashMap::new()),
capacity,
}
}
pub(crate) async fn get_or_minify<F>(
&self,
path: &Path,
mtime: SystemTime,
change_type: ChangeType,
minify_fn: F,
) -> Result<Bytes, MinifyError>
where
F: FnOnce(&[u8], ChangeType) -> Result<Bytes, MinifyError>,
{
if let Some(bytes) = self.hit(path, &[(path.to_path_buf(), mtime)]) {
return Ok(bytes);
}
let source = tokio::fs::read(path).await.map_err(MinifyError::Io)?;
let minified = minify_fn(&source, change_type)?;
self.insert(path.to_path_buf(), vec![(path.to_path_buf(), mtime)], minified.clone());
Ok(minified)
}
pub(crate) fn invalidate(&self, path: &Path) {
let mut entries = self.entries.lock().unwrap();
let mut reverse_deps = self.reverse_deps.lock().unwrap();
if let Some(dependent_entries) = reverse_deps.remove(path) {
for entry_path in dependent_entries {
entries.remove(&entry_path);
}
}
entries.remove(path);
}
pub(crate) fn subscribe_to_invalidation(self: Arc<Self>, broadcaster: &Broadcaster) {
let mut events = broadcaster.subscribe();
tokio::spawn(async move {
while let Some(event) = events.recv().await {
if matches!(event.change_type, ChangeType::Css | ChangeType::Script) {
self.invalidate(&event.path);
}
}
});
}
fn hit(&self, path: &Path, dependencies: &[(PathBuf, SystemTime)]) -> Option<Bytes> {
let entries = self.entries.lock().unwrap();
let entry = entries.get(path)?;
if entry.dependencies.len() != dependencies.len() {
return None;
}
for (stored_path, stored_mtime) in &entry.dependencies {
let current_mtime = dependencies
.iter()
.find(|(p, _)| p == stored_path)
.map(|(_, m)| m)?;
if stored_mtime != current_mtime {
return None;
}
}
Some(entry.bytes.clone())
}
fn insert(&self, path: PathBuf, dependencies: Vec<(PathBuf, SystemTime)>, bytes: Bytes) {
self.remove_entry_and_reverse_deps(&path, &dependencies);
let mut entries = self.entries.lock().unwrap();
let mut reverse_deps = self.reverse_deps.lock().unwrap();
if entries.len() >= self.capacity && !entries.contains_key(&path) {
if let Some(victim) = entries.keys().next().cloned() {
if let Some(entry) = entries.remove(&victim) {
for (dep_path, _) in &entry.dependencies {
if let Some(dependents) = reverse_deps.get_mut(dep_path) {
dependents.remove(&victim);
}
}
}
}
}
for (dep_path, _) in &dependencies {
reverse_deps.entry(dep_path.clone()).or_default().insert(path.clone());
}
entries.insert(path, CacheEntry { dependencies, bytes });
}
fn remove_entry_and_reverse_deps(&self, path: &PathBuf, _dependencies: &[(PathBuf, SystemTime)]) {
let mut entries = self.entries.lock().unwrap();
let mut reverse_deps = self.reverse_deps.lock().unwrap();
if let Some(entry) = entries.remove(path) {
for (dep_path, _) in &entry.dependencies {
if let Some(dependents) = reverse_deps.get_mut(dep_path) {
dependents.remove(path);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn counting_minify(counter: &AtomicUsize) -> impl Fn(&[u8], ChangeType) -> Result<Bytes, MinifyError> + '_ {
move |bytes, change_type| {
counter.fetch_add(1, Ordering::SeqCst);
crate::minify::minify(bytes, change_type)
}
}
#[tokio::test]
async fn minifies_once_per_mtime_then_serves_from_cache() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("app.css");
std::fs::write(&path, "body { color: red; }").unwrap();
let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
let cache = MinifyCache::new(DEFAULT_MINIFY_CACHE_CAPACITY);
let calls = AtomicUsize::new(0);
let first = cache
.get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
.await
.unwrap();
let second = cache
.get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
.await
.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1, "second request with the same mtime should hit the cache");
assert_eq!(first, second);
std::thread::sleep(std::time::Duration::from_millis(10));
std::fs::write(&path, "body { color: blue; }").unwrap();
let new_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
assert_ne!(mtime, new_mtime, "test fixture must actually produce a new mtime");
let third = cache
.get_or_minify(&path, new_mtime, ChangeType::Css, counting_minify(&calls))
.await
.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 2, "a changed mtime must be treated as a miss");
assert_ne!(first, third, "content changed, so minified bytes must differ");
}
#[tokio::test]
async fn invalidate_forces_a_reminify_even_with_an_unchanged_mtime() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("app.css");
std::fs::write(&path, "body { color: red; }").unwrap();
let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
let cache = MinifyCache::new(DEFAULT_MINIFY_CACHE_CAPACITY);
let calls = AtomicUsize::new(0);
cache
.get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
.await
.unwrap();
cache.invalidate(&path);
cache
.get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
.await
.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 2, "invalidate() must force a re-minify on the next request");
}
#[tokio::test]
async fn capacity_is_enforced() {
let dir = tempfile::TempDir::new().unwrap();
let cache = MinifyCache::new(2);
let calls = AtomicUsize::new(0);
for i in 0..5 {
let path = dir.path().join(format!("f{i}.css"));
std::fs::write(&path, format!("body {{ color: red{i}; }}")).unwrap();
let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
cache
.get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
.await
.unwrap();
}
assert!(
cache.entries.lock().unwrap().len() <= 2,
"cache must never exceed its stated capacity"
);
}
#[tokio::test]
async fn broadcaster_change_event_invalidates_before_the_next_request() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("app.css");
std::fs::write(&path, "body { color: red; }").unwrap();
let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
let cache = Arc::new(MinifyCache::new(DEFAULT_MINIFY_CACHE_CAPACITY));
let calls = AtomicUsize::new(0);
cache
.get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
.await
.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1);
let broadcaster = Broadcaster::new();
Arc::clone(&cache).subscribe_to_invalidation(&broadcaster);
broadcaster.broadcast(crate::watcher::ChangeEvent {
path: path.clone(),
change_type: ChangeType::Css,
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
cache
.get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
.await
.unwrap();
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"the broadcaster's change event must have evicted the entry before this request"
);
}
#[tokio::test]
async fn bundle_cache_hit_requires_every_dependency_mtime_unchanged() {
let dir = tempfile::TempDir::new().unwrap();
let base_path = dir.path().join("base.css");
let entry_path = dir.path().join("entry.css");
std::fs::write(&base_path, "body { margin: 0; }").unwrap();
std::fs::write(&entry_path, "@import \"base.css\";").unwrap();
let base_mtime = std::fs::metadata(&base_path).unwrap().modified().unwrap();
let entry_mtime = std::fs::metadata(&entry_path).unwrap().modified().unwrap();
let cache = MinifyCache::new(DEFAULT_MINIFY_CACHE_CAPACITY);
let deps = vec![(base_path.clone(), base_mtime), (entry_path.clone(), entry_mtime)];
cache.insert(entry_path.clone(), deps.clone(), Bytes::from("body{margin:0}"));
assert!(cache.hit(&entry_path, &deps).is_some());
std::thread::sleep(std::time::Duration::from_millis(10));
std::fs::write(&base_path, "body { margin: 1px; }").unwrap();
let new_base_mtime = std::fs::metadata(&base_path).unwrap().modified().unwrap();
assert_ne!(base_mtime, new_base_mtime);
let stale_deps = vec![(base_path.clone(), new_base_mtime), (entry_path.clone(), entry_mtime)];
assert!(cache.hit(&entry_path, &stale_deps).is_none());
}
#[tokio::test]
async fn changed_leaf_dependency_invalidates_all_bundles_that_import_it() {
let dir = tempfile::TempDir::new().unwrap();
let shared_path = dir.path().join("shared.css");
let entry1_path = dir.path().join("entry1.css");
let entry2_path = dir.path().join("entry2.css");
std::fs::write(&shared_path, "body { padding: 0; }").unwrap();
std::fs::write(&entry1_path, "@import \"shared.css\";").unwrap();
std::fs::write(&entry2_path, "@import \"shared.css\";").unwrap();
let shared_mtime = std::fs::metadata(&shared_path).unwrap().modified().unwrap();
let entry1_mtime = std::fs::metadata(&entry1_path).unwrap().modified().unwrap();
let entry2_mtime = std::fs::metadata(&entry2_path).unwrap().modified().unwrap();
let cache = Arc::new(MinifyCache::new(DEFAULT_MINIFY_CACHE_CAPACITY));
cache.insert(
entry1_path.clone(),
vec![(shared_path.clone(), shared_mtime), (entry1_path.clone(), entry1_mtime)],
Bytes::from("entry1"),
);
cache.insert(
entry2_path.clone(),
vec![(shared_path.clone(), shared_mtime), (entry2_path.clone(), entry2_mtime)],
Bytes::from("entry2"),
);
assert!(cache.entries.lock().unwrap().contains_key(&entry1_path));
assert!(cache.entries.lock().unwrap().contains_key(&entry2_path));
Arc::clone(&cache).invalidate(&shared_path);
assert!(!cache.entries.lock().unwrap().contains_key(&entry1_path));
assert!(!cache.entries.lock().unwrap().contains_key(&entry2_path));
}
#[tokio::test]
async fn eviction_cleans_up_reverse_dep_index() {
let dir = tempfile::TempDir::new().unwrap();
let dep_path = dir.path().join("dep.css");
let entry_path = dir.path().join("entry.css");
std::fs::write(&dep_path, "body { }").unwrap();
std::fs::write(&entry_path, "@import \"dep.css\";").unwrap();
let dep_mtime = std::fs::metadata(&dep_path).unwrap().modified().unwrap();
let entry_mtime = std::fs::metadata(&entry_path).unwrap().modified().unwrap();
let cache = MinifyCache::new(1);
cache.insert(
entry_path.clone(),
vec![(dep_path.clone(), dep_mtime), (entry_path.clone(), entry_mtime)],
Bytes::from("bundled"),
);
{
let reverse_deps = cache.reverse_deps.lock().unwrap();
assert!(reverse_deps.get(&dep_path).is_some());
assert!(reverse_deps[&dep_path].contains(&entry_path));
}
let other_dep = dir.path().join("other.css");
let other_entry = dir.path().join("other.css");
std::fs::write(&other_dep, "").unwrap();
cache.insert(
other_entry.clone(),
vec![(other_dep.clone(), dep_mtime)],
Bytes::from("other"),
);
assert!(!cache.entries.lock().unwrap().contains_key(&entry_path));
{
let reverse_deps = cache.reverse_deps.lock().unwrap();
let dependents = reverse_deps.get(&dep_path);
assert!(dependents.is_none() || !dependents.unwrap().contains(&entry_path));
}
}
}