feat(spa): implement fallback handler for serving static assets
ZhenYi committed
Modified lib/webapi/src/lib.rs lib/webapi/src/lib.rs +2 −0 View lib/webapi/src/lib.rs
@@ -1,5 +1,6 @@
11pub mod metrics;
22pub mod openapi;
3pub mod spa;
34
45use axum::Router;
56use export::AppState;
@@ -75,6 +76,7 @@
7576 .nest("/api/v1/internal", internal)
7677 .nest("/pages", auth::routes::pages_router(auth.clone()))
7778 .merge(export::health::router(health_state))
79 .fallback(spa::fallback)
7880 .layer(cors)
7981 .layer(SessionLayer::new(store))
8082 .layer(RequestBodyLimitLayer::new(MAX_REQUEST_BODY_BYTES))
Added lib/webapi/src/spa.rs lib/webapi/src/spa.rs +112 −0 View lib/webapi/src/spa.rs
@@ -0,0 +1,112 @@
1use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header};
2use axum::response::{IntoResponse, Response};
3use std::collections::HashMap;
4use std::hash::{Hash, Hasher};
5use std::sync::OnceLock;
6
7const CACHE_IMMUTABLE: &str = "public, max-age=31536000, immutable";
8const CACHE_NO_CACHE: &str = "no-cache";
9const CACHE_REVALIDATE: &str = "public, max-age=3600, must-revalidate";
10
11fn etags() -> &'static HashMap<&'static str, HeaderValue> {
12 static ETAGS: OnceLock<HashMap<&'static str, HeaderValue>> = OnceLock::new();
13 ETAGS.get_or_init(|| {
14 let mut m = HashMap::new();
15 let index = web::index();
16 m.insert(index.path, etag_of(index.content));
17 for p in web::paths() {
18 if let Some(a) = web::file(p) {
19 m.insert(a.path, etag_of(a.content));
20 }
21 }
22 m
23 })
24}
25
26fn etag_of(content: &[u8]) -> HeaderValue {
27 let mut h = std::collections::hash_map::DefaultHasher::new();
28 content.hash(&mut h);
29 h.write_u64(content.len() as u64);
30 HeaderValue::from_maybe_shared(format!("W/\"{:016x}\"", h.finish()).into_bytes())
31 .expect("valid etag header")
32}
33
34fn cache_control(path: &str) -> &'static str {
35 if path.starts_with("assets/") {
36 CACHE_IMMUTABLE
37 } else if path == "index.html" {
38 CACHE_NO_CACHE
39 } else {
40 CACHE_REVALIDATE
41 }
42}
43
44pub async fn fallback(method: Method, uri: Uri, headers: HeaderMap) -> Response {
45 if !matches!(method, Method::GET | Method::HEAD) {
46 return StatusCode::METHOD_NOT_ALLOWED.into_response();
47 }
48
49 let path = uri.path();
50 if path.starts_with("/api") {
51 return StatusCode::NOT_FOUND.into_response();
52 }
53
54 let asset = match web::file(path) {
55 Some(asset) => asset,
56 None => {
57 if path.rsplit('/').next().is_some_and(|s| s.contains('.')) {
58 return StatusCode::NOT_FOUND.into_response();
59 }
60 web::index()
61 }
62 };
63
64 serve(asset, &headers)
65}
66
67fn serve(asset: web::Asset, headers: &HeaderMap) -> Response {
68 let etag = etags()
69 .get(asset.path)
70 .cloned()
71 .unwrap_or_else(|| etag_of(asset.content));
72 let cache_control = HeaderValue::from_static(cache_control(asset.path));
73
74 if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH)
75 && etag_matches(if_none_match, &etag)
76 {
77 return (
78 StatusCode::NOT_MODIFIED,
79 [
80 (header::CACHE_CONTROL, cache_control),
81 (header::ETAG, etag),
82 ],
83 )
84 .into_response();
85 }
86
87 (
88 [
89 (header::CONTENT_TYPE, HeaderValue::from_static(asset.mime)),
90 (header::CACHE_CONTROL, cache_control),
91 (header::ETAG, etag),
92 ],
93 asset.content,
94 )
95 .into_response()
96}
97
98fn etag_matches(if_none_match: &HeaderValue, etag: &HeaderValue) -> bool {
99 let Ok(value) = if_none_match.to_str() else {
100 return false;
101 };
102 let Ok(target) = etag.to_str() else {
103 return false;
104 };
105 fn strong(s: &str) -> &str {
106 s.strip_prefix("W/").unwrap_or(s)
107 }
108 value
109 .split(',')
110 .map(str::trim)
111 .any(|c| c == "*" || strong(c) == strong(target))
112}
Modified lib/webapi/Cargo.toml lib/webapi/Cargo.toml +2 −1 View lib/webapi/Cargo.toml
@@ -22,4 +22,5 @@
2222axum-otel-metrics = { workspace = true }
2323opentelemetry = { workspace = true }
2424opentelemetry_sdk = { workspace = true }
25opentelemetry-otlp = { workspace = true }
No newline at end of file
25opentelemetry-otlp = { workspace = true }
26web = { git = "https://gitea.gitdata.me/GitDataAi/web.git", rev = "67436b2ad9" }
No newline at end of file