Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ include = [
"/llms.txt",
]

[features]
default = []
live-assets = []

[dependencies]
axum = "0.8.9"
mime_guess = "2.0.5"
Expand Down
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 39 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -417,10 +439,11 @@ window, or store immutable chunks in versioned object storage/CDN.

- `EmbeddedSpa<A>`: 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.

Expand All @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
54 changes: 43 additions & 11 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -118,6 +122,7 @@ impl Error for EmbeddedSpaError {}
/// A reusable embedded web asset service backed by a [`RustEmbed`] provider.
pub struct EmbeddedSpa<A> {
config: ValidatedConfig,
#[cfg(not(feature = "live-assets"))]
etags: HashMap<String, HeaderValue>,
assets: PhantomData<fn() -> A>,
}
Expand All @@ -126,7 +131,11 @@ impl<A> EmbeddedSpa<A>
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<Self, EmbeddedSpaError> {
if !is_safe_relative_path(&config.index_path) {
return Err(EmbeddedSpaError::InvalidIndexPath(config.index_path));
Expand All @@ -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())?;
Expand All @@ -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.
Expand Down Expand Up @@ -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);
};

Expand All @@ -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");

Expand Down Expand Up @@ -254,6 +273,18 @@ where
builder.body(body).expect("static asset response is valid")
}

fn response_etag(&self, selected: &SelectedRepresentation) -> Option<HeaderValue> {
#[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
Expand Down Expand Up @@ -322,6 +353,7 @@ fn header_value(name: &'static str, value: String) -> Result<HeaderValue, Embedd
}

struct SelectedRepresentation {
#[cfg_attr(feature = "live-assets", allow(dead_code))]
embedded_path: String,
file: EmbeddedFile,
encoding: Option<&'static str>,
Expand Down
116 changes: 116 additions & 0 deletions tests/live_assets.rs
Original file line number Diff line number Diff line change
@@ -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<Body> {
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::<LiveAssets>::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
);
}