ferro-bundle 0.3.7

In-memory immutable byte-blob serving with content-hashed URLs for the Ferro framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
//! In-memory immutable byte blobs with content-hashed URLs and one-year immutable caching.
//!
//! See the crate README for the bundle-vs-filesystem split: ferro-bundle handles
//! compile-time-embedded immutable assets; the framework's filesystem static-file
//! handler at `ferro::static_files` handles mutable on-disk tenant assets.
//!
//! # Usage
//!
//! Register bundles at boot, then dispatch via the framework adapter (`ferro::bundle::Bundle::serve`)
//! in a handler mounted on `/bundles/{filename}` and on each registered alias path.
//!
//! ```rust,ignore
//! use ferro_bundle::Bundle;
//!
//! // Boot-time registration. Builder order matters: content_type BEFORE with_alias.
//! Bundle::new("embed-v1", include_bytes!("../assets/embed-v1.js"))
//!     .content_type("application/javascript")
//!     .with_alias("/embed/v1.js");
//!
//! // Mount the framework adapter (in framework crate) on /bundles/* and alias paths.
//! // ferro::bundle::Bundle::serve(req) wraps serve_path into an HttpResponse.
//! ```
//!
//! # Builder order
//!
//! Boot-time builder chain: `Bundle::new(name, bytes).content_type(ct).with_alias(path)`.
//! Call `.content_type(...)` before `.with_alias(...)` — `.content_type` re-keys the
//! bundle's URL (the extension is appended), and `.with_alias` captures the current
//! hashed URL at the time it is called.

use bytes::Bytes;
use dashmap::DashMap;
use sha2::{Digest, Sha256};
use std::sync::OnceLock;

// ── Error type ─────────────────────────────────────────────────────────

/// Single error type for the ferro-bundle crate.
///
/// `serve_path` returns `BundleResponse` directly (not `Result`), so this enum is
/// primarily an internal/registration-time signal. The `DuplicateName` variant is
/// produced as a `panic!` message (developer error, caught at boot).
#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("bundle not found at path: {0}")]
    NotFound(String),
    #[error("duplicate bundle name: {0} already registered")]
    DuplicateName(String),
}

/// Convenience alias.
pub type Result<T> = std::result::Result<T, Error>;

// ── Process-global registries (D-02) ───────────────────────────────────

#[derive(Debug, Clone)]
struct BundleEntry {
    name: String,
    bytes: &'static [u8],
    content_type: String,
    sha256_full_hex: String,
    sha256_short_hex: String,
    ext: String,
    hashed_url: String,
}

static BUNDLE_REGISTRY: OnceLock<DashMap<String, BundleEntry>> = OnceLock::new();
static ALIAS_REGISTRY: OnceLock<DashMap<String, String>> = OnceLock::new();
// Secondary index: bundle name -> current hashed_url. Lets `.content_type` and
// `.hashed_url` find the entry in O(1) without scanning the registry.
static NAME_INDEX: OnceLock<DashMap<String, String>> = OnceLock::new();

fn bundle_registry() -> &'static DashMap<String, BundleEntry> {
    BUNDLE_REGISTRY.get_or_init(DashMap::new)
}

fn alias_registry() -> &'static DashMap<String, String> {
    ALIAS_REGISTRY.get_or_init(DashMap::new)
}

fn name_index() -> &'static DashMap<String, String> {
    NAME_INDEX.get_or_init(DashMap::new)
}

// ── Content-type to extension mapping ──────────────────────────────────

fn ext_from_content_type(ct: &str) -> &'static str {
    match ct.split(';').next().unwrap_or(ct).trim() {
        "application/javascript" | "text/javascript" => "js",
        "text/css" => "css",
        "text/html" => "html",
        "text/plain" => "txt",
        "application/json" => "json",
        "image/png" => "png",
        "image/jpeg" => "jpg",
        "image/svg+xml" => "svg",
        "image/gif" => "gif",
        "image/webp" => "webp",
        "font/woff2" => "woff2",
        "font/woff" => "woff",
        "application/wasm" => "wasm",
        _ => "",
    }
}

/// Map a file extension to its MIME type string.
///
/// Used by the `asset!()` macro to infer content-type from the path extension.
/// Unknown extensions return `"application/octet-stream"`, preserving
/// byte-identical passthrough for unrecognized file types (SC #2). Input is
/// expected lowercase (e.g. from `Path::extension`); matching is exact.
pub fn mime_from_ext(ext: &str) -> &'static str {
    match ext {
        "js" | "mjs" => "application/javascript",
        "css" => "text/css",
        "html" | "htm" => "text/html",
        "txt" => "text/plain",
        "json" => "application/json",
        "png" => "image/png",
        "jpg" | "jpeg" => "image/jpeg",
        "svg" => "image/svg+xml",
        "gif" => "image/gif",
        "webp" => "image/webp",
        "woff2" => "font/woff2",
        "woff" => "font/woff",
        "wasm" => "application/wasm",
        _ => "application/octet-stream",
    }
}

