1use std::borrow::Cow;
8use std::io::Write;
9use std::sync::LazyLock;
10
11use axum::extract::Path;
12use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
13use axum::response::{IntoResponse, Response};
14use flate2::Compression;
15use flate2::write::GzEncoder;
16use xxhash_rust::xxh3::xxh3_64;
17
18struct Source {
19 name: &'static str,
20 content_type: &'static str,
21 bytes: &'static [u8],
22}
23
24const SOURCES: &[Source] = &[
25 Source {
26 name: "Geist-Variable.woff2",
27 content_type: "font/woff2",
28 bytes: include_bytes!("../assets/fonts/Geist-Variable.woff2"),
29 },
30 Source {
31 name: "GeistMono-Variable.woff2",
32 content_type: "font/woff2",
33 bytes: include_bytes!("../assets/fonts/GeistMono-Variable.woff2"),
34 },
35 Source {
36 name: "htmx.min.js",
37 content_type: "text/javascript; charset=utf-8",
38 bytes: include_bytes!("../assets/htmx.min.js"),
39 },
40 Source {
41 name: "app.js",
42 content_type: "text/javascript; charset=utf-8",
43 bytes: include_bytes!("../assets/app.js"),
44 },
45 Source {
46 name: "favicon.svg",
47 content_type: "image/svg+xml",
48 bytes: include_bytes!("../assets/favicon.svg"),
49 },
50 Source {
51 name: "landing.css",
52 content_type: "text/css; charset=utf-8",
53 bytes: include_bytes!("../assets/landing.css"),
54 },
55 Source {
57 name: "app.css",
58 content_type: "text/css; charset=utf-8",
59 bytes: include_bytes!("../assets/app.css"),
60 },
61];
62
63pub struct Asset {
64 pub name: &'static str,
65 pub url: String,
67 content_type: &'static str,
68 bytes: Cow<'static, [u8]>,
69 gzip: Option<Vec<u8>>,
70}
71
72pub struct Assets {
73 items: Vec<Asset>,
74}
75
76static ASSETS: LazyLock<Assets> = LazyLock::new(Assets::build);
77
78impl Assets {
79 fn build() -> Assets {
80 let mut items: Vec<Asset> = Vec::with_capacity(SOURCES.len());
81 for src in SOURCES {
82 let mut bytes = Cow::Borrowed(src.bytes);
83 if src.name.ends_with(".css") {
84 let mut text = String::from_utf8_lossy(src.bytes).into_owned();
86 for done in &items {
87 text = text.replace(&format!("/assets/{}", done.name), &done.url);
88 }
89 bytes = Cow::Owned(text.into_bytes());
90 }
91 let hash = xxh3_64(&bytes);
92 let (stem, ext) = src.name.rsplit_once('.').unwrap_or((src.name, ""));
93 let url = format!("/assets/{stem}.{:08x}.{ext}", hash as u32);
94 let gzip = compressible(src.content_type).then(|| gzip(&bytes));
95 items.push(Asset {
96 name: src.name,
97 url,
98 content_type: src.content_type,
99 bytes,
100 gzip,
101 });
102 }
103 Assets { items }
104 }
105
106 fn by_url(&self, url: &str) -> Option<&Asset> {
107 self.items.iter().find(|a| a.url == url)
108 }
109
110 fn by_name(&self, name: &str) -> Option<&Asset> {
111 self.items.iter().find(|a| a.name == name)
112 }
113}
114
115fn compressible(content_type: &str) -> bool {
116 content_type.starts_with("text/") || content_type.starts_with("image/svg")
117}
118
119fn gzip(bytes: &[u8]) -> Vec<u8> {
120 let mut enc = GzEncoder::new(Vec::new(), Compression::best());
121 enc.write_all(bytes).expect("writing to a Vec cannot fail");
122 enc.finish().expect("writing to a Vec cannot fail")
123}
124
125pub fn url(name: &str) -> &'static str {
127 match ASSETS.by_name(name) {
128 Some(a) => a.url.as_str(),
129 None => panic!("unknown asset {name:?}"),
130 }
131}
132
133pub async fn serve(Path(file): Path<String>, headers: HeaderMap) -> Response {
135 let Some(asset) = ASSETS.by_url(&format!("/assets/{file}")) else {
136 return (
137 StatusCode::NOT_FOUND,
138 [(header::CACHE_CONTROL, "no-store")],
139 "not found",
140 )
141 .into_response();
142 };
143 let accepts_gzip = headers
144 .get(header::ACCEPT_ENCODING)
145 .and_then(|v| v.to_str().ok())
146 .is_some_and(|v| v.split(',').any(|e| e.trim().starts_with("gzip")));
147
148 let mut res = match (&asset.gzip, accepts_gzip) {
149 (Some(gz), true) => {
150 let mut r = gz.clone().into_response();
151 r.headers_mut()
152 .insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
153 r
154 }
155 _ => asset.bytes.to_vec().into_response(),
156 };
157 let h = res.headers_mut();
158 h.insert(
159 header::CONTENT_TYPE,
160 HeaderValue::from_static(asset.content_type),
161 );
162 h.insert(
163 header::CACHE_CONTROL,
164 HeaderValue::from_static("public, max-age=31536000, immutable"),
165 );
166 if asset.gzip.is_some() {
167 h.insert(header::VARY, HeaderValue::from_static("accept-encoding"));
168 }
169 res
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn every_asset_has_a_hashed_url() {
178 for src in SOURCES {
179 let u = url(src.name);
180 assert!(u.starts_with("/assets/"), "{u}");
181 assert_ne!(u, format!("/assets/{}", src.name));
182 }
183 }
184
185 #[test]
186 fn css_points_at_hashed_fonts() {
187 let css = ASSETS.by_name("app.css").unwrap();
188 let text = std::str::from_utf8(&css.bytes).unwrap();
189 assert!(text.contains(url("Geist-Variable.woff2")));
190 assert!(!text.contains("/assets/Geist-Variable.woff2"));
191 }
192}