Skip to main content

cja/
assets.rs

1//! Content-hashed assets embedded by `cja-build`.
2
3use axum::{
4    Router,
5    http::StatusCode,
6    http::header::{CACHE_CONTROL, CONTENT_TYPE},
7    routing::{any, get},
8};
9use std::{collections::HashMap, fmt};
10
11const STABLE_ROOT_FILES: &[&str] = &["favicon.ico", "robots.txt"];
12const IMMUTABLE_CACHE: &str = "public, max-age=31536000, immutable";
13const STABLE_CACHE: &str = "public, max-age=3600";
14
15/// One generated output embedded in the consuming binary.
16pub struct Asset {
17    pub logical_name: &'static str,
18    pub hashed_name: &'static str,
19    pub content_type: &'static str,
20    pub bytes: &'static [u8],
21}
22
23/// Assets owned by a single app or library crate.
24pub struct Manifest {
25    pub owner: &'static str,
26    pub entries: &'static [Asset],
27}
28
29impl Manifest {
30    pub const fn new(owner: &'static str, entries: &'static [Asset]) -> Self {
31        Self { owner, entries }
32    }
33
34    /// Resolve only within this manifest's logical namespace.
35    #[track_caller]
36    pub fn asset_url(&self, logical_name: &str) -> String {
37        let Some(asset) = self
38            .entries
39            .iter()
40            .find(|asset| asset.logical_name == logical_name)
41        else {
42            panic!("unknown asset: {logical_name}");
43        };
44        format!("/assets/{}", asset.hashed_name)
45    }
46}
47
48/// A duplicate served URL discovered before router startup.
49#[derive(Debug, PartialEq, Eq)]
50pub enum AssetError {
51    Collision {
52        url: String,
53        first_owner: &'static str,
54        first_logical_name: &'static str,
55        second_owner: &'static str,
56        second_logical_name: &'static str,
57    },
58}
59
60impl fmt::Display for AssetError {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        match self {
63            Self::Collision {
64                url,
65                first_owner,
66                first_logical_name,
67                second_owner,
68                second_logical_name,
69            } => {
70                write!(
71                    f,
72                    "asset URL collision at {url}: {first_owner} ({first_logical_name}) and {second_owner} ({second_logical_name})"
73                )
74            }
75        }
76    }
77}
78
79impl std::error::Error for AssetError {}
80
81/// Build a root-mounted Axum router for all manifests in one call.
82///
83/// The `/assets`, `/assets/`, and `/assets/{*rest}` routes force unknown asset
84/// URLs to return 404 even when the app has a fallback or root wildcard route.
85/// Calling this more than once, or adding another `/assets` wildcard, conflicts
86/// at startup.
87pub fn router<S>(manifests: &[&'static Manifest]) -> Result<Router<S>, AssetError>
88where
89    S: Clone + Send + Sync + 'static,
90{
91    let mut paths = HashMap::new();
92    let mut registrations = Vec::new();
93    for manifest in manifests {
94        for asset in manifest.entries {
95            let hashed_url = format!("/assets/{}", asset.hashed_name);
96            register(&mut paths, &hashed_url, manifest.owner, asset.logical_name)?;
97            registrations.push((hashed_url, asset, IMMUTABLE_CACHE));
98            if STABLE_ROOT_FILES.contains(&asset.logical_name) {
99                let stable_url = format!("/{}", asset.logical_name);
100                register(&mut paths, &stable_url, manifest.owner, asset.logical_name)?;
101                registrations.push((stable_url, asset, STABLE_CACHE));
102            }
103        }
104    }
105    let mut router = Router::new();
106    for (url, asset, cache) in registrations {
107        router = router.route(
108            &url,
109            get(move || async move {
110                (
111                    [(CONTENT_TYPE, asset.content_type), (CACHE_CONTROL, cache)],
112                    asset.bytes,
113                )
114            }),
115        );
116    }
117    Ok(router
118        .route("/assets", any(|| async { StatusCode::NOT_FOUND }))
119        .route("/assets/", any(|| async { StatusCode::NOT_FOUND }))
120        .route("/assets/{*rest}", any(|| async { StatusCode::NOT_FOUND })))
121}
122
123fn register(
124    paths: &mut HashMap<String, (&'static str, &'static str)>,
125    url: &str,
126    owner: &'static str,
127    logical_name: &'static str,
128) -> Result<(), AssetError> {
129    if let Some((first_owner, first_logical_name)) =
130        paths.insert(url.to_owned(), (owner, logical_name))
131    {
132        return Err(AssetError::Collision {
133            url: url.to_owned(),
134            first_owner,
135            first_logical_name,
136            second_owner: owner,
137            second_logical_name: logical_name,
138        });
139    }
140    Ok(())
141}
142
143/// Define a named module with this crate's generated manifest and scoped URL lookup.
144#[macro_export]
145macro_rules! include_assets {
146    ($name:ident) => {
147        pub mod $name {
148            use $crate::assets::{Asset, Manifest};
149
150            pub static MANIFEST: Manifest = Manifest::new(
151                env!("CARGO_PKG_NAME"),
152                &include!(concat!(env!("OUT_DIR"), "/cja_assets.rs")),
153            );
154
155            #[track_caller]
156            pub fn asset_url(logical_name: &str) -> String {
157                MANIFEST.asset_url(logical_name)
158            }
159        }
160    };
161}