diff --git a/.github/scripts/start-dbx-web.bat b/.github/scripts/start-dbx-web.bat new file mode 100644 index 0000000000..ba3882ffd9 --- /dev/null +++ b/.github/scripts/start-dbx-web.bat @@ -0,0 +1,41 @@ +@echo off +setlocal EnableDelayedExpansion + +:: 脚本所在目录(等价于 ROOT) +set ROOT=%~dp0 +:: 去掉末尾反斜杠 +set ROOT=%ROOT:~0,-1% + +set DBX_PACKAGE_ROOT=%ROOT% + +:: static dir +if "%DBX_STATIC_DIR%"=="" ( + :: dist静态资源文件已直接嵌入到二进制文件中,环境变量中没有设置的话,直接用二进制中的静态文件 + rem set DBX_STATIC_DIR=%ROOT%\dist +) +:: data dir +if "%DBX_DATA_DIR%"=="" ( + set DBX_DATA_DIR=%ROOT%\data +) + +:: port +if "%DBX_PORT%"=="" ( + set DBX_PORT=4224 +) + +:: base path +set BASE_PATH=%DBX_PUBLIC_BASE_PATH% +if "%BASE_PATH%"=="" set BASE_PATH=/ + +:: 如果不是以 / 开头,补上 / +echo %BASE_PATH% | findstr /r "^/" >nul || ( + set BASE_PATH=/!BASE_PATH! +) + +echo DBX_DATA_DIR=%DBX_DATA_DIR% +echo DBX browser UI: http://127.0.0.1:%DBX_PORT%%BASE_PATH% + +cd /d "%ROOT%" + +:: 执行程序 +dbx-web.exe %* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c398f5ef6a..e14855ae5b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -703,6 +703,89 @@ jobs: Remove-Item -LiteralPath $workDir -Recurse -Force } + build-windows-7-static-browser: + needs: release-ready + runs-on: windows-2022 + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-win7-windows-msvc + arch: x86_64 + env: + CARGO_INCREMENTAL: "0" + RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static + steps: + - uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Install frontend dependencies + run: pnpm install + + - name: Build frontend + run: pnpm build + + - name: Setup Rust for Windows 7 + uses: dtolnay/rust-toolchain@nightly + with: + toolchain: nightly-2026-07-22 + components: rust-src + + - name: Install NASM + run: choco install nasm -y --no-progress + + - name: Add NASM to PATH + shell: pwsh + run: Add-Content $env:GITHUB_PATH "C:\Program Files\NASM" + + - name: Build DBX for Windows 7 ${{ matrix.arch }} + shell: pwsh + run: | + cargo build --locked --package dbx-web --release --no-default-features --features embed-static,duckdb-sidecar,dynamodb,mq-admin,dbx-core/sqlite-bundled --target ${{ matrix.target }} -Z build-std=std,panic_abort + + - name: Audit Windows 7 PE imports + shell: pwsh + run: ./.github/scripts/assert-win7-pe-compat.ps1 -BinaryPath target/${{ matrix.target }}/release/dbx-web.exe + + - name: Upload Windows portable ${{ matrix.arch }} ZIP + timeout-minutes: 20 + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $version = "${env:GITHUB_REF_NAME}".TrimStart("v") + $arch = "${{ matrix.arch }}" + $portableRoot = "portable" + $portableDir = Join-Path $portableRoot "DBX_${version}_${arch}" + $zipName = "dbx-web_${version}_${arch}-browser-static.zip" + $zipSha256 = "dbx-web_${version}_${arch}-browser-static.zip.sha256" + $exePath = "target/${{ matrix.target }}/release/dbx-web.exe" + + if (!(Test-Path $exePath)) { + Write-Error "Missing Windows executable: $exePath" + exit 1 + } + + New-Item -ItemType Directory -Force -Path $portableDir | Out-Null + Copy-Item $exePath (Join-Path $portableDir "dbx-web.exe") -Force + Copy-Item "LICENSE" (Join-Path $portableDir "LICENSE") -Force + Copy-Item "README.md" (Join-Path $portableDir "README.md") -Force + Copy-Item ".github\scripts\start-dbx-web.bat" (Join-Path $portableDir "start.bat") -Force + + Compress-Archive -Path (Join-Path $portableDir "*") -DestinationPath $zipName -Force + + $zip_sha256 = (Get-FileHash -LiteralPath $zipName -Algorithm SHA256).Hash.ToLowerInvariant() + Set-Content -Path $zipSha256 -Value $zip_sha256 -Encoding utf8NoBOM + + gh release upload "${env:GITHUB_REF_NAME}" $zipName $zipSha256 --repo "${env:GITHUB_REPOSITORY}" --clobber + static-browser: # Fully static musl builds of the browser (dbx-web) variant. No glibc # dependency, so the tarball runs on any Linux distribution (verified in @@ -799,7 +882,7 @@ jobs: RUSTFLAGS: ${{ matrix.rustflags }} run: | cargo zigbuild --release -p dbx-web --target ${{ matrix.target }} \ - --no-default-features --features "duckdb-sidecar,dynamodb,mq-admin,dbx-core/sqlite-bundled" + --no-default-features --features "embed-static,duckdb-sidecar,dynamodb,mq-admin,dbx-core/sqlite-bundled" - name: Package static browser runtime env: @@ -938,7 +1021,8 @@ jobs: publish: # Keep the release as a draft until every release-blocking platform job passes. - # Nix packaging is advisory and runs independently from publication. + # Nix packaging and the Windows 7 static-browser build are advisory and + # upload their release assets independently of publication. needs: [cleanup-release-signatures, docker-manifest, jdbc-plugin, static-browser] runs-on: ubuntu-latest steps: diff --git a/Cargo.lock b/Cargo.lock index c706c5275a..551be19bad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2408,9 +2408,11 @@ dependencies = [ "dbx-mcp", "futures", "log", + "mime_guess", "pbkdf2 0.12.2", "redis", "reqwest 0.12.28", + "rust-embed", "rustls 0.23.43", "serde", "serde_json", @@ -7654,6 +7656,41 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "sha2 0.11.0", + "walkdir", +] + [[package]] name = "rust-ini" version = "0.21.3" diff --git a/crates/dbx-web/Cargo.toml b/crates/dbx-web/Cargo.toml index cabdbe52ee..5f2dd509cf 100644 --- a/crates/dbx-web/Cargo.toml +++ b/crates/dbx-web/Cargo.toml @@ -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"] } diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 40f6c511ac..a409c9b374 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -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>) -> 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 { diff --git a/scripts/package-web-static.sh b/scripts/package-web-static.sh index b8509cccd6..548062b7c9 100644 --- a/scripts/package-web-static.sh +++ b/scripts/package-web-static.sh @@ -31,10 +31,6 @@ if [ ! -x "$binary" ]; then exit 1 fi -if [ ! -f "${dist_dir}/index.html" ]; then - echo "missing frontend build output: ${dist_dir}/index.html" >&2 - exit 1 -fi if readelf -l "$binary" | grep -q 'Requesting program interpreter'; then readelf -l "$binary" | grep 'Requesting program interpreter' >&2 || true @@ -51,12 +47,11 @@ fi rm -rf "$output_dir" mkdir -p \ "$package_dir/bin" \ - "$package_dir/dist" \ "$package_dir/data" cp "$binary" "$package_dir/bin/dbx-web-bin" chmod +x "$package_dir/bin/dbx-web-bin" -cp -a "${dist_dir}/." "$package_dir/dist/" +#cp -a "${dist_dir}/." "$package_dir/dist/" cat > "$package_dir/dbx" <<'EOF' #!/usr/bin/env bash @@ -69,7 +64,7 @@ while [ -h "$SOURCE" ]; do done ROOT="$(cd -P "$(dirname "$SOURCE")" && pwd)" export DBX_PACKAGE_ROOT="$ROOT" -export DBX_STATIC_DIR="${DBX_STATIC_DIR:-$ROOT/dist}" +#export DBX_STATIC_DIR="${DBX_STATIC_DIR:-$ROOT/dist}" export DBX_DATA_DIR="${DBX_DATA_DIR:-$ROOT/data}" port="${DBX_PORT:-4224}" base_path="${DBX_PUBLIC_BASE_PATH:-/}" diff --git a/scripts/verify-web-static.sh b/scripts/verify-web-static.sh index a5ce7bd74b..4c5c591ffc 100644 --- a/scripts/verify-web-static.sh +++ b/scripts/verify-web-static.sh @@ -38,8 +38,7 @@ tar -xzf "$package_tarball" -C "$runtime_dir" --strip-components=1 for required_runtime_path in \ dbx \ - bin/dbx-web-bin \ - dist/index.html; do + bin/dbx-web-bin ; do if [ ! -e "$runtime_dir/$required_runtime_path" ]; then echo "missing packaged static web runtime path: $required_runtime_path" >&2 exit 1