ferrijs_bundle/cache.rs
1//! Cross-process disk cache for compiled `QuickJS` bytecode.
2//!
3//! Compiling a bundle to bytecode costs real time per process, and the
4//! bundle step before it more. An in-memory cache only helps within one
5//! process; a fresh start pays again. This persists the bytecode (plus
6//! its source map and a caller sidecar) to disk so an unchanged source
7//! tree skips BOTH the bundler and the compile entirely.
8//!
9//! ## Soundness
10//!
11//! `Module::load` on bytecode is `unsafe`: it trusts the input was
12//! produced by an identical `QuickJS` build with native endianness. A
13//! disk cache crosses process (and machine) boundaries, so every entry
14//! lives under an [`abi_tag`]-named directory folding the `QuickJS`
15//! version (which tracks the on-disk `BC_VERSION`), target arch,
16//! endianness, and pointer width. Bytecode is only ever loaded from the
17//! directory matching the running toolchain -- a mismatched build
18//! simply misses and recompiles. Bumping rquickjs changes
19//! `JS_GetVersion()` and thus the directory, so stale bytecode is never
20//! loaded.
21//!
22//! ## Freshness
23//!
24//! A bundle inlines its whole import graph, so the entry file's stamp
25//! is not enough -- an edited (but still-imported) helper must
26//! invalidate. Each entry records a stamp of every transitive input; a
27//! load re-checks them all and misses on any change, addition, or
28//! deletion.
29
30use std::hash::{Hash, Hasher};
31use std::path::{Path, PathBuf};
32use std::sync::OnceLock;
33
34/// One cached compile: the bytecode plus the auxiliary data each caller
35/// needs to reconstruct its result without re-running rolldown.
36pub struct CacheEntry {
37 pub bytecode: Vec<u8>,
38 /// The module name baked into `bytecode`. A caller that registers a
39 /// source map has to key it by the same name QuickJS labels the
40 /// module's frames with, and only the writer knew it.
41 pub module_name: String,
42 /// Source-map JSON -- `None` when the bundle had no map.
43 pub source_map_json: Option<String>,
44 /// Caller-specific sidecar -- whatever the caller must get back with
45 /// the bytecode to avoid re-running the bundle.
46 pub aux: Option<String>,
47 /// The input paths this entry's freshness was validated against, so a
48 /// caller promoting the entry into an in-process tier can carry the
49 /// same set instead of re-deriving it.
50 pub inputs: Vec<PathBuf>,
51}
52
53/// Where compiled bytecode is kept between processes. A value, so two
54/// hosts in one process can keep separate caches and a host that wants
55/// none says so.
56#[derive(Debug, Clone)]
57pub struct BytecodeCache {
58 dir: Option<PathBuf>,
59}
60
61impl BytecodeCache {
62 /// No cache: every compile is a cold compile.
63 #[must_use]
64 pub fn disabled() -> Self {
65 Self { dir: None }
66 }
67
68 /// `<base>/bytecode/<abi_tag>/`, created on demand. Answers
69 /// [`Self::disabled`] when the directory cannot be created; the cache
70 /// is an optimisation, never a correctness dependency.
71 #[must_use]
72 pub fn at(base: impl AsRef<Path>) -> Self {
73 let dir = base.as_ref().join("bytecode").join(abi_tag());
74 match std::fs::create_dir_all(&dir) {
75 Ok(()) => Self { dir: Some(dir) },
76 Err(_) => Self::disabled(),
77 }
78 }
79
80 /// The platform user cache directory for `app`: `$XDG_CACHE_HOME/<app>`,
81 /// `~/Library/Caches/<app>` on macOS, `~/.cache/<app>` elsewhere, and
82 /// the system temp dir when no home is known. A host that honours an
83 /// override variable resolves it first and calls [`Self::at`].
84 #[must_use]
85 pub fn for_app(app: &str) -> Self {
86 let base = user_cache_base().unwrap_or_else(std::env::temp_dir).join(app);
87 Self::at(base)
88 }
89
90 #[must_use]
91 pub fn is_enabled(&self) -> bool {
92 self.dir.is_some()
93 }
94
95 /// The directory entries live in, when enabled.
96 #[must_use]
97 pub fn dir(&self) -> Option<&Path> {
98 self.dir.as_deref()
99 }
100
101 fn paths(&self, key: u64) -> Option<(PathBuf, PathBuf)> {
102 let dir = self.dir.as_deref()?;
103 let hex = format!("{key:016x}");
104 Some((dir.join(format!("{hex}.bin")), dir.join(format!("{hex}.json"))))
105 }
106}
107
108/// Toolchain fingerprint. Bytecode under one tag is safe to
109/// `Module::load` only by an identical toolchain. `fjbc<N>` is our own
110/// format version -- bump it on any change to the record shape, or to
111/// anything baked into the bytecode that a reader now depends on.
112///
113/// Beyond the raw bytecode ABI (`QuickJS` version, arch, endianness,
114/// pointer width) the tag folds in this crate's version, as a proxy for
115/// the pinned rolldown/oxc bundler (a bundler upgrade alters
116/// transpilation/tree-shaking output while every input stamp still
117/// matches).
118#[must_use]
119pub fn abi_tag() -> &'static str {
120 static TAG: OnceLock<String> = OnceLock::new();
121 TAG.get_or_init(|| {
122 // SAFETY: returns a static C string owned by the linked QuickJS.
123 #[allow(unsafe_code)]
124 let qjs = unsafe { std::ffi::CStr::from_ptr(rquickjs::qjs::JS_GetVersion()) }
125 .to_str()
126 .unwrap_or("unknown");
127 let endian = if cfg!(target_endian = "big") { "be" } else { "le" };
128 format!(
129 "fjbc1-v{}-qjs{qjs}-{}-{endian}-p{}",
130 env!("CARGO_PKG_VERSION"),
131 std::env::consts::ARCH,
132 std::mem::size_of::<usize>() * 8,
133 )
134 })
135}
136
137fn user_cache_base() -> Option<PathBuf> {
138 if let Some(x) = std::env::var_os("XDG_CACHE_HOME") {
139 return Some(PathBuf::from(x));
140 }
141 #[cfg(target_os = "macos")]
142 if let Some(h) = std::env::var_os("HOME") {
143 return Some(PathBuf::from(h).join("Library").join("Caches"));
144 }
145 std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache"))
146}
147
148/// A stable key for a set of entry paths (canonicalized, order-independent).
149/// The transitive content check on load is what actually guards freshness;
150/// this only needs to be collision-free across distinct bundle requests.
151///
152/// `kind` namespaces the consumers: the same file compiled two ways
153/// (different module name, different aux payload) must not share one
154/// slot. `salt` carries extra pipeline state that changes the output
155/// without changing any input file -- the bundler options and the
156/// native module table. `cwd` is the directory the bundle is built
157/// from, which decides how every bare specifier resolves.
158#[must_use]
159pub fn entry_key(kind: &str, entry_paths: &[PathBuf], cwd: &Path, salt: u64) -> u64 {
160 let mut canon: Vec<String> = entry_paths
161 .iter()
162 .map(|p| {
163 std::fs::canonicalize(p)
164 .unwrap_or_else(|_| p.clone())
165 .to_string_lossy()
166 .into_owned()
167 })
168 .collect();
169 canon.sort();
170 let mut h = std::collections::hash_map::DefaultHasher::new();
171 abi_tag().hash(&mut h);
172 kind.hash(&mut h);
173 salt.hash(&mut h);
174 // The bundling cwd decides how bare specifiers and `node_modules`
175 // resolve, so the same entry files bundled from two directories are
176 // two different outputs — and used to share one cache slot.
177 std::fs::canonicalize(cwd)
178 .unwrap_or_else(|_| cwd.to_path_buf())
179 .hash(&mut h);
180 canon.hash(&mut h);
181 h.finish()
182}
183
184/// The transitive input set for a bundle: the entry files plus every
185/// module rolldown reported in the chunk's graph, canonicalized and
186/// deduped.
187///
188/// The module graph — not the source map — is the authority. A helper
189/// module whose bindings are all inlined leaves no mapping tokens and so
190/// never appears in the map's `sources`, which made the input set omit
191/// exactly the files an extension author edits most; both cache tiers
192/// then answered "unchanged" for a changed tree.
193#[must_use]
194pub fn input_set(entry_paths: &[PathBuf], modules: &[PathBuf]) -> Vec<PathBuf> {
195 let mut out: Vec<PathBuf> = Vec::new();
196 let mut push = |p: &Path| {
197 let c = std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
198 if !out.contains(&c) {
199 out.push(c);
200 }
201 };
202 for e in entry_paths {
203 push(e);
204 }
205 for m in modules {
206 if m.is_file() {
207 push(m);
208 }
209 }
210 out
211}
212
213/// Content fingerprint over a transitive input set, for an in-process
214/// cache tier that has to answer the same freshness question
215/// [`BytecodeCache::load`]
216/// answers on disk: has ANY input changed, not just the entry file.
217///
218/// `None` when an input cannot be read — the source moved, so the cached
219/// compile must not be reused.
220#[must_use]
221pub fn inputs_fingerprint(inputs: &[PathBuf]) -> Option<u64> {
222 let mut h = std::collections::hash_map::DefaultHasher::new();
223 for p in inputs {
224 p.hash(&mut h);
225 source_stamp(p)?.hash(&mut h);
226 }
227 Some(h.finish())
228}
229
230impl BytecodeCache {
231 /// Load a cached compile for `key`, validating that every recorded input
232 /// still stamps identically. Returns `None` on any miss, mismatch, or IO
233 /// error (the caller then compiles and [`Self::store`]s).
234 #[must_use]
235 pub fn load(&self, key: u64) -> Option<CacheEntry> {
236 let (bin_path, _) = self.paths(key)?;
237 let raw = std::fs::read(bin_path).ok()?;
238 let mut r = Reader::new(&raw);
239 if r.take(4)? != BUNDLE_MAGIC {
240 return None;
241 }
242 let n_inputs = r.u32()? as usize;
243 let mut inputs = Vec::with_capacity(n_inputs);
244 for _ in 0..n_inputs {
245 let stamp = r.u64()?;
246 let path = PathBuf::from(std::str::from_utf8(r.slice()?).ok()?);
247 // Freshness is a stat, not a read: proving 50 files are unchanged by
248 // hashing their contents costs exactly the IO the cache exists to avoid.
249 if source_stamp(&path)? != stamp {
250 return None;
251 }
252 inputs.push(path);
253 }
254 let module_name = std::str::from_utf8(r.slice()?).ok()?.to_string();
255 let source_map_json = r.opt_str().ok()?;
256 let aux = r.opt_str().ok()?;
257 let bytecode = r.slice()?.to_vec();
258 Some(CacheEntry {
259 bytecode,
260 module_name,
261 source_map_json,
262 aux,
263 inputs,
264 })
265 }
266
267 /// Persist a freshly compiled `key` -> bytecode entry. Best-effort: any IO
268 /// failure is swallowed (the cache is an optimization, never a correctness
269 /// dependency).
270 ///
271 /// One binary record, not a JSON manifest beside a blob: the manifest used to
272 /// carry the source map as a JSON *string*, so every write re-escaped a
273 /// map larger than the code it maps, and paid two write+rename pairs for it.
274 pub fn store(
275 &self,
276 key: u64,
277 bytecode: &[u8],
278 module_name: &str,
279 source_map_json: Option<&str>,
280 aux: Option<&str>,
281 inputs: &[PathBuf],
282 ) {
283 let Some((bin_path, _)) = self.paths(key) else {
284 return;
285 };
286 let stamped: Vec<(String, u64)> = inputs
287 .iter()
288 .filter_map(|p| Some((p.to_string_lossy().into_owned(), source_stamp(p)?)))
289 .collect();
290
291 let mut buf = Vec::with_capacity(bytecode.len() + source_map_json.map_or(0, str::len) + 4096);
292 buf.extend_from_slice(BUNDLE_MAGIC);
293 buf.extend_from_slice(&u32::try_from(stamped.len()).unwrap_or(0).to_le_bytes());
294 for (path, stamp) in &stamped {
295 buf.extend_from_slice(&stamp.to_le_bytes());
296 put_slice(&mut buf, path.as_bytes());
297 }
298 put_slice(&mut buf, module_name.as_bytes());
299 put_opt(&mut buf, source_map_json);
300 put_opt(&mut buf, aux);
301 put_slice(&mut buf, bytecode);
302 let _ = atomic_write(&bin_path, &buf);
303 }
304}
305
306/// Magic + format version of a bundle record.
307const BUNDLE_MAGIC: &[u8; 4] = b"FJB1";
308
309fn put_slice(buf: &mut Vec<u8>, bytes: &[u8]) {
310 buf.extend_from_slice(&u64::try_from(bytes.len()).unwrap_or(0).to_le_bytes());
311 buf.extend_from_slice(bytes);
312}
313
314fn put_opt(buf: &mut Vec<u8>, value: Option<&str>) {
315 match value {
316 Some(v) => {
317 buf.push(1);
318 put_slice(buf, v.as_bytes());
319 },
320 None => buf.push(0),
321 }
322}
323
324/// Cursor over a record, refusing anything that runs past the end -- which is
325/// what a write cut short by a crash looks like.
326struct Reader<'a> {
327 raw: &'a [u8],
328 at: usize,
329}
330
331impl<'a> Reader<'a> {
332 fn new(raw: &'a [u8]) -> Self {
333 Self { raw, at: 0 }
334 }
335
336 fn take(&mut self, n: usize) -> Option<&'a [u8]> {
337 let end = self.at.checked_add(n)?;
338 let out = self.raw.get(self.at..end)?;
339 self.at = end;
340 Some(out)
341 }
342
343 fn u32(&mut self) -> Option<u32> {
344 Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?))
345 }
346
347 fn u64(&mut self) -> Option<u64> {
348 Some(u64::from_le_bytes(self.take(8)?.try_into().ok()?))
349 }
350
351 fn slice(&mut self) -> Option<&'a [u8]> {
352 let len = usize::try_from(self.u64()?).ok()?;
353 self.take(len)
354 }
355
356 /// Reads an optional string, distinguishing "absent" from "unreadable":
357 /// `Err(())` is a truncated record, `Ok(None)` is a field that was never
358 /// written.
359 fn opt_str(&mut self) -> Result<Option<String>, ()> {
360 match self.take(1).ok_or(())?[0] {
361 0 => Ok(None),
362 _ => Ok(Some(
363 std::str::from_utf8(self.slice().ok_or(())?)
364 .map_err(|_| ())?
365 .to_string(),
366 )),
367 }
368 }
369}
370
371fn atomic_write(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
372 let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
373 std::fs::write(&tmp, bytes)?;
374 std::fs::rename(&tmp, path)
375}
376
377/// Identity of a module's source WITHOUT reading it: modification time and
378/// length, folded together.
379///
380/// Hashing the bytes would mean reading every file on every run just to learn
381/// it had not changed -- the read the cache exists to avoid. A stamp collision
382/// needs an edit that preserves both the exact byte length and the nanosecond
383/// mtime, which is what `tsc --incremental`, vite, and webpack all settle for.
384#[must_use]
385pub fn source_stamp(path: &Path) -> Option<u64> {
386 let meta = std::fs::metadata(path).ok()?;
387 let mtime = meta
388 .modified()
389 .ok()?
390 .duration_since(std::time::UNIX_EPOCH)
391 .ok()?
392 .as_nanos();
393 let mtime = u64::try_from(mtime).unwrap_or(u64::MAX);
394 let mut h = std::collections::hash_map::DefaultHasher::new();
395 mtime.hash(&mut h);
396 meta.len().hash(&mut h);
397 Some(h.finish())
398}