1use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
39
40use crate::manifest::{CachePolicy, Route};
41
42#[derive(Clone, Debug, Default, PartialEq, Eq)]
45pub struct CachePolicyTable {
46 rules: Vec<CacheRule>,
47}
48
49#[derive(Clone, Debug, PartialEq, Eq)]
50struct CacheRule {
51 pattern: String,
53 ok: Option<HeaderValue>,
56 negative: Option<HeaderValue>,
58 vary: Option<HeaderValue>,
60}
61
62#[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 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 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 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 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 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
198pub 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
212pub 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 #[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 #[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 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 #[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 #[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}