use std::{str::FromStr, time::Duration}; use ::mime::Mime; use axum::{ http::StatusCode, response::{self, IntoResponse, IntoResponseParts, ResponseParts}, }; use axum_extra::TypedHeader; use headers::{CacheControl, ContentType, ETag, IfNoneMatch}; use rust_embed::EmbeddedFile; use super::{error::NotFound, mime}; use crate::error::{ Internal, failed::{Failed, ResultExt}, }; #[derive(rust_embed::Embed)] #[folder = "$OUT_DIR/ui"] pub struct Assets; // Prefer the corresponding methods in `Response` unless you specifically do not want HTTP caching // behaviours. impl Assets { pub fn load(path: &str) -> Result { let mime = mime::from_path(path) .fail_with(|| format!("Failed to determine MIME type from asset path: {path}"))?; let file = Self::get(path).ok_or(Error::NotFound(path.into()))?; let asset = Asset::new(mime, file)?; Ok(asset) } pub fn index() -> Result { // "not found" in this case really is an internal error, as it should // never happen. `index.html` is a known-valid path with a known-valid // file extension. Ok(Self::load("index.html")?) } } pub struct Asset { mime: Mime, file: EmbeddedFile, etag: ETag, } impl Asset { fn new(mime: Mime, file: EmbeddedFile) -> Result { let etag = Self::etag_from(&file).fail("Failed to compute ETag")?; Ok(Self { mime, file, etag }) } fn etag_from(file: &EmbeddedFile) -> Result::Err> { let digest = file.metadata.sha256_hash(); let digest = hex::encode(digest); let tag = format!("\"{digest}\""); let tag = ETag::from_str(&tag)?; Ok(tag) } fn differs_from(&self, if_none_match: &IfNoneMatch) -> bool { if_none_match.precondition_passes(&self.etag) } } impl IntoResponse for Asset { fn into_response(self) -> response::Response { let Self { mime, file, etag } = self; ( StatusCode::OK, TypedHeader(ContentType::from(mime)), TypedHeader(etag), file.data, ) .into_response() } } // Clippy warns us here because the `Asset` variant is the size of, well, the Asset struct - itself // quite large due to the embedded file metadata. There isn't a better alternative that I can see, // and it's not causing any performance or stack exhaustion problems in practice. #[expect(clippy::large_enum_variant)] pub enum Response { NotModified, // I've opted to make Mutability part of the conditional-HTTP-response logic here and not part // of the underlying Asset struct, even though it would be pretty reasonable to deem that it is // the Asset that is immutable rather than its specific binding to an HTTP result. Assets don't // know their own path, and there isn't a tidy way to tag that information onto Asset at // construction without refactoring a _ton_ of things that are fine the way they are, though, // and since mutability only affects an HTTP header and nothing else, the resulting structs // work well enough. Asset(Asset, Mutability), } // This interface parallels the `Assets` interface, above. However, unlike `Assets`, this supports // browser and intermediate server cache mechanisms, both by considering the provided // `If-None-Match` header against each asset's `ETag`, and by tacking on cache control headers for // static assets that we commit to never changing. impl Response { pub fn load(path: &str, if_none_match: &IfNoneMatch) -> Result { let mutability = Mutability::from_path(path); let asset = Assets::load(path)?; let response = Self::respond_with(asset, if_none_match, mutability); Ok(response) } pub fn index(if_none_match: &IfNoneMatch) -> Result { let asset = Assets::index()?; let response = Self::respond_with(asset, if_none_match, Mutability::Mutable); Ok(response) } fn respond_with(asset: Asset, if_none_match: &IfNoneMatch, mutability: Mutability) -> Self { if asset.differs_from(if_none_match) { Self::Asset(asset, mutability) } else { Self::NotModified } } } impl IntoResponse for Response { fn into_response(self) -> response::Response { match self { Self::NotModified => StatusCode::NOT_MODIFIED.into_response(), Self::Asset(asset, mutability) => (mutability, asset).into_response(), } } } pub enum Mutability { Immutable, Mutable, } impl Mutability { fn from_path(path: &str) -> Self { // This is a complete hack and depends intimately on the output of SvelteKit's static site // adapter in ways that are not documented anywhere I can find. On the other hand, I also // can't find any sign of a better way to do this, and I think the odds of us (or SvelteKit) // messing with these paths in a way that breaks this cacheability check are pretty low. if path.starts_with("_app/immutable/") { Self::Immutable } else { Self::Mutable } } } impl IntoResponseParts for Mutability { type Error = as IntoResponseParts>::Error; fn into_response_parts(self, res: ResponseParts) -> Result { // 90 days is pretty arbitrary. Everything _works_ if immutable assets get // re-requested more frequently than this (or even on every page view), but it eats // up bandwidth and battery to transfer them, and they only ever change to and from // "404 Not Found" (which we do not instruct browsers to cache). If it was free to // cache them forever, we'd ask for that. // // If a user doesn't visit the app for 90 days, they're probably better off re-downloading // the whole thing if they ever come back, and better off getting that cache space back for // something else, until then. match self { Self::Immutable => TypedHeader( CacheControl::new() .with_immutable() .with_max_age(Duration::from_hours(90 * 24)), ) .into_response_parts(res), Self::Mutable => ().into_response_parts(res), } } } #[derive(Debug, thiserror::Error)] pub enum Error { #[error("not found: {0}")] NotFound(String), #[error(transparent)] Failed(#[from] Failed), } impl IntoResponse for Error { fn into_response(self) -> response::Response { match self { Self::NotFound(_) => NotFound(self.to_string()).into_response(), Self::Failed(_) => Internal::from(self).into_response(), } } }