From 96b6a635371a440196395bc27087fa7bbaa0920a Mon Sep 17 00:00:00 2001 From: Tomcat Date: Mon, 17 Aug 2026 21:55:08 +0800 Subject: [PATCH 1/3] Add live filesystem asset mode --- CHANGELOG.md | 6 +++ Cargo.toml | 4 ++ Makefile | 3 ++ README.md | 53 ++++++++++++++------ llms.txt | 5 +- src/lib.rs | 54 ++++++++++++++++---- tests/live_assets.rs | 116 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 215 insertions(+), 26 deletions(-) create mode 100644 tests/live_assets.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f5a6b1..5e5ebaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Added + +- Add the opt-in `live-assets` feature for filesystem-backed debug builds. It + keeps the existing `EmbeddedSpa` API while refreshing file membership, + response bytes, and strong ETags without rebuilding or restarting Rust. + ### Fixed - Combine repeated `Accept`, `Accept-Encoding`, and `If-None-Match` field diff --git a/Cargo.toml b/Cargo.toml index 70506ea..8bd8439 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,10 @@ include = [ "/llms.txt", ] +[features] +default = [] +live-assets = [] + [dependencies] axum = "0.8.9" mime_guess = "2.0.5" diff --git a/Makefile b/Makefile index 7b83233..df2d1de 100644 --- a/Makefile +++ b/Makefile @@ -9,11 +9,14 @@ fmt: check: cargo fmt --all -- --check cargo test --all-targets + cargo test --all-targets --all-features + cargo clippy --all-targets -- -D warnings cargo clippy --all-targets --all-features -- -D warnings cargo doc --no-deps test: cargo test --all-targets + cargo test --all-targets --all-features test-nginx: ./scripts/test-nginx.sh diff --git a/README.md b/README.md index cf6b2bc..b3ca454 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,10 @@ routes correctly. `/api/*` must be handled before the SPA fallback. - SPA fallback occurs only when `Accept` explicitly allows `text/html`. - `Accept-Encoding` q-values select identity, gzip, or Brotli. - Every final representation receives its own strong SHA-256 `ETag`. -- ETags use `rust-embed` metadata and are formatted once during construction; - request handling does not hash content. +- By default, ETags use `rust-embed` metadata and are formatted once during + construction; request handling does not hash content. +- The opt-in `live-assets` feature refreshes file membership and ETag metadata + per request for filesystem-backed debug builds. - Files with compressed siblings emit `Vary: Accept-Encoding`. - MIME is inferred from the logical filename, not from `.gz` or `.br`. - `index.html`, immutable assets, revalidated assets, and errors have separate @@ -141,20 +143,40 @@ fn app() -> Router { The nested API fallback is deliberate. A missing API route must not inherit the HTML fallback. -### Debug builds +### Live filesystem development By default, `rust-embed` reads from the filesystem in debug builds and embeds -files in release builds. Add its `debug-embed` feature if debug executables -must also be self-contained: +files in release builds. `embedded-spa` normally caches its ETag headers during +construction, so enable `live-assets` when frontend files must change without +recompiling or restarting the Rust process: ```toml -rust-embed = { - version = "8", - features = ["debug-embed", "deterministic-timestamps"] -} +[features] +dev-assets = ["embedded-spa/live-assets"] + +[dependencies] +embedded-spa = { git = "https://github.com/tomcatzh/embedded-spa" } +rust-embed = { version = "8", features = ["deterministic-timestamps"] } +``` + +Run the unchanged application and handler in development mode: + +```bash +cargo run --features dev-assets ``` -Production artifacts should still be built with `cargo build --release`. +The same long-lived `EmbeddedSpa` now sees added, changed, and removed files on +disk. It derives each response ETag from the metadata returned with the bytes +read for that request, and `asset_count()` reports the current file count. + +This feature intentionally does not override `rust-embed` itself. Use a debug +build and do not enable `rust-embed/debug-embed`; Cargo features are additive, +so another dependency enabling `debug-embed` would make the provider embedded +again. Release builds should omit `dev-assets` and use `cargo build --release`. +They remain self-contained automatically. + +If a debug executable must also be self-contained, enable +`rust-embed/debug-embed` and leave `embedded-spa/live-assets` disabled. ## Build-time precompression @@ -417,10 +439,11 @@ window, or store immutable chunks in versioned object storage/CDN. - `EmbeddedSpa`: validated, reusable response service for a `RustEmbed` provider. -- `EmbeddedSpa::new`: verifies the index and precomputes ETag header values. +- `EmbeddedSpa::new`: verifies the index and prepares cached or live ETag + handling. - `EmbeddedSpa::serve`: synchronously converts an Axum request into a response. -- `EmbeddedSpa::asset_count`: reports embedded files including compressed - siblings. +- `EmbeddedSpa::asset_count`: reports files including compressed siblings; + with `live-assets`, it reflects the current provider contents. - `EmbeddedSpaConfig`: index path, immutable prefixes, cache policies, and CSP. - `EmbeddedSpaError`: startup configuration failure. @@ -436,7 +459,9 @@ cargo doc --no-deps --open - Axum 0.8. - `rust-embed` 8. - No unsafe code. -- No runtime hashing. +- No runtime hashing in the default production mode. +- `live-assets` intentionally refreshes ETag metadata per request for + development. - No runtime compression. - No filesystem access in release builds. - No application-specific API behavior. diff --git a/llms.txt b/llms.txt index 1e71f5c..7e856c2 100644 --- a/llms.txt +++ b/llms.txt @@ -29,4 +29,7 @@ Run `make test-nginx` after changing HTTP, ETag, MIME, encoding, or cache logic. 4. Missing assets are real `404` responses with `Cache-Control: no-store`. 5. SPA fallback occurs only when the request explicitly accepts `text/html`. 6. API routing belongs outside this crate and must be mounted before fallback. -7. The request path performs no compression and no content hashing. +7. The default request path performs no compression and no content hashing. +8. The opt-in `live-assets` development feature refreshes the asset list and + ETag metadata on every request; use it only with filesystem-backed + `rust-embed` debug builds. diff --git a/src/lib.rs b/src/lib.rs index b00381e..694232e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,20 +1,24 @@ //! General-purpose embedded web asset and SPA responses for Axum. //! -//! The asset provider owns the final identity, gzip, and Brotli bytes. -//! This crate negotiates those immutable representations, exposes their -//! compile-time SHA-256 values as strong ETags, and applies a proxy-cache-safe -//! HTTP contract without runtime hashing or compression. +//! The asset provider owns the final identity, gzip, and Brotli bytes. This +//! crate negotiates those representations, exposes their SHA-256 values as +//! strong ETags, and applies a proxy-cache-safe HTTP contract without runtime +//! compression. By default ETags are formatted once during construction. The +//! `live-assets` feature instead resolves the file set and ETag metadata for +//! every request so a filesystem-backed debug provider can change in place. #![forbid(unsafe_code)] use std::{ borrow::Cow, - collections::HashMap, error::Error, fmt::{self, Write}, marker::PhantomData, }; +#[cfg(not(feature = "live-assets"))] +use std::collections::HashMap; + use axum::{ body::{Body, Bytes}, extract::Request, @@ -118,6 +122,7 @@ impl Error for EmbeddedSpaError {} /// A reusable embedded web asset service backed by a [`RustEmbed`] provider. pub struct EmbeddedSpa { config: ValidatedConfig, + #[cfg(not(feature = "live-assets"))] etags: HashMap, assets: PhantomData A>, } @@ -126,7 +131,11 @@ impl EmbeddedSpa where A: RustEmbed, { - /// Validate configuration and precompute response ETag header values. + /// Validate configuration and prepare response ETag handling. + /// + /// The default build precomputes ETag header values. With the + /// `live-assets` feature, ETags and the asset list are resolved from the + /// provider at request time instead. pub fn new(config: EmbeddedSpaConfig) -> Result { if !is_safe_relative_path(&config.index_path) { return Err(EmbeddedSpaError::InvalidIndexPath(config.index_path)); @@ -136,6 +145,7 @@ where return Err(EmbeddedSpaError::MissingIndex(config.index_path)); } + #[cfg(not(feature = "live-assets"))] let etags = A::iter() .filter_map(|path| { let file = A::get(path.as_ref())?; @@ -145,15 +155,24 @@ where Ok(Self { config: ValidatedConfig::try_from(config)?, + #[cfg(not(feature = "live-assets"))] etags, assets: PhantomData, }) } - /// Return the number of embedded files, including compressed siblings. + /// Return the current number of files, including compressed siblings. #[must_use] pub fn asset_count(&self) -> usize { - self.etags.len() + #[cfg(feature = "live-assets")] + { + A::iter().count() + } + + #[cfg(not(feature = "live-assets"))] + { + self.etags.len() + } } /// Convert an Axum request into a static, fallback, or error response. @@ -196,7 +215,7 @@ where return self.status_response(StatusCode::NOT_ACCEPTABLE, None); }; - let Some(etag) = self.etags.get(&selected.embedded_path) else { + let Some(etag) = self.response_etag(&selected) else { return self.status_response(StatusCode::INTERNAL_SERVER_ERROR, None); }; @@ -207,14 +226,14 @@ where } else { &self.config.revalidate_cache_control }; - let not_modified = etag_matches(request_headers, etag); + let not_modified = etag_matches(request_headers, &etag); let mut builder = Response::builder() .status(if not_modified { StatusCode::NOT_MODIFIED } else { StatusCode::OK }) - .header(header::ETAG, etag) + .header(header::ETAG, &etag) .header(header::CACHE_CONTROL, cache_control) .header(X_CONTENT_TYPE_OPTIONS, "nosniff"); @@ -254,6 +273,18 @@ where builder.body(body).expect("static asset response is valid") } + fn response_etag(&self, selected: &SelectedRepresentation) -> Option { + #[cfg(feature = "live-assets")] + { + Some(strong_etag(selected.file.metadata.sha256_hash())) + } + + #[cfg(not(feature = "live-assets"))] + { + self.etags.get(&selected.embedded_path).cloned() + } + } + fn is_immutable_path(&self, path: &str) -> bool { self.config .immutable_prefixes @@ -322,6 +353,7 @@ fn header_value(name: &'static str, value: String) -> Result, diff --git a/tests/live_assets.rs b/tests/live_assets.rs new file mode 100644 index 0000000..287a0f9 --- /dev/null +++ b/tests/live_assets.rs @@ -0,0 +1,116 @@ +#![cfg(feature = "live-assets")] + +use std::{fs, path::PathBuf}; + +use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, +}; +use embedded_spa::{EmbeddedSpa, EmbeddedSpaConfig}; +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "target/live-assets-test/"] +#[allow_missing = true] +struct LiveAssets; + +struct FixtureDirectory { + root: PathBuf, +} + +impl FixtureDirectory { + fn new() -> Self { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/live-assets-test"); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("assets")).expect("live fixture directory must be writable"); + + Self { root } + } + + fn write(&self, path: &str, bytes: &[u8]) { + fs::write(self.root.join(path), bytes).expect("live fixture file must be writable"); + } + + fn remove(&self, path: &str) { + fs::remove_file(self.root.join(path)).expect("live fixture file must be removable"); + } +} + +impl Drop for FixtureDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +fn request(path: &str) -> Request { + Request::builder() + .uri(path) + .body(Body::empty()) + .expect("test request is valid") +} + +#[tokio::test] +async fn one_spa_instance_tracks_disk_content_etags_and_file_membership() { + let fixture = FixtureDirectory::new(); + fixture.write("index.html", b"version one"); + fixture.write("assets/app.js", b"console.log('one')"); + + let spa = EmbeddedSpa::::new(EmbeddedSpaConfig::default()) + .expect("live fixture must contain index.html"); + assert_eq!(spa.asset_count(), 2); + + let first = spa.serve(request("/")); + assert_eq!(first.status(), StatusCode::OK); + let first_etag = first.headers()[header::ETAG].clone(); + assert_eq!( + to_bytes(first.into_body(), usize::MAX).await.unwrap(), + "version one" + ); + + fixture.write("index.html", b"version two"); + let changed = spa.serve( + Request::builder() + .uri("/") + .header(header::IF_NONE_MATCH, &first_etag) + .body(Body::empty()) + .unwrap(), + ); + assert_eq!(changed.status(), StatusCode::OK); + let changed_etag = changed.headers()[header::ETAG].clone(); + assert_ne!(changed_etag, first_etag); + assert_eq!( + to_bytes(changed.into_body(), usize::MAX).await.unwrap(), + "version two" + ); + + let unchanged = spa.serve( + Request::builder() + .uri("/") + .header(header::IF_NONE_MATCH, &changed_etag) + .body(Body::empty()) + .unwrap(), + ); + assert_eq!(unchanged.status(), StatusCode::NOT_MODIFIED); + assert!( + to_bytes(unchanged.into_body(), usize::MAX) + .await + .unwrap() + .is_empty() + ); + + fixture.write("robots.txt", b"User-agent: *\nDisallow:"); + assert_eq!(spa.asset_count(), 3); + let added = spa.serve(request("/robots.txt")); + assert_eq!(added.status(), StatusCode::OK); + assert_eq!( + to_bytes(added.into_body(), usize::MAX).await.unwrap(), + "User-agent: *\nDisallow:" + ); + + fixture.remove("assets/app.js"); + assert_eq!(spa.asset_count(), 2); + assert_eq!( + spa.serve(request("/assets/app.js")).status(), + StatusCode::NOT_FOUND + ); +} From b26ffcb65063a4bdb9b3c32f06ab6b7e23539488 Mon Sep 17 00:00:00 2001 From: Zhang Xiaofeng Date: Mon, 17 Aug 2026 22:07:26 +0800 Subject: [PATCH 2/3] Use live-assets consistently in documentation --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b3ca454..e3b6dac 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ recompiling or restarting the Rust process: ```toml [features] -dev-assets = ["embedded-spa/live-assets"] +live-assets = ["embedded-spa/live-assets"] [dependencies] embedded-spa = { git = "https://github.com/tomcatzh/embedded-spa" } @@ -162,7 +162,7 @@ rust-embed = { version = "8", features = ["deterministic-timestamps"] } Run the unchanged application and handler in development mode: ```bash -cargo run --features dev-assets +cargo run --features live-assets ``` The same long-lived `EmbeddedSpa` now sees added, changed, and removed files on @@ -172,7 +172,7 @@ read for that request, and `asset_count()` reports the current file count. This feature intentionally does not override `rust-embed` itself. Use a debug build and do not enable `rust-embed/debug-embed`; Cargo features are additive, so another dependency enabling `debug-embed` would make the provider embedded -again. Release builds should omit `dev-assets` and use `cargo build --release`. +again. Release builds should omit `live-assets` and use `cargo build --release`. They remain self-contained automatically. If a debug executable must also be self-contained, enable From af36f488ba97de80847cded5f58398a4397abb93 Mon Sep 17 00:00:00 2001 From: Tomcat Date: Mon, 17 Aug 2026 22:34:19 +0800 Subject: [PATCH 3/3] Gate live asset test to debug builds --- tests/live_assets.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/live_assets.rs b/tests/live_assets.rs index 287a0f9..46ba0bd 100644 --- a/tests/live_assets.rs +++ b/tests/live_assets.rs @@ -1,4 +1,4 @@ -#![cfg(feature = "live-assets")] +#![cfg(all(feature = "live-assets", debug_assertions))] use std::{fs, path::PathBuf};