1//! The files a Jev site's pages load, embedded in the binary and named by 2//! their content, so a Worker serves them from itself and a page never asks 3//! another site for anything. 4//! 5//! A site mounts `find` as a fallback route and answers with the asset's 6//! `content_type` and `cache_control`. `href` gives the address a page links 7//! to: the stable path with `?v=<hash of the bytes>`, so a long cache never 8//! serves an old stylesheet after the crate moves. 9 10/// One embedded file. 11#[derive(Clone, Copy, Debug)] 12pub struct Asset { 13 /// Where it is served from, with no query: `/ui.css`. 14 pub path: &'static str, 15 pub content_type: &'static str, 16 pub body: &'static [u8], 17} 18 19pub const CSS: Asset = Asset { path: "/ui.css", content_type: "text/css; charset=utf-8", body: include_bytes!("../assets/ui.css") }; 20pub const JS: Asset = Asset { path: "/ui.js", content_type: "text/javascript; charset=utf-8", body: include_bytes!("../assets/ui.js") }; 21/// Datastar v1.0.4, upstream's own release build. It is not edited here. 22pub const DATASTAR: Asset = 23 Asset { path: "/datastar.js", content_type: "text/javascript; charset=utf-8", body: include_bytes!("../assets/datastar.js") }; 24 25const INTER_TIGHT: Asset = Asset { path: "/fonts/inter-tight-500.woff2", content_type: "font/woff2", body: include_bytes!("../assets/fonts/inter-tight-500.woff2") }; 26const JETBRAINS_MONO: Asset = Asset { path: "/fonts/jetbrains-mono.woff2", content_type: "font/woff2", body: include_bytes!("../assets/fonts/jetbrains-mono.woff2") }; 27const VT323: Asset = Asset { path: "/fonts/vt323.woff2", content_type: "font/woff2", body: include_bytes!("../assets/fonts/vt323.woff2") }; 28 29/// SerenityOS's pixel emoji, four of them (`components::PixelEmoji`). Not preloaded: a page that shows none never fetches it. 30const PIXEL_EMOJI: Asset = Asset { path: "/fonts/pixel-emoji.woff2", content_type: "font/woff2", body: include_bytes!("../assets/fonts/pixel-emoji.woff2") }; 31 32/// The fonts every page uses, which the head preloads. 33pub const FONTS: [Asset; 3] = [INTER_TIGHT, JETBRAINS_MONO, VT323]; 34 35/// Every asset, by path. The one list: `find` reads it, and a test holds it 36/// to what the stylesheet asks for. 37pub const ALL: [Asset; 7] = [CSS, JS, DATASTAR, INTER_TIGHT, JETBRAINS_MONO, VT323, PIXEL_EMOJI]; 38 39/// The asset served at `path` (no query), if there is one. 40pub fn find(path: &str) -> Option<&'static Asset> { 41 ALL.iter().find(|asset| asset.path == path) 42} 43 44/// FNV-1a over `bytes`, as eight hex digits: a name for the content, not a 45/// secret. 46pub fn hash(bytes: &[u8]) -> String { 47 let hash = bytes.iter().fold(0x811c_9dc5_u32, |hash, &byte| (hash ^ u32::from(byte)).wrapping_mul(0x0100_0193)); 48 format!("{hash:08x}") 49} 50 51impl Asset { 52 /// The hash of its bytes. 53 pub fn version(&self) -> String { 54 hash(self.body) 55 } 56 57 /// What a page links to: `/ui.css?v=1a2b3c4d`. 58 pub fn href(&self) -> String { 59 format!("{}?v={}", self.path, self.version()) 60 } 61 62 /// The `Cache-Control` to answer with. A request that carries the right 63 /// `?v=` names exactly these bytes, so it never changes; one without 64 /// is for a day. 65 pub fn cache_control(&self, query: Option<&str>) -> &'static str { 66 let want = format!("v={}", self.version()); 67 if query.is_some_and(|query| query.split('&').any(|part| part == want)) { 68 "public, max-age=31536000, immutable" 69 } else { 70 "public, max-age=86400" 71 } 72 } 73 74 pub fn text(&self) -> &'static str { 75 std::str::from_utf8(self.body).expect("the text assets are UTF-8") 76 } 77} 78 79/// A stable name for a stylesheet or script a site makes itself (the demo's 80/// cat motion, say): `href` and `hash`, so it is cached the way the shared 81/// files are. 82pub fn versioned_href(path: &str, content: &str) -> String { 83 format!("{path}?v={}", hash(content.as_bytes())) 84} 85 86#[cfg(test)] 87mod tests { 88 use super::*; 89 90 #[test] 91 fn every_path_is_unique_and_absolute_without_a_query() { 92 for (i, a) in ALL.iter().enumerate() { 93 assert!(a.path.starts_with('/') && !a.path.contains('?'), "{}", a.path); 94 assert!(!a.body.is_empty(), "{}", a.path); 95 assert!(ALL.iter().skip(i + 1).all(|b| b.path != a.path), "{}", a.path); 96 } 97 } 98 99 #[test] 100 fn find_answers_by_path() { 101 assert_eq!(find("/ui.css").unwrap().path, "/ui.css"); 102 assert!(find("/ui.css?v=1").is_none(), "a path has no query"); 103 assert!(find("/nope").is_none()); 104 } 105 106 #[test] 107 fn the_stylesheet_asks_only_for_fonts_this_crate_serves() { 108 let css = CSS.text(); 109 let mut asked = 0; 110 for part in css.split("url(\"").skip(1) { 111 let url = part.split('"').next().unwrap(); 112 asked += 1; 113 assert!(find(url).is_some(), "{url}"); 114 } 115 // The preloaded fonts, and the pixel emoji that are fetched only when a glyph is drawn. 116 assert_eq!(asked, FONTS.len() + 1); 117 for font in FONTS.iter().chain([&PIXEL_EMOJI]) { 118 assert!(css.contains(font.path), "{}", font.path); 119 } 120 assert!(!css.contains("://") && !css.contains("@import"), "nothing is fetched from another site"); 121 } 122 123 #[test] 124 fn the_version_names_the_bytes() { 125 assert_eq!(hash(b""), "811c9dc5"); 126 assert_eq!(CSS.version().len(), 8); 127 assert_ne!(CSS.version(), JS.version()); 128 assert!(CSS.href().starts_with("/ui.css?v=")); 129 } 130 131 #[test] 132 fn a_matching_version_is_cached_for_good_and_a_bare_path_for_a_day() { 133 let own = format!("v={}", CSS.version()); 134 assert!(CSS.cache_control(Some(&own)).contains("immutable")); 135 assert!(!CSS.cache_control(Some("v=00000000")).contains("immutable")); 136 assert!(!CSS.cache_control(None).contains("immutable")); 137 } 138 139 #[test] 140 fn the_page_script_fetches_nothing() { 141 let js = JS.text(); 142 for banned in ["fetch(", "XMLHttpRequest", "sendBeacon", "WebSocket", "import("] { 143 assert!(!js.contains(banned), "{banned}"); 144 } 145 } 146}