Skip to main content

pharos_runtime_static/
browser_cache.rs

1//! Clears stale HTTP cache once per app deployment without deleting user storage.
2
3use pharos_runtime_core::http::HttpRequest;
4
5/// Emits cache-only invalidation on successful full-page navigations to a new build.
6/// The scoped, non-authoritative marker avoids clearing cache on every interaction.
7pub fn deployment_headers(
8    request: &HttpRequest,
9    response_head: &str,
10    app_id: &str,
11    mount: &str,
12    identity: &str,
13    secure: bool,
14) -> Vec<(&'static str, String)> {
15    if request.method != "GET"
16        || !response_head.starts_with("HTTP/1.1 200 ")
17        || !response_head.lines().any(|line| {
18            line.split_once(':').is_some_and(|(name, value)| {
19                name.eq_ignore_ascii_case("content-type") && value.trim().starts_with("text/html")
20            })
21        })
22        || request.headers.lines().any(|line| {
23            line.split_once(':').is_some_and(|(name, value)| {
24                name.eq_ignore_ascii_case("hx-request") && value.trim().eq_ignore_ascii_case("true")
25            })
26        })
27        || app_id.is_empty()
28        || app_id.len() > 64
29        || !app_id
30            .bytes()
31            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
32        || !mount.starts_with('/')
33        || mount.len() > 1024
34        || !mount
35            .bytes()
36            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'_' | b'-' | b'.'))
37        || identity.len() != 64
38        || !identity.bytes().all(|byte| byte.is_ascii_hexdigit())
39    {
40        return Vec::new();
41    }
42    let name = format!("pharos_build_{app_id}");
43    if request
44        .cookie
45        .as_deref()
46        .unwrap_or_default()
47        .split(';')
48        .any(|part| {
49            part.trim()
50                .split_once('=')
51                .is_some_and(|(key, value)| key == name && value == identity)
52        })
53    {
54        return Vec::new();
55    }
56    vec![
57        ("Clear-Site-Data", "\"cache\"".to_owned()),
58        (
59            "Set-Cookie",
60            format!(
61                "{name}={identity}; Path={mount}; Max-Age=31536000; HttpOnly; SameSite=Lax{}",
62                if secure { "; Secure" } else { "" }
63            ),
64        ),
65    ]
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    /// Proves upgrade detection, session preservation, and fragment/static exclusion.
73    #[test]
74    fn clears_only_http_cache_once_per_build() {
75        let identity = "a".repeat(64);
76        let head = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8";
77        let mut request = HttpRequest {
78            method: "GET".to_owned(),
79            ..Default::default()
80        };
81        let headers = deployment_headers(
82            &request,
83            head,
84            "commerce_spa",
85            "/beacon-beats",
86            &identity,
87            true,
88        );
89        assert_eq!(headers[0], ("Clear-Site-Data", "\"cache\"".to_owned()));
90        assert!(headers[1].1.ends_with("; Secure"));
91        assert!(!headers[1].1.contains("pharos_session"));
92        request.cookie = Some(format!(
93            "pharos_session=retained; pharos_build_commerce_spa={identity}"
94        ));
95        assert!(
96            deployment_headers(
97                &request,
98                head,
99                "commerce_spa",
100                "/beacon-beats",
101                &identity,
102                true
103            )
104            .is_empty()
105        );
106        assert!(
107            !deployment_headers(
108                &request,
109                head,
110                "commerce_spa",
111                "/beacon-beats",
112                &"b".repeat(64),
113                true
114            )
115            .is_empty()
116        );
117        request.headers = "HX-Request: true\r\n".to_owned();
118        assert!(
119            deployment_headers(
120                &request,
121                head,
122                "commerce_spa",
123                "/beacon-beats",
124                &identity,
125                true
126            )
127            .is_empty()
128        );
129        request.headers.clear();
130        request.cookie = None;
131        request.method = "POST".to_owned();
132        assert!(
133            deployment_headers(
134                &request,
135                head,
136                "commerce_spa",
137                "/beacon-beats",
138                &identity,
139                true
140            )
141            .is_empty()
142        );
143        request.method = "GET".to_owned();
144        assert!(
145            deployment_headers(
146                &request,
147                "HTTP/1.1 200 OK\r\nContent-Type: text/css",
148                "commerce_spa",
149                "/beacon-beats",
150                &identity,
151                true
152            )
153            .is_empty()
154        );
155        assert!(
156            deployment_headers(&request, head, "commerce_spa", "/bad;path", &identity, true)
157                .is_empty()
158        );
159    }
160}