Render handout page on server
Some checks failed
CI / Check typos (push) Successful in 8s
CI / Check links (push) Successful in 7s
CI / Clippy (push) Failing after 1m7s
CI / Build and test (push) Successful in 1m2s
CI / Build container (push) Has been skipped
CI / Deploy on waypoint (push) Has been skipped
Some checks failed
CI / Check typos (push) Successful in 8s
CI / Check links (push) Successful in 7s
CI / Clippy (push) Failing after 1m7s
CI / Build and test (push) Successful in 1m2s
CI / Build container (push) Has been skipped
CI / Deploy on waypoint (push) Has been skipped
This commit is contained in:
@@ -9,14 +9,19 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
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;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use serde::Deserialize;
|
||||
use std::{collections::HashMap, num::NonZero, sync::Arc, time::Duration};
|
||||
use tracing::{debug, trace};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tracing::{trace, warn};
|
||||
|
||||
use crate::components::{
|
||||
md::{FrontMatter, Markdown},
|
||||
@@ -70,6 +75,23 @@ impl Render for PageMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
impl PageMetadata {
|
||||
/// Try to read page metadata from a markdown file's frontmatter.
|
||||
/// - returns `none` if there is no frontmatter
|
||||
/// - returns an error if we fail to parse frontmatter
|
||||
pub fn from_markdown_frontmatter(
|
||||
root_node: &Node,
|
||||
) -> Result<Option<PageMetadata>, serde_yaml::Error> {
|
||||
root_node
|
||||
.children
|
||||
.get(0)
|
||||
.map(|x| x.cast::<FrontMatter>())
|
||||
.flatten()
|
||||
.map(|x| serde_yaml::from_str::<PageMetadata>(&x.content))
|
||||
.map_or(Ok(None), |v| v.map(Some))
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: page
|
||||
//
|
||||
@@ -79,9 +101,10 @@ 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<Duration>,
|
||||
pub html_ttl: Option<TimeDelta>,
|
||||
|
||||
/// A function that generates this page's html.
|
||||
///
|
||||
@@ -89,41 +112,40 @@ pub struct Page {
|
||||
/// 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 + Fn(&Self) -> Markup>,
|
||||
pub generate_html: Box<
|
||||
dyn Send
|
||||
+ Sync
|
||||
+ for<'a> Fn(&'a Page) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
|
||||
>,
|
||||
}
|
||||
|
||||
impl Default for Page {
|
||||
fn default() -> Self {
|
||||
Page {
|
||||
meta: Default::default(),
|
||||
html_ttl: Some(Duration::from_secs(60 * 24 * 30)),
|
||||
html_ttl: Some(TimeDelta::seconds(60 * 24 * 30)),
|
||||
//css_ttl: Duration::from_secs(60 * 24 * 30),
|
||||
//generate_css: None,
|
||||
generate_html: Box::new(|_| html!()),
|
||||
generate_html: Box::new(|_| Box::pin(async { html!() })),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Page {
|
||||
pub fn generate_html(&self) -> Markup {
|
||||
(self.generate_html)(self)
|
||||
pub async fn generate_html(&self) -> Markup {
|
||||
(self.generate_html)(self).await
|
||||
}
|
||||
|
||||
pub fn from_markdown(md: impl Into<String>, default_image: Option<String>) -> Self {
|
||||
let md: String = md.into();
|
||||
let md = Markdown::parse(&md);
|
||||
|
||||
let mut meta = md
|
||||
.children
|
||||
.get(0)
|
||||
.map(|x| x.cast::<FrontMatter>())
|
||||
.flatten()
|
||||
.map(|x| serde_yaml::from_str::<PageMetadata>(&x.content))
|
||||
.unwrap_or(Ok(Default::default()))
|
||||
.unwrap_or(PageMetadata {
|
||||
let mut meta = PageMetadata::from_markdown_frontmatter(&md)
|
||||
.unwrap_or(Some(PageMetadata {
|
||||
title: "Invalid frontmatter!".into(),
|
||||
..Default::default()
|
||||
});
|
||||
}))
|
||||
.unwrap_or(Default::default());
|
||||
|
||||
if meta.image.is_none() {
|
||||
meta.image = default_image
|
||||
@@ -134,13 +156,16 @@ impl Page {
|
||||
Page {
|
||||
meta,
|
||||
generate_html: Box::new(move |page| {
|
||||
html! {
|
||||
@if let Some(slug) = &page.meta.slug {
|
||||
(Backlinks(&[("/", "home")], slug))
|
||||
}
|
||||
let html = html.clone();
|
||||
Box::pin(async move {
|
||||
html! {
|
||||
@if let Some(slug) = &page.meta.slug {
|
||||
(Backlinks(&[("/", "home")], slug))
|
||||
}
|
||||
|
||||
(html)
|
||||
}
|
||||
(html)
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
..Default::default()
|
||||
@@ -153,35 +178,50 @@ impl 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: HashMap<String, 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, (String, DateTime<Utc>)>>,
|
||||
html_cache: RwLock<HashMap<String, (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 + Fn(&Page) -> Markup>,
|
||||
render_page: Box<
|
||||
dyn Send
|
||||
+ Sync
|
||||
+ for<'a> Fn(&'a Page) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
|
||||
>,
|
||||
}
|
||||
|
||||
impl PageServer {
|
||||
pub fn new(page_wrapper: Box<dyn Send + Sync + Fn(&Page) -> Markup>) -> Self {
|
||||
#[expect(clippy::unwrap_used)]
|
||||
let cache_size = LruCache::new(NonZero::new(128).unwrap());
|
||||
|
||||
Self {
|
||||
pages: HashMap::new(),
|
||||
html_cache: Mutex::new(cache_size),
|
||||
render_page: Box::new(page_wrapper),
|
||||
}
|
||||
pub fn new(
|
||||
render_page: Box<
|
||||
dyn Send
|
||||
+ Sync
|
||||
+ for<'a> Fn(&'a Page) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
|
||||
>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
pages: Arc::new(Mutex::new(HashMap::new())),
|
||||
html_cache: RwLock::new(HashMap::new()),
|
||||
render_page,
|
||||
never_rerender_on_request: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add_page(mut self, route: impl Into<String>, page: Page) -> Self {
|
||||
pub fn add_page(&self, route: impl Into<String>, page: Page) -> &Self {
|
||||
#[expect(clippy::expect_used)]
|
||||
let route = route
|
||||
.into()
|
||||
@@ -189,24 +229,79 @@ impl PageServer {
|
||||
.expect("page route must start with /")
|
||||
.to_owned();
|
||||
|
||||
self.pages.insert(route, page);
|
||||
|
||||
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) -> Option<String> {
|
||||
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).await.0;
|
||||
|
||||
if let Some(ttl) = page.html_ttl {
|
||||
self.html_cache
|
||||
.write()
|
||||
.insert(route.to_owned(), (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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handler(
|
||||
Path(path): Path<String>,
|
||||
Path(route): Path<String>,
|
||||
State(state): State<Arc<Self>>,
|
||||
ConnectInfo(addr): ConnectInfo<ServiceConnectInfo>,
|
||||
) -> Response {
|
||||
trace!("Serving {path} to {}", addr.addr);
|
||||
|
||||
let page = match state.pages.get(&path) {
|
||||
Some(x) => x,
|
||||
|
||||
// TODO: 404 page
|
||||
None => return (StatusCode::NOT_FOUND, "page doesn't exist").into_response(),
|
||||
};
|
||||
trace!("Serving {route} to {}", addr.addr);
|
||||
|
||||
let now = Utc::now();
|
||||
let headers = [(
|
||||
@@ -214,30 +309,28 @@ impl PageServer {
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
)];
|
||||
|
||||
if let Some((html, expires)) = state.html_cache.lock().get(&path)
|
||||
&& *expires > now
|
||||
if let Some((html, expires)) = state.html_cache.read().get(&route)
|
||||
&& (*expires > now || state.never_rerender_on_request)
|
||||
{
|
||||
// TODO: no clone?
|
||||
return (headers, html.clone()).into_response();
|
||||
};
|
||||
|
||||
debug!("Rendering {path}");
|
||||
let html = (state.render_page)(page).0;
|
||||
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 let Some(ttl) = page.html_ttl {
|
||||
state.html_cache.lock().put(path, (html.clone(), now + ttl));
|
||||
}
|
||||
|
||||
return (headers, html.clone()).into_response();
|
||||
return (headers, html).into_response();
|
||||
}
|
||||
|
||||
pub fn into_router(self) -> Router<()> {
|
||||
pub fn into_router(self: Arc<Self>) -> Router<()> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/",
|
||||
get(|state, conn| async { Self::handler(Path(String::new()), state, conn).await }),
|
||||
)
|
||||
.route("/{*path}", get(Self::handler))
|
||||
.with_state(Arc::new(self))
|
||||
.with_state(self)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user