feat(web): embed static dist and add win7 static browser release
This commit is contained in:
1 parent
37fc2f8a9b
commit
3640f6827e
7 files changed
+300
-27
No files matched your search
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user