Skip to main content

ferro_bundle/
lib.rs

1//! In-memory immutable byte blobs with content-hashed URLs and one-year immutable caching.
2//!
3//! See the crate README for the bundle-vs-filesystem split: ferro-bundle handles
4//! compile-time-embedded immutable assets; the framework's filesystem static-file
5//! handler at `ferro::static_files` handles mutable on-disk tenant assets.
6//!
7//! # Usage
8//!
9//! Register bundles at boot, then dispatch via the framework adapter (`ferro::bundle::Bundle::serve`)
10//! in a handler mounted on `/bundles/{filename}` and on each registered alias path.
11//!
12//! ```rust,ignore
13//! use ferro_bundle::Bundle;
14//!
15//! // Boot-time registration. Builder order matters: content_type BEFORE with_alias.
16//! Bundle::new("embed-v1", include_bytes!("../assets/embed-v1.js"))
17//!     .content_type("application/javascript")
18//!     .with_alias("/embed/v1.js");
19//!
20//! // Mount the framework adapter (in framework crate) on /bundles/* and alias paths.
21//! // ferro::bundle::Bundle::serve(req) wraps serve_path into an HttpResponse.
22//! ```
23//!
24//! # Builder order
25//!
26//! Boot-time builder chain: `Bundle::new(name, bytes).content_type(ct).with_alias(path)`.
27//! Call `.content_type(...)` before `.with_alias(...)` — `.content_type` re-keys the
28//! bundle's URL (the extension is appended), and `.with_alias` captures the current
29//! hashed URL at the time it is called.
30
31use bytes::Bytes;
32use dashmap::DashMap;
33use sha2::{Digest, Sha256};
34use std::sync::OnceLock;
35
36// ── Error type ─────────────────────────────────────────────────────────
37
38/// Single error type for the ferro-bundle crate.
39///
40/// `serve_path` returns `BundleResponse` directly (not `Result`), so this enum is
41/// primarily an internal/registration-time signal. The `DuplicateName` variant is
42/// produced as a `panic!` message (developer error, caught at boot).
43#[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
51/// Convenience alias.
52pub type Result<T> = std::result::Result<T, Error>;
53
54// ── Process-global registries (D-02) ───────────────────────────────────
55
56#[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();
69// Secondary index: bundle name -> current hashed_url. Lets `.content_type` and
70// `.hashed_url` find the entry in O(1) without scanning the registry.
71static 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
85// ── Content-type to extension mapping ──────────────────────────────────
86
87fn 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
106/// Map a file extension to its MIME type string.
107///
108/// Used by the `asset!()` macro to infer content-type from the path extension.
109/// Unknown extensions return `"application/octet-stream"`, preserving
110/// byte-identical passthrough for unrecognized file types (SC #2). Input is
111/// expected lowercase (e.g. from `Path::extension`); matching is exact.
112pub 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
131// ── Framework-agnostic response type ───────────────────────────────────
132
133/// Framework-agnostic result of dispatching a bundle request.
134///
135/// `ferro-bundle` is a leaf crate: it does NOT depend on `ferro-rs`. The
136/// `framework` crate wraps this into an `HttpResponse` at its serve boundary.
137pub 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    /// HTTP status code (200, 301, 304, 404).
163    pub fn status_code(&self) -> u16 {
164        self.status
165    }
166
167    /// Response headers as `(name, value)` pairs.
168    pub fn headers(&self) -> &[(String, String)] {
169        &self.headers
170    }
171
172    /// Response body bytes (empty for 301/304/404).
173    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
186// ── Public API ─────────────────────────────────────────────────────────
187
188/// In-memory immutable byte blob registered at boot.
189///
190/// See the crate-level docs for the builder chain and ordering.
191pub struct Bundle {
192    name: String,
193}
194
195impl Bundle {
196    /// Register a new bundle. Hashes the bytes (SHA-256), inserts an entry into the
197    /// process-global registry keyed by the hashed URL, and returns a `Bundle` handle.
198    ///
199    /// # Panics
200    ///
201    /// Panics if a bundle with the same `name` is already registered (D-06). Duplicate
202    /// registration is developer error caught at boot.
203    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    /// Set the content-type. Re-keys the bundle's hashed URL (appends the extension
234    /// derived from the content-type). Call BEFORE `.with_alias(...)` so aliases
235    /// capture the final hashed URL.
236    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, // unreachable; new() always inserts
242        };
243
244        // Remove the entry under the old key, mutate, reinsert under the new key.
245        let mut entry = match bundle_registry().remove(&old_url) {
246            Some((_, e)) => e,
247            None => return self, // unreachable
248        };
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    /// Register a stable plain URL that 301-redirects to the current hashed URL.
260    /// Multiple aliases per bundle are allowed; each call adds one entry.
261    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, // unreachable
265        };
266        alias_registry().insert(alias_path.to_string(), target);
267        self
268    }
269
270    /// Return the current hashed URL (`/bundles/{name}.{sha8}.{ext}` or
271    /// `/bundles/{name}.{sha8}` if the content-type has no known extension).
272    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
280// ── Public dispatcher ──────────────────────────────────────────────────
281
282/// Dispatch a request to the bundle registry by path + optional If-None-Match.
283///
284/// Returns a framework-agnostic [`BundleResponse`]. Mount via the framework
285/// adapter (`ferro::bundle::Bundle::serve`) on `/bundles/{filename}` and each
286/// alias path.
287///
288/// Order of checks (D-03): 1) alias → 301 redirect, 2) bundle → 304 fast-path
289/// on ETag match else 200 with bytes, 3) 404.
290pub fn serve_path(path: &str, if_none_match: Option<&str>) -> BundleResponse {
291    // Alias check first (D-03 ordering).
292    if let Some(target) = alias_registry().get(path) {
293        return BundleResponse::new(301).header("Location", target.value().clone());
294    }
295
296    // Bundle check.
297    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    // 404 fallback (defensive — caller is expected to route only /bundles/... here).
314    // No Content-Type header: the body is empty, so the header would be misleading.
315    BundleResponse::new(404)
316}
317
318// ── Test isolation helper (D-13) ───────────────────────────────────────
319
320/// Clear all registries. Visible only under `#[cfg(test)]`. Call at the top of every
321/// test that registers bundles to prevent process-global state leakage between tests
322/// in the same binary.
323#[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// ── Unit tests ─────────────────────────────────────────────────────────
337
338#[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        // SHA-256 of "hello" = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
373        // First 8 chars = 2cf24dba
374        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}