caching static resources

This commit is contained in:
liquidex 2024-07-19 18:04:11 +02:00
parent 902191d91c
commit 9bf3409197
7 changed files with 168 additions and 21 deletions

32
Cargo.lock generated
View file

@ -96,6 +96,18 @@ version = "1.0.86"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
[[package]]
name = "arrayref"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b4930d2cb77ce62f89ee5d5289b4ac049559b1c45539271f5ed4fdc7db34545"
[[package]]
name = "arrayvec"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711"
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.80" version = "0.1.80"
@ -207,6 +219,19 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1"
[[package]]
name = "blake3"
version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9ec96fe9a81b5e365f9db71fe00edc4fe4ca2cc7dcb7861f0603012a7caa210"
dependencies = [
"arrayref",
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@ -328,6 +353,12 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422"
[[package]]
name = "constant_time_eq"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2"
[[package]] [[package]]
name = "copy_dir" name = "copy_dir"
version = "0.1.3" version = "0.1.3"
@ -1502,6 +1533,7 @@ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
"base64", "base64",
"blake3",
"chrono", "chrono",
"clap", "clap",
"codespan-reporting", "codespan-reporting",

View file

@ -9,6 +9,7 @@ treehouse-format = { workspace = true }
anyhow = "1.0.75" anyhow = "1.0.75"
axum = "0.7.4" axum = "0.7.4"
blake3 = "1.5.3"
clap = { version = "4.3.22", features = ["derive"] } clap = { version = "4.3.22", features = ["derive"] }
codespan-reporting = "0.11.1" codespan-reporting = "0.11.1"
copy_dir = "0.1.3" copy_dir = "0.1.3"

View file

@ -1,3 +1,5 @@
mod static_urls;
use std::{ use std::{
collections::HashMap, collections::HashMap,
ffi::OsStr, ffi::OsStr,
@ -11,9 +13,10 @@ use codespan_reporting::{
files::Files as _, files::Files as _,
}; };
use copy_dir::copy_dir; use copy_dir::copy_dir;
use handlebars::Handlebars; use handlebars::{handlebars_helper, Handlebars};
use log::{debug, error, info}; use log::{debug, error, info};
use serde::Serialize; use serde::Serialize;
use static_urls::StaticUrls;
use walkdir::WalkDir; use walkdir::WalkDir;
use crate::{ use crate::{
@ -214,6 +217,17 @@ impl Generator {
let mut handlebars = Handlebars::new(); let mut handlebars = Handlebars::new();
let mut config_derived_data = ConfigDerivedData::default(); let mut config_derived_data = ConfigDerivedData::default();
handlebars_helper!(cat: |a: String, b: String| a + &b);
handlebars.register_helper("cat", Box::new(cat));
handlebars.register_helper(
"asset",
Box::new(StaticUrls::new(
paths.static_dir.to_owned(),
format!("{}/static", config.site),
)),
);
let mut template_file_ids = HashMap::new(); let mut template_file_ids = HashMap::new();
for entry in WalkDir::new(paths.template_dir) { for entry in WalkDir::new(paths.template_dir) {
let entry = entry.context("cannot read directory entry")?; let entry = entry.context("cannot read directory entry")?;

View file

@ -0,0 +1,76 @@
use std::{
collections::HashMap,
fs::File,
io::{self, BufReader},
path::PathBuf,
sync::RwLock,
};
use handlebars::{Context, Handlebars, Helper, HelperDef, RenderContext, RenderError, ScopedJson};
use serde_json::Value;
pub struct StaticUrls {
base_dir: PathBuf,
base_url: String,
// Really annoying that we have to use an RwLock for this. We only ever generate in a
// single-threaded environment.
// Honestly it would be a lot more efficient if Handlebars just assumed single-threadedness
// and required you to clone it over to different threads.
// Stuff like this is why I really want to implement my own templating engine...
hash_cache: RwLock<HashMap<String, String>>,
}
impl StaticUrls {
pub fn new(base_dir: PathBuf, base_url: String) -> Self {
Self {
base_dir,
base_url,
hash_cache: RwLock::new(HashMap::new()),
}
}
pub fn get(&self, filename: &str) -> Result<String, io::Error> {
let hash_cache = self.hash_cache.read().unwrap();
if let Some(cached) = hash_cache.get(filename) {
return Ok(cached.to_owned());
}
drop(hash_cache);
let mut hasher = blake3::Hasher::new();
let file = BufReader::new(File::open(self.base_dir.join(filename))?);
hasher.update_reader(file)?;
// NOTE: Here the hash is truncated to 8 characters. This is fine, because we don't
// care about security here - only detecting changes in files.
let hash = format!(
"{}/{}?cache=b3-{}",
self.base_url,
filename,
&hasher.finalize().to_hex()[0..8]
);
{
let mut hash_cache = self.hash_cache.write().unwrap();
hash_cache.insert(filename.to_owned(), hash.clone());
}
Ok(hash)
}
}
impl HelperDef for StaticUrls {
fn call_inner<'reg: 'rc, 'rc>(
&self,
helper: &Helper<'reg, 'rc>,
_: &'reg Handlebars<'reg>,
_: &'rc Context,
_: &mut RenderContext<'reg, 'rc>,
) -> Result<ScopedJson<'reg, 'rc>, RenderError> {
if let Some(param) = helper.param(0).and_then(|v| v.value().as_str()) {
return Ok(ScopedJson::Derived(Value::String(
self.get(param).map_err(|error| {
RenderError::new(format!("cannot get asset url for {param}: {error}"))
})?,
)));
}
Err(RenderError::new("asset path must be provided"))
}
}

View file

@ -5,9 +5,9 @@ use std::{net::Ipv4Addr, path::PathBuf, sync::Arc};
use anyhow::Context; use anyhow::Context;
use axum::{ use axum::{
extract::{Path, RawQuery, State}, extract::{Path, Query, RawQuery, State},
http::{ http::{
header::{CONTENT_TYPE, LOCATION}, header::{CACHE_CONTROL, CONTENT_TYPE, LOCATION},
HeaderValue, StatusCode, HeaderValue, StatusCode,
}, },
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
@ -16,6 +16,7 @@ use axum::{
}; };
use log::{error, info}; use log::{error, info};
use pulldown_cmark::escape::escape_html; use pulldown_cmark::escape::escape_html;
use serde::Deserialize;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use crate::{ use crate::{
@ -111,9 +112,19 @@ async fn four_oh_four(State(state): State<Arc<Server>>) -> Response {
.into_response() .into_response()
} }
async fn static_file(Path(path): Path<String>, State(state): State<Arc<Server>>) -> Response { #[derive(Deserialize)]
struct StaticFileQuery {
cache: Option<String>,
}
async fn static_file(
Path(path): Path<String>,
Query(query): Query<StaticFileQuery>,
State(state): State<Arc<Server>>,
) -> Response {
if let Ok(file) = tokio::fs::read(state.target_dir.join("static").join(&path)).await { if let Ok(file) = tokio::fs::read(state.target_dir.join("static").join(&path)).await {
let mut response = file.into_response(); let mut response = file.into_response();
if let Some(content_type) = get_content_type(&path) { if let Some(content_type) = get_content_type(&path) {
response response
.headers_mut() .headers_mut()
@ -121,6 +132,14 @@ async fn static_file(Path(path): Path<String>, State(state): State<Arc<Server>>)
} else { } else {
response.headers_mut().remove(CONTENT_TYPE); response.headers_mut().remove(CONTENT_TYPE);
} }
if query.cache.is_some() {
response.headers_mut().insert(
CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
}
response response
} else { } else {
four_oh_four(State(state)).await four_oh_four(State(state)).await

View file

@ -98,12 +98,17 @@ body::selection {
@font-face { @font-face {
font-family: 'RecVar'; font-family: 'RecVar';
src: url('../font/Recursive_VF_1.085.woff2'); /* NOTE: I put the hash in here manually instead of adding the complexity of piping CSS through
Handlebars because I don't really think it's worth it for this single asset.
Other assets are referenced rarely enough that caching probably isn't gonna make too much of
an impact.
It's unlikely I'll ever update the font anyways, so eh, whatever. */
src: url('../font/Recursive_VF_1.085.woff2?cache=b3-445487d5');
} }
@font-face { @font-face {
font-family: 'RecVarMono'; font-family: 'RecVarMono';
src: url('../font/Recursive_VF_1.085.woff2'); src: url('../font/Recursive_VF_1.085.woff2?cache=b3-445487d5');
font-variation-settings: "MONO" 1.0; font-variation-settings: "MONO" 1.0;
} }

View file

@ -4,10 +4,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" href="{{ config.site }}/static/font/Recursive_VF_1.085.woff2" as="font" type="font/woff2" <link rel="preload" href="{{ asset 'font/Recursive_VF_1.085.woff2' }}" as="font" type="font/woff2"
crossorigin="anonymous"> crossorigin="anonymous">
<link rel="stylesheet" href="{{ config.site }}/static/css/main.css"> <link rel="stylesheet" href="{{ asset 'css/main.css' }}">
<link rel="stylesheet" href="{{ config.site }}/static/css/tree.css"> <link rel="stylesheet" href="{{ asset 'css/tree.css' }}">
<script type="importmap">{ <script type="importmap">{
"imports": { "imports": {
@ -42,15 +42,15 @@ It just needs to be a string replacement.
<meta property="og:image:alt" content="{{ page.thumbnail.alt }}"> <meta property="og:image:alt" content="{{ page.thumbnail.alt }}">
{{/if}} {{/if}}
<link rel="icon" sizes="16x16" href="{{ config.site }}/static/favicon/{{ season }}@1x.png"> <link rel="icon" sizes="16x16" href="{{ asset (cat (cat 'favicon/' season) '@1x.png') }}">
<link rel="icon" sizes="32x32" href="{{ config.site }}/static/favicon/{{ season }}@2x.png"> <link rel="icon" sizes="32x32" href="{{ asset (cat (cat 'favicon/' season) '@2x.png') }}">
<link rel="icon" sizes="64x64" href="{{ config.site }}/static/favicon/{{ season }}@4x.png"> <link rel="icon" sizes="64x64" href="{{ asset (cat (cat 'favicon/' season) '@4x.png') }}">
<link rel="icon" sizes="128x128" href="{{ config.site }}/static/favicon/{{ season }}@8x.png"> <link rel="icon" sizes="128x128" href="{{ asset (cat (cat 'favicon/' season) '@8x.png') }}">
<link rel="icon" sizes="256x256" href="{{ config.site }}/static/favicon/{{ season }}@16x.png"> <link rel="icon" sizes="256x256" href="{{ asset (cat (cat 'favicon/' season) '@16x.png') }}">
<link rel="icon" sizes="512x512" href="{{ config.site }}/static/favicon/{{ season }}@32x.png"> <link rel="icon" sizes="512x512" href="{{ asset (cat (cat 'favicon/' season) '@32x.png') }}">
<link rel="apple-touch-icon" sizes="16x16" href="{{ config.site }}/static/favicon/{{ season }}@1x.png"> <link rel="apple-touch-icon" sizes="16x16" href="{{ asset (cat (cat 'favicon/' season) '@1x.png') }}">
<link rel="apple-touch-icon" sizes="32x32" href="{{ config.site }}/static/favicon/{{ season }}@2x.png"> <link rel="apple-touch-icon" sizes="32x32" href="{{ asset (cat (cat 'favicon/' season) '@2x.png') }}">
<link rel="apple-touch-icon" sizes="64x64" href="{{ config.site }}/static/favicon/{{ season }}@4x.png"> <link rel="apple-touch-icon" sizes="64x64" href="{{ asset (cat (cat 'favicon/' season) '@4x.png') }}">
<link rel="apple-touch-icon" sizes="128x128" href="{{ config.site }}/static/favicon/{{ season }}@8x.png"> <link rel="apple-touch-icon" sizes="128x128" href="{{ asset (cat (cat 'favicon/' season) '@8x.png') }}">
<link rel="apple-touch-icon" sizes="256x256" href="{{ config.site }}/static/favicon/{{ season }}@16x.png"> <link rel="apple-touch-icon" sizes="256x256" href="{{ asset (cat (cat 'favicon/' season) '@16x.png') }}">
<link rel="apple-touch-icon" sizes="512x512" href="{{ config.site }}/static/favicon/{{ season }}@32x.png"> <link rel="apple-touch-icon" sizes="512x512" href="{{ asset (cat (cat 'favicon/' season) '@32x.png') }}">