fix: serve vite public assets

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-04 16:45:25 +08:00
co-authored by Codex
parent 38ab14fb19
commit 94ef9784ba
4 changed files with 88 additions and 68 deletions
+27
View File
@@ -1112,6 +1112,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -1496,6 +1502,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2839,6 +2855,11 @@ dependencies = [
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
@@ -2994,6 +3015,12 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
+2 -2
View File
@@ -37,7 +37,8 @@ tar = "0.4.44"
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "signal", "fs", "time", "sync", "net", "test-util"] }
tokio-tungstenite = { version = "0.28.0", features = ["proxy", "rustls-tls-native-roots"] }
tower-http = { version = "0.6.11", features = ["trace", "cors", "request-id", "timeout"] }
tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.6.11", features = ["trace", "cors", "request-id", "timeout", "fs"] }
tracing = "0.1.44"
tracing-appender = "0.2.5"
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
@@ -46,7 +47,6 @@ uuid = { version = "1.23.3", features = ["v4", "v7", "serde"] }
[dev-dependencies]
tempfile = "3.27.0"
tower = { version = "0.5.3", features = ["util"] }
wiremock = "0.6.5"
[patch.crates-io]
+28 -66
View File
@@ -1,41 +1,32 @@
use std::{
path::{Component, Path, PathBuf},
sync::Arc,
convert::Infallible,
path::{Path, PathBuf},
};
use axum::{
body::Body,
extract::{Path as AxumPath, State},
http::{header, HeaderValue, StatusCode, Uri},
http::{Request, StatusCode},
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use serde_json::json;
#[derive(Clone)]
struct AssetState {
dist_dir: Arc<PathBuf>,
}
use tower::service_fn;
use tower_http::services::ServeDir;
pub fn spa_router(dist_dir: impl AsRef<Path>) -> Router {
let state = AssetState {
dist_dir: Arc::new(dist_dir.as_ref().to_path_buf()),
};
let dist_dir = dist_dir.as_ref().to_path_buf();
let index_file = dist_dir.join("index.html");
let fallback = service_fn(move |request: Request<Body>| {
let index_file = index_file.clone();
async move { Ok::<_, Infallible>(spa_fallback_response(request, index_file).await) }
});
Router::new()
.route("/", get(serve_index))
.route("/assets/{*path}", get(serve_asset))
.fallback(serve_spa_fallback)
.with_state(state)
Router::new().fallback_service(ServeDir::new(dist_dir).fallback(fallback))
}
async fn serve_index(State(state): State<AssetState>) -> Response {
serve_file(&state.dist_dir.join("index.html")).await
}
async fn serve_spa_fallback(State(state): State<AssetState>, uri: Uri) -> Response {
if is_api_path(uri.path()) {
async fn spa_fallback_response(request: Request<Body>, index_file: PathBuf) -> Response {
let path = request.uri().path();
if is_api_path(path) {
return (
StatusCode::NOT_FOUND,
Json(json!({
@@ -47,61 +38,32 @@ async fn serve_spa_fallback(State(state): State<AssetState>, uri: Uri) -> Respon
.into_response();
}
serve_file(&state.dist_dir.join("index.html")).await
if is_static_file_path(path) {
return StatusCode::NOT_FOUND.into_response();
}
serve_index_file(&index_file).await
}
fn is_api_path(path: &str) -> bool {
path == "/api" || path.starts_with("/api/") || path == "/v1" || path.starts_with("/v1/")
}
async fn serve_asset(
State(state): State<AssetState>,
AxumPath(path): AxumPath<String>,
) -> Response {
let Some(relative_path) = safe_relative_path(&path) else {
return StatusCode::NOT_FOUND.into_response();
};
serve_file(&state.dist_dir.join("assets").join(relative_path)).await
}
async fn serve_file(path: &Path) -> Response {
async fn serve_index_file(path: &Path) -> Response {
let Ok(bytes) = tokio::fs::read(path).await else {
return StatusCode::NOT_FOUND.into_response();
};
let mut response = Response::new(Body::from(bytes));
let headers = response.headers_mut();
headers.insert(
header::CONTENT_TYPE,
content_type_for_path(&path.to_string_lossy()),
response.headers_mut().insert(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("text/html; charset=utf-8"),
);
response
}
fn content_type_for_path(path: &str) -> HeaderValue {
if path.ends_with(".html") || path == "/" {
HeaderValue::from_static("text/html; charset=utf-8")
} else if path.ends_with(".js") {
HeaderValue::from_static("text/javascript; charset=utf-8")
} else if path.ends_with(".css") {
HeaderValue::from_static("text/css; charset=utf-8")
} else if path.ends_with(".json") {
HeaderValue::from_static("application/json")
} else if path.ends_with(".svg") {
HeaderValue::from_static("image/svg+xml")
} else {
HeaderValue::from_static("application/octet-stream")
}
}
fn safe_relative_path(path: &str) -> Option<PathBuf> {
let mut sanitized = PathBuf::new();
for component in Path::new(path).components() {
match component {
Component::Normal(segment) => sanitized.push(segment),
_ => return None,
}
}
(!sanitized.as_os_str().is_empty()).then_some(sanitized)
fn is_static_file_path(path: &str) -> bool {
Path::new(path.trim_start_matches('/'))
.extension()
.is_some()
}
+31
View File
@@ -22,6 +22,11 @@ async fn server_router_should_serve_frontend_assets_without_shadowing_api_routes
"<!doctype html><main id=\"app\">Codex Proxy Admin</main>",
)
.expect("index should be written");
std::fs::write(
dist.join("favicon.svg"),
"<svg><title>Codex Proxy</title></svg>",
)
.expect("favicon should be written");
std::fs::write(dist.join("assets").join("app.js"), "window.__asset = true;")
.expect("asset should be written");
let db = dir.path().join("assets-routes.sqlite");
@@ -29,6 +34,21 @@ async fn server_router_should_serve_frontend_assets_without_shadowing_api_routes
let (app, _pool) = router_with_dist_and_database(&dist, &database_url).await;
let index = app.clone().oneshot(request("/")).await.expect("index");
let route_fallback = app
.clone()
.oneshot(request("/dashboard"))
.await
.expect("route fallback");
let favicon = app
.clone()
.oneshot(request("/favicon.svg"))
.await
.expect("favicon");
let missing_static = app
.clone()
.oneshot(request("/missing.svg"))
.await
.expect("missing static response");
let asset = app
.clone()
.oneshot(request("/assets/app.js"))
@@ -45,12 +65,23 @@ async fn server_router_should_serve_frontend_assets_without_shadowing_api_routes
.expect("health response");
assert_eq!(index.status(), StatusCode::OK);
assert_eq!(route_fallback.status(), StatusCode::OK);
assert_eq!(favicon.status(), StatusCode::OK);
assert_eq!(missing_static.status(), StatusCode::NOT_FOUND);
assert_eq!(asset.status(), StatusCode::OK);
assert_eq!(api.status(), StatusCode::UNAUTHORIZED);
assert_eq!(health.status(), StatusCode::NO_CONTENT);
assert_eq!(
favicon.headers().get(header::CONTENT_TYPE),
Some(&header::HeaderValue::from_static("image/svg+xml"))
);
assert_no_static_policy_headers(&index);
assert_no_static_policy_headers(&route_fallback);
assert_no_static_policy_headers(&favicon);
assert_no_static_policy_headers(&asset);
assert_body_contains(index, "Codex Proxy Admin").await;
assert_body_contains(route_fallback, "Codex Proxy Admin").await;
assert_body_contains(favicon, "Codex Proxy").await;
assert_body_contains(asset, "__asset").await;
assert_eq!(response_json(api).await["code"], Value::from(40101));
}