// ── Framework-agnostic response type ───────────────────────────────────

/// Framework-agnostic result of dispatching a bundle request.
///
/// `ferro-bundle` is a leaf crate: it does NOT depend on `ferro-rs`. The
/// `framework` crate wraps this into an `HttpResponse` at its serve boundary.
pub struct BundleResponse {
    status: u16,
    headers: Vec<(String, String)>,
    body: Bytes,
}

impl BundleResponse {
    fn new(status: u16) -> Self {
        Self {
            status,
            headers: Vec::new(),
            body: Bytes::new(),
        }
    }

    fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        self.headers.push((name.into(), value.into()));
        self
    }

    fn with_body(mut self, body: Bytes) -> Self {
        self.body = body;
        self
    }

    /// HTTP status code (200, 301, 304, 404).
    pub fn status_code(&self) -> u16 {
        self.status
    }

    /// Response headers as `(name, value)` pairs.
    pub fn headers(&self) -> &[(String, String)] {
        &self.headers
    }

    /// Response body bytes (empty for 301/304/404).
    pub fn body_bytes(&self) -> &Bytes {
        &self.body
    }
}

fn hashed_url_for(name: &str, sha8: &str, ext: &str) -> String {
    if ext.is_empty() {
        format!("/bundles/{name}.{sha8}")
    } else {
        format!("/bundles/{name}.{sha8}.{ext}")
    }
}

// ── Public API ─────────────────────────────────────────────────────────

/// In-memory immutable byte blob registered at boot.
///
/// See the crate-level docs for the builder chain and ordering.
pub struct Bundle {
    name: String,
}

impl Bundle {
    /// Register a new bundle. Hashes the bytes (SHA-256), inserts an entry into the
    /// process-global registry keyed by the hashed URL, and returns a `Bundle` handle.
    ///
    /// # Panics
    ///
    /// Panics if a bundle with the same `name` is already registered (D-06). Duplicate
    /// registration is developer error caught at boot.
    pub fn new(name: &str, bytes: &'static [u8]) -> Self {
        if name_index().contains_key(name) {
            panic!("ferro-bundle: duplicate registration for bundle name {name:?}");
        }

        let digest = Sha256::digest(bytes);
        let sha256_full_hex = hex::encode(digest);
        let sha256_short_hex = sha256_full_hex[..8].to_string();
        let content_type = "application/octet-stream".to_string();
        let ext = ext_from_content_type(&content_type).to_string();
        let hashed_url = hashed_url_for(name, &sha256_short_hex, &ext);

        let entry = BundleEntry {
            name: name.to_string(),
            bytes,
            content_type,
            sha256_full_hex,
            sha256_short_hex,
            ext,
            hashed_url: hashed_url.clone(),
        };

        bundle_registry().insert(hashed_url.clone(), entry);
        name_index().insert(name.to_string(), hashed_url);

        Bundle {
            name: name.to_string(),
        }
    }

    /// Set the content-type. Re-keys the bundle's hashed URL (appends the extension
    /// derived from the content-type). Call BEFORE `.with_alias(...)` so aliases
    /// capture the final hashed URL.
    pub fn content_type(self, ct: &str) -> Self {
        let ext = ext_from_content_type(ct).to_string();

        let old_url = match name_index().get(&self.name) {
            Some(v) => v.value().clone(),
            None => return self, // unreachable; new() always inserts
        };

        // Remove the entry under the old key, mutate, reinsert under the new key.
        let mut entry = match bundle_registry().remove(&old_url) {
            Some((_, e)) => e,
            None => return self, // unreachable
        };
        entry.content_type = ct.to_string();
        entry.ext = ext.clone();
        let new_url = hashed_url_for(&entry.name, &entry.sha256_short_hex, &ext);
        entry.hashed_url = new_url.clone();
        bundle_registry().insert(new_url.clone(), entry);
        name_index().insert(self.name.clone(), new_url);

        self
    }

    /// Register a stable plain URL that 301-redirects to the current hashed URL.
    /// Multiple aliases per bundle are allowed; each call adds one entry.
    pub fn with_alias(self, alias_path: &str) -> Self {
        let target = match name_index().get(&self.name) {
            Some(v) => v.value().clone(),
            None => return self, // unreachable
        };
        alias_registry().insert(alias_path.to_string(), target);
        self
    }

    /// Return the current hashed URL (`/bundles/{name}.{sha8}.{ext}` or
    /// `/bundles/{name}.{sha8}` if the content-type has no known extension).
    pub fn hashed_url(&self) -> String {
        name_index()
            .get(&self.name)
            .map(|v| v.value().clone())
            .unwrap_or_default()
    }
}

// ── Public dispatcher ──────────────────────────────────────────────────

