1use 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
15pub 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
23pub 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 #[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#[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
81pub 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#[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}