Skip to main content

forma_server/
assets.rs

1use axum::{
2    extract::Path,
3    http::{header, HeaderMap, StatusCode},
4    response::{IntoResponse, Response},
5};
6use rust_embed::Embed;
7
8/// Load a text asset from the embedded dist directory. Panics if missing.
9pub fn asset<A: Embed>(name: &str) -> String {
10    let file = A::get(name).unwrap_or_else(|| panic!("Missing asset: {name}"));
11    String::from_utf8(file.data.to_vec()).unwrap_or_else(|_| panic!("Non-UTF8 asset: {name}"))
12}
13
14/// Load raw bytes from the embedded dist directory. Returns None if missing.
15pub fn asset_bytes<A: Embed>(name: &str) -> Option<Vec<u8>> {
16    A::get(name).map(|f| f.data.to_vec())
17}
18
19/// Load and parse the asset manifest from embedded assets.
20pub fn load_manifest<A: Embed>() -> crate::AssetManifest {
21    let data = asset::<A>("manifest.json");
22    serde_json::from_str(&data).expect("Failed to parse manifest.json")
23}
24
25/// Axum handler: serve a static asset with content negotiation (brotli, gzip).
26///
27/// Mount at `/_assets/{filename}` in your router.
28pub async fn serve_asset<A: Embed>(Path(filename): Path<String>, headers: HeaderMap) -> Response {
29    let accept = headers
30        .get(header::ACCEPT_ENCODING)
31        .and_then(|v| v.to_str().ok())
32        .unwrap_or("");
33
34    // Try brotli first, then gzip, then raw
35    let (data, encoding) = if accept.contains("br") {
36        if let Some(br) = A::get(&format!("{filename}.br")) {
37            (br.data.to_vec(), Some("br"))
38        } else if let Some(raw) = A::get(&filename) {
39            (raw.data.to_vec(), None)
40        } else {
41            return StatusCode::NOT_FOUND.into_response();
42        }
43    } else if accept.contains("gzip") {
44        if let Some(gz) = A::get(&format!("{filename}.gz")) {
45            (gz.data.to_vec(), Some("gzip"))
46        } else if let Some(raw) = A::get(&filename) {
47            (raw.data.to_vec(), None)
48        } else {
49            return StatusCode::NOT_FOUND.into_response();
50        }
51    } else if let Some(raw) = A::get(&filename) {
52        (raw.data.to_vec(), None)
53    } else {
54        return StatusCode::NOT_FOUND.into_response();
55    };
56
57    asset_response(&filename, data, encoding)
58}
59
60fn asset_response(filename: &str, data: Vec<u8>, encoding: Option<&str>) -> Response {
61    let mime = mime_for(filename);
62    let mut builder = Response::builder()
63        .header(header::CONTENT_TYPE, mime)
64        .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
65        .header("vary", "accept-encoding")
66        .header("x-content-type-options", "nosniff");
67
68    if let Some(enc) = encoding {
69        builder = builder.header(header::CONTENT_ENCODING, enc);
70    }
71
72    builder
73        .body(axum::body::Body::from(data))
74        .unwrap()
75        .into_response()
76}
77
78fn mime_for(filename: &str) -> &'static str {
79    if filename.ends_with(".js") {
80        "application/javascript; charset=utf-8"
81    } else if filename.ends_with(".css") {
82        "text/css; charset=utf-8"
83    } else if filename.ends_with(".woff2") {
84        "font/woff2"
85    } else if filename.ends_with(".wasm") {
86        "application/wasm"
87    } else if filename.ends_with(".json") {
88        "application/json"
89    } else if filename.ends_with(".html") {
90        "text/html; charset=utf-8"
91    } else if filename.ends_with(".svg") {
92        "image/svg+xml"
93    } else {
94        "application/octet-stream"
95    }
96}