Generic servable
Some checks failed
CI / Check typos (push) Successful in 8s
CI / Check links (push) Failing after 10s
CI / Clippy (push) Successful in 55s
CI / Build and test (push) Successful in 1m10s
CI / Build container (push) Successful in 52s
CI / Deploy on waypoint (push) Successful in 46s

This commit is contained in:
2025-11-07 09:33:26 -08:00
parent a3ff195de9
commit 83f42ac19f
10 changed files with 263 additions and 161 deletions

View File

@@ -1,5 +1,5 @@
mod page; mod servable;
pub use page::*; pub use servable::*;
mod requestcontext; mod requestcontext;
pub use requestcontext::*; pub use requestcontext::*;

View File

@@ -0,0 +1,2 @@
pub mod page;
pub mod redirect;

View File

@@ -1,9 +1,13 @@
use axum::http::{
HeaderMap, HeaderValue, StatusCode,
header::{self},
};
use chrono::TimeDelta; use chrono::TimeDelta;
use maud::{Markup, Render, html}; use maud::{Markup, Render, html};
use serde::Deserialize; use serde::Deserialize;
use std::pin::Pin; use std::pin::Pin;
use crate::RequestContext; use crate::{Rendered, RequestContext, Servable};
// //
// MARK: metadata // MARK: metadata
@@ -99,7 +103,31 @@ impl Default for Page {
} }
impl Page { impl Page {
pub async fn generate_html(&self, req_info: &RequestContext) -> Markup { pub async fn generate_html(&self, ctx: &RequestContext) -> Markup {
(self.generate_html)(self, req_info).await (self.generate_html)(self, ctx).await
}
}
impl Servable for Page {
fn render<'a>(
&'a self,
ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = crate::Rendered> + 'a + Send + Sync>> {
Box::pin(async {
let mut headers = HeaderMap::with_capacity(3);
let html = self.generate_html(ctx).await;
headers.append(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
return Rendered {
code: StatusCode::OK,
headers,
body: html.0.into_bytes(),
ttl: self.html_ttl,
};
})
} }
} }

View File

@@ -0,0 +1,39 @@
use std::pin::Pin;
use axum::http::{
HeaderMap, HeaderValue, StatusCode,
header::{self, InvalidHeaderValue},
};
use crate::{Rendered, RequestContext, Servable};
pub struct Redirect {
to: HeaderValue,
}
impl Redirect {
pub fn new(to: impl Into<String>) -> Result<Self, InvalidHeaderValue> {
Ok(Self {
to: HeaderValue::from_str(&to.into())?,
})
}
}
impl Servable for Redirect {
fn render<'a>(
&'a self,
_ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>> {
Box::pin(async {
let mut headers = HeaderMap::with_capacity(1);
headers.append(header::LOCATION, self.to.clone());
return Rendered {
code: StatusCode::PERMANENT_REDIRECT,
headers,
body: Vec::new(),
ttl: None,
};
})
}
}

View File

