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        let cc = if status.is_success() {
177            rule.ok.as_ref()
178        } else if status == StatusCode::NOT_FOUND || status == StatusCode::GONE {
179            rule.negative.as_ref()
180        } else {
181            None
182        };
183        if let Some(cc) = cc {
184            headers.insert(header::CACHE_CONTROL, cc.clone());
185            if let Some(vary) = &rule.vary {
186                headers.insert(header::VARY, vary.clone());
187            }
188        }
189    }
190}
191
192/// `Cache-Control` for a 2xx. `ttl: 0` with no `swr` is the inert policy and
193/// yields nothing; `ttl: 0` *with* `swr` is a real declaration (revalidate
194/// always, serve stale while you do).
195pub fn positive_cache_control(policy: &CachePolicy, scope: &str) -> Option<String> {
196    if policy.ttl == 0 && policy.swr.is_none() {
197        return None;
198    }
199    let mut value = format!("{scope}, max-age={}", policy.ttl);
200    if let Some(swr) = policy.swr {
201        value.push_str(&format!(", stale-while-revalidate={swr}"));
202    }
203    Some(value)
204}
205
206/// Segment-aware match of a route pattern (`/c/:slug`) against a concrete path
207/// (`/c/abc123`) — byte-parallel with the worker's `matchRoutePattern`. A
208/// `:param` segment matches any single non-empty segment; segment counts must
209/// be equal, so a trailing `:param` never swallows extra segments.
210pub fn match_route_pattern(pattern: &str, pathname: &str) -> bool {
211    let pat = pattern.split('/').filter(|s| !s.is_empty());
212    let path: Vec<&str> = pathname.split('/').filter(|s| !s.is_empty()).collect();
213    let pat: Vec<&str> = pat.collect();
214    if pat.len() != path.len() {
215        return false;
216    }
217    pat.iter().zip(path.iter()).all(|(seg, actual)| {
218        if seg.starts_with(':') {
219            !actual.is_empty()
220        } else {
221            seg == actual
222        }
223    })
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::manifest::{Requires, RouteMode};
230
231    fn route(path: &str, policy: CachePolicy, requires: Option<Vec<Requires>>) -> Route {
232        Route {
233            route: path.into(),
234            mode: RouteMode::Static,
235            render_entrypoint: "e.js".into(),
236            requires,
237            source_reads: None,
238            data_inputs: None,
239            cache_policy: policy,
240            concurrency: None,
241            hydration: None,
242            prerender: None,
243            placement: None,
244            resilience: None,
245        }
246    }
247
248    fn policy(
249        ttl: u64,
250        swr: Option<u64>,
251        negative_ttl: Option<u64>,
252        vary: Option<Vec<String>>,
253    ) -> CachePolicy {
254        CachePolicy {
255            ttl,
256            swr,
257            negative_ttl,
258            vary,
259        }
260    }
261
262    fn header_of(table: &CachePolicyTable, path: &str, status: StatusCode) -> Option<String> {
263        let mut headers = HeaderMap::new();
264        table.apply(path, status, &mut headers);
265        headers
266            .get(header::CACHE_CONTROL)
267            .map(|v| v.to_str().unwrap().to_string())
268    }
269
270    #[test]
271    fn the_inert_policy_every_route_carries_produces_no_rule() {
272        let table = CachePolicyTable::from_routes(&[route("/", policy(0, None, None, None), None)]);
273        assert!(table.is_empty());
274        assert_eq!(header_of(&table, "/", StatusCode::OK), None);
275    }
276
277    #[test]
278    fn a_declared_ttl_reaches_the_response() {
279        let table = CachePolicyTable::from_routes(&[route(
280            "/issues",
281            policy(3600, Some(86_400), None, None),
282            None,
283        )]);
284        assert_eq!(
285            header_of(&table, "/issues", StatusCode::OK).as_deref(),
286            Some("public, max-age=3600, stale-while-revalidate=86400"),
287        );
288    }
289
290    /// The row that is a correctness bug rather than a missing optimization:
291    /// a gated route must never be marked shareable.
292    #[test]
293    fn an_authed_route_is_private_not_public() {
294        let table = CachePolicyTable::from_routes(&[route(
295            "/app",
296            policy(60, None, None, None),
297            Some(vec![Requires::User]),
298        )]);
299        let cc = header_of(&table, "/app", StatusCode::OK).unwrap();
300        assert!(cc.starts_with("private,"), "{cc}");
301    }
302
303    #[test]
304    fn a_param_route_matches_its_instances_only_on_segment_boundaries() {
305        let table =
306            CachePolicyTable::from_routes(&[route("/c/:slug", policy(60, None, None, None), None)]);
307        assert!(header_of(&table, "/c/abc", StatusCode::OK).is_some());
308        assert!(header_of(&table, "/c/abc/extra", StatusCode::OK).is_none());
309        assert!(header_of(&table, "/c", StatusCode::OK).is_none());
310    }
311
312    #[test]
313    fn negative_ttl_applies_to_misses_and_the_positive_ttl_does_not() {
314        let table =
315            CachePolicyTable::from_routes(&[route("/r", policy(600, None, Some(30), None), None)]);
316        assert_eq!(
317            header_of(&table, "/r", StatusCode::NOT_FOUND).as_deref(),
318            Some("public, max-age=30"),
319        );
320        assert_eq!(
321            header_of(&table, "/r", StatusCode::OK).as_deref(),
322            Some("public, max-age=600"),
323        );
324        // A 500 is nobody's cache entry.
325        assert_eq!(
326            header_of(&table, "/r", StatusCode::INTERNAL_SERVER_ERROR),
327            None
328        );
329    }
330
331    #[test]
332    fn vary_rides_along_with_the_cache_control_it_qualifies() {
333        let table = CachePolicyTable::from_routes(&[route(
334            "/r",
335            policy(
336                60,
337                None,
338                None,
339                Some(vec!["accept-language".into(), "cookie".into()]),
340            ),
341            None,
342        )]);
343        let mut headers = HeaderMap::new();
344        table.apply("/r", StatusCode::OK, &mut headers);
345        assert_eq!(
346            headers.get(header::VARY).unwrap().to_str().unwrap(),
347            "accept-language, cookie",
348        );
349    }
350
351    /// `swr` alone is a real declaration even at `ttl: 0` — "always
352    /// revalidate, but serve stale while you do" is a policy, not the absence
353    /// of one, and dropping it would be the silent no-op this ticket forbids.
354    #[test]
355    fn swr_at_ttl_zero_is_still_a_declaration() {
356        let table =
357            CachePolicyTable::from_routes(&[route("/r", policy(0, Some(60), None, None), None)]);
358        assert!(!table.is_empty());
359        assert_eq!(
360            header_of(&table, "/r", StatusCode::OK).as_deref(),
361            Some("public, max-age=0, stale-while-revalidate=60"),
362        );
363    }
364
365    /// The publish path's accessor and the serve path's middleware must answer
366    /// the same string for the same route — they are one table precisely so a
367    /// page cannot be published with one TTL and served with another.
368    #[test]
369    fn the_publish_accessor_agrees_with_the_serve_middleware() {
370        let table = CachePolicyTable::from_routes(&[
371            route("/issues", policy(3600, Some(86_400), None, None), None),
372            route("/app", policy(60, None, None, None), Some(vec![Requires::User])),
373            route("/plain", policy(0, None, None, None), None),
374        ]);
375        for path in ["/issues", "/app", "/plain", "/nope"] {
376            assert_eq!(
377                table.cache_control_for(path).map(str::to_string),
378                header_of(&table, path, StatusCode::OK),
379                "{path}",
380            );
381        }
382    }
383}