lib.rsannotatedlib.rssource165 lines · 5.4 KB · raw
1//! The files a site embeds in its binary and serves from itself, each named by its content so a long
2//! cache never serves an old copy.
3//!
4//! A site lists its files once, in a [`Bundle`], and mounts [`Bundle::find`] as a fallback route. A page
5//! links to a file with [`Asset::href`] (`/ui.css?v=1a2b3c4d`); the route answers with the asset's
6//! `content_type` and [`Asset::cache_control`]. No runtime and no I/O: a framework adapter turns these
7//! into a response.
8
9/// One embedded file.
10#[derive(Clone, Copy, Debug)]
11pub struct Asset {
12    /// Where it is served from, with no query: `/ui.css`.
13    pub path: &'static str,
14    pub content_type: &'static str,
15    pub body: &'static [u8],
16}
17
18/// FNV-1a over `bytes`, as eight hex digits: a name for the content, not a secret.
19pub fn hash(bytes: &[u8]) -> String {
20    let hash = bytes.iter().fold(0x811c_9dc5_u32, |hash, &byte| (hash ^ u32::from(byte)).wrapping_mul(0x0100_0193));
21    format!("{hash:08x}")
22}
23
24impl Asset {
25    /// The hash of its bytes.
26    pub fn version(&self) -> String {
27        hash(self.body)
28    }
29
30    /// What a page links to: `/ui.css?v=1a2b3c4d`.
31    pub fn href(&self) -> String {
32        format!("{}?v={}", self.path, self.version())
33    }
34
35    /// `?v=` names exactly these bytes, so a request that carries it never changes; one without is
36    /// kept for a day.
37    pub fn cache_control(&self, query: Option<&str>) -> &'static str {
38        let want = format!("v={}", self.version());
39        if query.is_some_and(|query| query.split('&').any(|part| part == want)) {
40            "public, max-age=31536000, immutable"
41        } else {
42            "public, max-age=86400"
43        }
44    }
45
46    pub fn text(&self) -> &'static str {
47        std::str::from_utf8(self.body).expect("the text assets are UTF-8")
48    }
49}
50
51/// The address of a file a site makes itself at run time (a stylesheet it builds), named by its content.
52pub fn versioned_href(path: &str, content: &str) -> String {
53    format!("{path}?v={}", hash(content.as_bytes()))
54}
55
56/// Every asset a site serves. Building one refuses a path that is not absolute, carries a query, or is
57/// listed twice, and in a `const` it does so when the site is compiled.
58#[derive(Clone, Copy, Debug)]
59pub struct Bundle(&'static [Asset]);
60
61const fn same(a: &str, b: &str) -> bool {
62    let (a, b) = (a.as_bytes(), b.as_bytes());
63    if a.len() != b.len() {
64        return false;
65    }
66    let mut i = 0;
67    while i < a.len() {
68        if a[i] != b[i] {
69            return false;
70        }
71        i += 1;
72    }
73    true
74}
75
76const fn has_query(path: &str) -> bool {
77    let path = path.as_bytes();
78    let mut i = 0;
79    while i < path.len() {
80        if path[i] == b'?' {
81            return true;
82        }
83        i += 1;
84    }
85    false
86}
87
88impl Bundle {
89    pub const fn new(assets: &'static [Asset]) -> Bundle {
90        let mut i = 0;
91        while i < assets.len() {
92            let path = assets[i].path.as_bytes();
93            assert!(!path.is_empty() && path[0] == b'/', "an asset's path starts with a slash");
94            assert!(!has_query(assets[i].path), "an asset's path has no query");
95            assert!(!assets[i].body.is_empty(), "an asset has bytes");
96            let mut j = i + 1;
97            while j < assets.len() {
98                assert!(!same(assets[i].path, assets[j].path), "two assets share a path");
99                j += 1;
100            }
101            i += 1;
102        }
103        Bundle(assets)
104    }
105
106    /// The asset served at `path` (no query), if there is one.
107    pub fn find(&self, path: &str) -> Option<&'static Asset> {
108        self.0.iter().find(|asset| asset.path == path)
109    }
110
111    pub fn all(&self) -> &'static [Asset] {
112        self.0
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    const CSS: Asset = Asset { path: "/a.css", content_type: "text/css; charset=utf-8", body: b"a{}" };
121    const JS: Asset = Asset { path: "/a.js", content_type: "text/javascript; charset=utf-8", body: b"1;" };
122    static BUNDLE: Bundle = Bundle::new(&[CSS, JS]);
123
124    #[test]
125    fn find_answers_by_path_without_a_query() {
126        assert_eq!(BUNDLE.find("/a.css").unwrap().path, "/a.css");
127        assert!(BUNDLE.find("/a.css?v=1").is_none());
128        assert!(BUNDLE.find("/nope").is_none());
129    }
130
131    #[test]
132    fn the_version_names_the_bytes() {
133        assert_eq!(hash(b""), "811c9dc5");
134        assert_eq!(CSS.version().len(), 8);
135        assert_ne!(CSS.version(), JS.version());
136        assert_eq!(CSS.href(), format!("/a.css?v={}", CSS.version()));
137        assert_eq!(versioned_href("/x.css", "a{}"), format!("/x.css?v={}", CSS.version()));
138    }
139
140    #[test]
141    fn a_matching_version_is_cached_for_good_and_a_bare_path_for_a_day() {
142        let own = format!("x=1&v={}", CSS.version());
143        assert!(CSS.cache_control(Some(&own)).contains("immutable"));
144        assert!(!CSS.cache_control(Some("v=00000000")).contains("immutable"));
145        assert!(!CSS.cache_control(None).contains("immutable"));
146    }
147
148    #[test]
149    #[should_panic(expected = "two assets share a path")]
150    fn a_repeated_path_is_refused() {
151        Bundle::new(&[CSS, CSS]);
152    }
153
154    #[test]
155    #[should_panic(expected = "starts with a slash")]
156    fn a_relative_path_is_refused() {
157        Bundle::new(&[Asset { path: "a.css", content_type: "text/css", body: b"x" }]);
158    }
159
160    #[test]
161    #[should_panic(expected = "no query")]
162    fn a_path_with_a_query_is_refused() {
163        Bundle::new(&[Asset { path: "/a.css?v=1", content_type: "text/css", body: b"x" }]);
164    }
165}