@@ -5,16 +5,31 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::get, routing::get,
}; };
use chrono::{DateTime, Utc}; use chrono::{DateTime, TimeDelta, Utc};
use libservice::ServiceConnectInfo; use libservice::ServiceConnectInfo;
use lru::LruCache; use lru::LruCache;
use maud::Markup;
use parking_lot::Mutex; use parking_lot::Mutex;
use std::{collections::HashMap, num::NonZero, pin::Pin, sync::Arc, time::Instant}; use std::{collections::HashMap, num::NonZero, pin::Pin, sync::Arc, time::Instant};
use tower_http::compression::{CompressionLayer, DefaultPredicate}; use tower_http::compression::{CompressionLayer, DefaultPredicate};
use tracing::trace; use tracing::trace;
use crate::{ClientInfo, RequestContext, page::Page}; use crate::{ClientInfo, RequestContext};
#[derive(Clone)]
pub struct Rendered {
pub code: StatusCode,
pub headers: HeaderMap,
pub body: Vec<u8>,
pub ttl: Option<TimeDelta>,
}
pub trait Servable: Send + Sync {
fn render<'a>(
&'a self,
ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>>;
}
pub struct PageServer { pub struct PageServer {
/// If true, expired pages will be rerendered before being sent to the user. /// If true, expired pages will be rerendered before being sent to the user.
@@ -25,56 +40,32 @@ pub struct PageServer {
never_rerender_on_request: bool, never_rerender_on_request: bool,
/// Map of `{ route: page }` /// Map of `{ route: page }`
pages: Arc<Mutex<HashMap<String, Arc<Page>>>>, pages: Arc<Mutex<HashMap<String, Arc<dyn Servable>>>>,
/// Map of `{ route: (page data, expire time) }` /// Map of `{ route: (page data, expire time) }`
/// ///
/// We use an LruCache for bounded memory usage. /// We use an LruCache for bounded memory usage.
html_cache: Mutex<LruCache<(String, RequestContext), (String, DateTime<Utc>)>>, page_cache: Mutex<LruCache<RequestContext, (Rendered, 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 { impl PageServer {
pub fn new( pub fn new() -> Arc<Self> {
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)] #[expect(clippy::unwrap_used)]
let cache_size = NonZero::new(128).unwrap(); let cache_size = NonZero::new(128).unwrap();
Arc::new(Self { Arc::new(Self {
pages: Arc::new(Mutex::new(HashMap::new())), pages: Arc::new(Mutex::new(HashMap::new())),
html_cache: Mutex::new(LruCache::new(cache_size)), page_cache: Mutex::new(LruCache::new(cache_size)),
render_page,
never_rerender_on_request: true, never_rerender_on_request: true,
}) })
} }
pub fn add_page(&self, route: impl Into<String>, page: Page) -> &Self { pub fn add_page<S: Servable + 'static>(&self, route: impl Into<String>, page: S) -> &Self {
#[expect(clippy::expect_used)] #[expect(clippy::expect_used)]
let route = route let route = route
.into() .into()
.strip_prefix("/") .strip_prefix("/")
.expect("page route must start with /") .expect("route must start with /")
.to_owned(); .to_owned();
self.pages.lock().insert(route, Arc::new(page)); self.pages.lock().insert(route, Arc::new(page));
@@ -89,8 +80,8 @@ impl PageServer {
&self, &self,
reason: &'static str, reason: &'static str,
route: &str, route: &str,
req_ctx: RequestContext, ctx: RequestContext,
) -> Option<(String, Option<DateTime<Utc>>)> { ) -> Option<(Rendered, Option<DateTime<Utc>>)> {
let now = Utc::now(); let now = Utc::now();
let start = Instant::now(); let start = Instant::now();
let page = match self.pages.lock().get(route) { let page = match self.pages.lock().get(route) {
@@ -105,19 +96,20 @@ impl PageServer {
lock_time_ms = start.elapsed().as_millis() lock_time_ms = start.elapsed().as_millis()
); );
let html = (self.render_page)(&page, &req_ctx).await.0; let rendered = page.render(&ctx).await;
//let html = (self.render_page)(&page, &req_ctx).await.0;
let mut expires = None; let mut expires = None;
if let Some(ttl) = page.html_ttl { if let Some(ttl) = rendered.ttl {
expires = Some(now + ttl); expires = Some(now + ttl);
self.html_cache self.page_cache
.lock() .lock()
.put((route.to_owned(), req_ctx), (html.clone(), now + ttl)); .put(ctx, (rendered.clone(), now + ttl));
} }
let elapsed = start.elapsed().as_millis(); let elapsed = start.elapsed().as_millis();
trace!(message = "Rendered page", route, reason, time_ms = elapsed); trace!(message = "Rendered page", route, reason, time_ms = elapsed);
return Some((html, expires)); return Some((rendered, expires));
} }
async fn handler( async fn handler(
@@ -126,6 +118,7 @@ impl PageServer {
ConnectInfo(addr): ConnectInfo<ServiceConnectInfo>, ConnectInfo(addr): ConnectInfo<ServiceConnectInfo>,
headers: HeaderMap, headers: HeaderMap,
) -> Response { ) -> Response {
let start = Instant::now();
let client_info = ClientInfo::from_headers(&headers); let client_info = ClientInfo::from_headers(&headers);
let ua = headers let ua = headers
.get("user-agent") .get("user-agent")
@@ -172,24 +165,23 @@ impl PageServer {
return (StatusCode::PERMANENT_REDIRECT, headers).into_response(); return (StatusCode::PERMANENT_REDIRECT, headers).into_response();
} }
let req_ctx = RequestContext { let ctx = RequestContext {
client_info, client_info,
route: format!("/{route}"), route: format!("/{route}"),
}; };
let cache_key = (route.clone(), req_ctx.clone());
let now = Utc::now(); let now = Utc::now();
let mut html_expires = None; let mut html_expires = None;
// Get from cache, if available // Get from cache, if available
if let Some((html, expires)) = state.html_cache.lock().get(&cache_key) if let Some((html, expires)) = state.page_cache.lock().get(&ctx)
&& (*expires > now || state.never_rerender_on_request) && (*expires > now || state.never_rerender_on_request)
{ {
html_expires = Some((html.clone(), Some(*expires))); html_expires = Some((html.clone(), Some(*expires)));
}; };
if html_expires.is_none() { if html_expires.is_none() {
html_expires = match state.render_page("request", &route, req_ctx).await { html_expires = match state.render_page("request", &route, ctx).await {
Some(x) => Some(x.clone()), Some(x) => Some(x.clone()),
None => { None => {
trace!( trace!(
@@ -200,19 +192,22 @@ impl PageServer {
device_type = ?client_info.device_type device_type = ?client_info.device_type
); );
return (StatusCode::NOT_FOUND, "page doesn't exist").into_response(); trace!(
message = "Served route",
route,
addr = ?addr.addr,
user_agent = ua,
device_type = ?client_info.device_type,
time_ms = start.elapsed().as_millis()
);
return StatusCode::NOT_FOUND.into_response();
} }
}; };
} }
#[expect(clippy::unwrap_used)] #[expect(clippy::unwrap_used)]
let (html, expires) = html_expires.unwrap(); let (mut 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 { let max_age = match expires {
Some(expires) => (expires - now).num_seconds().max(1), Some(expires) => (expires - now).num_seconds().max(1),
@@ -220,15 +215,25 @@ impl PageServer {
}; };
#[expect(clippy::unwrap_used)] #[expect(clippy::unwrap_used)]
headers.append( html.headers.insert(
header::CACHE_CONTROL, header::CACHE_CONTROL,
// immutable; public/private // immutable; public/private
HeaderValue::from_str(&format!("immutable, public, max-age={}", max_age)).unwrap(), HeaderValue::from_str(&format!("immutable, public, max-age={}", max_age)).unwrap(),
); );
headers.append("Accept-CH", HeaderValue::from_static("Sec-CH-UA-Mobile")); html.headers
.insert("Accept-CH", HeaderValue::from_static("Sec-CH-UA-Mobile"));
return (headers, html).into_response(); trace!(
message = "Served route",
route,
addr = ?addr.addr,
user_agent = ua,
device_type = ?client_info.device_type,
time_ms = start.elapsed().as_millis()
);
return (html.code, html.headers, html.body).into_response();
} }
pub fn into_router(self: Arc<Self>) -> Router<()> { pub fn into_router(self: Arc<Self>) -> Router<()> {

View File

@@ -2,7 +2,8 @@ use lazy_static::lazy_static;
use markdown_it::generics::inline::full_link; use markdown_it::generics::inline::full_link;
use markdown_it::{MarkdownIt, Node}; use markdown_it::{MarkdownIt, Node};
use maud::{Markup, PreEscaped, Render, html}; use maud::{Markup, PreEscaped, Render, html};
use page::{Page, PageMetadata, RequestContext}; use page::RequestContext;
use page::page::{Page, PageMetadata};
use crate::components::md::emote::InlineEmote; use crate::components::md::emote::InlineEmote;
use crate::components::md::frontmatter::{TomlFrontMatter, YamlFrontMatter}; use crate::components::md::frontmatter::{TomlFrontMatter, YamlFrontMatter};
@@ -101,43 +102,6 @@ pub fn meta_from_markdown(root_node: &Node) -> Result<Option<PageMetadata>, toml
.map_or(Ok(None), |v| v.map(Some)) .map_or(Ok(None), |v| v.map(Some))
} }
pub fn page_from_markdown(md: impl Into<String>, default_image: Option<String>) -> Page {
let md: String = md.into();
let md = Markdown::parse(&md);
let mut meta = meta_from_markdown(&md)
.unwrap_or(Some(PageMetadata {
title: "Invalid frontmatter!".into(),
..Default::default()
}))
.unwrap_or_default();
if meta.image.is_none() {
meta.image = default_image
}
let html = PreEscaped(md.render());
Page {
meta,
generate_html: Box::new(move |page, ctx| {
let html = html.clone();
Box::pin(async move {
html! {
@if let Some(backlinks) = backlinks(page, ctx) {
(backlinks)
}
(html)
}
})
}),
..Default::default()
}
}
pub fn backlinks(page: &Page, ctx: &RequestContext) -> Option<Markup> { pub fn backlinks(page: &Page, ctx: &RequestContext) -> Option<Markup> {
let mut last = None; let mut last = None;
let mut backlinks = vec![("/", "home")]; let mut backlinks = vec![("/", "home")];

View File

@@ -7,7 +7,7 @@ use std::{
use assetserver::Asset; use assetserver::Asset;
use chrono::{DateTime, TimeDelta, Utc}; use chrono::{DateTime, TimeDelta, Utc};
use maud::{Markup, PreEscaped, html}; use maud::{Markup, PreEscaped, html};
use page::{DeviceType, Page, RequestContext}; use page::{DeviceType, RequestContext, page::Page};
use parking_lot::Mutex; use parking_lot::Mutex;
use serde::Deserialize; use serde::Deserialize;
use tracing::{debug, warn}; use tracing::{debug, warn};
@@ -17,6 +17,7 @@ use crate::{
md::{Markdown, backlinks, meta_from_markdown}, md::{Markdown, backlinks, meta_from_markdown},
misc::FarLink, misc::FarLink,
}, },
pages::page_wrapper,
routes::assets::Image_Icon, routes::assets::Image_Icon,
}; };
@@ -234,7 +235,7 @@ pub fn handouts() -> Page {
Err(_) => fallback, Err(_) => fallback,
}; };
html! { let inner = html! {
@if let Some(backlinks) = backlinks(page, ctx) { @if let Some(backlinks) = backlinks(page, ctx) {
(backlinks) (backlinks)
} }
@@ -260,7 +261,9 @@ pub fn handouts() -> Page {
))) )))
(advanced) (advanced)
br {} br {}
} };
page_wrapper(&page.meta, inner).await
}) })
}), }),
} }

