1use bytes::Bytes;
33use dashmap::DashMap;
34use ferro_rs::{HttpResponse, Request};
35use sha2::{Digest, Sha256};
36use std::sync::OnceLock;
37
38#[derive(Debug, thiserror::Error)]
46pub enum Error {
47 #[error("bundle not found at path: {0}")]
48 NotFound(String),
49 #[error("duplicate bundle name: {0} already registered")]
50 DuplicateName(String),
51}
52
53pub type Result<T> = std::result::Result<T, Error>;
55
56#[derive(Debug, Clone)]
59struct BundleEntry {
60 name: String,
61 bytes: &'static [u8],
62 content_type: String,
63 sha256_full_hex: String,
64 sha256_short_hex: String,
65 ext: String,
66 hashed_url: String,
67}
68
69static BUNDLE_REGISTRY: OnceLock<DashMap<String, BundleEntry>> = OnceLock::new();
70static ALIAS_REGISTRY: OnceLock<DashMap<String, String>> = OnceLock::new();
71static NAME_INDEX: OnceLock<DashMap<String, String>> = OnceLock::new();
74
75fn bundle_registry() -> &'static DashMap<String, BundleEntry> {
76 BUNDLE_REGISTRY.get_or_init(DashMap::new)
77}
78
79fn alias_registry() -> &'static DashMap<String, String> {
80 ALIAS_REGISTRY.get_or_init(DashMap::new)
81}
82
83fn name_index() -> &'static DashMap<String, String> {
84 NAME_INDEX.get_or_init(DashMap::new)
85}
86
87fn ext_from_content_type(ct: &str) -> &'static str {
90 match ct.split(';').next().unwrap_or(ct).trim() {
91 "application/javascript" | "text/javascript" => "js",
92 "text/css" => "css",
93 "text/html" => "html",
94 "text/plain" => "txt",
95 "application/json" => "json",
96 "image/png" => "png",
97 "image/jpeg" => "jpg",
98 "image/svg+xml" => "svg",
99 "image/gif" => "gif",
100 "image/webp" => "webp",
101 "font/woff2" => "woff2",
102 "font/woff" => "woff",
103 "application/wasm" => "wasm",
104 _ => "",
105 }
106}
107
108fn hashed_url_for(name: &str, sha8: &str, ext: &str) -> String {
109 if ext.is_empty() {
110 format!("/bundles/{name}.{sha8}")
111 } else {
112 format!("/bundles/{name}.{sha8}.{ext}")
113 }
114}
115
116pub struct Bundle {
122 name: String,
123}
124
125impl Bundle {
126 pub fn new(name: &str, bytes: &'static [u8]) -> Self {
134 if name_index().contains_key(name) {
135 panic!("ferro-bundle: duplicate registration for bundle name {name:?}");
136 }
137
138 let digest = Sha256::digest(bytes);
139 let sha256_full_hex = hex::encode(digest);
140 let sha256_short_hex = sha256_full_hex[..8].to_string();
141 let content_type = "application/octet-stream".to_string();
142 let ext = ext_from_content_type(&content_type).to_string();
143 let hashed_url = hashed_url_for(name, &sha256_short_hex, &ext);
144
145 let entry = BundleEntry {
146 name: name.to_string(),
147 bytes,
148 content_type,
149 sha256_full_hex,
150 sha256_short_hex,
151 ext,
152 hashed_url: hashed_url.clone(),
153 };
154
155 bundle_registry().insert(hashed_url.clone(), entry);
156 name_index().insert(name.to_string(), hashed_url);
157
158 Bundle {
159 name: name.to_string(),
160 }
161 }
162
163 pub fn content_type(self, ct: &str) -> Self {
167 let ext = ext_from_content_type(ct).to_string();
168
169 let old_url = match name_index().get(&self.name) {
170 Some(v) => v.value().clone(),
171 None => return self, };
173
174 let mut entry = match bundle_registry().remove(&old_url) {
176 Some((_, e)) => e,
177 None => return self, };
179 entry.content_type = ct.to_string();
180 entry.ext = ext.clone();
181 let new_url = hashed_url_for(&entry.name, &entry.sha256_short_hex, &ext);
182 entry.hashed_url = new_url.clone();
183 bundle_registry().insert(new_url.clone(), entry);
184 name_index().insert(self.name.clone(), new_url);
185
186 self
187 }
188
189 pub fn with_alias(self, alias_path: &str) -> Self {
192 let target = match name_index().get(&self.name) {
193 Some(v) => v.value().clone(),
194 None => return self, };
196 alias_registry().insert(alias_path.to_string(), target);
197 self
198 }
199
200 pub fn hashed_url(&self) -> String {
203 name_index()
204 .get(&self.name)
205 .map(|v| v.value().clone())
206 .unwrap_or_default()
207 }
208
209 pub fn serve(req: Request) -> HttpResponse {
217 let path = req.path().to_string();
218 let if_none_match = req.header("if-none-match").map(|s| s.to_string());
219 serve_inner(&path, if_none_match.as_deref())
220 }
221}
222
223pub(crate) fn serve_inner(path: &str, if_none_match: Option<&str>) -> HttpResponse {
229 if let Some(target) = alias_registry().get(path) {
231 return HttpResponse::new()
232 .status(301)
233 .header("Location", target.value().clone());
234 }
235
236 if let Some(entry) = bundle_registry().get(path) {
238 let etag = format!("\"{}\"", entry.sha256_full_hex);
239 if let Some(inm) = if_none_match {
240 if inm == etag {
241 return HttpResponse::new()
242 .status(304)
243 .header("ETag", etag)
244 .header("Cache-Control", "public, max-age=31536000, immutable");
245 }
246 }
247 return HttpResponse::bytes(Bytes::from_static(entry.bytes))
248 .header("Content-Type", entry.content_type.clone())
249 .header("Cache-Control", "public, max-age=31536000, immutable")
250 .header("ETag", etag);
251 }
252
253 HttpResponse::new()
255 .status(404)
256 .header("Content-Type", "text/plain")
257}
258
259#[doc(hidden)]
272pub mod __test_internals {
273 use ferro_rs::HttpResponse;
274
275 #[inline]
277 pub fn serve_inner(path: &str, if_none_match: Option<&str>) -> HttpResponse {
278 crate::serve_inner(path, if_none_match)
279 }
280}
281
282#[cfg(test)]
288pub(crate) fn reset() {
289 if let Some(r) = BUNDLE_REGISTRY.get() {
290 r.clear();
291 }
292 if let Some(r) = ALIAS_REGISTRY.get() {
293 r.clear();
294 }
295 if let Some(r) = NAME_INDEX.get() {
296 r.clear();
297 }
298}
299
300#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn hash_is_deterministic() {
308 reset();
309 let b = Bundle::new("test1", b"hello").content_type("text/plain");
310 assert_eq!(b.hashed_url(), "/bundles/test1.2cf24dba.txt");
313 }
314
315 #[test]
316 fn default_content_type_is_octet_stream() {
317 reset();
318 let b = Bundle::new("test2", b"x");
319 let url = b.hashed_url();
320 assert!(
321 url.starts_with("/bundles/test2."),
322 "expected /bundles/test2. prefix, got {url}"
323 );
324 assert!(
325 !url.ends_with(".txt") && !url.ends_with(".js") && !url.ends_with(".css"),
326 "default URL should not have a known extension; got {url}"
327 );
328 let suffix = url.strip_prefix("/bundles/test2.").unwrap();
329 assert_eq!(suffix.len(), 8, "expected 8-char short hash; got {suffix}");
330 }
331
332 #[test]
333 #[should_panic(expected = "duplicate")]
334 fn duplicate_name_panics() {
335 reset();
336 Bundle::new("dup", b"a");
337 Bundle::new("dup", b"a");
338 }
339
340 #[test]
341 fn error_not_found_displays_message() {
342 let e = Error::NotFound("/x".to_string());
343 assert_eq!(e.to_string(), "bundle not found at path: /x");
344 }
345
346 #[test]
347 fn error_duplicate_name_displays_message() {
348 let e = Error::DuplicateName("dup".to_string());
349 assert_eq!(
350 e.to_string(),
351 "duplicate bundle name: dup already registered"
352 );
353 }
354}