1use bytes::Bytes;
32use dashmap::DashMap;
33use sha2::{Digest, Sha256};
34use std::sync::OnceLock;
35
36#[derive(Debug, thiserror::Error)]
44pub enum Error {
45 #[error("bundle not found at path: {0}")]
46 NotFound(String),
47 #[error("duplicate bundle name: {0} already registered")]
48 DuplicateName(String),
49}
50
51pub type Result<T> = std::result::Result<T, Error>;
53
54#[derive(Debug, Clone)]
57struct BundleEntry {
58 name: String,
59 bytes: &'static [u8],
60 content_type: String,
61 sha256_full_hex: String,
62 sha256_short_hex: String,
63 ext: String,
64 hashed_url: String,
65}
66
67static BUNDLE_REGISTRY: OnceLock<DashMap<String, BundleEntry>> = OnceLock::new();
68static ALIAS_REGISTRY: OnceLock<DashMap<String, String>> = OnceLock::new();
69static NAME_INDEX: OnceLock<DashMap<String, String>> = OnceLock::new();
72
73fn bundle_registry() -> &'static DashMap<String, BundleEntry> {
74 BUNDLE_REGISTRY.get_or_init(DashMap::new)
75}
76
77fn alias_registry() -> &'static DashMap<String, String> {
78 ALIAS_REGISTRY.get_or_init(DashMap::new)
79}
80
81fn name_index() -> &'static DashMap<String, String> {
82 NAME_INDEX.get_or_init(DashMap::new)
83}
84
85fn ext_from_content_type(ct: &str) -> &'static str {
88 match ct.split(';').next().unwrap_or(ct).trim() {
89 "application/javascript" | "text/javascript" => "js",
90 "text/css" => "css",
91 "text/html" => "html",
92 "text/plain" => "txt",
93 "application/json" => "json",
94 "image/png" => "png",
95 "image/jpeg" => "jpg",
96 "image/svg+xml" => "svg",
97 "image/gif" => "gif",
98 "image/webp" => "webp",
99 "font/woff2" => "woff2",
100 "font/woff" => "woff",
101 "application/wasm" => "wasm",
102 _ => "",
103 }
104}
105
106pub fn mime_from_ext(ext: &str) -> &'static str {
113 match ext {
114 "js" | "mjs" => "application/javascript",
115 "css" => "text/css",
116 "html" | "htm" => "text/html",
117 "txt" => "text/plain",
118 "json" => "application/json",
119 "png" => "image/png",
120 "jpg" | "jpeg" => "image/jpeg",
121 "svg" => "image/svg+xml",
122 "gif" => "image/gif",
123 "webp" => "image/webp",
124 "woff2" => "font/woff2",
125 "woff" => "font/woff",
126 "wasm" => "application/wasm",
127 _ => "application/octet-stream",
128 }
129}
130
131pub struct BundleResponse {
138 status: u16,
139 headers: Vec<(String, String)>,
140 body: Bytes,
141}
142
143impl BundleResponse {
144 fn new(status: u16) -> Self {
145 Self {
146 status,
147 headers: Vec::new(),
148 body: Bytes::new(),
149 }
150 }
151
152 fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
153 self.headers.push((name.into(), value.into()));
154 self
155 }
156
157 fn with_body(mut self, body: Bytes) -> Self {
158 self.body = body;
159 self
160 }
161
162 pub fn status_code(&self) -> u16 {
164 self.status
165 }
166
167 pub fn headers(&self) -> &[(String, String)] {
169 &self.headers
170 }
171
172 pub fn body_bytes(&self) -> &Bytes {
174 &self.body
175 }
176}
177
178fn hashed_url_for(name: &str, sha8: &str, ext: &str) -> String {
179 if ext.is_empty() {
180 format!("/bundles/{name}.{sha8}")
181 } else {
182 format!("/bundles/{name}.{sha8}.{ext}")
183 }
184}
185
186pub struct Bundle {
192 name: String,
193}
194
195impl Bundle {
196 pub fn new(name: &str, bytes: &'static [u8]) -> Self {
204 if name_index().contains_key(name) {
205 panic!("ferro-bundle: duplicate registration for bundle name {name:?}");
206 }
207
208 let digest = Sha256::digest(bytes);
209 let sha256_full_hex = hex::encode(digest);
210 let sha256_short_hex = sha256_full_hex[..8].to_string();
211 let content_type = "application/octet-stream".to_string();
212 let ext = ext_from_content_type(&content_type).to_string();
213 let hashed_url = hashed_url_for(name, &sha256_short_hex, &ext);
214
215 let entry = BundleEntry {
216 name: name.to_string(),
217 bytes,
218 content_type,
219 sha256_full_hex,
220 sha256_short_hex,
221 ext,
222 hashed_url: hashed_url.clone(),
223 };
224
225 bundle_registry().insert(hashed_url.clone(), entry);
226 name_index().insert(name.to_string(), hashed_url);
227
228 Bundle {
229 name: name.to_string(),
230 }
231 }
232
233 pub fn content_type(self, ct: &str) -> Self {
237 let ext = ext_from_content_type(ct).to_string();
238
239 let old_url = match name_index().get(&self.name) {
240 Some(v) => v.value().clone(),
241 None => return self, };
243
244 let mut entry = match bundle_registry().remove(&old_url) {
246 Some((_, e)) => e,
247 None => return self, };
249 entry.content_type = ct.to_string();
250 entry.ext = ext.clone();
251 let new_url = hashed_url_for(&entry.name, &entry.sha256_short_hex, &ext);
252 entry.hashed_url = new_url.clone();
253 bundle_registry().insert(new_url.clone(), entry);
254 name_index().insert(self.name.clone(), new_url);
255
256 self
257 }
258
259 pub fn with_alias(self, alias_path: &str) -> Self {
262 let target = match name_index().get(&self.name) {
263 Some(v) => v.value().clone(),
264 None => return self, };
266 alias_registry().insert(alias_path.to_string(), target);
267 self
268 }
269
270 pub fn hashed_url(&self) -> String {
273 name_index()
274 .get(&self.name)
275 .map(|v| v.value().clone())
276 .unwrap_or_default()
277 }
278}
279
280pub fn serve_path(path: &str, if_none_match: Option<&str>) -> BundleResponse {
291 if let Some(target) = alias_registry().get(path) {
293 return BundleResponse::new(301).header("Location", target.value().clone());
294 }
295
296 if let Some(entry) = bundle_registry().get(path) {
298 let etag = format!("\"{}\"", entry.sha256_full_hex);
299 if let Some(inm) = if_none_match {
300 if inm == etag {
301 return BundleResponse::new(304)
302 .header("ETag", etag)
303 .header("Cache-Control", "public, max-age=31536000, immutable");
304 }
305 }
306 return BundleResponse::new(200)
307 .with_body(Bytes::from_static(entry.bytes))
308 .header("Content-Type", entry.content_type.clone())
309 .header("Cache-Control", "public, max-age=31536000, immutable")
310 .header("ETag", etag);
311 }
312
313 BundleResponse::new(404)
316}
317
318#[cfg(test)]
324pub(crate) fn reset() {
325 if let Some(r) = BUNDLE_REGISTRY.get() {
326 r.clear();
327 }
328 if let Some(r) = ALIAS_REGISTRY.get() {
329 r.clear();
330 }
331 if let Some(r) = NAME_INDEX.get() {
332 r.clear();
333 }
334}
335
336#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn test_mime_from_ext() {
344 assert_eq!(mime_from_ext("js"), "application/javascript");
345 assert_eq!(mime_from_ext("mjs"), "application/javascript");
346 assert_eq!(mime_from_ext("css"), "text/css");
347 assert_eq!(mime_from_ext("html"), "text/html");
348 assert_eq!(mime_from_ext("htm"), "text/html");
349 assert_eq!(mime_from_ext("txt"), "text/plain");
350 assert_eq!(mime_from_ext("json"), "application/json");
351 assert_eq!(mime_from_ext("png"), "image/png");
352 assert_eq!(mime_from_ext("jpg"), "image/jpeg");
353 assert_eq!(mime_from_ext("jpeg"), "image/jpeg");
354 assert_eq!(mime_from_ext("svg"), "image/svg+xml");
355 assert_eq!(mime_from_ext("gif"), "image/gif");
356 assert_eq!(mime_from_ext("webp"), "image/webp");
357 assert_eq!(mime_from_ext("woff2"), "font/woff2");
358 assert_eq!(mime_from_ext("woff"), "font/woff");
359 assert_eq!(mime_from_ext("wasm"), "application/wasm");
360 }
361
362 #[test]
363 fn mime_from_ext_unknown_is_octet_stream() {
364 assert_eq!(mime_from_ext("xyz"), "application/octet-stream");
365 assert_eq!(mime_from_ext(""), "application/octet-stream");
366 }
367
368 #[test]
369 fn hash_is_deterministic() {
370 reset();
371 let b = Bundle::new("test1", b"hello").content_type("text/plain");
372 assert_eq!(b.hashed_url(), "/bundles/test1.2cf24dba.txt");
375 }
376
377 #[test]
378 fn default_content_type_is_octet_stream() {
379 reset();
380 let b = Bundle::new("test2", b"x");
381 let url = b.hashed_url();
382 assert!(
383 url.starts_with("/bundles/test2."),
384 "expected /bundles/test2. prefix, got {url}"
385 );
386 assert!(
387 !url.ends_with(".txt") && !url.ends_with(".js") && !url.ends_with(".css"),
388 "default URL should not have a known extension; got {url}"
389 );
390 let suffix = url.strip_prefix("/bundles/test2.").unwrap();
391 assert_eq!(suffix.len(), 8, "expected 8-char short hash; got {suffix}");
392 }
393
394 #[test]
395 #[should_panic(expected = "duplicate")]
396 fn duplicate_name_panics() {
397 reset();
398 Bundle::new("dup", b"a");
399 Bundle::new("dup", b"a");
400 }
401
402 #[test]
403 fn error_not_found_displays_message() {
404 let e = Error::NotFound("/x".to_string());
405 assert_eq!(e.to_string(), "bundle not found at path: /x");
406 }
407
408 #[test]
409 fn error_duplicate_name_displays_message() {
410 let e = Error::DuplicateName("dup".to_string());
411 assert_eq!(
412 e.to_string(),
413 "duplicate bundle name: dup already registered"
414 );
415 }
416}