View File

@@ -1,6 +1,6 @@
use assetserver::Asset; use assetserver::Asset;
use maud::html; use maud::html;
use page::{Page, PageMetadata}; use page::page::{Page, PageMetadata};
use crate::{ use crate::{
components::{ components::{
@@ -9,6 +9,7 @@ use crate::{
md::Markdown, md::Markdown,
misc::FarLink, misc::FarLink,
}, },
pages::page_wrapper,
routes::assets::{Image_Cover, Image_Icon}, routes::assets::{Image_Cover, Image_Icon},
}; };
@@ -22,9 +23,9 @@ pub fn index() -> Page {
backlinks: Some(false), backlinks: Some(false),
}, },
generate_html: Box::new(move |_page, _| { generate_html: Box::new(move |page, _ctx| {
Box::pin(async { Box::pin(async {
html! { let inner = html! {
h2 id="about" { "About" } h2 id="about" { "About" }
div { div {
@@ -70,9 +71,12 @@ pub fn index() -> Page {
} }
(Markdown(include_str!("index.md"))) (Markdown(include_str!("index.md")))
} };
page_wrapper(&page.meta, inner).await
}) })
}), }),
..Default::default() ..Default::default()
} }
} }

View File

@@ -1,7 +1,16 @@
use assetserver::Asset; use assetserver::Asset;
use page::Page; use chrono::TimeDelta;
use maud::{DOCTYPE, Markup, PreEscaped, html};
use page::page::{Page, PageMetadata};
use std::pin::Pin;
use crate::{components::md::page_from_markdown, routes::assets::Image_Icon}; use crate::{
components::{
md::{Markdown, backlinks, meta_from_markdown},
misc::FarLink,
},
routes::assets::{Image_Icon, Styles_Main},
};
mod handouts; mod handouts;
mod index; mod index;
@@ -33,3 +42,97 @@ pub fn htwah_typesetting() -> Page {
Some(Image_Icon::URL.to_owned()), Some(Image_Icon::URL.to_owned()),
) )
} }
//
// MARK: md
//
fn page_from_markdown(md: impl Into<String>, default_image: Option<String>) -> Page {
let md: String = md.into();
let md = Markdown::parse(&md);
let mut meta = meta_from_markdown(&md)
.unwrap_or(Some(PageMetadata {
title: "Invalid frontmatter!".into(),
..Default::default()
}))
.unwrap_or_default();
if meta.image.is_none() {
meta.image = default_image
}
let html = PreEscaped(md.render());
Page {
meta,
html_ttl: Some(TimeDelta::seconds(60 * 24 * 30)),
generate_html: Box::new(move |page, ctx| {
let html = html.clone();
Box::pin(async move {
let inner = html! {
@if let Some(backlinks) = backlinks(page, ctx) {
(backlinks)
}
(html)
};
page_wrapper(&page.meta, inner).await
})
}),
}
}
//
// MARK: wrapper
//
pub fn page_wrapper<'a>(
meta: &'a PageMetadata,
inner: Markup,
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>> {
Box::pin(async move {
html! {
(DOCTYPE)
html {
head {
meta charset="UTF" {}
meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" {}
meta content="text/html; charset=UTF-8" http-equiv="content-type" {}
meta property="og:type" content="website" {}
link rel="stylesheet" href=(Styles_Main::URL) {}
(&meta)
title { (PreEscaped(meta.title.clone())) }
}
body {
div class="wrapper" {
main { (inner) }
footer {
hr class = "footline" {}
div class = "footContainer" {
p {
"This site was built by hand using "
(FarLink("https://rust-lang.org", "Rust"))
", "
(FarLink("https://maud.lambda.xyz", "Maud"))
", "
(FarLink("https://github.com/connorskees/grass", "Grass"))
", and "
(FarLink("https://docs.rs/axum/latest/axum", "Axum"))
"."
}
}
}
}
}
}
}
})
}

