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_rs::static_files` handles mutable on-disk tenant assets.
6//!
7//! # Usage
8//!
9//! Register bundles at boot, then dispatch via [`Bundle::serve`] in a handler mounted
10//! 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//! async fn handler(req: ferro_rs::Request) -> ferro_rs::HttpResponse {
21//!     Bundle::serve(req)
22//! }
23//! ```
24//!
25//! # Builder order
26//!
27//! Boot-time builder chain: `Bundle::new(name, bytes).content_type(ct).with_alias(path)`.
28//! Call `.content_type(...)` before `.with_alias(...)` — `.content_type` re-keys the
29//! bundle's URL (the extension is appended), and `.with_alias` captures the current
30//! hashed URL at the time it is called.
31
32use bytes::Bytes;
33use dashmap::DashMap;
34use ferro_rs::{HttpResponse, Request};
35use sha2::{Digest, Sha256};
36use std::sync::OnceLock;
37
38// ── Error type ─────────────────────────────────────────────────────────
39
40/// Single error type for the ferro-bundle crate.
41///
42/// `Bundle::serve` returns `HttpResponse` directly (not `Result`), so this enum is
43/// primarily an internal/registration-time signal. The `DuplicateName` variant is
44/// produced as a `panic!` message per D-06 (developer error, caught at boot).
45#[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
53/// Convenience alias.
54pub type Result<T> = std::result::Result<T, Error>;
55
56// ── Process-global registries (D-02) ───────────────────────────────────
57
58#[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();
71// Secondary index: bundle name -> current hashed_url. Lets `.content_type` and
72// `.hashed_url` find the entry in O(1) without scanning the registry.
73static 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
87// ── Content-type to extension mapping ──────────────────────────────────
88
89fn 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
116// ── Public API ─────────────────────────────────────────────────────────
117
118/// In-memory immutable byte blob registered at boot.
119///
120/// See the crate-level docs for the builder chain and ordering.
121pub struct Bundle {
122    name: String,
123}
124
125impl Bundle {
126    /// Register a new bundle. Hashes the bytes (SHA-256), inserts an entry into the
127    /// process-global registry keyed by the hashed URL, and returns a `Bundle` handle.
128    ///
129    /// # Panics
130    ///
131    /// Panics if a bundle with the same `name` is already registered (D-06). Duplicate
132    /// registration is developer error caught at boot.
133    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    /// Set the content-type. Re-keys the bundle's hashed URL (appends the extension
164    /// derived from the content-type). Call BEFORE `.with_alias(...)` so aliases
165    /// capture the final hashed URL.
166    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, // unreachable; new() always inserts
172        };
173
174        // Remove the entry under the old key, mutate, reinsert under the new key.
175        let mut entry = match bundle_registry().remove(&old_url) {
176            Some((_, e)) => e,
177            None => return self, // unreachable
178        };
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    /// Register a stable plain URL that 301-redirects to the current hashed URL.
190    /// Multiple aliases per bundle are allowed; each call adds one entry.
191    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, // unreachable
195        };
196        alias_registry().insert(alias_path.to_string(), target);
197        self
198    }
199
200    /// Return the current hashed URL (`/bundles/{name}.{sha8}.{ext}` or
201    /// `/bundles/{name}.{sha8}` if the content-type has no known extension).
202    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    /// Dispatch a request to the bundle registry. Mount this as the handler for
210    /// `/bundles/{filename}` and for each registered alias path.
211    ///
212    /// Order of checks (D-03):
213    /// 1. Alias check → 301 redirect.
214    /// 2. Bundle check → 304 fast-path on `If-None-Match` match, else 200 with bytes.
215    /// 3. Otherwise → 404.
216    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
223// ── Dispatcher (private; pub(crate) so integration tests can bypass Request) ────
224
225/// Dispatch by path + optional If-None-Match. Exposed at crate-visibility so
226/// integration tests can call it directly without constructing a synthetic
227/// `Request` (RESEARCH OQ #3 resolution).
228pub(crate) fn serve_inner(path: &str, if_none_match: Option<&str>) -> HttpResponse {
229    // Alias check first (D-03 ordering).
230    if let Some(target) = alias_registry().get(path) {
231        return HttpResponse::new()
232            .status(301)
233            .header("Location", target.value().clone());
234    }
235
236    // Bundle check.
237    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    // 404 fallback (defensive — caller is expected to route only /bundles/... here).
254    HttpResponse::new()
255        .status(404)
256        .header("Content-Type", "text/plain")
257}
258
259// ── Integration-test access shim ───────────────────────────────────────
260
261/// Doc-hidden wrapper around the crate-private `serve_inner` dispatcher for
262/// integration tests.
263///
264/// Each `tests/*.rs` file is its own compilation unit and cannot see `pub(crate)`
265/// items. This shim provides reachability without polluting the public crate API.
266/// The `__test_internals` name signals "do not call from production code."
267///
268/// We expose a thin `pub fn` wrapper (rather than a `pub use`) because Rust's
269/// visibility rules forbid `pub use` of a `pub(crate)` item — the re-export
270/// cannot exceed the imported item's visibility. The wrapper is inlined.
271#[doc(hidden)]
272pub mod __test_internals {
273    use ferro_rs::HttpResponse;
274
275    /// Doc-hidden bridge to the crate-private dispatcher. Integration tests only.
276    #[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// ── Test isolation helper (D-13) ───────────────────────────────────────
283
284/// Clear all registries. Visible only under `#[cfg(test)]`. Call at the top of every
285/// test that registers bundles to prevent process-global state leakage between tests
286/// in the same binary.
287#[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// ── Unit tests ─────────────────────────────────────────────────────────
301
302#[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        // SHA-256 of "hello" = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
311        // First 8 chars = 2cf24dba
312        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}