Skip to main content

solverforge_ui/
lib.rs

1use axum::{
2    extract::Path,
3    http::{header, StatusCode},
4    response::{IntoResponse, Response},
5    routing::get,
6    Router,
7};
8use include_dir::{include_dir, Dir};
9
10static ASSETS: Dir = include_dir!("$CARGO_MANIFEST_DIR/static/sf");
11
12pub fn routes() -> Router {
13    Router::new().route("/sf/{*path}", get(serve_asset))
14}
15
16async fn serve_asset(Path(path): Path<String>) -> Response {
17    let Some(file) = ASSETS.get_file(&path) else {
18        return StatusCode::NOT_FOUND.into_response();
19    };
20
21    let mime = mime_from_path(&path);
22    let cache = if is_immutable(&path) {
23        "public, max-age=31536000, immutable"
24    } else {
25        "public, max-age=3600"
26    };
27
28    (
29        StatusCode::OK,
30        [(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
31        file.contents(),
32    )
33        .into_response()
34}
35
36fn mime_from_path(path: &str) -> &'static str {
37    match path.rsplit('.').next() {
38        Some("css") => "text/css; charset=utf-8",
39        Some("js") => "application/javascript; charset=utf-8",
40        Some("svg") => "image/svg+xml",
41        Some("woff2") => "font/woff2",
42        Some("woff") => "font/woff",
43        Some("ttf") => "font/ttf",
44        Some("eot") => "application/vnd.ms-fontobject",
45        Some("png") => "image/png",
46        Some("jpg" | "jpeg") => "image/jpeg",
47        Some("ico") => "image/x-icon",
48        Some("json") => "application/json",
49        Some("html") => "text/html; charset=utf-8",
50        Some("map") => "application/json",
51        _ => "application/octet-stream",
52    }
53}
54
55fn is_immutable(path: &str) -> bool {
56    path.starts_with("fonts/")
57        || path.starts_with("vendor/")
58        || path.starts_with("img/")
59        || is_versioned_bundle(path)
60}
61
62fn is_versioned_bundle(path: &str) -> bool {
63    path.strip_prefix("sf.")
64        .and_then(|rest| rest.rsplit_once('.'))
65        .map(|(version, ext)| {
66            !version.is_empty()
67                && version.chars().all(|ch| {
68                    ch.is_ascii_digit()
69                        || ch == '.'
70                        || ch == '-'
71                        || ch == '+'
72                        || ch.is_ascii_alphabetic()
73                })
74                && matches!(ext, "css" | "js")
75        })
76        .unwrap_or(false)
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use axum::{
83        body::{to_bytes, Body},
84        http::{Method, Request, StatusCode},
85    };
86    use tower::util::ServiceExt;
87
88    #[test]
89    fn versioned_bundles_are_detected() {
90        assert!(is_versioned_bundle("sf.0.3.0.css"));
91        assert!(is_versioned_bundle("sf.0.3.0.js"));
92        assert!(is_versioned_bundle("sf.0.3.0-beta.1.js"));
93        assert!(is_versioned_bundle("sf.0.3.0+build.7.css"));
94        assert!(!is_versioned_bundle("sf.css"));
95        assert!(!is_versioned_bundle("sf.js"));
96        assert!(!is_versioned_bundle("vendor/sf.0.3.0.js"));
97    }
98
99    #[test]
100    fn caches_paths_are_predicted_correctly() {
101        assert_eq!(mime_from_path("styles/sf.css"), "text/css; charset=utf-8");
102        assert_eq!(
103            mime_from_path("scripts/sf.js"),
104            "application/javascript; charset=utf-8"
105        );
106        assert_eq!(mime_from_path("img/logo.svg"), "image/svg+xml");
107        assert_eq!(mime_from_path("font.woff2"), "font/woff2");
108
109        assert!(is_immutable("fonts/jetbrains-mono.woff2"));
110        assert!(is_immutable("vendor/leaflet/leaflet.js"));
111        assert!(is_immutable("img/solverforge-logo.svg"));
112        assert!(is_immutable("sf.0.3.0.css"));
113        assert!(is_immutable("sf.0.3.0+build.7.js"));
114        assert!(!is_immutable("sf.css"));
115    }
116
117    #[test]
118    fn mime_detection_still_works_for_versioned_assets() {
119        assert_eq!(mime_from_path("sf.0.3.0.css"), "text/css; charset=utf-8");
120        assert_eq!(
121            mime_from_path("sf.0.3.0+build.7.js"),
122            "application/javascript; charset=utf-8"
123        );
124    }
125
126    #[tokio::test]
127    async fn serves_assets_with_expected_headers() {
128        let app = routes();
129
130        let immutable_resp = app
131            .clone()
132            .oneshot(
133                Request::builder()
134                    .method(Method::GET)
135                    .uri("/sf/fonts/jetbrains-mono.woff2")
136                    .body(Body::empty())
137                    .unwrap(),
138            )
139            .await
140            .unwrap();
141
142        assert_eq!(immutable_resp.status(), StatusCode::OK);
143        assert_eq!(
144            immutable_resp.headers().get("cache-control").unwrap(),
145            "public, max-age=31536000, immutable"
146        );
147        assert_eq!(
148            immutable_resp.headers().get("content-type").unwrap(),
149            "font/woff2"
150        );
151        assert!(!to_bytes(immutable_resp.into_body(), 16_000_000)
152            .await
153            .unwrap()
154            .is_empty());
155
156        let mutable_resp = app
157            .clone()
158            .oneshot(
159                Request::builder()
160                    .method(Method::GET)
161                    .uri("/sf/sf.css")
162                    .body(Body::empty())
163                    .unwrap(),
164            )
165            .await
166            .unwrap();
167
168        assert_eq!(mutable_resp.status(), StatusCode::OK);
169        assert_eq!(
170            mutable_resp.headers().get("cache-control").unwrap(),
171            "public, max-age=3600"
172        );
173        assert_eq!(
174            mutable_resp.headers().get("content-type").unwrap(),
175            "text/css; charset=utf-8"
176        );
177        assert!(!to_bytes(mutable_resp.into_body(), 16_000_000)
178            .await
179            .unwrap()
180            .is_empty());
181
182        let missing_resp = app
183            .oneshot(
184                Request::builder()
185                    .method(Method::GET)
186                    .uri("/sf/does-not-exist")
187                    .body(Body::empty())
188                    .unwrap(),
189            )
190            .await
191            .unwrap();
192
193        assert_eq!(missing_resp.status(), StatusCode::NOT_FOUND);
194    }
195
196    #[tokio::test]
197    async fn serves_top_level_assets_with_short_cache_and_expected_mime() {
198        let response = routes()
199            .oneshot(
200                Request::builder()
201                    .uri("/sf/sf.css")
202                    .body(Body::empty())
203                    .unwrap(),
204            )
205            .await
206            .unwrap();
207
208        assert_eq!(response.status(), StatusCode::OK);
209        assert_eq!(
210            response.headers().get(header::CONTENT_TYPE).unwrap(),
211            "text/css; charset=utf-8"
212        );
213        assert_eq!(
214            response.headers().get(header::CACHE_CONTROL).unwrap(),
215            "public, max-age=3600"
216        );
217
218        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
219        let css = String::from_utf8(body.to_vec()).unwrap();
220        assert!(css.contains("--sf-emerald-50"));
221        assert!(css.contains(".sf-gantt-split"));
222    }
223
224    #[tokio::test]
225    async fn serves_immutable_assets_with_long_cache_and_expected_mime() {
226        let image = routes()
227            .oneshot(
228                Request::builder()
229                    .method(Method::GET)
230                    .uri("/sf/img/ouroboros.svg")
231                    .body(Body::empty())
232                    .unwrap(),
233            )
234            .await
235            .unwrap();
236
237        assert_eq!(image.status(), StatusCode::OK);
238        assert_eq!(
239            image.headers().get(header::CONTENT_TYPE).unwrap(),
240            "image/svg+xml"
241        );
242        assert_eq!(
243            image.headers().get(header::CACHE_CONTROL).unwrap(),
244            "public, max-age=31536000, immutable"
245        );
246
247        let vendor = routes()
248            .oneshot(
249                Request::builder()
250                    .method(Method::GET)
251                    .uri("/sf/vendor/frappe-gantt/frappe-gantt.min.js")
252                    .body(Body::empty())
253                    .unwrap(),
254            )
255            .await
256            .unwrap();
257
258        assert_eq!(vendor.status(), StatusCode::OK);
259        assert_eq!(
260            vendor.headers().get(header::CONTENT_TYPE).unwrap(),
261            "application/javascript; charset=utf-8"
262        );
263        assert_eq!(
264            vendor.headers().get(header::CACHE_CONTROL).unwrap(),
265            "public, max-age=31536000, immutable"
266        );
267    }
268
269    #[tokio::test]
270    async fn returns_not_found_for_missing_assets() {
271        let response = routes()
272            .oneshot(
273                Request::builder()
274                    .method(Method::GET)
275                    .uri("/sf/does-not-exist.js")
276                    .body(Body::empty())
277                    .unwrap(),
278            )
279            .await
280            .unwrap();
281
282        assert_eq!(response.status(), StatusCode::NOT_FOUND);
283    }
284}