View File

@@ -1,12 +1,9 @@
use std::{pin::Pin, sync::Arc};
use assetserver::Asset;
use axum::Router; use axum::Router;
use maud::{DOCTYPE, Markup, PreEscaped, html}; use page::{PageServer, redirect::Redirect};
use page::{Page, PageServer, RequestContext}; use std::sync::Arc;
use tracing::info; use tracing::info;
use crate::{components::misc::FarLink, pages, routes::assets::Styles_Main}; use crate::pages;
pub mod assets; pub mod assets;
@@ -20,63 +17,20 @@ pub(super) fn router() -> Router<()> {
} }
fn build_server() -> Arc<PageServer> { fn build_server() -> Arc<PageServer> {
let server = PageServer::new(Box::new(page_wrapper)); let server = PageServer::new();
#[expect(clippy::unwrap_used)]
server server
.add_page("/", pages::index()) .add_page("/", pages::index())
.add_page("/links", pages::links()) .add_page("/links", pages::links())
.add_page("/whats-a-betalupi", pages::betalupi()) .add_page("/whats-a-betalupi", pages::betalupi())
.add_page("/handouts", pages::handouts()) .add_page("/handouts", pages::handouts())
.add_page("/htwah", Redirect::new("/handouts").unwrap())
.add_page("/htwah/typesetting", pages::htwah_typesetting()); .add_page("/htwah/typesetting", pages::htwah_typesetting());
server server
} }
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)
html {
head {
meta charset="UTF" {}
meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" {}
meta content="text/html; charset=UTF-8" http-equiv="content-type" {}
meta property="og:type" content="website" {}
link rel="stylesheet" href=(Styles_Main::URL) {}
(&page.meta)
title { (PreEscaped(page.meta.title.clone())) }
}
body {
div class="wrapper" {
main { ( page.generate_html(req_ctx).await ) }
footer {
hr class = "footline" {}
div class = "footContainer" {
p {
"This site was built by hand using "
(FarLink("https://rust-lang.org", "Rust"))
", "
(FarLink("https://maud.lambda.xyz", "Maud"))
", "
(FarLink("https://github.com/connorskees/grass", "Grass"))
", and "
(FarLink("https://docs.rs/axum/latest/axum", "Axum"))
"."
}
}
}
}
}
}
}
})
}
#[test] #[test]
fn server_builds_without_panic() { fn server_builds_without_panic() {
tokio::runtime::Builder::new_current_thread() tokio::runtime::Builder::new_current_thread()