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..e3b6dac 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]
+live-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 live-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 `live-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..46ba0bd
--- /dev/null
+++ b/tests/live_assets.rs
@@ -0,0 +1,116 @@
+#![cfg(all(feature = "live-assets", debug_assertions))]
+
+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
+ );
+}