/// Dispatch a request to the bundle registry by path + optional If-None-Match.
///
/// Returns a framework-agnostic [`BundleResponse`]. Mount via the framework
/// adapter (`ferro::bundle::Bundle::serve`) on `/bundles/{filename}` and each
/// alias path.
///
/// Order of checks (D-03): 1) alias → 301 redirect, 2) bundle → 304 fast-path
/// on ETag match else 200 with bytes, 3) 404.
pub fn serve_path(path: &str, if_none_match: Option<&str>) -> BundleResponse {
    // Alias check first (D-03 ordering).
    if let Some(target) = alias_registry().get(path) {
        return BundleResponse::new(301).header("Location", target.value().clone());
    }

    // Bundle check.
    if let Some(entry) = bundle_registry().get(path) {
        let etag = format!("\"{}\"", entry.sha256_full_hex);
        if let Some(inm) = if_none_match {
            if inm == etag {
                return BundleResponse::new(304)
                    .header("ETag", etag)
                    .header("Cache-Control", "public, max-age=31536000, immutable");
            }
        }
        return BundleResponse::new(200)
            .with_body(Bytes::from_static(entry.bytes))
            .header("Content-Type", entry.content_type.clone())
            .header("Cache-Control", "public, max-age=31536000, immutable")
            .header("ETag", etag);
    }

    // 404 fallback (defensive — caller is expected to route only /bundles/... here).
    // No Content-Type header: the body is empty, so the header would be misleading.
    BundleResponse::new(404)
}

// ── Test isolation helper (D-13) ───────────────────────────────────────

/// Clear all registries. Visible only under `#[cfg(test)]`. Call at the top of every
/// test that registers bundles to prevent process-global state leakage between tests
/// in the same binary.
#[cfg(test)]
pub(crate) fn reset() {
    if let Some(r) = BUNDLE_REGISTRY.get() {
        r.clear();
    }
    if let Some(r) = ALIAS_REGISTRY.get() {
        r.clear();
    }
    if let Some(r) = NAME_INDEX.get() {
        r.clear();
    }
}

// ── Unit tests ─────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_mime_from_ext() {
        assert_eq!(mime_from_ext("js"), "application/javascript");
        assert_eq!(mime_from_ext("mjs"), "application/javascript");
        assert_eq!(mime_from_ext("css"), "text/css");
        assert_eq!(mime_from_ext("html"), "text/html");
        assert_eq!(mime_from_ext("htm"), "text/html");
        assert_eq!(mime_from_ext("txt"), "text/plain");
        assert_eq!(mime_from_ext("json"), "application/json");
        assert_eq!(mime_from_ext("png"), "image/png");
        assert_eq!(mime_from_ext("jpg"), "image/jpeg");
        assert_eq!(mime_from_ext("jpeg"), "image/jpeg");
        assert_eq!(mime_from_ext("svg"), "image/svg+xml");
        assert_eq!(mime_from_ext("gif"), "image/gif");
        assert_eq!(mime_from_ext("webp"), "image/webp");
        assert_eq!(mime_from_ext("woff2"), "font/woff2");
        assert_eq!(mime_from_ext("woff"), "font/woff");
        assert_eq!(mime_from_ext("wasm"), "application/wasm");
    }

    #[test]
    fn mime_from_ext_unknown_is_octet_stream() {
        assert_eq!(mime_from_ext("xyz"), "application/octet-stream");
        assert_eq!(mime_from_ext(""), "application/octet-stream");
    }

    #[test]
    fn hash_is_deterministic() {
        reset();
        let b = Bundle::new("test1", b"hello").content_type("text/plain");
        // SHA-256 of "hello" = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
        // First 8 chars = 2cf24dba
        assert_eq!(b.hashed_url(), "/bundles/test1.2cf24dba.txt");
    }

    #[test]
    fn default_content_type_is_octet_stream() {
        reset();
        let b = Bundle::new("test2", b"x");
        let url = b.hashed_url();
        assert!(
            url.starts_with("/bundles/test2."),
            "expected /bundles/test2. prefix, got {url}"
        );
        assert!(
            !url.ends_with(".txt") && !url.ends_with(".js") && !url.ends_with(".css"),
            "default URL should not have a known extension; got {url}"
        );
        let suffix = url.strip_prefix("/bundles/test2.").unwrap();
        assert_eq!(suffix.len(), 8, "expected 8-char short hash; got {suffix}");
    }

    #[test]
    #[should_panic(expected = "duplicate")]
    fn duplicate_name_panics() {
        reset();
        Bundle::new("dup", b"a");
        Bundle::new("dup", b"a");
    }

    #[test]
    fn error_not_found_displays_message() {
        let e = Error::NotFound("/x".to_string());
        assert_eq!(e.to_string(), "bundle not found at path: /x");
    }

    #[test]
    fn error_duplicate_name_displays_message() {
        let e = Error::DuplicateName("dup".to_string());
        assert_eq!(
            e.to_string(),
            "duplicate bundle name: dup already registered"
        );
    }
}