Skip to main content

mesofact_core/
cache_policy.rs

1//! The one derivation of `cache_policy` → response headers (R749-T1, R749-B4).
2//!
3//! `cache_policy` is declared once by the author and has to mean the same thing
4//! at every place bytes leave the system. There are three:
5//!
6//! | consumer | what it does with the derived value |
7//! |---|---|
8//! | `mesofact serve` / the W272 bundle tier | axum middleware stamps it on the response ([`crate::cache_policy`] via `mesofact::cache_headers`) |
9//! | `mesofact proxy` | the in-process [`ResponseCache`](crate::ResponseCache) holds entries for `ttl` |
10//! | `mesofact publish` | the `html/` object's `Cache-Control` at PUT time (`mesofact_publisher::publish`) |
11//!
12//! This module is the shared half so those cannot drift. It lives in
13//! `mesofact-core` rather than in the facade because the publisher crate is
14//! *below* the facade in the dep graph (`mesofact` → `mesofact-publisher` →
15//! `mesofact-core`), so a table defined in the facade is unreachable from the
16//! publish path — which is exactly how the publish tier ended up picking
17//! `Cache-Control` by path prefix and dropping the declaration (R749-B4).
18//!
19//! | declared | emitted |
20//! |---|---|
21//! | `ttl: N` | `Cache-Control: public, max-age=N` |
22//! | `swr: N` | `, stale-while-revalidate=N` |
23//! | `vary: [h, …]` | `Vary: h, …` |
24//! | `negative_ttl: N` | `Cache-Control: public, max-age=N` **on 404/410 only** |
25//! | `requires: [...]` | `private` instead of `public`, on every one of the above |
26//!
27//! That last row is not decoration. A route behind an auth gate whose response
28//! carries `public` is a cache-poisoning bug — a shared cache would serve one
29//! user's rendered page to the next. The declaration that the route is gated
30//! and the declaration of how long it may be held are the same author's two
31//! sentences about one route; reading them together is the minimum honest
32//! interpretation.
33//!
34//! The byte-for-byte twin on the edge is
35//! `packages/mesofact-edge/src/cache-policy.ts`, and the two are held to the
36//! same fixture by `tests/fixtures/cache-policy/parity.json`.
37
38use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
39
40use crate::manifest::{CachePolicy, Route};
41
42/// Per-route cache headers derived from the built manifest. Empty = no route
43/// declared a non-inert policy, and every `apply` is a no-op.
44#[derive(Clone, Debug, Default, PartialEq, Eq)]
45pub struct CachePolicyTable {
46    rules: Vec<CacheRule>,
47}
48
49#[derive(Clone, Debug, PartialEq, Eq)]
50struct CacheRule {
51    /// The manifest route pattern (`/c/:slug`).
52    pattern: String,
53    /// `Cache-Control` for a successful response. `None` when the policy only
54    /// declared a negative TTL.
55    ok: Option<HeaderValue>,
56    /// `Cache-Control` for a 404/410. `None` unless `negative_ttl` was set.
57    negative: Option<HeaderValue>,
58    /// `Vary`, when the policy declared one.
59    vary: Option<HeaderValue>,
60}
61
62/// The three fields a cache rule is derived from, read without demanding the
63/// rest of the manifest.
64///
65/// A local slice rather than [`crate::Manifest`] on purpose: a full deserialize
66/// makes *every* required manifest field a startup dependency of the cache
67/// layer, so a manifest missing `version` would refuse the serve with a message
68/// about caching. Completeness is not this type's job — the raw-key check in
69/// [`crate::policy`] runs first and is what guarantees no policy field reaches
70/// here unnoticed.
71#[derive(serde::Deserialize)]
72struct RouteSlice {
73    route: String,
74    #[serde(default)]
75    requires: Option<Vec<String>>,
76    #[serde(default)]
77    cache_policy: Option<CachePolicy>,
78}
79
80impl CachePolicyTable {
81    /// Build from the manifest's routes. Routes whose policy is inert
82    /// (`{ ttl: 0 }` — the "never cache this" every route file writes) produce
83    /// no rule, so the table stays empty for the common workload and this
84    /// layer costs one `is_empty` per response.
85    pub fn from_routes(routes: &[Route]) -> Self {
86        Self::build(routes.iter().map(|r| {
87            (
88                r.route.as_str(),
89                r.requires.as_ref().is_some_and(|q| !q.is_empty()),
90                Some(&r.cache_policy),
91            )
92        }))
93    }
94
95    /// Build from a built `manifest.json`'s bytes.
96    pub fn from_manifest_json(raw: &[u8]) -> serde_json::Result<Self> {
97        #[derive(serde::Deserialize)]
98        struct ManifestSlice {
99            #[serde(default)]
100            routes: Vec<RouteSlice>,
101        }
102        let manifest: ManifestSlice = serde_json::from_slice(raw)?;
103        Ok(Self::build(manifest.routes.iter().map(|r| {
104            (
105                r.route.as_str(),
106                r.requires.as_ref().is_some_and(|q| !q.is_empty()),
107                r.cache_policy.as_ref(),
108            )
109        })))
110    }
111
112    fn build<'a>(
113        routes: impl Iterator<Item = (&'a str, bool, Option<&'a CachePolicy>)>,
114    ) -> Self {
115        let mut rules = Vec::new();
116        for (pattern, gated, policy) in routes {
117            let Some(policy) = policy else { continue };
118            let scope = if gated { "private" } else { "public" };
119            let rule = CacheRule {
120                pattern: pattern.to_string(),
121                ok: positive_cache_control(policy, scope).and_then(|v| HeaderValue::try_from(v).ok()),
122                negative: policy
123                    .negative_ttl
124                    .and_then(|ttl| HeaderValue::try_from(format!("{scope}, max-age={ttl}")).ok()),
125                vary: policy
126                    .vary
127                    .as_ref()
128                    .filter(|v| !v.is_empty())
129                    .and_then(|v| HeaderValue::try_from(v.join(", ")).ok()),
130            };
131            if rule.ok.is_some() || rule.negative.is_some() || rule.vary.is_some() {
132                rules.push(rule);
133            }
134        }
135        Self { rules }
136    }
137
138    pub fn is_empty(&self) -> bool {
139        self.rules.is_empty()
140    }
141
142    pub fn len(&self) -> usize {
143        self.rules.len()
144    }
145
146    /// The `Cache-Control` a **2xx** at `path` must carry, or `None` when no
147    /// route declared a positive policy for it.
148    ///
149    /// The header-free accessor exists for the publish path, which stamps a
150    /// value onto an object at PUT time rather than onto a live response and
151    /// has no `axum` dependency of its own. Same rules, same first-match-wins
152    /// order — a second derivation over there is precisely the drift this
153    /// module was pulled down into `mesofact-core` to prevent.
154    pub fn cache_control_for(&self, path: &str) -> Option<&str> {
155        self.rules
156            .iter()
157            .find(|r| match_route_pattern(&r.pattern, path))
158            .and_then(|r| r.ok.as_ref())
159            .and_then(|v| v.to_str().ok())
160    }
161
162    /// Stamp the first matching route's derived headers onto `headers`.
163    ///
164    /// First match wins, matching the manifest's own routing rule — the same
165    /// discipline `mesofact::route_headers` states for the domain table.
166    pub fn apply(&self, path: &str, status: StatusCode, headers: &mut HeaderMap) {
167        let Some(rule) = self
168            .rules
169            .iter()
170            .find(|r| match_route_pattern(&r.pattern, path))
171        else {
172            return;
173        };
174        // 3xx/5xx get nothing: a redirect's lifetime is not the page's, and
175        // caching a 500 for the page's TTL turns a blip into an outage.
176        //
177        // Except 304, which is not a redirect but a re-validation of the 200
178        // (MFT-R825-F1: the static tier answers `If-None-Match` now). A cache
179        // REPLACES the stored response's headers with the 304's (RFC 9111
180        // §4.3.4), so a 304 without the page's `Cache-Control` would silently
181        // downgrade the declared TTL to whatever the static tier defaulted to.
182        let cc = if status.is_success() || status == StatusCode::NOT_MODIFIED {
183            rule.ok.as_ref()
184        } else if status == StatusCode::NOT_FOUND || status == StatusCode::GONE {
185            rule.negative.as_ref()
186        } else {
187            None
188        };
189        if let Some(cc) = cc {
190            headers.insert(header::CACHE_CONTROL, cc.clone());
191            if let Some(vary) = &rule.vary {
192                headers.insert(header::VARY, vary.clone());
193            }
194        }
195    }
196}
197
198/// `Cache-Control` for a 2xx. `ttl: 0` with no `swr` is the inert policy and
199/// yields nothing; `ttl: 0` *with* `swr` is a real declaration (revalidate
200/// always, serve stale while you do).
201pub fn positive_cache_control(policy: &CachePolicy, scope: &str) -> Option<String> {
202    if policy.ttl == 0 && policy.swr.is_none() {
203        return None;
204    }
205    let mut value = format!("{scope}, max-age={}", policy.ttl);
206    if let Some(swr) = policy.swr {
207        value.push_str(&format!(", stale-while-revalidate={swr}"));
208    }
209    Some(value)
210}
211
212/// Segment-aware match of a route pattern (`/c/:slug`) against a concrete path
213/// (`/c/abc123`) — byte-parallel with the worker's `matchRoutePattern`. A
214/// `:param` segment matches any single non-empty segment; segment counts must
215/// be equal, so a trailing `:param` never swallows extra segments.
216pub fn match_route_pattern(pattern: &str, pathname: &str) -> bool {
217    let pat = pattern.split('/').filter(|s| !s.is_empty());
218    let path: Vec<&str> = pathname.split('/').filter(|s| !s.is_empty()).collect();
219    let pat: Vec<&str> = pat.collect();
220    if pat.len() != path.len() {
221        return false;
222    }
223    pat.iter().zip(path.iter()).all(|(seg, actual)| {
224        if seg.starts_with(':') {
225            !actual.is_empty()
226        } else {
227            seg == actual
228        }
229    })
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::manifest::{Requires, RouteMode};
236
237    fn route(path: &str, policy: CachePolicy, requires: Option<Vec<Requires>>) -> Route {
238        Route {
239            route: path.into(),
240            mode: RouteMode::Static,
241            render_entrypoint: "e.js".into(),
242            requires,
243            source_reads: None,
244            data_inputs: None,
245            cache_policy: policy,
246            concurrency: None,
247            hydration: None,
248            prerender: None,
249            placement: None,
250            resilience: None,
251        }
252    }
253
254    fn policy(
255        ttl: u64,
256        swr: Option<u64>,
257        negative_ttl: Option<u64>,
258        vary: Option<Vec<String>>,
259    ) -> CachePolicy {
260        CachePolicy {
261            ttl,
262            swr,
263            negative_ttl,
264            vary,
265        }
266    }
267
268    fn header_of(table: &CachePolicyTable, path: &str, status: StatusCode) -> Option<String> {
269        let mut headers = HeaderMap::new();
270        table.apply(path, status, &mut headers);
271        headers
272            .get(header::CACHE_CONTROL)
273            .map(|v| v.to_str().unwrap().to_string())
274    }
275
276    #[test]
277    fn the_inert_policy_every_route_carries_produces_no_rule() {
278        let table = CachePolicyTable::from_routes(&[route("/", policy(0, None, None, None), None)]);
279        assert!(table.is_empty());
280        assert_eq!(header_of(&table, "/", StatusCode::OK), None);
281    }
282
283    #[test]
284    fn a_declared_ttl_reaches_the_response() {
285        let table = CachePolicyTable::from_routes(&[route(
286            "/issues",
287            policy(3600, Some(86_400), None, None),
288            None,
289        )]);
290        assert_eq!(
291            header_of(&table, "/issues", StatusCode::OK).as_deref(),
292            Some("public, max-age=3600, stale-while-revalidate=86400"),
293        );
294    }
295
296    /// A 304 refreshes the cached 200's headers, so it must carry the same
297    /// declared policy — or one revalidation erases the route's TTL.
298    #[test]
299    fn a_not_modified_revalidation_carries_the_declared_ttl() {
300        let table = CachePolicyTable::from_routes(&[route("/issues", policy(3600, None, None, None), None)]);
301        assert_eq!(
302            header_of(&table, "/issues", StatusCode::NOT_MODIFIED).as_deref(),
303            Some("public, max-age=3600"),
304        );
305        assert_eq!(header_of(&table, "/issues", StatusCode::FOUND), None, "a redirect still gets nothing");
306    }
307
308    /// The row that is a correctness bug rather than a missing optimization:
309    /// a gated route must never be marked shareable.
310    #[test]
311    fn an_authed_route_is_private_not_public() {
312        let table = CachePolicyTable::from_routes(&[route(
313            "/app",
314            policy(60, None, None, None),
315            Some(vec![Requires::User]),
316        )]);
317        let cc = header_of(&table, "/app", StatusCode::OK).unwrap();
318        assert!(cc.starts_with("private,"), "{cc}");
319    }
320
321    #[test]
322    fn a_param_route_matches_its_instances_only_on_segment_boundaries() {
323        let table =
324            CachePolicyTable::from_routes(&[route("/c/:slug", policy(60, None, None, None), None)]);
325        assert!(header_of(&table, "/c/abc", StatusCode::OK).is_some());
326        assert!(header_of(&table, "/c/abc/extra", StatusCode::OK).is_none());
327        assert!(header_of(&table, "/c", StatusCode::OK).is_none());
328    }
329
330    #[test]
331    fn negative_ttl_applies_to_misses_and_the_positive_ttl_does_not() {
332        let table =
333            CachePolicyTable::from_routes(&[route("/r", policy(600, None, Some(30), None), None)]);
334        assert_eq!(
335            header_of(&table, "/r", StatusCode::NOT_FOUND).as_deref(),
336            Some("public, max-age=30"),
337        );
338        assert_eq!(
339            header_of(&table, "/r", StatusCode::OK).as_deref(),
340            Some("public, max-age=600"),
341        );
342        // A 500 is nobody's cache entry.
343        assert_eq!(
344            header_of(&table, "/r", StatusCode::INTERNAL_SERVER_ERROR),
345            None
346        );
347    }
348
349    #[test]
350    fn vary_rides_along_with_the_cache_control_it_qualifies() {
351        let table = CachePolicyTable::from_routes(&[route(
352            "/r",
353            policy(
354                60,
355                None,
356                None,
357                Some(vec!["accept-language".into(), "cookie".into()]),
358            ),
359            None,
360        )]);
361        let mut headers = HeaderMap::new();
362        table.apply("/r", StatusCode::OK, &mut headers);
363        assert_eq!(
364            headers.get(header::VARY).unwrap().to_str().unwrap(),
365            "accept-language, cookie",
366        );
367    }
368
369    /// `swr` alone is a real declaration even at `ttl: 0` — "always
370    /// revalidate, but serve stale while you do" is a policy, not the absence
371    /// of one, and dropping it would be the silent no-op this ticket forbids.
372    #[test]
373    fn swr_at_ttl_zero_is_still_a_declaration() {
374        let table =
375            CachePolicyTable::from_routes(&[route("/r", policy(0, Some(60), None, None), None)]);
376        assert!(!table.is_empty());
377        assert_eq!(
378            header_of(&table, "/r", StatusCode::OK).as_deref(),
379            Some("public, max-age=0, stale-while-revalidate=60"),
380        );
381    }
382
383    /// The publish path's accessor and the serve path's middleware must answer
384    /// the same string for the same route — they are one table precisely so a
385    /// page cannot be published with one TTL and served with another.
386    #[test]
387    fn the_publish_accessor_agrees_with_the_serve_middleware() {
388        let table = CachePolicyTable::from_routes(&[
389            route("/issues", policy(3600, Some(86_400), None, None), None),
390            route("/app", policy(60, None, None, None), Some(vec![Requires::User])),
391            route("/plain", policy(0, None, None, None), None),
392        ]);
393        for path in ["/issues", "/app", "/plain", "/nope"] {
394            assert_eq!(
395                table.cache_control_for(path).map(str::to_string),
396                header_of(&table, path, StatusCode::OK),
397                "{path}",
398            );
399        }
400    }
401}