Refactor
Some checks failed
CI / Check links (push) Failing after 31s
CI / Check typos (push) Successful in 52s
CI / Clippy (push) Successful in 1m11s
CI / Build and test (push) Failing after 1m12s
CI / Build container (push) Has been skipped
CI / Deploy on waypoint (push) Has been skipped

This commit is contained in:
2025-11-05 08:59:20 -08:00
parent 063ea165d1
commit 2ee3ad3898
14 changed files with 280 additions and 245 deletions

View File

@@ -0,0 +1,20 @@
[package]
name = "page"
version = { workspace = true }
rust-version = { workspace = true }
edition = { workspace = true }
[lints]
workspace = true
[dependencies]
libservice = { workspace = true }
axum = { workspace = true }
tracing = { workspace = true }
maud = { workspace = true }
chrono = { workspace = true }
parking_lot = { workspace = true }
serde = { workspace = true }
lru = { workspace = true }
tower-http = { workspace = true }

View File

@@ -0,0 +1,8 @@
mod page;
pub use page::*;
mod requestcontext;
pub use requestcontext::*;
mod server;
pub use server::*;

105
crates/lib/page/src/page.rs Normal file
View File

@@ -0,0 +1,105 @@
use chrono::TimeDelta;
use maud::{Markup, Render, html};
use serde::Deserialize;
use std::pin::Pin;
use crate::RequestContext;
//
// MARK: metadata
//
#[derive(Debug, Clone, Hash, PartialEq, Eq, Deserialize)]
pub struct PageMetadata {
pub title: String,
pub author: Option<String>,
pub description: Option<String>,
pub image: Option<String>,
pub slug: Option<String>,
}
impl Default for PageMetadata {
fn default() -> Self {
Self {
title: "Untitled page".into(),
author: None,
description: None,
image: None,
slug: None,
}
}
}
impl Render for PageMetadata {
fn render(&self) -> Markup {
let empty = String::new();
let title = &self.title;
let author = &self.author.as_ref().unwrap_or(&empty);
let description = &self.description.as_ref().unwrap_or(&empty);
let image = &self.image.as_ref().unwrap_or(&empty);
html !(
meta property="og:site_name" content=(title) {}
meta name="title" content=(title) {}
meta property="og:title" content=(title) {}
meta property="twitter:title" content=(title) {}
meta name="author" content=(author) {}
meta name="description" content=(description) {}
meta property="og:description" content=(description) {}
meta property="twitter:description" content=(description) {}
meta content=(image) property="og:image" {}
link rel="shortcut icon" href=(image) type="image/x-icon" {}
)
}
}
//
// MARK: page
//
// Some HTML
pub struct Page {
pub meta: PageMetadata,
/// How long this page's html may be cached.
/// This controls the maximum age of a page shown to the user.
///
/// If `None`, this page is always rendered from scratch.
pub html_ttl: Option<TimeDelta>,
/// A function that generates this page's html.
///
/// This should return the contents of this page's <body> tag,
/// or the contents of a wrapper element (defined in the page server struct).
///
/// This closure must never return `<html>` or `<head>`.
pub generate_html: Box<
dyn Send
+ Sync
+ for<'a> Fn(
&'a Page,
&'a RequestContext,
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
>,
}
impl Default for Page {
fn default() -> Self {
Page {
meta: Default::default(),
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!() })),
}
}
}
impl Page {
pub async fn generate_html(&self, req_info: &RequestContext) -> Markup {
(self.generate_html)(self, req_info).await
}
}

View File

@@ -0,0 +1,60 @@
use axum::http::HeaderMap;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RequestContext {
pub client_info: ClientInfo,
}
//
//
//
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DeviceType {
Mobile,
Desktop,
}
impl Default for DeviceType {
fn default() -> Self {
Self::Desktop
}
}
//
// MARK: clientinfo
//
#[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

@@ -0,0 +1,206 @@
use axum::{
Router,
extract::{ConnectInfo, Path, State},
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
routing::get,
};
use chrono::{DateTime, Utc};
use libservice::ServiceConnectInfo;
use lru::LruCache;
use maud::Markup;
use parking_lot::Mutex;
use std::{collections::HashMap, num::NonZero, pin::Pin, sync::Arc, time::Instant};
use tower_http::compression::{CompressionLayer, DefaultPredicate};
use tracing::{trace, warn};
use crate::{ClientInfo, RequestContext, page::Page};
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.
///
/// If true, we deliver fresher pages but delay responses.
/// TODO: replace this with a smarter rendering strategy?
never_rerender_on_request: bool,
/// Map of `{ route: page }`
pages: Arc<Mutex<HashMap<String, Arc<Page>>>>,
/// Map of `{ route: (page data, expire time) }`
///
/// We use an LruCache for bounded memory usage.
html_cache: Mutex<LruCache<(String, RequestContext), (String, DateTime<Utc>)>>,
/// Called whenever we need to render a page.
/// - this method should call `page.generate_html()`,
/// - wrap the result in `<html><body>`,
/// - and add `<head>`
/// ```
render_page: Box<
dyn Send
+ Sync
+ for<'a> Fn(
&'a Page,
&'a RequestContext,
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
>,
}
impl PageServer {
pub fn new(
render_page: Box<
dyn 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: Mutex::new(LruCache::new(cache_size)),
render_page,
never_rerender_on_request: true,
})
}
pub fn add_page(&self, route: impl Into<String>, page: Page) -> &Self {
#[expect(clippy::expect_used)]
let route = route
.into()
.strip_prefix("/")
.expect("page route must start with /")
.to_owned();
self.pages.lock().insert(route, Arc::new(page));
self
}
/// Re-render the page at `route`, regardless of cache state.
/// 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,
req_ctx: RequestContext,
) -> Option<(String, Option<DateTime<Utc>>)> {
let now = Utc::now();
let start = Instant::now();
trace!(message = "Rendering page", route, reason);
let page = match self.pages.lock().get(route) {
Some(x) => x.clone(),
None => {
warn!(message = "Not rerendering, no such route", route, reason);
return None;
}
};
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
.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, expires));
}
async fn handler(
Path(route): Path<String>,
State(state): State<Arc<Self>>,
ConnectInfo(addr): ConnectInfo<ServiceConnectInfo>,
headers: HeaderMap,
) -> Response {
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 mut html_expires = None;
// Get from cache, if available
if let Some((html, expires)) = state.html_cache.lock().get(&cache_key)
&& (*expires > now || state.never_rerender_on_request)
{
html_expires = Some((html.clone(), Some(*expires)));
};
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(
"/",
get(|state, conn, headers| async {
Self::handler(Path(String::new()), state, conn, headers).await
}),
)
.route("/{*path}", get(Self::handler))
.layer(compression)
.with_state(self)
}
}