diff --git a/Cargo.lock b/Cargo.lock
index d75649aa2..b62ab7465 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -153,6 +153,21 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd"
+[[package]]
+name = "alloc-no-stdlib"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
+
+[[package]]
+name = "alloc-stdlib"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
+dependencies = [
+ "alloc-no-stdlib",
+]
+
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -315,6 +330,18 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "async-compression"
+version = "0.4.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
+dependencies = [
+ "compression-codecs",
+ "compression-core",
+ "pin-project-lite",
+ "tokio",
+]
+
[[package]]
name = "async-lock"
version = "3.4.2"
@@ -1070,6 +1097,27 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
+[[package]]
+name = "brotli"
+version = "8.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9991eea70ea4f293524138648e41ee89b0b2b12ddef3b255effa43c8056e0e0d"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+ "brotli-decompressor",
+]
+
+[[package]]
+name = "brotli-decompressor"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+]
+
[[package]]
name = "bs58"
version = "0.5.1"
@@ -1294,6 +1342,24 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "compression-codecs"
+version = "0.4.38"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
+dependencies = [
+ "brotli",
+ "compression-core",
+ "flate2",
+ "memchr",
+]
+
+[[package]]
+name = "compression-core"
+version = "0.4.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
+
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -3382,6 +3448,16 @@ dependencies = [
"rustc_version",
]
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
[[package]]
name = "float-cmp"
version = "0.10.0"
@@ -5104,6 +5180,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
+ "simd-adler32",
]
[[package]]
@@ -7301,6 +7378,12 @@ dependencies = [
"rand_core 0.6.4",
]
+[[package]]
+name = "simd-adler32"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
+
[[package]]
name = "similar"
version = "2.7.0"
@@ -8238,13 +8321,17 @@ version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
+ "async-compression",
"bitflags",
"bytes",
+ "futures-core",
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
"iri-string",
"pin-project-lite",
+ "tokio",
+ "tokio-util",
"tower",
"tower-layer",
"tower-service",
diff --git a/apps/fabro-web/scripts/build.ts b/apps/fabro-web/scripts/build.ts
index 2e7c967fb..78e520d29 100644
--- a/apps/fabro-web/scripts/build.ts
+++ b/apps/fabro-web/scripts/build.ts
@@ -79,7 +79,10 @@ async function buildOnce() {
await copyPierreWorkerAssets(join(buildAssetsDir, "pierre-diffs-worker"));
await writeIndexHtml(
buildDir,
- result.outputs.map((output: any) => relative(buildDir, output.path)),
+ result.outputs.map((output: any) => ({
+ kind: output.kind,
+ path: relative(buildDir, output.path),
+ })),
);
await publishBuild(buildDir);
@@ -100,15 +103,30 @@ async function copyPierreWorkerAssets(targetDir: string) {
}
}
-async function writeIndexHtml(buildDir: string, outputs: string[]) {
+// `kind` mirrors Bun's `BuildArtifact.kind`; the union keeps the
+// "entry-point" comparison below typo-safe.
+type IndexHtmlOutput = {
+ kind: "entry-point" | "chunk" | "asset" | "sourcemap" | "bytecode";
+ path: string;
+};
+
+async function writeIndexHtml(buildDir: string, outputs: IndexHtmlOutput[]) {
const template = await readFile(templatePath, "utf8");
+ // Only entry points get `)
+ .filter((output) => output.kind === "entry-point" && output.path.endsWith(".js"))
+ .map((output) => ``)
.join("\n ");
const styles = [
"/assets/app.css",
- ...outputs.filter((path) => path.endsWith(".css")).map((path) => `/${path.replaceAll("\\\\", "/")}`),
+ ...outputs
+ .filter((output) => output.path.endsWith(".css"))
+ .map((output) => `/${output.path.replaceAll("\\\\", "/")}`),
]
.filter((value, index, array) => array.indexOf(value) === index)
.map((path) => ``)
diff --git a/lib/crates/fabro-server/Cargo.toml b/lib/crates/fabro-server/Cargo.toml
index 8627bebb9..3d54fb216 100644
--- a/lib/crates/fabro-server/Cargo.toml
+++ b/lib/crates/fabro-server/Cargo.toml
@@ -62,7 +62,7 @@ cookie.workspace = true
dirs.workspace = true
globset.workspace = true
tower = "0.5"
-tower-http = { version = "0.6", features = ["trace"] }
+tower-http = { version = "0.6", features = ["trace", "compression-br", "compression-gzip"] }
tokio-stream = { workspace = true, features = ["sync"] }
tokio-util.workspace = true
base64.workspace = true
diff --git a/lib/crates/fabro-server/src/install.rs b/lib/crates/fabro-server/src/install.rs
index a24fd5b3b..7afb7a8b2 100644
--- a/lib/crates/fabro-server/src/install.rs
+++ b/lib/crates/fabro-server/src/install.rs
@@ -52,7 +52,7 @@ use zeroize::Zeroizing;
use crate::error::ApiError;
use crate::serve::{self, DEFAULT_TCP_PORT};
use crate::server_secrets::{ServerSecrets, process_env_snapshot};
-use crate::{security_headers, static_files};
+use crate::{security_headers, server, static_files};
#[derive(Clone)]
pub struct InstallAppState {
@@ -667,6 +667,10 @@ pub fn build_install_router(state: InstallAppState) -> Router {
}
}
}))
+ // Install mode serves the same multi-megabyte SPA bundle as the main
+ // router; a first-run setup over a slow link needs compression just
+ // as much.
+ .layer(server::compression_layer())
.layer(middleware::from_fn(security_headers::layer))
}
diff --git a/lib/crates/fabro-server/src/security_headers.rs b/lib/crates/fabro-server/src/security_headers.rs
index 9963fa43d..4ca1e6a9e 100644
--- a/lib/crates/fabro-server/src/security_headers.rs
+++ b/lib/crates/fabro-server/src/security_headers.rs
@@ -72,9 +72,17 @@ fn apply_defaults(headers: &mut HeaderMap, is_https: bool) {
// Conservative cache defaults. Routes that deliberately want to cache
// (hashed static assets, public GETs) set their own Cache-Control before
- // this middleware runs, which prevents the default from being applied.
- set_default(headers, header::CACHE_CONTROL, "no-store");
- set_default(headers, header::PRAGMA, "no-cache");
+ // this middleware runs. When they have, we must not also stamp the no-cache
+ // pair: `Pragma: no-cache` next to a long-lived `Cache-Control: immutable`
+ // is contradictory, and browsers resolve it by revalidating on every load.
+ // Since these assets carry no ETag/Last-Modified, that revalidation
+ // degrades into a full re-download each time. Apply the no-store/no-cache
+ // defaults only to responses that haven't opted into caching; a present
+ // Cache-Control is the signal that the handler chose its own policy.
+ if !headers.contains_key(header::CACHE_CONTROL) {
+ headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
+ headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
+ }
set_default(headers, header::VARY, "Accept-Encoding");
// HSTS is a no-op over plain HTTP per RFC 6797, but only emit it on
@@ -202,7 +210,10 @@ mod tests {
#[test]
fn existing_cache_control_is_not_overridden() {
// Static assets set their own cache-control with long immutability.
- // The middleware default must not clobber it.
+ // The middleware default must not clobber it, and must not stamp a
+ // contradictory `Pragma: no-cache` alongside it — that combination
+ // forces browsers to revalidate (and, absent validators, re-download)
+ // supposedly-immutable assets on every load.
let headers = headers_after(&req("/assets/app-abc.js", &[]), &[(
"cache-control",
"public, max-age=31536000, immutable",
@@ -211,6 +222,10 @@ mod tests {
headers.get("cache-control").unwrap(),
"public, max-age=31536000, immutable"
);
+ assert!(
+ !headers.contains_key("pragma"),
+ "cacheable responses must not carry Pragma: no-cache"
+ );
}
#[test]
diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs
index 254dc9658..c246e396d 100644
--- a/lib/crates/fabro-server/src/server.rs
+++ b/lib/crates/fabro-server/src/server.rs
@@ -137,6 +137,7 @@ use tokio_stream::StreamExt;
use tokio_stream::wrappers::{BroadcastStream, UnboundedReceiverStream};
use tokio_util::sync::CancellationToken;
use tower::{ServiceExt, service_fn};
+use tower_http::compression::{CompressionLayer, CompressionLevel};
use tracing::{Instrument, debug, error, info, warn};
use ulid::Ulid;
@@ -1852,6 +1853,10 @@ pub fn build_router_with_options(
}
router
+ // Innermost of the outer layers so every response body — static SPA
+ // assets and JSON API alike — is compressed before the header/log
+ // middlewares see it.
+ .layer(compression_layer())
.layer(middleware::from_fn_with_state(
canonical_host::Config {
state: state_for_canonical_host,
@@ -1864,6 +1869,17 @@ pub fn build_router_with_options(
.layer(middleware::from_fn(request_id::layer))
}
+/// Response-compression layer shared by the main and install-mode routers.
+///
+/// The default predicate skips streaming SSE (`text/event-stream`), gRPC,
+/// images, and tiny bodies. The quality is pinned because tower-http's
+/// default defers to each codec's own default, and brotli's is quality 11 —
+/// seconds of CPU on a multi-megabyte asset. Level 4 keeps both codecs fast
+/// at a near-optimal ratio.
+pub(crate) fn compression_layer() -> CompressionLayer {
+ CompressionLayer::new().quality(CompressionLevel::Precise(4))
+}
+
async fn http_log_middleware(mut req: axum_extract::Request, next: Next) -> Response {
let path = req.uri().path();
if path.starts_with("/assets/") || path.starts_with("/images/") {
diff --git a/lib/crates/fabro-server/src/static_files.rs b/lib/crates/fabro-server/src/static_files.rs
index 590a97f82..cb5c500a2 100644
--- a/lib/crates/fabro-server/src/static_files.rs
+++ b/lib/crates/fabro-server/src/static_files.rs
@@ -1,10 +1,12 @@
+use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
-use axum::body::Body;
+use axum::body::{Body, Bytes};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use fabro_static::EnvVars;
+use sha2::{Digest, Sha256};
use tokio::fs;
use crate::csp;
@@ -100,9 +102,8 @@ async fn load_injected_install_shell(
asset_root: Option<&Path>,
dev_disk_only: bool,
) -> Option> {
- Some(inject_install_mode(
- load_asset("index.html", asset_root, dev_disk_only).await?,
- ))
+ let shell = load_asset("index.html", asset_root, dev_disk_only).await?;
+ Some(inject_install_mode(shell.bytes.into()))
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -125,7 +126,7 @@ async fn serve_with_mode(
}
if let Some(asset) = load_asset_for_mode(&normalized, mode, asset_root, dev_disk_only).await {
- return asset_response(&normalized, asset);
+ return asset_response(&normalized, asset, headers);
}
// SPA fallback: serve index.html only for browser navigations that
@@ -136,7 +137,7 @@ async fn serve_with_mode(
if let Some(index) =
load_asset_for_mode("index.html", mode, asset_root, dev_disk_only).await
{
- return asset_response("index.html", index);
+ return asset_response("index.html", index, headers);
}
if dev_disk_only {
return build_in_progress_response();
@@ -187,7 +188,39 @@ fn normalize(path: &str) -> String {
}
}
-async fn load_asset(path: &str, asset_root: Option<&Path>, dev_disk_only: bool) -> Option> {
+/// An asset body plus, when the source precomputed it (the embedded SPA
+/// snapshot), its SHA-256. Carrying the hash lets mutable-asset ETags reuse
+/// rust-embed's compile-time digest instead of rehashing process-lifetime
+/// bytes on every revalidation.
+struct Asset {
+ bytes: Bytes,
+ sha256: Option<[u8; 32]>,
+}
+
+impl Asset {
+ fn from_vec(bytes: Vec) -> Self {
+ Self {
+ bytes: bytes.into(),
+ sha256: None,
+ }
+ }
+
+ fn from_embedded(asset: fabro_spa::AssetBytes) -> Self {
+ let sha256 = asset.sha256();
+ let bytes = match asset.into_cow() {
+ // Release builds embed assets as statics; serve them without
+ // copying the (potentially multi-megabyte) body per request.
+ Cow::Borrowed(bytes) => Bytes::from_static(bytes),
+ Cow::Owned(bytes) => Bytes::from(bytes),
+ };
+ Self {
+ bytes,
+ sha256: Some(sha256),
+ }
+ }
+}
+
+async fn load_asset(path: &str, asset_root: Option<&Path>, dev_disk_only: bool) -> Option {
if spa_assets_disabled_for_test() {
return None;
}
@@ -196,11 +229,11 @@ async fn load_asset(path: &str, asset_root: Option<&Path>, dev_disk_only: bool)
// workspace's live `dist/` fallback or test isolation breaks.
if let Some(root) = asset_root {
if let Some(bytes) = read_disk_asset_from_root(root, path).await {
- return Some(bytes);
+ return Some(Asset::from_vec(bytes));
}
} else if cfg!(debug_assertions) {
if let Some(bytes) = read_disk_asset(path).await {
- return Some(bytes);
+ return Some(Asset::from_vec(bytes));
}
}
@@ -211,7 +244,7 @@ async fn load_asset(path: &str, asset_root: Option<&Path>, dev_disk_only: bool)
return None;
}
- fabro_spa::get(path).map(fabro_spa::AssetBytes::into_vec)
+ fabro_spa::get(path).map(Asset::from_embedded)
}
async fn load_asset_for_mode(
@@ -219,9 +252,11 @@ async fn load_asset_for_mode(
mode: SpaMode,
asset_root: Option<&Path>,
dev_disk_only: bool,
-) -> Option> {
+) -> Option {
if mode == SpaMode::Install && path == "index.html" {
- return cached_install_mode_shell(asset_root, dev_disk_only).await;
+ return cached_install_mode_shell(asset_root, dev_disk_only)
+ .await
+ .map(Asset::from_vec);
}
load_asset(path, asset_root, dev_disk_only).await
}
@@ -276,43 +311,113 @@ fn disk_asset_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../apps/fabro-web/dist")
}
-fn asset_response(path: &str, bytes: Vec) -> Response {
+const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
+const REVALIDATE_CACHE_CONTROL: &str = "no-cache";
+
+fn asset_response(path: &str, asset: Asset, request_headers: &HeaderMap) -> Response {
+ let content_hashed = is_content_hashed(path);
+ let cache_control = cache_control(content_hashed);
+ // Mutable assets keep stable names across deploys, so their `no-cache`
+ // policy needs a validator to revalidate as a cheap 304 instead of a full
+ // body download on every use. Hashed immutable assets never revalidate,
+ // so an ETag would be dead weight.
+ let etag = (!content_hashed).then(|| asset_etag(&asset));
+
+ if let Some(etag) = &etag {
+ if if_none_match_matches(request_headers, etag) {
+ let mut response = Response::new(Body::empty());
+ *response.status_mut() = StatusCode::NOT_MODIFIED;
+ apply_cache_headers(response.headers_mut(), cache_control, Some(etag));
+ return response;
+ }
+ }
+
let mime = mime_guess::from_path(path).first_or_octet_stream();
- let mut response = Response::new(Body::from(bytes));
+ let mut response = Response::new(Body::from(asset.bytes));
*response.status_mut() = StatusCode::OK;
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_str(mime.as_ref())
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
);
- response.headers_mut().insert(
- header::CACHE_CONTROL,
- HeaderValue::from_static(cache_control(path)),
- );
+ apply_cache_headers(response.headers_mut(), cache_control, etag.as_deref());
response
}
-fn cache_control(path: &str) -> &'static str {
- if path.contains("/assets/") || path.contains('-') && has_hashed_extension(path) {
- "public, max-age=31536000, immutable"
- } else {
- "no-cache"
+fn apply_cache_headers(headers: &mut HeaderMap, cache_control: &'static str, etag: Option<&str>) {
+ headers.insert(
+ header::CACHE_CONTROL,
+ HeaderValue::from_static(cache_control),
+ );
+ if let Some(etag) = etag {
+ if let Ok(value) = HeaderValue::from_str(etag) {
+ headers.insert(header::ETAG, value);
+ }
}
}
-fn has_hashed_extension(path: &str) -> bool {
- Path::new(path)
- .file_name()
- .and_then(|name| name.to_str())
- .is_some_and(|name| {
- let mut parts = name.split('.');
- let Some(stem) = parts.next() else {
- return false;
- };
- stem.split('-').count() > 1
+fn asset_etag(asset: &Asset) -> String {
+ let digest = asset
+ .sha256
+ .unwrap_or_else(|| Sha256::digest(&asset.bytes).into());
+ format!("\"{}\"", hex::encode(digest))
+}
+
+fn if_none_match_matches(headers: &HeaderMap, etag: &str) -> bool {
+ headers
+ .get(header::IF_NONE_MATCH)
+ .and_then(|value| value.to_str().ok())
+ .is_some_and(|value| {
+ value.split(',').map(str::trim).any(|candidate| {
+ candidate == "*" || candidate.strip_prefix("W/").unwrap_or(candidate) == etag
+ })
})
}
+fn cache_control(content_hashed: bool) -> &'static str {
+ if content_hashed {
+ IMMUTABLE_CACHE_CONTROL
+ } else {
+ REVALIDATE_CACHE_CONTROL
+ }
+}
+
+/// True only for the bundler's content-hashed outputs: files directly under
+/// `assets/` named `-.js|css` with an 8-char lowercase base-36
+/// hash (e.g. `assets/entry-0sv53bs3.js`). Only those names change whenever
+/// their bytes change, which is what makes a year-long `immutable` policy
+/// safe.
+///
+/// Stable-named files must NOT match — `index.html`, `assets/app.css`, the
+/// pierre-diffs worker under `assets/pierre-diffs-worker/`, images — because
+/// caching those immutably pins stale copies in browsers across deploys.
+/// When in doubt this classifier says "not hashed": the cost of a false
+/// negative is one 304 revalidation, the cost of a false positive is a
+/// wrongly-pinned asset for up to a year.
+fn is_content_hashed(path: &str) -> bool {
+ let Some(file_name) = path.trim_start_matches('/').strip_prefix("assets/") else {
+ return false;
+ };
+ if file_name.contains('/') {
+ // Subdirectories under assets/ (the pierre-diffs worker) hold
+ // stable-named files copied verbatim from their package.
+ return false;
+ }
+ let Some((stem, extension)) = file_name.rsplit_once('.') else {
+ return false;
+ };
+ if !matches!(extension, "js" | "css") {
+ return false;
+ }
+ let Some((_, hash)) = stem.rsplit_once('-') else {
+ return false;
+ };
+ hash.len() == 8
+ && hash
+ .bytes()
+ .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
+}
+
fn is_source_map(path: &str) -> bool {
Path::new(path)
.extension()
@@ -328,8 +433,8 @@ mod tests {
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use super::{
- accepts_html, cache_control, inject_install_mode, is_source_map, read_disk_asset_from_root,
- serve_with_asset_root,
+ accepts_html, cache_control, inject_install_mode, is_content_hashed, is_source_map,
+ read_disk_asset_from_root, serve_with_asset_root,
};
fn headers_with_accept(value: &str) -> HeaderMap {
@@ -364,11 +469,126 @@ mod tests {
#[test]
fn hashed_assets_are_cached_immutably() {
+ for path in [
+ "assets/entry-0sv53bs3.js",
+ "assets/chunk-4tr91ktd.js",
+ "assets/chunk-x912wb67.css",
+ ] {
+ assert!(is_content_hashed(path), "{path} should be content-hashed");
+ }
+ assert_eq!(cache_control(true), "public, max-age=31536000, immutable");
+ }
+
+ #[test]
+ fn stable_named_assets_must_revalidate() {
+ // Files whose names do NOT change when their bytes change would be
+ // pinned stale in browsers for a year if marked immutable.
+ for path in [
+ "index.html",
+ "assets/app.css",
+ "assets/pierre-diffs-worker/worker-portable.js",
+ "images/apple-touch-icon.png",
+ // Dash segment that isn't an 8-char lowercase base-36 hash.
+ "assets/entry-abc123.js",
+ // Right hash shape, but not a bundler output extension.
+ "assets/photo-a1b2c3d4.png",
+ ] {
+ assert!(
+ !is_content_hashed(path),
+ "{path} should not be content-hashed"
+ );
+ }
+ assert_eq!(cache_control(false), "no-cache");
+ }
+
+ #[tokio::test]
+ async fn mutable_assets_serve_etag_and_conditional_304() {
+ let temp_dir = tempfile::tempdir().unwrap();
+ let asset_path = temp_dir.path().join("assets/app.css");
+ std::fs::create_dir_all(asset_path.parent().unwrap()).unwrap();
+ std::fs::write(&asset_path, b"body { color: red }").unwrap();
+
+ let first = serve_with_asset_root(
+ "/assets/app.css",
+ &HeaderMap::new(),
+ Some(temp_dir.path()),
+ false,
+ )
+ .await;
+ assert_eq!(first.status(), StatusCode::OK);
assert_eq!(
- cache_control("assets/entry-abc123.js"),
- "public, max-age=31536000, immutable"
+ first.headers().get(header::CACHE_CONTROL).unwrap(),
+ "no-cache"
+ );
+ let etag = first
+ .headers()
+ .get(header::ETAG)
+ .expect("mutable assets should carry an ETag validator")
+ .clone();
+
+ let mut conditional = HeaderMap::new();
+ conditional.insert(header::IF_NONE_MATCH, etag.clone());
+ let second = serve_with_asset_root(
+ "/assets/app.css",
+ &conditional,
+ Some(temp_dir.path()),
+ false,
+ )
+ .await;
+ assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
+ assert_eq!(second.headers().get(header::ETAG).unwrap(), &etag);
+ assert_eq!(
+ second.headers().get(header::CACHE_CONTROL).unwrap(),
+ "no-cache"
+ );
+ let bytes = axum::body::to_bytes(second.into_body(), usize::MAX)
+ .await
+ .unwrap();
+ assert!(bytes.is_empty(), "304 must not carry a body");
+ }
+
+ #[tokio::test]
+ async fn stale_if_none_match_gets_full_response() {
+ let temp_dir = tempfile::tempdir().unwrap();
+ let asset_path = temp_dir.path().join("assets/app.css");
+ std::fs::create_dir_all(asset_path.parent().unwrap()).unwrap();
+ std::fs::write(&asset_path, b"body { color: red }").unwrap();
+
+ let mut conditional = HeaderMap::new();
+ conditional.insert(
+ header::IF_NONE_MATCH,
+ HeaderValue::from_static("\"0000stale0000\""),
+ );
+ let response = serve_with_asset_root(
+ "/assets/app.css",
+ &conditional,
+ Some(temp_dir.path()),
+ false,
+ )
+ .await;
+ assert_eq!(response.status(), StatusCode::OK);
+ assert!(response.headers().contains_key(header::ETAG));
+ }
+
+ #[tokio::test]
+ async fn immutable_assets_skip_etag() {
+ let temp_dir = tempfile::tempdir().unwrap();
+ let asset_path = temp_dir.path().join("assets/entry-0sv53bs3.js");
+ std::fs::create_dir_all(asset_path.parent().unwrap()).unwrap();
+ std::fs::write(&asset_path, b"console.log(1)").unwrap();
+
+ let response = serve_with_asset_root(
+ "/assets/entry-0sv53bs3.js",
+ &HeaderMap::new(),
+ Some(temp_dir.path()),
+ false,
+ )
+ .await;
+ assert_eq!(response.status(), StatusCode::OK);
+ assert!(
+ !response.headers().contains_key(header::ETAG),
+ "immutable assets never revalidate, so a validator is dead weight"
);
- assert_eq!(cache_control("index.html"), "no-cache");
}
#[tokio::test]
diff --git a/lib/crates/fabro-server/tests/it/api/compression.rs b/lib/crates/fabro-server/tests/it/api/compression.rs
new file mode 100644
index 000000000..fe3872840
--- /dev/null
+++ b/lib/crates/fabro-server/tests/it/api/compression.rs
@@ -0,0 +1,202 @@
+//! Response compression on the outer router.
+//!
+//! The compression layer sits at the outermost edge of `build_router`, so
+//! these tests exercise it through the full middleware stack rather than in
+//! isolation. `/api/v1/openapi.json` is used as the probe response: it is a
+//! multi-hundred-KB JSON body, comfortably above the compression size floor.
+
+#![expect(
+ clippy::disallowed_methods,
+ reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
+)]
+
+use std::net::SocketAddr;
+
+use axum::Router;
+use axum::body::Body;
+use axum::http::{Request, StatusCode, header};
+use fabro_server::server::RouterOptions;
+use tempfile::TempDir;
+use tower::ServiceExt;
+
+use crate::helpers::{api, test_app_state};
+
+/// Router serving an SPA shell comfortably above the compression size floor,
+/// through the same fallback service production uses for static assets.
+fn spa_router_with_big_index() -> (Router, TempDir) {
+ let temp_dir = tempfile::tempdir().expect("SPA fixture tempdir should create");
+ std::fs::write(
+ temp_dir.path().join("index.html"),
+ format!("spa{}", "x".repeat(8192)),
+ )
+ .expect("SPA fixture index.html should write");
+ let app = fabro_server::test_support::build_test_router_with_options(
+ test_app_state(),
+ RouterOptions {
+ web_enabled: true,
+ static_asset_root: Some(temp_dir.path().to_path_buf()),
+ ..RouterOptions::default()
+ },
+ );
+ (app, temp_dir)
+}
+
+async fn serve_on_ephemeral_port(app: Router) -> SocketAddr {
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("test TCP listener should bind");
+ let addr = listener
+ .local_addr()
+ .expect("test TCP listener should have a local address");
+ tokio::spawn(async move {
+ let _ = axum::serve(listener, app).await;
+ });
+ addr
+}
+
+fn openapi_request(accept_encoding: Option<&str>) -> Request {
+ let mut builder = Request::builder().method("GET").uri(api("/openapi.json"));
+ if let Some(encoding) = accept_encoding {
+ builder = builder.header(header::ACCEPT_ENCODING, encoding);
+ }
+ builder
+ .body(Body::empty())
+ .expect("openapi request should build")
+}
+
+#[tokio::test]
+async fn responses_are_gzip_compressed_when_client_accepts_gzip() {
+ let app = fabro_server::test_support::build_test_router(test_app_state());
+ let response = app.oneshot(openapi_request(Some("gzip"))).await.unwrap();
+
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(
+ response
+ .headers()
+ .get(header::CONTENT_ENCODING)
+ .and_then(|v| v.to_str().ok()),
+ Some("gzip"),
+ "large JSON responses should be gzip-compressed when the client asks"
+ );
+}
+
+#[tokio::test]
+async fn responses_are_brotli_compressed_when_client_prefers_br() {
+ let app = fabro_server::test_support::build_test_router(test_app_state());
+ let response = app
+ .oneshot(openapi_request(Some("gzip, br")))
+ .await
+ .unwrap();
+
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(
+ response
+ .headers()
+ .get(header::CONTENT_ENCODING)
+ .and_then(|v| v.to_str().ok()),
+ Some("br"),
+ "brotli should win encoding negotiation when offered"
+ );
+}
+
+#[tokio::test]
+async fn spa_assets_are_compressed() {
+ // SPA assets are served by the router's fallback service, not a regular
+ // route — this test pins that compression covers that path too, since the
+ // multi-megabyte JS bundle is the single largest thing the server sends.
+ let (app, _temp_dir) = spa_router_with_big_index();
+ let request = Request::builder()
+ .method("GET")
+ .uri("/")
+ .header(header::ACCEPT, "text/html")
+ .header(header::ACCEPT_ENCODING, "gzip")
+ .body(Body::empty())
+ .expect("spa request should build");
+ let response = app.oneshot(request).await.unwrap();
+
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(
+ response
+ .headers()
+ .get(header::CONTENT_ENCODING)
+ .and_then(|v| v.to_str().ok()),
+ Some("gzip"),
+ "SPA shell served through the fallback must be compressed"
+ );
+}
+
+#[tokio::test]
+async fn compression_applies_over_a_real_tcp_connection() {
+ // `oneshot` exercises the tower stack directly; this pins the same
+ // behavior through hyper's real connection handling, matching how the
+ // production server actually serves (`axum::serve`).
+ let app = fabro_server::test_support::build_test_router(test_app_state());
+ let addr = serve_on_ephemeral_port(app).await;
+
+ let response = fabro_test::test_http_client()
+ .get(format!("http://{addr}/api/v1/openapi.json"))
+ // Setting the header manually also disables reqwest's transparent
+ // decompression, so Content-Encoding stays visible on the response.
+ .header(header::ACCEPT_ENCODING.as_str(), "gzip")
+ .send()
+ .await
+ .expect("openapi request should succeed");
+
+ assert_eq!(response.status(), fabro_http::StatusCode::OK);
+ assert_eq!(
+ response
+ .headers()
+ .get(header::CONTENT_ENCODING.as_str())
+ .and_then(|v| v.to_str().ok()),
+ Some("gzip"),
+ "compression must survive real hyper serving, not just oneshot"
+ );
+}
+
+#[tokio::test]
+async fn spa_assets_compress_over_a_real_tcp_connection() {
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
+
+ let (app, _temp_dir) = spa_router_with_big_index();
+ let addr = serve_on_ephemeral_port(app).await;
+
+ // Raw HTTP/1.1 over the socket: no client-side redirect following or
+ // transparent decompression can distort what the server actually sent.
+ // Host matches the test state's canonical origin so the canonical-host
+ // redirect stays out of the way.
+ let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
+ stream
+ .write_all(
+ b"GET / HTTP/1.1\r\nHost: localhost:3000\r\nAccept: text/html\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n",
+ )
+ .await
+ .unwrap();
+ let mut raw = Vec::new();
+ stream.read_to_end(&mut raw).await.unwrap();
+ let head_len = raw
+ .windows(4)
+ .position(|w| w == b"\r\n\r\n")
+ .expect("response should have a header block");
+ let head = String::from_utf8_lossy(&raw[..head_len]).to_lowercase();
+
+ assert!(
+ head.starts_with("http/1.1 200"),
+ "unexpected response: {head}"
+ );
+ assert!(
+ head.contains("content-encoding: gzip"),
+ "fallback-served SPA shell must compress over real TCP; got:\n{head}"
+ );
+}
+
+#[tokio::test]
+async fn responses_stay_identity_encoded_without_accept_encoding() {
+ let app = fabro_server::test_support::build_test_router(test_app_state());
+ let response = app.oneshot(openapi_request(None)).await.unwrap();
+
+ assert_eq!(response.status(), StatusCode::OK);
+ assert!(
+ !response.headers().contains_key(header::CONTENT_ENCODING),
+ "clients that don't advertise Accept-Encoding must get identity bodies"
+ );
+}
diff --git a/lib/crates/fabro-server/tests/it/api/mod.rs b/lib/crates/fabro-server/tests/it/api/mod.rs
index 9fe4bcbc0..7fcdf254a 100644
--- a/lib/crates/fabro-server/tests/it/api/mod.rs
+++ b/lib/crates/fabro-server/tests/it/api/mod.rs
@@ -1,6 +1,7 @@
mod auth_sessions;
mod automations;
mod cli_auth_token;
+mod compression;
mod docs;
mod environments;
mod events;
diff --git a/lib/crates/fabro-spa/src/lib.rs b/lib/crates/fabro-spa/src/lib.rs
index 66defab64..dd050d0c9 100644
--- a/lib/crates/fabro-spa/src/lib.rs
+++ b/lib/crates/fabro-spa/src/lib.rs
@@ -8,24 +8,43 @@ use rust_embed::RustEmbed;
#[exclude = "**/*.map"]
struct EmbeddedAssets;
-pub struct AssetBytes(Cow<'static, [u8]>);
+pub struct AssetBytes {
+ data: Cow<'static, [u8]>,
+ sha256: [u8; 32],
+}
impl AssetBytes {
#[must_use]
pub fn into_vec(self) -> Vec {
- self.0.into_owned()
+ self.data.into_owned()
+ }
+
+ #[must_use]
+ pub fn into_cow(self) -> Cow<'static, [u8]> {
+ self.data
+ }
+
+ /// SHA-256 of the asset bytes. rust-embed computes it at compile time in
+ /// release builds, so callers can use it as a validator without rehashing
+ /// the body per request.
+ #[must_use]
+ pub fn sha256(&self) -> [u8; 32] {
+ self.sha256
}
}
impl AsRef<[u8]> for AssetBytes {
fn as_ref(&self) -> &[u8] {
- self.0.as_ref()
+ self.data.as_ref()
}
}
#[must_use]
pub fn get(path: &str) -> Option {
- EmbeddedAssets::get(path).map(|file| AssetBytes(file.data))
+ EmbeddedAssets::get(path).map(|file| AssetBytes {
+ sha256: file.metadata.sha256_hash(),
+ data: file.data,
+ })
}
#[cfg(test)]