use std::hash::Hasher;
use std::path::PathBuf;
use deno_core::url::Url;
use deno_resolver::cjs::analyzer::DenoCjsAnalysis;
use deno_resolver::cjs::analyzer::NodeAnalysisCache;
use deno_resolver::cjs::analyzer::NodeAnalysisCacheSourceHash;
use deno_runtime::code_cache::CodeCache;
use deno_runtime::code_cache::CodeCacheType;
pub struct FsCodeCache {
dir: PathBuf,
}
pub fn engine_abi_key() -> String {
format!("v8-{}", deno_core::v8::VERSION_STRING)
}
fn hash64(bytes: &[u8]) -> u64 {
let mut hasher = std::hash::DefaultHasher::new();
hasher.write(bytes);
hasher.finish()
}
impl FsCodeCache {
pub fn new(dir: PathBuf) -> Self {
Self { dir }
}
pub fn source_hash(source: &[u8]) -> u64 {
hash64(source)
}
fn entry_key(specifier: &Url) -> u64 {
if specifier.query().is_none() && specifier.fragment().is_none() {
return hash64(specifier.as_str().as_bytes());
}
let kept: Vec<&str> = specifier
.query()
.unwrap_or("")
.split('&')
.filter(|p| {
let name = p.split('=').next().unwrap_or(p);
!p.is_empty() && name != "v" && name != "t"
})
.collect();
let mut base = specifier.clone();
base.set_fragment(None);
if kept.is_empty() {
base.set_query(None);
} else {
base.set_query(Some(&kept.join("&")));
}
hash64(base.as_str().as_bytes())
}
fn entry_path(&self, specifier: &Url, suffix: &str) -> PathBuf {
self.dir
.join(format!("{:016x}-{suffix}.bin", Self::entry_key(specifier)))
}
fn kind_suffix(kind: CodeCacheType) -> &'static str {
match kind {
CodeCacheType::EsModule => "esm",
CodeCacheType::Script => "cjs",
}
}
fn get_entry(&self, specifier: &Url, suffix: &str, source_hash: u64) -> Option<Vec<u8>> {
let bytes = std::fs::read(self.entry_path(specifier, suffix)).ok()?;
let (head, data) = bytes.split_at_checked(8)?;
if head != source_hash.to_le_bytes() {
return None;
}
Some(data.to_vec())
}
fn put_entry(&self, specifier: &Url, suffix: &str, source_hash: u64, data: &[u8]) {
let path = self.entry_path(specifier, suffix);
if std::fs::create_dir_all(&self.dir).is_err() {
return;
}
let tmp = path.with_extension(format!("tmp{}", std::process::id()));
let mut bytes = Vec::with_capacity(8 + data.len());
bytes.extend_from_slice(&source_hash.to_le_bytes());
bytes.extend_from_slice(data);
if std::fs::write(&tmp, bytes).is_ok() && std::fs::rename(&tmp, &path).is_err() {
let _ = std::fs::remove_file(&tmp);
}
}
pub fn get(&self, specifier: &Url, kind: CodeCacheType, source_hash: u64) -> Option<Vec<u8>> {
self.get_entry(specifier, Self::kind_suffix(kind), source_hash)
}
pub fn put(&self, specifier: &Url, kind: CodeCacheType, source_hash: u64, data: &[u8]) {
self.put_entry(specifier, Self::kind_suffix(kind), source_hash, data);
}
pub fn sweep_stale_tmp(&self, max_age: std::time::Duration) {
let Ok(entries) = std::fs::read_dir(&self.dir) else {
return;
};
let now = std::time::SystemTime::now();
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if !name.contains(".tmp") {
continue;
}
let stale = e
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|m| now.duration_since(m).ok())
.is_some_and(|age| age > max_age);
if stale {
let _ = std::fs::remove_file(e.path());
}
}
}
}
pub const STALE_TMP_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
impl CodeCache for FsCodeCache {
fn get_sync(
&self,
specifier: &Url,
code_cache_type: CodeCacheType,
source_hash: u64,
) -> Option<Vec<u8>> {
self.get(specifier, code_cache_type, source_hash)
}
fn set_sync(
&self,
specifier: Url,
code_cache_type: CodeCacheType,
source_hash: u64,
data: &[u8],
) {
self.put(&specifier, code_cache_type, source_hash, data);
}
}
impl NodeAnalysisCache for FsCodeCache {
fn compute_source_hash(&self, source: &str) -> NodeAnalysisCacheSourceHash {
NodeAnalysisCacheSourceHash(hash64(source.as_bytes()))
}
fn get_cjs_analysis(
&self,
specifier: &Url,
source_hash: NodeAnalysisCacheSourceHash,
) -> Option<DenoCjsAnalysis> {
let bytes = self.get_entry(specifier, "ana", source_hash.0)?;
serde_json::from_slice(&bytes).ok()
}
fn set_cjs_analysis(
&self,
specifier: &Url,
source_hash: NodeAnalysisCacheSourceHash,
analysis: &DenoCjsAnalysis,
) {
if let Ok(bytes) = serde_json::to_vec(analysis) {
self.put_entry(specifier, "ana", source_hash.0, &bytes);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_and_source_hash_guard() {
let dir = tempfile::tempdir().unwrap();
let cache = FsCodeCache::new(dir.path().join("cc"));
let url = Url::parse("file:///app/node_modules/dep/index.js").unwrap();
assert_eq!(cache.get(&url, CodeCacheType::EsModule, 7), None);
cache.put(&url, CodeCacheType::EsModule, 7, b"bytecode");
assert_eq!(
cache.get(&url, CodeCacheType::EsModule, 7).as_deref(),
Some(b"bytecode".as_slice())
);
assert_eq!(cache.get(&url, CodeCacheType::EsModule, 8), None);
assert_eq!(cache.get(&url, CodeCacheType::Script, 7), None);
}
#[test]
fn abi_key_is_the_v8_version_not_the_crate_version() {
let key = engine_abi_key();
assert_eq!(key, format!("v8-{}", deno_core::v8::VERSION_STRING));
let crate_version = env!("CARGO_PKG_VERSION");
if !deno_core::v8::VERSION_STRING.contains(crate_version) {
assert!(!key.contains(crate_version));
}
}
}
#[cfg(test)]
mod hygiene_tests {
use super::*;
fn url(s: &str) -> Url {
Url::parse(s).unwrap()
}
#[test]
fn version_bumps_overwrite_one_entry_instead_of_accumulating() {
let dir = tempfile::tempdir().unwrap();
let cache = FsCodeCache::new(dir.path().to_path_buf());
for v in 1..=5u32 {
let spec = url(&format!("oj:///src/App.tsx?v={v}"));
cache.put(
&spec,
CodeCacheType::EsModule,
u64::from(v),
format!("bytecode-{v}").as_bytes(),
);
}
let entries = std::fs::read_dir(dir.path()).unwrap().count();
assert_eq!(entries, 1, "five version bumps must reuse one entry");
let spec = url("oj:///src/App.tsx?v=5");
assert_eq!(
cache.get(&spec, CodeCacheType::EsModule, 5).as_deref(),
Some(b"bytecode-5".as_ref())
);
assert_eq!(cache.get(&spec, CodeCacheType::EsModule, 4), None);
}
#[test]
fn an_unedited_module_hits_across_a_version_reset() {
let dir = tempfile::tempdir().unwrap();
let cache = FsCodeCache::new(dir.path().to_path_buf());
cache.put(
&url("oj:///src/App.tsx?v=7"),
CodeCacheType::EsModule,
42,
b"bytecode",
);
assert_eq!(
cache
.get(&url("oj:///src/App.tsx?v=1"), CodeCacheType::EsModule, 42)
.as_deref(),
Some(b"bytecode".as_ref())
);
assert_eq!(
cache
.get(
&url("oj:///src/App.tsx?t=123#frag"),
CodeCacheType::EsModule,
42
)
.as_deref(),
Some(b"bytecode".as_ref())
);
}
#[test]
fn intent_params_keep_their_own_entries() {
let dir = tempfile::tempdir().unwrap();
let cache = FsCodeCache::new(dir.path().to_path_buf());
cache.put(
&url("file:///a/logo.svg?url&v=1"),
CodeCacheType::EsModule,
1,
b"as-url",
);
cache.put(
&url("file:///a/logo.svg?raw&v=2"),
CodeCacheType::EsModule,
2,
b"as-raw",
);
cache.put(
&url("file:///a/logo.svg"),
CodeCacheType::EsModule,
3,
b"plain",
);
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 3);
assert_eq!(
cache
.get(
&url("file:///a/logo.svg?url&v=9"),
CodeCacheType::EsModule,
1
)
.as_deref(),
Some(b"as-url".as_ref())
);
}
#[test]
fn sweep_removes_only_stale_tmp_leftovers() {
let dir = tempfile::tempdir().unwrap();
let cache = FsCodeCache::new(dir.path().to_path_buf());
cache.put(
&url("file:///m.js"),
CodeCacheType::EsModule,
1,
b"bytecode",
);
let stale = dir.path().join("deadbeef-esm.bin.tmp999");
std::fs::write(&stale, b"torn").unwrap();
let old = std::time::SystemTime::now() - std::time::Duration::from_secs(48 * 60 * 60);
std::fs::File::options()
.append(true)
.open(&stale)
.unwrap()
.set_times(std::fs::FileTimes::new().set_modified(old))
.unwrap();
std::fs::write(dir.path().join("cafebabe-esm.bin.tmp111"), b"in flight").unwrap();
cache.sweep_stale_tmp(STALE_TMP_MAX_AGE);
let names: Vec<String> = std::fs::read_dir(dir.path())
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert!(
!names.iter().any(|n| n.ends_with(".tmp999")),
"stale tmp swept: {names:?}"
);
assert!(
names.iter().any(|n| n.ends_with(".tmp111")),
"fresh tmp kept: {names:?}"
);
assert_eq!(
names.iter().filter(|n| n.ends_with(".bin")).count(),
1,
"entry kept: {names:?}"
);
}
}