diff options
Diffstat (limited to 'src/ui/assets.rs')
| -rw-r--r-- | src/ui/assets.rs | 154 |
1 files changed, 141 insertions, 13 deletions
diff --git a/src/ui/assets.rs b/src/ui/assets.rs index ede1f7c..2fa703b 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -1,8 +1,12 @@ +use std::{str::FromStr, time::Duration}; + use ::mime::Mime; use axum::{ - http::{StatusCode, header}, - response::{IntoResponse, Response}, + 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}; @@ -15,14 +19,16 @@ use crate::error::{ #[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: impl AsRef<str>) -> Result<Asset, Error> { - let path = path.as_ref(); - let mime = mime::from_path(path).fail("Failed to determine MIME type from asset path")?; + pub fn load(path: &str) -> Result<Asset, Error> { + 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)?; - Self::get(path) - .map(|file| Asset(mime, file)) - .ok_or(Error::NotFound(path.into())) + Ok(asset) } pub fn index() -> Result<Asset, Internal> { @@ -33,20 +39,142 @@ impl Assets { } } -pub struct Asset(Mime, EmbeddedFile); +pub struct Asset { + mime: Mime, + file: EmbeddedFile, + etag: ETag, +} + +impl Asset { + fn new(mime: Mime, file: EmbeddedFile) -> Result<Self, Failed> { + let etag = Self::etag_from(&file).fail("Failed to compute ETag")?; + Ok(Self { mime, file, etag }) + } + + fn etag_from(file: &EmbeddedFile) -> Result<ETag, <ETag as FromStr>::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 { - let Self(mime, file) = self; + fn into_response(self) -> response::Response { + let Self { mime, file, etag } = self; ( StatusCode::OK, - [(header::CONTENT_TYPE, mime.as_ref())], + 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<Self, Error> { + 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<Self, Internal> { + 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 = <TypedHeader<CacheControl> as IntoResponseParts>::Error; + + fn into_response_parts(self, res: ResponseParts) -> Result<ResponseParts, Self::Error> { + // 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}")] @@ -56,7 +184,7 @@ pub enum Error { } impl IntoResponse for Error { - fn into_response(self) -> Response { + fn into_response(self) -> response::Response { match self { Self::NotFound(_) => NotFound(self.to_string()).into_response(), Self::Failed(_) => Internal::from(self).into_response(), |
