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