Something went wrong. Try again.
Paper Mario mod hosting site - AT Protocol appview starhaven.dev
appview
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252//! `assets` directory embedded into the binary, or hot-reloaded from disk in debug builds.
use axum::{ extract::Path, http::{header, HeaderMap, StatusCode}, response::{IntoResponse, Response}, routing::get, Router,};use rust_embed::Embed;
#[derive(Embed)]#[folder = "assets/"]struct Assets;
pub fn router<S: Clone + Send + Sync + 'static>() -> Router<S> { Router::new().route("/assets/{*path}", get(serve_asset))}
async fn serve_asset(Path(path): Path<String>, request: HeaderMap) -> Response { serve(Assets::get(&path), &request)}
/// Serves `file`, which a browser or a proxy like Cloudflare keeps only as long/// as it checks back, each time, that it's still the one Star Haven has. They/// would otherwise keep a stylesheet for hours, long after a fix to it is live.fn serve(file: Option<rust_embed::EmbeddedFile>, request: &HeaderMap) -> Response { let Some(file) = file else { return StatusCode::NOT_FOUND.into_response(); };
let etag = format!( "\"{}\"", file.metadata .sha256_hash() .iter() .map(|byte| format!("{byte:02x}")) .collect::<String>() ); let unchanged = request .get(header::IF_NONE_MATCH) .is_some_and(|sent| sent.as_bytes() == etag.as_bytes());
let mut response = if unchanged { StatusCode::NOT_MODIFIED.into_response() } else { ( [(header::CONTENT_TYPE, file.metadata.mimetype())], file.data, ) .into_response() }; let headers = response.headers_mut(); headers.insert( header::CACHE_CONTROL, header::HeaderValue::from_static(if cfg!(debug_assertions) { "no-store" } else { "no-cache" }), ); if let Ok(etag) = header::HeaderValue::from_str(&etag) { headers.insert(header::ETAG, etag); }
response}
#[cfg(debug_assertions)]pub use reload::{router as reload_router, script as reload_script, watch};
/// Release builds don't reload assets, so pages get no script for it.#[cfg(not(debug_assertions))]pub fn reload_script() -> maud::Markup { maud::html! {}}
/// Watches the `assets` directory and broadcasts them using a server-sent event/// on /assets/_reload. JavaScript picks this up and reloads the changed asset(s).#[cfg(debug_assertions)]mod reload { use axum::{ response::sse::{Event, KeepAlive, Sse}, routing::get, Router, }; use maud::{html, Markup, PreEscaped}; use notify_debouncer_mini::{ new_debouncer, notify::RecommendedWatcher, notify::RecursiveMode, DebounceEventResult, Debouncer, }; use std::{ collections::hash_map::DefaultHasher, collections::HashMap, hash::{Hash, Hasher}, path::{Path, PathBuf}, sync::Mutex, time::Duration, }; use tokio::sync::broadcast; use tokio_stream::{wrappers::BroadcastStream, StreamExt};
/// Changed paths are broadcast to every connected browser. pub type Changes = broadcast::Sender<String>;
/// Watch the assets directory, broadcasting the path of whatever changed. /// /// Dropping the returned debouncer stops the watch, so it has to be kept /// alive for as long as the server runs. pub fn watch(changes: Changes) -> Debouncer<RecommendedWatcher> { let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); let asset_dir = manifest.join("assets");
// notify subscribes to inotify's IN_OPEN, so merely reading a file looks // like an event -- and debug builds read assets from disk on every // request, which would make each reload trigger the next one. Compare // contents instead of trusting the event. Primed up front so the first // request for an asset doesn't look like a change. let hashes: Mutex<HashMap<String, u64>> = Mutex::new(hash_tree(&asset_dir, &asset_dir));
let asset_root = asset_dir.clone();
let mut debouncer = new_debouncer( Duration::from_millis(100), move |result: DebounceEventResult| { let Ok(events) = result else { return }; let mut hashes = hashes.lock().expect("asset watcher poisoned");
for event in events { let path = event .path .strip_prefix(&asset_root) .unwrap_or(&event.path) .to_string_lossy() .into_owned();
match std::fs::read(&event.path) { Ok(contents) => { let mut hasher = DefaultHasher::new(); contents.hash(&mut hasher); let hash = hasher.finish();
if hashes.insert(path.clone(), hash) == Some(hash) { continue; } } // Unreadable now: reload only if we had served it before, // which covers deletes while ignoring directories. Err(_) if hashes.remove(&path).is_none() => continue, Err(_) => {} }
let _ = changes.send(path); } }, ) .expect("failed to start asset watcher");
debouncer .watcher() .watch(&asset_dir, RecursiveMode::Recursive) .expect("failed to watch assets");
debouncer }
/// Hash every file under `dir`, keyed by its path relative to `root`. fn hash_tree(dir: &Path, root: &Path) -> HashMap<String, u64> { let mut hashes = HashMap::new(); let Ok(entries) = std::fs::read_dir(dir) else { return hashes; };
for entry in entries.flatten() { let path: PathBuf = entry.path(); if path.is_dir() { hashes.extend(hash_tree(&path, root)); } else if let Ok(contents) = std::fs::read(&path) { let mut hasher = DefaultHasher::new(); contents.hash(&mut hasher); let key = path .strip_prefix(root) .unwrap_or(&path) .to_string_lossy() .into_owned(); hashes.insert(key, hasher.finish()); } }
hashes }
pub fn router(changes: Changes) -> Router { Router::new().route( "/assets/_reload", get(move || { let stream = BroadcastStream::new(changes.subscribe()) .filter_map(|changed| changed.ok()) .map(|path| Ok::<_, std::convert::Infallible>(Event::default().data(path))); async move { Sse::new(stream).keep_alive(KeepAlive::default()) } }), ) }
pub fn script() -> Markup { html! { script { (PreEscaped(SCRIPT)) } } }
const SCRIPT: &str = include_str!("assets_reload.js");}
#[cfg(test)]mod tests { use super::*;
fn get(path: &str, request: &HeaderMap) -> Response { serve(Assets::get(path), request) }
#[test] fn an_unchanged_asset_is_not_sent_again() { let first = get("base.css", &HeaderMap::new()); assert_eq!(first.status(), StatusCode::OK); let etag = first.headers()[header::ETAG].clone();
let mut request = HeaderMap::new(); request.insert(header::IF_NONE_MATCH, etag.clone()); let again = get("base.css", &request);
assert_eq!(again.status(), StatusCode::NOT_MODIFIED); assert_eq!(again.headers()[header::ETAG], etag); }
#[test] fn a_stale_copy_is_replaced() { let mut request = HeaderMap::new(); request.insert(header::IF_NONE_MATCH, "\"another\"".parse().unwrap());
assert_eq!(get("base.css", &request).status(), StatusCode::OK); }
#[test] fn an_asset_that_isnt_there_is_not_found() { assert_eq!( get("nothing.css", &HeaderMap::new()).status(), StatusCode::NOT_FOUND ); }}