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}