Headers, cache tweaks

This commit is contained in:
2025-11-05 08:46:33 -08:00
parent e9863707d8
commit 063ea165d1
8 changed files with 455 additions and 132 deletions

111
Cargo.lock generated
View File

@@ -29,6 +29,21 @@ dependencies = [
"memchr",
]
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -263,6 +278,27 @@ dependencies = [
"generic-array",
]
[[package]]
name = "brotli"
version = "8.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
]
[[package]]
name = "brotli-decompressor"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bumpalo"
version = "3.19.0"
@@ -282,6 +318,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -368,9 +406,12 @@ version = "0.4.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23"
dependencies = [
"brotli",
"compression-core",
"flate2",
"memchr",
"zstd",
"zstd-safe",
]
[[package]]
@@ -618,6 +659,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -785,6 +832,11 @@ name = "hashbrown"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "heck"
@@ -1096,6 +1148,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.82"
@@ -1200,6 +1262,15 @@ dependencies = [
"prost-types",
]
[[package]]
name = "lru"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96051b46fc183dc9cd4a223960ef37b9af631b55191852a8274bfef064cda20f"
dependencies = [
"hashbrown 0.16.0",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -1566,6 +1637,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plist"
version = "1.8.0"
@@ -2137,6 +2214,7 @@ dependencies = [
"emojis",
"lazy_static",
"libservice",
"lru",
"macro-assets",
"macro-sass",
"markdown-it",
@@ -2147,6 +2225,7 @@ dependencies = [
"serde_yaml",
"strum",
"tokio",
"tower-http",
"tracing",
]
@@ -2532,13 +2611,17 @@ version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [
"async-compression",
"bitflags",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"iri-string",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
@@ -3342,3 +3425,31 @@ dependencies = [
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]

View File

@@ -77,7 +77,7 @@ service-webpage = { path = "crates/service/service-webpage" }
# MARK: Server
#
axum = { version = "0.8.6", features = ["macros", "multipart"] }
tower-http = { version = "0.6.6", features = ["trace"] }
tower-http = { version = "0.6.6", features = ["trace", "compression-full"] }
utoipa = "5.4.0"
utoipa-swagger-ui = { version = "9.0.2", features = [
"axum",

View File

@@ -142,8 +142,18 @@ pub fn assets(input: TokenStream) -> TokenStream {
quote! {
#[doc = #router_doc]
pub fn #router_name() -> (&'static str, ::axum::Router<()>) {
use ::tower_http::compression::{CompressionLayer, DefaultPredicate};
let compression: CompressionLayer = CompressionLayer::new()
.br(true)
.deflate(true)
.gzip(true)
.zstd(true)
.compress_when(DefaultPredicate::new());
let router = ::axum::Router::new()
#(#route_definitions)*;
#(#route_definitions)*
.layer(compression);
(#prefix, router)
}
}
@@ -240,27 +250,39 @@ impl Parse for AssetDefinition {
match field_name.to_string().as_str() {
"source" => {
if source.is_some() {
return Err(syn::Error::new(field_name.span(), "duplicate 'source' field"));
return Err(syn::Error::new(
field_name.span(),
"duplicate 'source' field",
));
}
source = Some(content.parse()?);
}
"target" => {
if target.is_some() {
return Err(syn::Error::new(field_name.span(), "duplicate 'target' field"));
return Err(syn::Error::new(
field_name.span(),
"duplicate 'target' field",
));
}
let target_lit: LitStr = content.parse()?;
target = Some(target_lit.value());
}
"headers" => {
if headers.is_some() {
return Err(syn::Error::new(field_name.span(), "duplicate 'headers' field"));
return Err(syn::Error::new(
field_name.span(),
"duplicate 'headers' field",
));
}
headers = Some(content.parse()?);
}
_ => {
return Err(syn::Error::new(
field_name.span(),
format!("unknown field '{}', expected 'source', 'target', or 'headers'", field_name)
format!(
"unknown field '{}', expected 'source', 'target', or 'headers'",
field_name
),
));
}
}
@@ -272,8 +294,10 @@ impl Parse for AssetDefinition {
}
// Validate required fields
let source = source.ok_or_else(|| syn::Error::new(name.span(), "missing required field 'source'"))?;
let target = target.ok_or_else(|| syn::Error::new(name.span(), "missing required field 'target'"))?;
let source = source
.ok_or_else(|| syn::Error::new(name.span(), "missing required field 'source'"))?;
let target = target
.ok_or_else(|| syn::Error::new(name.span(), "missing required field 'target'"))?;
Ok(AssetDefinition {
name,

View File

@@ -25,4 +25,6 @@ lazy_static = { workspace = true }
serde_yaml = { workspace = true }
serde = { workspace = true }
reqwest = { workspace = true }
lru = { workspace = true }
tower-http = { workspace = true }
tokio = { workspace = true }

View File

@@ -11,16 +11,13 @@ use axum::{
};
use chrono::{DateTime, TimeDelta, Utc};
use libservice::ServiceConnectInfo;
use lru::LruCache;
use markdown_it::Node;
use maud::{Markup, PreEscaped, Render, html};
use parking_lot::{Mutex, RwLock};
use parking_lot::Mutex;
use serde::Deserialize;
use std::{
collections::HashMap,
pin::Pin,
sync::Arc,
time::{Duration, Instant},
};
use std::{collections::HashMap, num::NonZero, pin::Pin, sync::Arc, time::Instant};
use tower_http::compression::{CompressionLayer, DefaultPredicate};
use tracing::{trace, warn};
use crate::components::{
@@ -95,6 +92,11 @@ impl PageMetadata {
// MARK: page
//
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RequestContext {
pub client_info: ClientInfo,
}
// Some HTML
pub struct Page {
pub meta: PageMetadata,
@@ -114,7 +116,10 @@ pub struct Page {
pub generate_html: Box<
dyn Send
+ Sync
+ for<'a> Fn(&'a Page) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
+ for<'a> Fn(
&'a Page,
&'a RequestContext,
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
>,
}
@@ -125,14 +130,14 @@ impl Default for Page {
html_ttl: Some(TimeDelta::seconds(60 * 24 * 30)),
//css_ttl: Duration::from_secs(60 * 24 * 30),
//generate_css: None,
generate_html: Box::new(|_| Box::pin(async { html!() })),
generate_html: Box::new(|_, _| Box::pin(async { html!() })),
}
}
}
impl Page {
pub async fn generate_html(&self) -> Markup {
(self.generate_html)(self).await
pub async fn generate_html(&self, req_info: &RequestContext) -> Markup {
(self.generate_html)(self, req_info).await
}
pub fn from_markdown(md: impl Into<String>, default_image: Option<String>) -> Self {
@@ -154,7 +159,7 @@ impl Page {
Page {
meta,
generate_html: Box::new(move |page| {
generate_html: Box::new(move |page, _| {
let html = html.clone();
Box::pin(async move {
html! {
@@ -176,6 +181,13 @@ impl Page {
// MARK: server
//
// Rerender considerations:
// - rerendering often in the background is wasteful. Maybe we should fall asleep?
// - rerendering on request is slow
// - rerendering in the background after a request could be a good idea. Maybe implement?
//
// - cached pages only make sense for static assets.
// - user pages can't be pre-rendered!
pub struct PageServer {
/// If true, expired pages will be rerendered before being sent to the user.
/// If false, requests never trigger rerenders. We rely on the rerender task.
@@ -190,7 +202,7 @@ pub struct PageServer {
/// Map of `{ route: (page data, expire time) }`
///
/// We use an LruCache for bounded memory usage.
html_cache: RwLock<HashMap<String, (String, DateTime<Utc>)>>,
html_cache: Mutex<LruCache<(String, RequestContext), (String, DateTime<Utc>)>>,
/// Called whenever we need to render a page.
/// - this method should call `page.generate_html()`,
@@ -200,7 +212,10 @@ pub struct PageServer {
render_page: Box<
dyn Send
+ Sync
+ for<'a> Fn(&'a Page) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
+ for<'a> Fn(
&'a Page,
&'a RequestContext,
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
>,
}
@@ -209,12 +224,18 @@ impl PageServer {
render_page: Box<
dyn Send
+ Sync
+ for<'a> Fn(&'a Page) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
+ for<'a> Fn(
&'a Page,
&'a RequestContext,
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
>,
) -> Arc<Self> {
#[expect(clippy::unwrap_used)]
let cache_size = NonZero::new(128).unwrap();
Arc::new(Self {
pages: Arc::new(Mutex::new(HashMap::new())),
html_cache: RwLock::new(HashMap::new()),
html_cache: Mutex::new(LruCache::new(cache_size)),
render_page,
never_rerender_on_request: true,
})
@@ -236,7 +257,12 @@ impl PageServer {
/// Does nothing if there is no page at `route`.
///
/// Returns the rendered page's content.
async fn render_page(&self, reason: &'static str, route: &str) -> Option<String> {
async fn render_page(
&self,
reason: &'static str,
route: &str,
req_ctx: RequestContext,
) -> Option<(String, Option<DateTime<Utc>>)> {
let now = Utc::now();
let start = Instant::now();
trace!(message = "Rendering page", route, reason);
@@ -249,50 +275,19 @@ impl PageServer {
}
};
let html = (self.render_page)(&page).await.0;
let html = (self.render_page)(&page, &req_ctx).await.0;
let mut expires = None;
if let Some(ttl) = page.html_ttl {
expires = Some(now + ttl);
self.html_cache
.write()
.insert(route.to_owned(), (html.clone(), now + ttl));
.lock()
.put((route.to_owned(), req_ctx), (html.clone(), now + ttl));
}
let elapsed = start.elapsed().as_millis();
trace!(message = "Rendered page", route, reason, time_ms = elapsed);
return Some(html);
}
// Rerender considerations:
// - rerendering often in the background is wasteful. Maybe we should fall asleep?
// - rerendering on request is slow
// - rerendering in the background after a request could be a good idea. Maybe implement?
//
// - cached pages only make sense for static assets.
// - user pages can't be pre-rendered!
pub async fn start_rerender_task(self: Arc<Self>, interval: Duration) {
loop {
tokio::time::sleep(interval).await;
let now = Utc::now();
let pages = self
.pages
.lock()
.iter()
.filter(|(_, v)| v.html_ttl.is_some())
.map(|(k, _)| k.clone())
.collect::<Vec<_>>();
for route in pages {
let needs_render = match self.html_cache.read().get(&route) {
Some(x) => x.1 < now, // Expired
None => true, // Never rendered
};
if needs_render {
self.render_page("rerender_task", &route).await;
}
}
}
return Some((html, expires));
}
async fn handler(
@@ -301,30 +296,74 @@ impl PageServer {
ConnectInfo(addr): ConnectInfo<ServiceConnectInfo>,
headers: HeaderMap,
) -> Response {
trace!(message = "Serving route", route, addr = ?addr.addr, user_agent = ?headers["user-agent"]);
let client_info = ClientInfo::from_headers(&headers);
let ua = headers
.get("user-agent")
.and_then(|x| x.to_str().ok())
.unwrap_or("");
trace!(
message = "Serving route",
route,
addr = ?addr.addr,
user_agent = ua,
device_type = ?client_info.device_type
);
let req_ctx = RequestContext { client_info };
let cache_key = (route.clone(), req_ctx.clone());
let now = Utc::now();
let headers = [(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
)];
let mut html_expires = None;
if let Some((html, expires)) = state.html_cache.read().get(&route)
// Get from cache, if availablee
if let Some((html, expires)) = state.html_cache.lock().get(&cache_key)
&& (*expires > now || state.never_rerender_on_request)
{
// TODO: no clone?
return (headers, html.clone()).into_response();
html_expires = Some((html.clone(), Some(*expires)));
};
let html = match state.render_page("request", &route).await {
Some(x) => x.clone(),
None => return (StatusCode::NOT_FOUND, "page doesn't exist").into_response(),
if html_expires.is_none() {
html_expires = match state.render_page("request", &route, req_ctx).await {
Some(x) => Some(x.clone()),
None => return (StatusCode::NOT_FOUND, "page doesn't exist").into_response(),
};
}
#[expect(clippy::unwrap_used)]
let (html, expires) = html_expires.unwrap();
let mut headers = HeaderMap::with_capacity(3);
headers.append(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
let max_age = match expires {
Some(expires) => (expires - now).num_seconds().max(1),
None => 1,
};
#[expect(clippy::unwrap_used)]
headers.append(
header::CACHE_CONTROL,
// immutable; public/private
HeaderValue::from_str(&format!("immutable, public, max-age={}", max_age)).unwrap(),
);
headers.append("Accept-CH", HeaderValue::from_static("Sec-CH-UA-Mobile"));
return (headers, html).into_response();
}
pub fn into_router(self: Arc<Self>) -> Router<()> {
let compression: CompressionLayer = CompressionLayer::new()
.br(true)
.deflate(true)
.gzip(true)
.zstd(true)
.compress_when(DefaultPredicate::new());
Router::new()
.route(
"/",
@@ -333,6 +372,57 @@ impl PageServer {
}),
)
.route("/{*path}", get(Self::handler))
.layer(compression)
.with_state(self)
}
}
//
// MARK: UserAgent
//
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DeviceType {
Mobile,
Desktop,
}
impl Default for DeviceType {
fn default() -> Self {
Self::Desktop
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ClientInfo {
/// This is an estimate, but it's probably good enough.
pub device_type: DeviceType,
}
impl ClientInfo {
pub fn from_headers(headers: &HeaderMap) -> Self {
let ua = headers
.get("user-agent")
.and_then(|x| x.to_str().ok())
.unwrap_or("");
let ch_mobile = headers
.get("Sec-CH-UA-Mobile")
.and_then(|x| x.to_str().ok())
.unwrap_or("");
let mut device_type = None;
if device_type.is_none() && ch_mobile.contains("1") {
device_type = Some(DeviceType::Mobile);
}
if device_type.is_none() && ua.contains("Mobile") {
device_type = Some(DeviceType::Mobile);
}
Self {
device_type: device_type.unwrap_or_default(),
}
}
}

View File

@@ -1,8 +1,13 @@
use std::time::Instant;
use std::{
pin::Pin,
sync::Arc,
time::{Duration, Instant},
};
use assetserver::Asset;
use chrono::TimeDelta;
use chrono::{DateTime, TimeDelta, Utc};
use maud::{Markup, PreEscaped, html};
use parking_lot::Mutex;
use serde::Deserialize;
use tracing::{debug, warn};
@@ -11,7 +16,7 @@ use crate::{
md::Markdown,
misc::{Backlinks, FarLink},
},
page::{Page, PageMetadata},
page::{DeviceType, Page, PageMetadata, RequestContext},
routes::assets::Image_Icon,
};
@@ -23,6 +28,66 @@ struct HandoutEntry {
solutions: Option<String>,
}
struct CachedRequestInner<T> {
last_fetch: DateTime<Utc>,
last_value: Option<Arc<T>>,
}
pub struct CachedRequest<T> {
inner: Mutex<CachedRequestInner<T>>,
ttl: TimeDelta,
get: Box<dyn Fn() -> Pin<Box<dyn Future<Output = T> + Send + Sync>> + Send + Sync>,
}
impl<T> CachedRequest<T> {
pub fn new(
ttl: TimeDelta,
get: Box<dyn Fn() -> Pin<Box<dyn Future<Output = T> + Send + Sync>> + Send + Sync>,
) -> Arc<Self> {
Arc::new(Self {
get,
ttl,
inner: Mutex::new(CachedRequestInner {
last_fetch: Utc::now(),
last_value: None,
}),
})
}
pub async fn get(self: Arc<Self>) -> Arc<T> {
let now = Utc::now();
let expires = self.inner.lock().last_fetch + self.ttl;
if now < expires
&& let Some(last_value) = self.inner.lock().last_value.clone()
{
return last_value;
}
let res = Arc::new((self.get)().await);
let mut inner = self.inner.lock();
inner.last_fetch = now;
inner.last_value = Some(res.clone());
return res;
}
pub async fn autoget(self: Arc<Self>, interval: Duration) {
loop {
{
let now = Utc::now();
let res = Arc::new((self.get)().await);
let mut inner = self.inner.lock();
inner.last_fetch = now;
inner.last_value = Some(res.clone());
}
tokio::time::sleep(interval).await;
}
}
}
async fn get_index() -> Result<Vec<HandoutEntry>, reqwest::Error> {
let start = Instant::now();
let res = reqwest::get(
@@ -49,28 +114,59 @@ async fn get_index() -> Result<Vec<HandoutEntry>, reqwest::Error> {
return Ok(res);
}
fn build_list_for_group(handouts: &[HandoutEntry], group: &str) -> Markup {
html! {
ul class="handout-ul" {
fn build_list_for_group(
handouts: &[HandoutEntry],
group: &str,
req_ctx: &RequestContext,
) -> Markup {
let mobile = req_ctx.client_info.device_type == DeviceType::Mobile;
@for h in handouts {
@if h.group ==group {
li {
span class="handdout-li-title" {
strong { (h.title) }
}
" "
span class="handout-li-links" {
"[ "
if mobile {
html! {
ul class="handout-ul" {
@for h in handouts {
@if h.group ==group {
li {
span class="handout-li-title" {
a href=(h.handout) class="underline-link" {
strong style="text-decoration: underline;text-underline-offset:1.5pt;color:var(--fgColor);" { (h.title) }
}
}
@if let Some(solutions) = &h.solutions {
a href=(h.handout) {"handout"}
" | "
a href=(solutions) {"solutions"}
} @else {
a href=(h.handout) {"handout"}
" ["
a href=(solutions) { "sols" }
"]"
}
}
}
}
}
}
} else {
html! {
ul class="handout-ul" {
@for h in handouts {
@if h.group ==group {
li {
span class="handout-li-title" {
strong { (h.title) }
}
" "
span class="handout-li-links" {
"[ "
@if let Some(solutions) = &h.solutions {
a href=(h.handout) {"handout"}
" | "
a href=(solutions) {"solutions"}
} @else {
a href=(h.handout) {"handout"}
}
" ]"
}
" ]"
}
}
}
@@ -86,6 +182,13 @@ fn build_list_for_group(handouts: &[HandoutEntry], group: &str) -> Markup {
pub fn handouts() -> Page {
let md = Markdown::parse(include_str!("handouts.md"));
let index = CachedRequest::new(
TimeDelta::minutes(30),
Box::new(|| Box::pin(async move { get_index().await })),
);
tokio::spawn(index.clone().autoget(Duration::from_secs(60 * 20)));
#[expect(clippy::unwrap_used)]
let mut meta = PageMetadata::from_markdown_frontmatter(&md)
.unwrap()
@@ -101,44 +204,36 @@ pub fn handouts() -> Page {
meta,
html_ttl: Some(TimeDelta::seconds(300)),
generate_html: Box::new(move |page| {
generate_html: Box::new(move |page, req_ctx| {
let html = html.clone(); // TODO: find a way to not clone here
let index = index.clone();
Box::pin(async move {
let handouts = get_index().await;
let handouts = index.get().await;
let warmups = match &handouts {
Ok(handouts) => build_list_for_group(handouts, "Warm-Ups"),
let fallback = html! {
span style="color:var(--yellow)" {
"Could not load handouts, something broke."
}
" "
(
FarLink(
"https://git.betalupi.com/Mark/-/packages/generic/ormc-handouts/latest",
"Try this direct link."
)
)
};
let warmups = match &*handouts {
Ok(handouts) => build_list_for_group(handouts, "Warm-Ups", req_ctx),
Err(error) => {
warn!("Could not load handout index: {error:?}");
html! {
span style="color:var(--yellow)" {
"Could not load handouts, something broke."
}
" "
(
FarLink(
"https://git.betalupi.com/Mark/-/packages/generic/ormc-handouts/latest",
"Try this direct link."
)
)
}
fallback.clone()
}
};
let advanced = match &handouts {
Ok(handouts) => build_list_for_group(handouts, "Advanced"),
Err(_) => html! {
span style="color:var(--yellow)" {
"Could not load handouts, something broke."
}
" "
(
FarLink(
"https://git.betalupi.com/Mark/-/packages/generic/ormc-handouts/latest",
"Try this direct link."
)
)
},
let advanced = match &*handouts {
Ok(handouts) => build_list_for_group(handouts, "Advanced", req_ctx),
Err(_) => fallback,
};
html! {

View File

@@ -22,7 +22,7 @@ pub fn index() -> Page {
slug: None,
},
generate_html: Box::new(move |_page| {
generate_html: Box::new(move |_page, _| {
Box::pin(async {
html! {
h2 id="about" { "About" }

View File

@@ -1,4 +1,4 @@
use std::{pin::Pin, sync::Arc, time::Duration};
use std::{pin::Pin, sync::Arc};
use assetserver::Asset;
use axum::Router;
@@ -7,7 +7,7 @@ use tracing::info;
use crate::{
components::misc::FarLink,
page::{Page, PageServer},
page::{Page, PageServer, RequestContext},
pages,
routes::assets::Styles_Main,
};
@@ -18,9 +18,7 @@ pub(super) fn router() -> Router<()> {
let (asset_prefix, asset_router) = assets::asset_router();
info!("Serving assets at {asset_prefix}");
let server = build_server();
tokio::task::spawn(server.clone().start_rerender_task(Duration::from_secs(3)));
let router = server.into_router();
let router = build_server().into_router();
Router::new().merge(router).nest(asset_prefix, asset_router)
}
@@ -35,7 +33,10 @@ fn build_server() -> Arc<PageServer> {
server
}
fn page_wrapper<'a>(page: &'a Page) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>> {
fn page_wrapper<'a>(
page: &'a Page,
req_ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>> {
Box::pin(async move {
html! {
(DOCTYPE)
@@ -54,7 +55,7 @@ fn page_wrapper<'a>(page: &'a Page) -> Pin<Box<dyn Future<Output = Markup> + 'a
body {
div class="wrapper" {
main { ( page.generate_html().await ) }
main { ( page.generate_html(req_ctx).await ) }
footer {
hr class = "footline" {}