feat(web): embed static dist and add win7 static browser release

This commit is contained in:
zhouwenqing authored and GitHub committed 2026-09-24 23:22:40 +08:00
1 parent 37fc2f8a9b
commit 3640f6827e
7 files changed
+300 -27

No files matched your search

+7
View File
@@ -15,6 +15,10 @@ dynamodb = ["dbx-core/dynamodb"]
mq-admin = ["dbx-core/mq-admin", "dbx-mcp/mq-admin"]
sqlite-sqlcipher = ["dbx-core/sqlite-sqlcipher"]
system-fonts = ["dbx-core/system-fonts"]
# Embed the frontend build output (../../dist) into the binary. When enabled
# and DBX_STATIC_DIR is not set, the embedded files are served by default.
# Requires the frontend to have been built before invoking cargo.
embed-static = ["dep:rust-embed", "dep:mime_guess"]
[dependencies]
dbx-core = { path = "../dbx-core", default-features = false, features = ["openapi"] }
@@ -42,6 +46,9 @@ base64 = "0.22"
pbkdf2 = "0.12"
sha2 = "0.10"
tempfile = "3"
rust-embed = { version = "8", features = ["debug-embed"], optional = true }
mime_guess = { version = "2", optional = true }
[dev-dependencies]
reqwest = { version = "0.12", default-features = false, features = ["multipart"] }
+126 -16
View File
@@ -159,12 +159,96 @@ where
)
}
fn mount_public_base_path(mut app: Router, public_base_path: &str, static_dir: Option<&std::path::Path>) -> Router {
if let Some(static_dir) = static_dir {
use tower_http::services::{ServeDir, ServeFile};
let index_path = static_dir.join("index.html");
let serve_dir = ServeDir::new(static_dir).not_found_service(ServeFile::new(index_path));
app = app.fallback_service(serve_dir);
/// Frontend build output compiled into the binary by the `embed-static` feature.
/// The folder is relative to this crate's manifest (crates/dbx-web) and points
/// at the workspace root `dist` directory produced by the frontend build.
#[cfg(feature = "embed-static")]
#[derive(rust_embed::RustEmbed)]
#[folder = "../../dist"]
struct EmbeddedStaticAssets;
/// Serves an embedded static asset, falling back to `index.html` so that
/// client-side (SPA) routes resolve like they do with the disk-based ServeDir.
#[cfg(feature = "embed-static")]
async fn serve_embedded_asset(uri: Uri) -> axum::response::Response {
use axum::http::header;
let path = uri.path().trim_start_matches('/');
if let Some(asset) = EmbeddedStaticAssets::get(path) {
let content_type = mime_guess::from_path(path).first_or_octet_stream();
let mut response = axum::response::Response::builder()
.header(header::CONTENT_TYPE, content_type.as_ref())
.body(axum::body::Body::from(asset.data.into_owned()))
.expect("valid embedded asset response");
// Vite emits content-hashed, immutable files under assets/. The HTML
// entry point must always revalidate so binary upgrades take effect;
// other files (favicon, fonts) keep browser heuristic caching, which
// matches tower-http's ServeDir behaviour.
let cache_control = if path.starts_with("assets/") {
Some("public, max-age=31536000, immutable")
} else if path == "index.html" {
Some("no-cache")
} else {
None
};
if let Some(cache_control) = cache_control {
response.headers_mut().insert(
header::CACHE_CONTROL,
header::HeaderValue::from_str(cache_control).expect("valid cache-control"),
);
}
response
} else {
serve_embedded_index().await
}
}
/// Serves the embedded SPA entry point. It must never be cached aggressively so
/// that upgraded binaries are not shadowed by an old index referencing removed
/// hashed assets.
#[cfg(feature = "embed-static")]
async fn serve_embedded_index() -> axum::response::Response {
use axum::http::header;
let Some(index) = EmbeddedStaticAssets::get("index.html") else {
// Mirrors the disk-based variant, where a missing index.html surfaces
// as a 404 from the not-found service instead of crashing the handler.
return axum::response::Response::builder()
.status(axum::http::StatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(axum::body::Body::from("embedded dist does not contain index.html"))
.expect("valid missing index response");
};
axum::response::Response::builder()
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache")
.body(axum::body::Body::from(index.data.into_owned()))
.expect("valid embedded index response")
}
/// Selects where the static frontend files are served from.
#[derive(Clone, Copy)]
enum StaticSource<'a> {
/// Serve files from a directory on disk (DBX_STATIC_DIR).
Dir(&'a std::path::Path),
/// Serve the dist files compiled into the binary (`embed-static` feature).
#[cfg(feature = "embed-static")]
Embedded,
}
fn mount_static_assets(mut app: Router, public_base_path: &str, source: Option<StaticSource<'_>>) -> Router {
match source {
Some(StaticSource::Dir(static_dir)) => {
use tower_http::services::{ServeDir, ServeFile};
let index_path = static_dir.join("index.html");
let serve_dir = ServeDir::new(static_dir).not_found_service(ServeFile::new(index_path));
app = app.fallback_service(serve_dir);
}
#[cfg(feature = "embed-static")]
Some(StaticSource::Embedded) => {
app = app.fallback(serve_embedded_asset);
}
None => {}
}
if public_base_path == "/" {
@@ -173,9 +257,16 @@ fn mount_public_base_path(mut app: Router, public_base_path: &str, static_dir: O
app = Router::new().nest(public_base_path, app);
app = add_public_base_path_redirect(app, public_base_path);
if let Some(static_dir) = static_dir {
use tower_http::services::ServeFile;
app = app.route_service(&format!("{public_base_path}/"), ServeFile::new(static_dir.join("index.html")));
match source {
Some(StaticSource::Dir(static_dir)) => {
use tower_http::services::ServeFile;
app = app.route_service(&format!("{public_base_path}/"), ServeFile::new(static_dir.join("index.html")));
}
#[cfg(feature = "embed-static")]
Some(StaticSource::Embedded) => {
app = app.route(&format!("{public_base_path}/"), get(serve_embedded_index));
}
None => {}
}
app
}
@@ -1278,7 +1369,23 @@ async fn serve() {
}
let static_dir = std::env::var_os("DBX_STATIC_DIR").map(std::path::PathBuf::from);
app = mount_public_base_path(app, &public_base_path, static_dir.as_deref());
// DBX_STATIC_DIR always wins (frontend development); otherwise serve the
// assets embedded into the binary when the embed-static feature is enabled.
let static_source = {
let dir_source = static_dir.as_deref().map(StaticSource::Dir);
#[cfg(feature = "embed-static")]
{
dir_source.or_else(|| {
tracing::info!("Serving embedded frontend assets (DBX_STATIC_DIR not set)");
Some(StaticSource::Embedded)
})
}
#[cfg(not(feature = "embed-static"))]
{
dir_source
}
};
app = mount_static_assets(app, &public_base_path, static_source);
// Bind address
let port: u16 = std::env::var("DBX_PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(4224);
@@ -1320,8 +1427,8 @@ async fn serve() {
#[cfg(test)]
mod tests {
use super::{
mount_public_base_path, normalize_public_base_path, web_agent_dir_from_env, web_body_limit_bytes_from_value,
web_compression_predicate, XLSX_CONTENT_TYPE,
mount_static_assets, normalize_public_base_path, web_agent_dir_from_env, web_body_limit_bytes_from_value,
web_compression_predicate, StaticSource, XLSX_CONTENT_TYPE,
};
use crate::routes::table_import;
use axum::body::Body;
@@ -1493,10 +1600,10 @@ mod tests {
std::fs::write(static_dir.join("app.js"), "subpath asset").expect("write asset");
for public_base_path in ["/dbx", "/xxxx/rsu"] {
let router = mount_public_base_path(
let router = mount_static_assets(
Router::new().route("/api/ping", get(|| async { "pong" })),
public_base_path,
Some(&static_dir),
Some(StaticSource::Dir(&static_dir)),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind test listener");
let address = listener.local_addr().expect("test listener address");
@@ -1559,8 +1666,11 @@ mod tests {
std::fs::create_dir_all(&static_dir).expect("create static directory");
std::fs::write(static_dir.join("index.html"), "root index").expect("write index");
std::fs::write(static_dir.join("app.js"), "root asset").expect("write asset");
let router =
mount_public_base_path(Router::new().route("/api/ping", get(|| async { "pong" })), "/", Some(&static_dir));
let router = mount_static_assets(
Router::new().route("/api/ping", get(|| async { "pong" })),
"/",
Some(StaticSource::Dir(&static_dir)),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind test listener");
let address = listener.local_addr().expect("test listener address");
let server = tokio::spawn(async move {