summaryrefslogtreecommitdiff
path: root/src/ui/assets.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/ui/assets.rs')
-rw-r--r--src/ui/assets.rs154
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(),