mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-21 00:21:27 +00:00
A tab left open across a deploy keeps running the previous build's JavaScript indefinitely. index.html is fetched only on a full page load, all later navigation is client-side, and hashed bundles are served `immutable`, so nothing reveals that the code is stale. This produced a false-positive bug report where two correctly-deployed fixes appeared to be missing. Publishes a build id and offers a reload when the running document falls behind. The toast never reloads on its own; the only automatic reload is recovery from a chunk that no longer exists. Build id derivation ------------------- The obvious approach — hash the emitted asset filenames, which already embed content hashes — does not work: Bun's minified identifier naming is not deterministic. Building an unchanged tree twice produces byte-different output roughly one run in three (same length, ~100k differing bytes, all of it mangled names). Output hashes therefore move with no source change, which would fire the toast on redeploys of identical code and train people to ignore it. The id is instead derived from the bundle's source inputs, so it changes if and only if something we control changed. Verified stable across eight consecutive builds while the entry hash flipped between both variants. This non-determinism also means two builds of the same commit embed different bytes into the server binary, which is worth addressing separately for reproducible builds. Detection --------- SWR with `refreshInterval` + `revalidateOnFocus`, per the repo's React effects policy. SWR does not poll while the document is hidden, so background tabs stay quiet without extra gating. Unknown state on either side — missing meta tag, failed fetch, 503 during a dev rebuild — never produces a prompt. Stylesheet hashing ------------------ Tailwind's output was stable-named and therefore served `no-cache`, letting a tab revalidate into new CSS while running old JS. Tailwind purges unused classes per build, so classes the old bundle still emits could silently lose their styles. It is now content-hashed and moves with the build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
696 lines
23 KiB
Rust
696 lines
23 KiB
Rust
use std::borrow::Cow;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::OnceLock;
|
|
|
|
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;
|
|
|
|
const INSTALL_MODE_MARKER: &str = "__FABRO_MODE__ = \"install\"";
|
|
|
|
// Tiny shell shown in `--watch-web` mode when the requested asset isn't on disk
|
|
// yet — typically the brief window between the build watcher's `rm -rf` and
|
|
// the moment the new index.html is renamed into place. The auto-refresh keeps
|
|
// the page honest without forcing the developer to mash F5.
|
|
const BUILD_IN_PROGRESS_HTML: &str = r#"<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta http-equiv="refresh" content="2">
|
|
<title>Build in progress</title>
|
|
<style>
|
|
body { font-family: -apple-system, system-ui, sans-serif; padding: 2rem; color: #444; }
|
|
code { background: #f3f3f3; padding: 0.1rem 0.3rem; border-radius: 3px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Build in progress</h1>
|
|
<p>The web bundle is being rebuilt. This page will refresh automatically.</p>
|
|
<p>If you keep seeing this, check the <code>bun run dev</code> output for errors.</p>
|
|
</body>
|
|
</html>
|
|
"#;
|
|
|
|
pub async fn serve(path: &str, headers: &HeaderMap) -> Response {
|
|
serve_with_asset_root(path, headers, None, false).await
|
|
}
|
|
|
|
pub async fn serve_install(path: &str, headers: &HeaderMap) -> Response {
|
|
serve_install_with_asset_root(path, headers, None, false).await
|
|
}
|
|
|
|
pub async fn serve_with_asset_root(
|
|
path: &str,
|
|
headers: &HeaderMap,
|
|
asset_root: Option<&Path>,
|
|
dev_disk_only: bool,
|
|
) -> Response {
|
|
serve_with_mode(path, headers, SpaMode::Normal, asset_root, dev_disk_only).await
|
|
}
|
|
|
|
pub async fn serve_install_with_asset_root(
|
|
path: &str,
|
|
headers: &HeaderMap,
|
|
asset_root: Option<&Path>,
|
|
dev_disk_only: bool,
|
|
) -> Response {
|
|
serve_with_mode(path, headers, SpaMode::Install, asset_root, dev_disk_only).await
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn assets_available() -> bool {
|
|
assets_available_with_root(None)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn assets_available_with_root(asset_root: Option<&Path>) -> bool {
|
|
if spa_assets_disabled_for_test() {
|
|
return false;
|
|
}
|
|
if asset_root.is_some_and(|root| root.join("index.html").is_file()) {
|
|
return true;
|
|
}
|
|
if cfg!(debug_assertions) && disk_asset_root().join("index.html").is_file() {
|
|
return true;
|
|
}
|
|
fabro_spa::get("index.html").is_some()
|
|
}
|
|
|
|
async fn cached_install_mode_shell(
|
|
asset_root: Option<&Path>,
|
|
dev_disk_only: bool,
|
|
) -> Option<Vec<u8>> {
|
|
static SHELL: OnceLock<Option<Vec<u8>>> = OnceLock::new();
|
|
if dev_disk_only || asset_root.is_some() || cfg!(debug_assertions) {
|
|
// In dev or test builds the SPA is reloaded from disk on every request
|
|
// so edits to the install shell show up without a server restart.
|
|
return load_injected_install_shell(asset_root, dev_disk_only).await;
|
|
}
|
|
if let Some(cached) = SHELL.get() {
|
|
return cached.clone();
|
|
}
|
|
let loaded = load_injected_install_shell(None, false).await;
|
|
SHELL.get_or_init(|| loaded).clone()
|
|
}
|
|
|
|
async fn load_injected_install_shell(
|
|
asset_root: Option<&Path>,
|
|
dev_disk_only: bool,
|
|
) -> Option<Vec<u8>> {
|
|
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)]
|
|
enum SpaMode {
|
|
Normal,
|
|
Install,
|
|
}
|
|
|
|
async fn serve_with_mode(
|
|
path: &str,
|
|
headers: &HeaderMap,
|
|
mode: SpaMode,
|
|
asset_root: Option<&Path>,
|
|
dev_disk_only: bool,
|
|
) -> Response {
|
|
let normalized = normalize(path);
|
|
|
|
if is_source_map(&normalized) {
|
|
return (StatusCode::NOT_FOUND, "Static asset not found").into_response();
|
|
}
|
|
|
|
if let Some(asset) = load_asset_for_mode(&normalized, mode, asset_root, dev_disk_only).await {
|
|
return asset_response(&normalized, asset, headers);
|
|
}
|
|
|
|
// SPA fallback: serve index.html only for browser navigations that
|
|
// explicitly accept HTML. Scripts, curl, fetch() with default
|
|
// `Accept: */*`, and similar non-HTML clients get a 404 so typos
|
|
// don't silently return 25KB of UI shell.
|
|
if accepts_html(headers) {
|
|
if let Some(index) =
|
|
load_asset_for_mode("index.html", mode, asset_root, dev_disk_only).await
|
|
{
|
|
return asset_response("index.html", index, headers);
|
|
}
|
|
if dev_disk_only {
|
|
return build_in_progress_response();
|
|
}
|
|
} else if dev_disk_only {
|
|
// Asset miss for a non-navigation request (chunk, css, image) in
|
|
// watch mode: surface the same "build in progress" signal with a 503
|
|
// so callers can retry instead of caching a 404.
|
|
return build_in_progress_response();
|
|
}
|
|
|
|
(StatusCode::NOT_FOUND, "Static asset not found").into_response()
|
|
}
|
|
|
|
fn build_in_progress_response() -> Response {
|
|
let mut response = Response::new(Body::from(BUILD_IN_PROGRESS_HTML));
|
|
*response.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
|
|
response.headers_mut().insert(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("text/html; charset=utf-8"),
|
|
);
|
|
response
|
|
.headers_mut()
|
|
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
|
response
|
|
}
|
|
|
|
fn accepts_html(headers: &HeaderMap) -> bool {
|
|
headers
|
|
.get(header::ACCEPT)
|
|
.and_then(|v| v.to_str().ok())
|
|
.is_some_and(|accept| {
|
|
accept.split(',').any(|part| {
|
|
part.trim()
|
|
.split(';')
|
|
.next()
|
|
.is_some_and(|m| m == "text/html")
|
|
})
|
|
})
|
|
}
|
|
|
|
fn normalize(path: &str) -> String {
|
|
let trimmed = path.trim_start_matches('/');
|
|
if trimmed.is_empty() {
|
|
"index.html".to_string()
|
|
} else {
|
|
trimmed.to_string()
|
|
}
|
|
}
|
|
|
|
/// 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<u8>) -> 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<Asset> {
|
|
if spa_assets_disabled_for_test() {
|
|
return None;
|
|
}
|
|
// An explicit asset_root means the caller (typically a test fixture) has
|
|
// chosen exactly which directory to serve from; don't also peek at the
|
|
// 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(Asset::from_vec(bytes));
|
|
}
|
|
} else if cfg!(debug_assertions) {
|
|
if let Some(bytes) = read_disk_asset(path).await {
|
|
return Some(Asset::from_vec(bytes));
|
|
}
|
|
}
|
|
|
|
if dev_disk_only {
|
|
// Watch mode: never fall back to the embedded SPA snapshot. Stale
|
|
// embedded bytes silently masking edits is the exact failure mode
|
|
// `--watch-web` exists to avoid.
|
|
return None;
|
|
}
|
|
|
|
fabro_spa::get(path).map(Asset::from_embedded)
|
|
}
|
|
|
|
async fn load_asset_for_mode(
|
|
path: &str,
|
|
mode: SpaMode,
|
|
asset_root: Option<&Path>,
|
|
dev_disk_only: bool,
|
|
) -> Option<Asset> {
|
|
if mode == SpaMode::Install && path == "index.html" {
|
|
return cached_install_mode_shell(asset_root, dev_disk_only)
|
|
.await
|
|
.map(Asset::from_vec);
|
|
}
|
|
load_asset(path, asset_root, dev_disk_only).await
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "test-only process-env switch disables SPA discovery for asset-independent tests"
|
|
)]
|
|
fn spa_assets_disabled_for_test() -> bool {
|
|
std::env::var(EnvVars::FABRO_TEST_DISABLE_SPA_ASSETS)
|
|
.ok()
|
|
.is_some_and(|value| !matches!(value.as_str(), "" | "0" | "false" | "no"))
|
|
}
|
|
|
|
fn inject_install_mode(bytes: Vec<u8>) -> Vec<u8> {
|
|
let html = match String::from_utf8(bytes) {
|
|
Ok(html) => html,
|
|
Err(err) => return err.into_bytes(),
|
|
};
|
|
if html.contains(INSTALL_MODE_MARKER) {
|
|
return html.into_bytes();
|
|
}
|
|
|
|
let injected = html.replace(
|
|
"</head>",
|
|
&format!(
|
|
" <script>{}</script>\n </head>",
|
|
csp::INSTALL_MODE_SCRIPT_BODY
|
|
),
|
|
);
|
|
assert!(
|
|
injected.contains(INSTALL_MODE_MARKER),
|
|
"install-mode SPA shell is missing a writable </head> tag"
|
|
);
|
|
injected.into_bytes()
|
|
}
|
|
|
|
async fn read_disk_asset(path: &str) -> Option<Vec<u8>> {
|
|
read_disk_asset_from_root(&disk_asset_root(), path).await
|
|
}
|
|
|
|
async fn read_disk_asset_from_root(root: &Path, path: &str) -> Option<Vec<u8>> {
|
|
let candidate = root.join(path);
|
|
if candidate.is_file() {
|
|
fs::read(candidate).await.ok()
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn disk_asset_root() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../apps/fabro-web/dist")
|
|
}
|
|
|
|
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(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")),
|
|
);
|
|
apply_cache_headers(response.headers_mut(), cache_control, etag.as_deref());
|
|
response
|
|
}
|
|
|
|
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 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 `<stem>-<hash>.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()
|
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("map"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "tests stage static asset fixtures with sync std::fs::write"
|
|
)]
|
|
mod tests {
|
|
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
|
|
|
use super::{
|
|
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 {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(header::ACCEPT, HeaderValue::from_str(value).unwrap());
|
|
headers
|
|
}
|
|
|
|
#[test]
|
|
fn source_maps_are_excluded_from_static_loading() {
|
|
assert!(is_source_map("assets/app.js.map"));
|
|
assert!(!is_source_map("assets/app.js"));
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_html_recognizes_browser_navigation() {
|
|
assert!(accepts_html(&headers_with_accept(
|
|
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
|
)));
|
|
assert!(accepts_html(&headers_with_accept("text/html")));
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_html_rejects_scripted_and_curl_clients() {
|
|
// curl default
|
|
assert!(!accepts_html(&headers_with_accept("*/*")));
|
|
// fetch() default
|
|
assert!(!accepts_html(&headers_with_accept("application/json")));
|
|
// missing Accept altogether
|
|
assert!(!accepts_html(&HeaderMap::new()));
|
|
}
|
|
|
|
#[test]
|
|
fn hashed_assets_are_cached_immutably() {
|
|
for path in [
|
|
"assets/entry-0sv53bs3.js",
|
|
"assets/chunk-4tr91ktd.js",
|
|
"assets/chunk-x912wb67.css",
|
|
// Tailwind's stylesheet is hashed by the build so it moves with the
|
|
// bundle; a stable name would let a tab revalidate into new CSS
|
|
// while still running the previous build's JavaScript.
|
|
"assets/app-381qtfxr.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",
|
|
// Clients poll this to learn whether their tab is running a stale
|
|
// build, so it must revalidate rather than be pinned for a year.
|
|
"build-id.json",
|
|
"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!(
|
|
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"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disk_assets_are_loaded_from_explicit_root() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let asset_path = temp_dir.path().join("assets/override.txt");
|
|
std::fs::create_dir_all(asset_path.parent().unwrap()).unwrap();
|
|
std::fs::write(&asset_path, b"override").unwrap();
|
|
|
|
let bytes = read_disk_asset_from_root(temp_dir.path(), "assets/override.txt")
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(bytes, b"override");
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "install-mode SPA shell is missing a writable </head> tag")]
|
|
fn install_mode_injection_panics_when_html_head_is_missing() {
|
|
let _ = inject_install_mode(b"<html><body>no head tag</body></html>".to_vec());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dev_disk_only_returns_503_when_index_is_missing() {
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let response = serve_with_asset_root(
|
|
"/",
|
|
&headers_with_accept("text/html"),
|
|
Some(temp_dir.path()),
|
|
true,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
let body = std::str::from_utf8(&bytes).unwrap();
|
|
assert!(body.contains("Build in progress"), "body was: {body}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dev_disk_only_does_not_fall_back_to_embedded() {
|
|
// The embedded SPA snapshot has chunk-12xq903b.js (observed in
|
|
// lib/apps/fabro-spa/assets/index.html). In disk-only mode that
|
|
// bytes-on-disk-or-503 contract must hold even for assets the
|
|
// embedded snapshot would otherwise serve.
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
let response = serve_with_asset_root(
|
|
"/assets/chunk-12xq903b.js",
|
|
&HeaderMap::new(),
|
|
Some(temp_dir.path()),
|
|
true,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn production_mode_still_falls_back_to_embedded() {
|
|
// No disk root, no watch mode: index.html should come from the
|
|
// embedded SPA. Skip if the embedded SPA hasn't been refreshed
|
|
// (e.g., a fresh checkout before `cargo dev build`).
|
|
if fabro_spa::get("index.html").is_none() {
|
|
return;
|
|
}
|
|
let response =
|
|
serve_with_asset_root("/", &headers_with_accept("text/html"), None, false).await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dev_disk_only_serves_disk_index_for_unknown_spa_routes() {
|
|
// Verifies that the disk-only mode doesn't break SPA routing:
|
|
// unknown paths still get index.html when the browser asks for HTML
|
|
// and dist/index.html exists.
|
|
let temp_dir = tempfile::tempdir().unwrap();
|
|
std::fs::write(
|
|
temp_dir.path().join("index.html"),
|
|
b"<!doctype html><title>spa</title>",
|
|
)
|
|
.unwrap();
|
|
|
|
let response = serve_with_asset_root(
|
|
"/runs/some-deep-route",
|
|
&headers_with_accept("text/html"),
|
|
Some(temp_dir.path()),
|
|
true,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
assert!(bytes.starts_with(b"<!doctype html>"));
|
|
}
|
|
}
|