summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.toml2
-rw-r--r--src/ui/assets.rs87
2 files changed, 73 insertions, 16 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 5a5513b..4085a19 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2,7 +2,7 @@
name = "pilcrow"
version = "0.1.0"
edition = "2024"
-rust-version = "1.90"
+rust-version = "1.91"
authors = [
"Owen Jacobson <owen@grimoire.ca>",
"Kit La Touche <kit@transneptune.net>",
diff --git a/src/ui/assets.rs b/src/ui/assets.rs
index b8ca00b..2fa703b 100644
--- a/src/ui/assets.rs
+++ b/src/ui/assets.rs
@@ -1,12 +1,12 @@
-use std::str::FromStr;
+use std::{str::FromStr, time::Duration};
use ::mime::Mime;
use axum::{
http::StatusCode,
- response::{self, IntoResponse},
+ response::{self, IntoResponse, IntoResponseParts, ResponseParts},
};
use axum_extra::TypedHeader;
-use headers::{ContentType, ETag, IfNoneMatch};
+use headers::{CacheControl, ContentType, ETag, IfNoneMatch};
use rust_embed::EmbeddedFile;
use super::{error::NotFound, mime};
@@ -19,11 +19,12 @@ 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)?;
@@ -60,7 +61,7 @@ impl Asset {
Ok(tag)
}
- pub fn differs_from(&self, if_none_match: &IfNoneMatch) -> bool {
+ fn differs_from(&self, if_none_match: &IfNoneMatch) -> bool {
if_none_match.precondition_passes(&self.etag)
}
}
@@ -84,25 +85,37 @@ impl IntoResponse for Asset {
#[expect(clippy::large_enum_variant)]
pub enum Response {
NotModified,
- Asset(Asset),
+ // 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: impl AsRef<str>, if_none_match: &IfNoneMatch) -> Result<Self, Error> {
+ 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);
+ 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);
+ let response = Self::respond_with(asset, if_none_match, Mutability::Mutable);
Ok(response)
}
- fn respond_with(asset: Asset, if_none_match: &IfNoneMatch) -> Self {
+ fn respond_with(asset: Asset, if_none_match: &IfNoneMatch, mutability: Mutability) -> Self {
if asset.differs_from(if_none_match) {
- Self::Asset(asset)
+ Self::Asset(asset, mutability)
} else {
Self::NotModified
}
@@ -113,7 +126,51 @@ impl IntoResponse for Response {
fn into_response(self) -> response::Response {
match self {
Self::NotModified => StatusCode::NOT_MODIFIED.into_response(),
- Self::Asset(asset) => asset.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),
}
}
}