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>(
29    Path(filename): Path<String>,
30    headers: HeaderMap,
31) -> Response {
32    let accept = headers
33        .get(header::ACCEPT_ENCODING)
34        .and_then(|v| v.to_str().ok())
35        .unwrap_or("");
36
37    // Try brotli first, then gzip, then raw
38    let (data, encoding) = if accept.contains("br") {
39        if let Some(br) = A::get(&format!("{filename}.br")) {
40            (br.data.to_vec(), Some("br"))
41        } else if let Some(raw) = A::get(&filename) {
42            (raw.data.to_vec(), None)
43        } else {
44            return StatusCode::NOT_FOUND.into_response();
45        }
46    } else if accept.contains("gzip") {
47        if let Some(gz) = A::get(&format!("{filename}.gz")) {
48            (gz.data.to_vec(), Some("gzip"))
49        } else if let Some(raw) = A::get(&filename) {
50            (raw.data.to_vec(), None)
51        } else {
52            return StatusCode::NOT_FOUND.into_response();
53        }
54    } else if let Some(raw) = A::get(&filename) {
55        (raw.data.to_vec(), None)
56    } else {
57        return StatusCode::NOT_FOUND.into_response();
58    };
59
60    asset_response(&filename, data, encoding)
61}
62
63fn asset_response(filename: &str, data: Vec<u8>, encoding: Option<&str>) -> Response {
64    let mime = mime_for(filename);
65    let mut builder = Response::builder()
66        .header(header::CONTENT_TYPE, mime)
67        .header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
68        .header("vary", "accept-encoding")
69        .header("x-content-type-options", "nosniff");
70
71    if let Some(enc) = encoding {
72        builder = builder.header(header::CONTENT_ENCODING, enc);
73    }
74
75    builder
76        .body(axum::body::Body::from(data))
77        .unwrap()
78        .into_response()
79}
80
81fn mime_for(filename: &str) -> &'static str {
82    if filename.ends_with(".js") {
83        "application/javascript; charset=utf-8"
84    } else if filename.ends_with(".css") {
85        "text/css; charset=utf-8"
86    } else if filename.ends_with(".woff2") {
87        "font/woff2"
88    } else if filename.ends_with(".wasm") {
89        "application/wasm"
90    } else if filename.ends_with(".json") {
91        "application/json"
92    } else if filename.ends_with(".html") {
93        "text/html; charset=utf-8"
94    } else if filename.ends_with(".svg") {
95        "image/svg+xml"
96    } else {
97        "application/octet-stream"
98    }
99}