Skip to main content

rws_server/cache/
mod.rs

1//! In-memory response cache middleware.
2//!
3//! [`CacheLayer`] implements [`Middleware`] and short-circuits the inner
4//! application for cacheable `GET` responses within their TTL.  Entries are
5//! bounded by a configurable capacity; the oldest entry is evicted when the
6//! store is full and no expired entries remain.
7//!
8//! # What is cached
9//!
10//! - Method: **GET only**; all other methods bypass the cache.
11//! - Status: 2xx responses (200, 201, 203, 204, 206, …).
12//! - Response `Cache-Control: no-store` or `private` — **not** cached.
13//! - Private or partial requests — `Authorization`, `Cookie` or `Range` — skip
14//!   the cache entirely (no lookup, no store): their responses may differ per
15//!   user or cover only part of the resource.
16//! - Responses that set a cookie (`Set-Cookie`) or stream their body
17//!   (`stream_file` / `stream_pipe`, not held in memory) are **not** cached.
18//! - Request `Cache-Control: no-cache` — cache is bypassed, handler is called,
19//!   but the fresh response **is** stored (revalidation).
20//! - How long: the response's `Cache-Control: s-maxage`, else `max-age`,
21//!   capped by the layer's [`ttl`](CacheLayer::ttl); the `ttl` when it says
22//!   neither. `max-age=0` is not stored (since 18.11.0; before, every
23//!   response was kept for the `ttl`). Hits carry an `Age` header.
24//!
25//! # Example
26//!
27//! ```rust,no_run
28//! # extern crate rws_server as rust_web_server;
29//! use rust_web_server::app::App;
30//! use rust_web_server::cache::CacheLayer;
31//! use rust_web_server::core::New;
32//!
33//! let app = App::new()
34//!     .wrap(CacheLayer::memory(1000).ttl(60).vary_by_header("Accept"));
35//! ```
36
37#[cfg(test)]
38mod tests;
39mod value;
40pub use value::ValueCache;
41#[cfg(not(target_arch = "wasm32"))]
42#[doc(hidden)]
43pub mod disk;
44
45use std::collections::{HashMap, VecDeque};
46use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
47use std::sync::{Arc, Mutex};
48use std::time::{Duration, Instant};
49
50use crate::application::Application;
51use crate::header::Header;
52use crate::middleware::Middleware;
53use crate::request::{METHOD, Request};
54use crate::response::Response;
55use crate::server::ConnectionInfo;
56
57// ── cache store ───────────────────────────────────────────────────────────────
58
59struct CachedEntry {
60    response: Response,
61    inserted_at: Instant,
62    /// How long this entry is fresh: the layer's `ttl`, or less when the
63    /// response's `Cache-Control` said so.
64    ttl: Duration,
65}
66
67struct CacheStore {
68    entries: HashMap<String, CachedEntry>,
69    /// Insertion order — front is oldest; used for capacity eviction.
70    order: VecDeque<String>,
71}
72
73impl CacheStore {
74    fn new() -> Self {
75        CacheStore { entries: HashMap::new(), order: VecDeque::new() }
76    }
77
78    fn get(&self, key: &str) -> Option<&CachedEntry> {
79        self.entries.get(key).filter(|e| e.inserted_at.elapsed() < e.ttl)
80    }
81
82    fn insert(&mut self, key: String, entry: CachedEntry, capacity: usize) {
83        // Update in place without disturbing insertion order.
84        if self.entries.contains_key(&key) {
85            self.entries.insert(key, entry);
86            return;
87        }
88        // `purge_expired` is called by the caller before `insert`, so any
89        // remaining entries are still live. Evict the oldest if we're full.
90        if self.entries.len() >= capacity {
91            if let Some(oldest) = self.order.pop_front() {
92                self.entries.remove(&oldest);
93            }
94        }
95        self.order.push_back(key.clone());
96        self.entries.insert(key, entry);
97    }
98
99    fn purge_expired(&mut self) {
100        let expired: Vec<String> = self.entries.iter()
101            .filter(|(_, e)| e.inserted_at.elapsed() >= e.ttl)
102            .map(|(k, _)| k.clone())
103            .collect();
104        for k in &expired {
105            self.entries.remove(k);
106            self.order.retain(|o| o != k);
107        }
108    }
109}
110
111/// How long `response` may be served from a shared cache: its
112/// `Cache-Control: s-maxage`, else `max-age` (RFC 9111 §5.2.2), capped by
113/// `cap`, the layer's `ttl`; `cap` when it says neither. Zero: don't store
114/// (`max-age=0`). A directive whose value isn't a number is ignored.
115#[doc(hidden)]
116pub fn freshness(response: &Response, cap: Duration) -> Duration {
117    let mut s_maxage = None;
118    let mut max_age = None;
119    for h in response.headers.iter().filter(|h| h.name.eq_ignore_ascii_case(Header::_CACHE_CONTROL)) {
120        for directive in h.value.split(',') {
121            let Some((name, value)) = directive.split_once('=') else { continue };
122            let Ok(secs) = value.trim().trim_matches('"').parse::<u64>() else { continue };
123            match name.trim().to_ascii_lowercase().as_str() {
124                "s-maxage" => s_maxage = Some(s_maxage.map_or(secs, |v: u64| v.min(secs))),
125                "max-age" => max_age = Some(max_age.map_or(secs, |v: u64| v.min(secs))),
126                _ => {}
127            }
128        }
129    }
130    match s_maxage.or(max_age) {
131        Some(secs) => cap.min(Duration::from_secs(secs)),
132        None => cap,
133    }
134}
135
136// ── purge ─────────────────────────────────────────────────────────────────────
137
138/// What [`CacheLayer::purge`] removes. A cache key is the request target
139/// (path and query) plus the values of `vary_by_header`s, so `Exact` removes
140/// every variant of one target.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum Purge {
143    /// Everything.
144    All,
145    /// One request target, e.g. `/products/42` or `/search?q=lamp`.
146    Exact(String),
147    /// Every target starting with this, e.g. `/products/`.
148    Prefix(String),
149}
150
151impl Purge {
152    fn matches(&self, key: &str) -> bool {
153        let target = key.split('\x00').next().unwrap_or(key);
154        match self {
155            Purge::All => true,
156            Purge::Exact(t) => target == t,
157            Purge::Prefix(p) => target.starts_with(p.as_str()),
158        }
159    }
160
161    /// A `PURGE` request's target: a trailing `*` makes it a prefix.
162    pub fn from_target(target: &str) -> Purge {
163        match target.strip_suffix('*') {
164            Some(prefix) => Purge::Prefix(prefix.to_string()),
165            None => Purge::Exact(target.to_string()),
166        }
167    }
168}
169
170/// Equal, in time that depends only on the lengths.
171fn same_bytes(a: &[u8], b: &[u8]) -> bool {
172    a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
173}
174
175/// Named caches for the admin API: the binary's own and one per
176/// config-file route that has `[route.middleware.cache]`.
177pub mod registry {
178    use super::CacheLayer;
179    use std::collections::BTreeMap;
180    use std::sync::{Mutex, OnceLock};
181
182    fn caches() -> &'static Mutex<BTreeMap<String, CacheLayer>> {
183        static CACHES: OnceLock<Mutex<BTreeMap<String, CacheLayer>>> = OnceLock::new();
184        CACHES.get_or_init(Mutex::default)
185    }
186
187    /// Make `cache` reachable as `name` (a clone: it shares the store).
188    pub fn register(name: impl Into<String>, cache: CacheLayer) {
189        caches().lock().unwrap_or_else(|e| e.into_inner()).insert(name.into(), cache);
190    }
191
192    /// Replace every cache whose name starts with `group` by `caches` (a new
193    /// config generation's routes).
194    #[doc(hidden)]
195    pub fn replace_group(group: &str, new: Vec<(String, CacheLayer)>) {
196        let mut map = caches().lock().unwrap_or_else(|e| e.into_inner());
197        map.retain(|name, _| !name.starts_with(group));
198        map.extend(new);
199    }
200
201    /// Every registered cache, by name.
202    pub fn list() -> Vec<(String, CacheLayer)> {
203        caches().lock().unwrap_or_else(|e| e.into_inner()).iter().map(|(n, c)| (n.clone(), c.clone())).collect()
204    }
205
206    pub fn get(name: &str) -> Option<CacheLayer> {
207        caches().lock().unwrap_or_else(|e| e.into_inner()).get(name).cloned()
208    }
209}
210
211// ── CacheLayer ────────────────────────────────────────────────────────────────
212
213/// An in-memory response cache middleware.
214///
215/// Construct with [`CacheLayer::memory`] and configure with the builder methods
216/// [`ttl`](CacheLayer::ttl) and [`vary_by_header`](CacheLayer::vary_by_header).
217///
218/// `CacheLayer` is cheaply `Clone`-able — clones share the same underlying
219/// store, which lets you keep a handle for cache stats and invalidation while
220/// the other clone is used as middleware.
221#[derive(Clone)]
222pub struct CacheLayer {
223    store: Arc<Mutex<CacheStore>>,
224    /// `store`'s entry count, kept after every change under its lock: an
225    /// empty cache (nothing cacheable seen yet — e.g. static files, sent
226    /// `no-cache`) costs a request no key and no lock. The lock was shared
227    /// by every worker and taken on every GET.
228    stored: Arc<AtomicUsize>,
229    hits: Arc<AtomicU64>,
230    misses: Arc<AtomicU64>,
231    capacity: usize,
232    ttl: Duration,
233    vary_headers: Vec<String>,
234    /// [`CacheLayer::disk`]: entries in files instead of `store`.
235    #[cfg(not(target_arch = "wasm32"))]
236    disk: Option<Arc<disk::DiskStore>>,
237    /// [`CacheLayer::purge_token`]: accept `PURGE` with this bearer token.
238    purge_token: Option<Arc<String>>,
239}
240
241impl CacheLayer {
242    /// Create a new in-memory cache bounded to `capacity` entries.
243    ///
244    /// Default TTL is **60 seconds**. Adjust with [`.ttl()`](CacheLayer::ttl).
245    pub fn memory(capacity: usize) -> Self {
246        CacheLayer {
247            store: Arc::new(Mutex::new(CacheStore::new())),
248            stored: Arc::new(AtomicUsize::new(0)),
249            hits: Arc::new(AtomicU64::new(0)),
250            misses: Arc::new(AtomicU64::new(0)),
251            capacity,
252            ttl: Duration::from_secs(60),
253            vary_headers: vec![],
254            #[cfg(not(target_arch = "wasm32"))]
255            disk: None,
256            purge_token: None,
257        }
258    }
259
260    /// Accept the HTTP `PURGE` method on the routes behind this layer,
261    /// authenticated by `Authorization: Bearer <token>` (since 18.11.0).
262    /// `PURGE /products/42` removes that target (every `Vary` variant);
263    /// `PURGE /products/*` removes the prefix. The answer is
264    /// `{"purged":<n>}`; a missing or wrong token gets `401`. Without a
265    /// token, `PURGE` passes through to the application as before.
266    pub fn purge_token(mut self, token: impl Into<String>) -> Self {
267        let token = token.into();
268        self.purge_token = (!token.is_empty()).then(|| Arc::new(token));
269        self
270    }
271
272    /// Remove the entries `target` names; how many.
273    pub fn purge(&self, target: &Purge) -> usize {
274        if let Some(disk) = self.disk_store() {
275            let n = disk.remove_where(|key| target.matches(key));
276            self.stored.store(disk.len(), Ordering::Relaxed);
277            return n;
278        }
279        let mut guard = self.store.lock().unwrap_or_else(|e| e.into_inner());
280        let keys: Vec<String> = guard.entries.keys().filter(|k| target.matches(k)).cloned().collect();
281        for key in &keys {
282            guard.entries.remove(key);
283        }
284        guard.order.retain(|k| !keys.contains(k));
285        self.stored.store(guard.entries.len(), Ordering::Relaxed);
286        keys.len()
287    }
288
289    /// `"memory"` or `"disk"`.
290    pub fn kind(&self) -> &'static str {
291        if self.disk_store().is_some() { "disk" } else { "memory" }
292    }
293
294    /// The `PURGE` answer, when this request is one and a token is set.
295    fn answer_purge(&self, request: &Request) -> Option<Response> {
296        let token = self.purge_token.as_ref()?;
297        if !request.method.eq_ignore_ascii_case("PURGE") {
298            return None;
299        }
300        use crate::response::STATUS_CODE_REASON_PHRASE;
301        let sent = request.header(Header::_AUTHORIZATION).and_then(|h| h.value.strip_prefix("Bearer ").map(str::trim));
302        let authorized = sent.is_some_and(|s| same_bytes(s.as_bytes(), token.as_bytes()));
303        if !authorized {
304            let mut response = crate::error::problem::problem_with_text(STATUS_CODE_REASON_PHRASE.n401_unauthorized, "PURGE needs the purge token", r#"{"error":"PURGE needs the purge token"}"#);
305            response.headers.push(Header::new("WWW-Authenticate", r#"Bearer realm="cache purge""#));
306            return Some(response);
307        }
308        let n = self.purge(&Purge::from_target(&request.request_uri));
309        Some(Response::json(STATUS_CODE_REASON_PHRASE.n200_ok, format!(r#"{{"purged":{n}}}"#).into_bytes()))
310    }
311
312    /// A cache kept in files under `dir` (created if missing), bounded to
313    /// `max_bytes` in total, oldest dropped first (since 18.11.0). Entries
314    /// survive a restart: the directory is indexed when the layer is built,
315    /// expired and unreadable files removed. Same rules as
316    /// [`memory`](CacheLayer::memory) for what is cached and for how long.
317    ///
318    /// ```rust,no_run
319    /// # extern crate rws_server as rust_web_server;
320    /// use rust_web_server::cache::CacheLayer;
321    ///
322    /// let layer = CacheLayer::disk("/var/cache/rws/api", 512 * 1024 * 1024)?.ttl(600);
323    /// # Ok::<(), std::io::Error>(())
324    /// ```
325    #[cfg(not(target_arch = "wasm32"))]
326    pub fn disk(dir: impl Into<std::path::PathBuf>, max_bytes: u64) -> std::io::Result<Self> {
327        let store = disk::DiskStore::open(dir, max_bytes)?;
328        let mut layer = CacheLayer::memory(0);
329        layer.stored.store(store.len(), Ordering::Relaxed);
330        layer.disk = Some(Arc::new(store));
331        Ok(layer)
332    }
333
334    /// The disk store, when this layer has one.
335    #[cfg(not(target_arch = "wasm32"))]
336    fn disk_store(&self) -> Option<&disk::DiskStore> {
337        self.disk.as_deref()
338    }
339
340    #[cfg(target_arch = "wasm32")]
341    fn disk_store(&self) -> Option<&NoDisk> {
342        None
343    }
344
345    /// Number of cache hits since the cache was created.
346    pub fn hits(&self) -> u64 {
347        self.hits.load(Ordering::Relaxed)
348    }
349
350    /// Number of cache misses since the cache was created.
351    pub fn misses(&self) -> u64 {
352        self.misses.load(Ordering::Relaxed)
353    }
354
355    /// Current number of entries in the store (including potentially expired ones
356    /// that haven't been purged yet).
357    pub fn size(&self) -> usize {
358        if let Some(disk) = self.disk_store() {
359            return disk.len();
360        }
361        self.store.lock().unwrap().entries.len()
362    }
363
364    /// Bytes held by a disk cache's files; `None` for a memory cache.
365    pub fn stored_bytes(&self) -> Option<u64> {
366        #[cfg(not(target_arch = "wasm32"))]
367        return self.disk_store().map(disk::DiskStore::bytes);
368        #[cfg(target_arch = "wasm32")]
369        None
370    }
371
372    /// Remove all entries from the cache.
373    pub fn clear(&self) {
374        if let Some(disk) = self.disk_store() {
375            disk.remove_where(|_| true);
376            self.stored.store(0, Ordering::Relaxed);
377            return;
378        }
379        let mut guard = self.store.lock().unwrap();
380        guard.entries.clear();
381        guard.order.clear();
382        self.stored.store(0, Ordering::Relaxed);
383    }
384
385    /// Set the time-to-live for cached entries.
386    pub fn ttl(mut self, secs: u64) -> Self {
387        self.ttl = Duration::from_secs(secs);
388        self
389    }
390
391    /// Include a request header in the cache key so that different values of
392    /// that header produce separate cache entries.
393    ///
394    /// Header name matching is case-insensitive. Call multiple times to vary
395    /// by more than one header.
396    ///
397    /// ```rust,no_run
398    /// # extern crate rws_server as rust_web_server;
399    /// use rust_web_server::cache::CacheLayer;
400    ///
401    /// let layer = CacheLayer::memory(500)
402    ///     .vary_by_header("Accept")
403    ///     .vary_by_header("Accept-Language");
404    /// ```
405    pub fn vary_by_header(mut self, name: &str) -> Self {
406        self.vary_headers.push(name.to_ascii_lowercase());
407        self
408    }
409
410    fn store(&self) -> &Mutex<CacheStore> {
411        &self.store
412    }
413
414    /// Build a cache key from the request URI and any configured vary headers.
415    fn cache_key(&self, request: &Request) -> String {
416        let mut key = request.request_uri.clone();
417        for vh in &self.vary_headers {
418            let val = request.headers.iter()
419                .find(|h| h.name.eq_ignore_ascii_case(vh))
420                .map(|h| h.value.as_ref())
421                .unwrap_or("");
422            key.push('\x00');
423            key.push_str(val);
424        }
425        key
426    }
427
428    /// `true` when the request carries `Cache-Control: no-cache`, meaning the
429    /// client wants a fresh response (but we may still store the result).
430    fn request_bypasses_cache(request: &Request) -> bool {
431        request.headers.iter().any(|h| {
432            h.name.eq_ignore_ascii_case(Header::_CACHE_CONTROL)
433                && h.value.to_ascii_lowercase().contains("no-cache")
434        })
435    }
436
437    /// `true` for a request whose response may be private to the client
438    /// (credentials, cookies) or partial (`Range`): never answered from, nor
439    /// stored in, the shared cache (#49).
440    fn request_is_private(request: &Request) -> bool {
441        request.headers.iter().any(|h| {
442            h.name.eq_ignore_ascii_case(Header::_AUTHORIZATION)
443                || h.name.eq_ignore_ascii_case(Header::_COOKIE)
444                || h.name.eq_ignore_ascii_case(Header::_RANGE)
445        })
446    }
447
448    /// `true` when the response may be stored and replayed as is:
449    /// - a 2xx to a request without `Range` (a 206 is one slice, not the
450    ///   resource), held in memory (not streamed), without `Set-Cookie`;
451    /// - no `Cache-Control: no-store`, `private` or `no-cache` (`no-cache`
452    ///   allows storing only to revalidate before every reuse, which this
453    ///   cache can't do — static files are sent with it by default);
454    /// - every request header its `Vary` names is part of the cache key, or
455    ///   didn't change this response: `Origin` without an
456    ///   `Access-Control-Allow-Origin` in it, `Accept-Encoding` with an
457    ///   unencoded body (valid for every client). Otherwise e.g. a gzipped
458    ///   body would be replayed to a client that didn't ask for gzip.
459    ///   `Vary: *` is never cacheable.
460    fn response_is_cacheable(&self, request: &Request, response: &Response) -> bool {
461        if response.status_code < 200 || response.status_code >= 300 {
462            return false;
463        }
464        if request.header(Header::_RANGE).is_some() {
465            return false;
466        }
467        // A streamed body isn't held in the response: a stored copy would
468        // replay an empty body. A cookie-setting response is per client (#49).
469        if response.stream_file.is_some() || response.stream_pipe.is_some() {
470            return false;
471        }
472        if response.headers.iter().any(|h| h.name.eq_ignore_ascii_case(Header::_SET_COOKIE)) {
473            return false;
474        }
475        let has = |name: &str| response.headers.iter().any(|h| h.name.eq_ignore_ascii_case(name));
476        let varies_harmlessly = |name: &str| {
477            (name.eq_ignore_ascii_case(Header::_ORIGIN) && !has(Header::_ACCESS_CONTROL_ALLOW_ORIGIN))
478                || (name.eq_ignore_ascii_case(Header::_ACCEPT_ENCODING) && !has(Header::_CONTENT_ENCODING))
479        };
480        response.headers.iter().all(|h| {
481            if h.name.eq_ignore_ascii_case(Header::_CACHE_CONTROL) {
482                let v = h.value.to_ascii_lowercase();
483                !(v.contains("no-store") || v.contains("private") || v.contains("no-cache"))
484            } else if h.name.eq_ignore_ascii_case(Header::_VARY) {
485                h.value.split(',').map(str::trim).filter(|name| !name.is_empty()).all(|name| {
486                    name != "*" && (self.vary_headers.iter().any(|vh| vh.eq_ignore_ascii_case(name)) || varies_harmlessly(name))
487                })
488            } else {
489                true
490            }
491        })
492    }
493
494    /// Age of the entry in whole seconds, capped at u64::MAX.
495    fn age_secs(entry: &CachedEntry) -> u64 {
496        entry.inserted_at.elapsed().as_secs()
497    }
498
499    /// Build a response from a cache hit, injecting an `Age` header.
500    fn cached_response(entry: &CachedEntry) -> Response {
501        let mut response = entry.response.clone();
502        let age = Self::age_secs(entry);
503        // Replace or add the Age header.
504        if let Some(h) = response.headers.iter_mut().find(|h| h.name.eq_ignore_ascii_case("Age")) {
505            h.value = age.to_string().into();
506        } else {
507            response.headers.push(Header { name: "Age".into(), value: age.to_string().into() });
508        }
509        response
510    }
511}
512
513impl Middleware for CacheLayer {
514    /// Headers only: a streamed body passes through untouched.
515    fn accepts_streamed_body(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
516        true
517    }
518
519
520    fn handle(
521        &self,
522        request: &Request,
523        connection: &ConnectionInfo,
524        next: &dyn Application,
525    ) -> Result<Response, String> {
526        if let Some(answer) = self.answer_purge(request) {
527            return Ok(answer);
528        }
529        // Only cache GET requests, and never private or partial ones.
530        if request.method != METHOD.get {
531            return Ok(cache_status(next.execute(request, connection)?, "fwd=method"));
532        }
533        if Self::request_is_private(request) {
534            return Ok(cache_status(next.execute(request, connection)?, "fwd=bypass; detail=private"));
535        }
536
537        let bypass = Self::request_bypasses_cache(request);
538
539        if let Some(disk) = self.disk_store() {
540            if !bypass && !disk.is_empty() {
541                let key = self.cache_key(request);
542                if let Some((mut response, age)) = disk.get(&key) {
543                    self.hits.fetch_add(1, Ordering::Relaxed);
544                    response.headers.retain(|h| !h.name.eq_ignore_ascii_case("Age"));
545                    response.headers.push(Header { name: "Age".into(), value: age.as_secs().to_string().into() });
546                    let left = freshness(&response, self.ttl).saturating_sub(age).as_secs();
547                    return Ok(cache_status(response, &format!("hit; ttl={left}")));
548                }
549            }
550            self.misses.fetch_add(1, Ordering::Relaxed);
551            let response = next.execute(request, connection)?;
552            let ttl = freshness(&response, self.ttl);
553            let forwarded = if bypass { "fwd=request" } else { "fwd=uri-miss" };
554            if !ttl.is_zero() && self.response_is_cacheable(request, &response) {
555                disk.put(&self.cache_key(request), &response, ttl);
556                self.stored.store(disk.len(), Ordering::Relaxed);
557                return Ok(cache_status(response, &format!("{forwarded}; stored")));
558            }
559            return Ok(cache_status(response, forwarded));
560        }
561
562        let mut forwarded = if bypass { "fwd=request" } else { "fwd=uri-miss" };
563        if !bypass && self.stored.load(Ordering::Relaxed) > 0 {
564            // Check for a valid cache hit.
565            let key = self.cache_key(request);
566            let guard = self.store().lock().unwrap();
567            if let Some(entry) = guard.get(&key) {
568                self.hits.fetch_add(1, Ordering::Relaxed);
569                let left = entry.ttl.saturating_sub(entry.inserted_at.elapsed()).as_secs();
570                return Ok(cache_status(Self::cached_response(entry), &format!("hit; ttl={left}")));
571            }
572            if guard.entries.contains_key(&key) {
573                forwarded = "fwd=stale";
574            }
575        }
576        self.misses.fetch_add(1, Ordering::Relaxed);
577
578        // Cache miss (or bypass): call the handler.
579        let response = next.execute(request, connection)?;
580
581        let ttl = freshness(&response, self.ttl);
582        if !ttl.is_zero() && self.response_is_cacheable(request, &response) {
583            let key = self.cache_key(request);
584            let mut guard = self.store().lock().unwrap();
585            guard.purge_expired();
586            guard.insert(
587                key,
588                CachedEntry { response: response.clone(), inserted_at: Instant::now(), ttl },
589                self.capacity,
590            );
591            self.stored.store(guard.entries.len(), Ordering::Relaxed);
592            return Ok(cache_status(response, &format!("{forwarded}; stored")));
593        }
594
595        Ok(cache_status(response, forwarded))
596    }
597
598    /// In-memory: safe on the event loop. A disk store reads and writes
599    /// files, so it runs on the blocking pool.
600    fn runs_inline(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
601        self.disk_store().is_none()
602    }
603}
604
605/// Add this cache's `Cache-Status` member (RFC 9211, 18.17.0): `hit;
606/// ttl=N`, or `fwd=<why>` with `stored` when the response was kept. It goes
607/// after any `Cache-Status` the response already has (the hop nearest the
608/// client is last). `RWS_CONFIG_CACHE_STATUS=false` leaves it out.
609fn cache_status(mut response: Response, parameters: &str) -> Response {
610    let config = crate::server_config::ServerConfig::current();
611    if !config.cache_status {
612        return response;
613    }
614    let member = format!("{}; {parameters}", crate::header::sf::item(&config.proxy_name));
615    match response.headers.iter_mut().find(|h| h.name.eq_ignore_ascii_case("cache-status")) {
616        Some(existing) => existing.value = format!("{}, {member}", existing.value).into(),
617        None => response.headers.push(Header::new("Cache-Status", member)),
618    }
619    response
620}
621
622/// Stand-in for the disk store where there is none (wasm32).
623#[cfg(target_arch = "wasm32")]
624struct NoDisk;
625
626#[cfg(target_arch = "wasm32")]
627impl NoDisk {
628    fn len(&self) -> usize { 0 }
629    fn is_empty(&self) -> bool { true }
630    fn remove_where(&self, _matches: impl Fn(&str) -> bool) -> usize { 0 }
631    fn get(&self, _key: &str) -> Option<(Response, Duration)> { None }
632    fn put(&self, _key: &str, _response: &Response, _ttl: Duration) {}
633}