Generic servable
Some checks failed
CI / Check typos (push) Successful in 8s
CI / Check links (push) Failing after 11s
CI / Clippy (push) Successful in 53s
CI / Build and test (push) Successful in 1m10s
CI / Build container (push) Successful in 54s
CI / Deploy on waypoint (push) Successful in 43s
Some checks failed
CI / Check typos (push) Successful in 8s
CI / Check links (push) Failing after 11s
CI / Clippy (push) Successful in 53s
CI / Build and test (push) Successful in 1m10s
CI / Build container (push) Successful in 54s
CI / Deploy on waypoint (push) Successful in 43s
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
mod page;
|
||||
pub use page::*;
|
||||
mod servable;
|
||||
pub use servable::*;
|
||||
|
||||
mod requestcontext;
|
||||
pub use requestcontext::*;
|
||||
|
||||
2
crates/lib/page/src/servable/mod.rs
Normal file
2
crates/lib/page/src/servable/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod page;
|
||||
pub mod redirect;
|
||||
@@ -1,9 +1,13 @@
|
||||
use axum::http::{
|
||||
HeaderMap, HeaderValue, StatusCode,
|
||||
header::{self},
|
||||
};
|
||||
use chrono::TimeDelta;
|
||||
use maud::{Markup, Render, html};
|
||||
use serde::Deserialize;
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::RequestContext;
|
||||
use crate::{Rendered, RequestContext, Servable};
|
||||
|
||||
//
|
||||
// MARK: metadata
|
||||
@@ -99,7 +103,31 @@ impl Default for Page {
|
||||
}
|
||||
|
||||
impl Page {
|
||||
pub async fn generate_html(&self, req_info: &RequestContext) -> Markup {
|
||||
(self.generate_html)(self, req_info).await
|
||||
pub async fn generate_html(&self, ctx: &RequestContext) -> Markup {
|
||||
(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,
|
||||
};
|
||||
})
|
||||
}
|
||||
}
|
||||
39
crates/lib/page/src/servable/redirect.rs
Normal file
39
crates/lib/page/src/servable/redirect.rs
Normal 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,
|
||||
};
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,31 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono::{DateTime, TimeDelta, 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;
|
||||
|
||||
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 {
|
||||
/// 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,
|
||||
|
||||
/// 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) }`
|
||||
///
|
||||
/// 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>>,
|
||||
>,
|
||||
page_cache: Mutex<LruCache<RequestContext, (Rendered, DateTime<Utc>)>>,
|
||||
}
|
||||
|
||||
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> {
|
||||
pub fn new() -> 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,
|
||||
page_cache: Mutex::new(LruCache::new(cache_size)),
|
||||
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)]
|
||||
let route = route
|
||||
.into()
|
||||
.strip_prefix("/")
|
||||
.expect("page route must start with /")
|
||||
.expect("route must start with /")
|
||||
.to_owned();
|
||||
|
||||
self.pages.lock().insert(route, Arc::new(page));
|
||||
@@ -89,8 +80,8 @@ impl PageServer {
|
||||
&self,
|
||||
reason: &'static str,
|
||||
route: &str,
|
||||
req_ctx: RequestContext,
|
||||
) -> Option<(String, Option<DateTime<Utc>>)> {
|
||||
ctx: RequestContext,
|
||||
) -> Option<(Rendered, Option<DateTime<Utc>>)> {
|
||||
let now = Utc::now();
|
||||
let start = Instant::now();
|
||||
let page = match self.pages.lock().get(route) {
|
||||
@@ -105,19 +96,20 @@ impl PageServer {
|
||||
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;
|
||||
if let Some(ttl) = page.html_ttl {
|
||||
if let Some(ttl) = rendered.ttl {
|
||||
expires = Some(now + ttl);
|
||||
self.html_cache
|
||||
self.page_cache
|
||||
.lock()
|
||||
.put((route.to_owned(), req_ctx), (html.clone(), now + ttl));
|
||||
.put(ctx, (rendered.clone(), now + ttl));
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed().as_millis();
|
||||
trace!(message = "Rendered page", route, reason, time_ms = elapsed);
|
||||
return Some((html, expires));
|
||||
return Some((rendered, expires));
|
||||
}
|
||||
|
||||
async fn handler(
|
||||
@@ -126,6 +118,7 @@ impl PageServer {
|
||||
ConnectInfo(addr): ConnectInfo<ServiceConnectInfo>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let start = Instant::now();
|
||||
let client_info = ClientInfo::from_headers(&headers);
|
||||
let ua = headers
|
||||
.get("user-agent")
|
||||
@@ -172,24 +165,23 @@ impl PageServer {
|
||||
return (StatusCode::PERMANENT_REDIRECT, headers).into_response();
|
||||
}
|
||||
|
||||
let req_ctx = RequestContext {
|
||||
let ctx = RequestContext {
|
||||
client_info,
|
||||
route: format!("/{route}"),
|
||||
};
|
||||
|
||||
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)
|
||||
if let Some((html, expires)) = state.page_cache.lock().get(&ctx)
|
||||
&& (*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 {
|
||||
html_expires = match state.render_page("request", &route, ctx).await {
|
||||
Some(x) => Some(x.clone()),
|
||||
None => {
|
||||
trace!(
|
||||
@@ -200,19 +192,22 @@ impl PageServer {
|
||||
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_ns = start.elapsed().as_nanos()
|
||||
);
|
||||
|
||||
return StatusCode::NOT_FOUND.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 (mut html, expires) = html_expires.unwrap();
|
||||
|
||||
let max_age = match expires {
|
||||
Some(expires) => (expires - now).num_seconds().max(1),
|
||||
@@ -220,15 +215,25 @@ impl PageServer {
|
||||
};
|
||||
|
||||
#[expect(clippy::unwrap_used)]
|
||||
headers.append(
|
||||
html.headers.insert(
|
||||
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"));
|
||||
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_ns = start.elapsed().as_nanos()
|
||||
);
|
||||
|
||||
return (html.code, html.headers, html.body).into_response();
|
||||
}
|
||||
|
||||
pub fn into_router(self: Arc<Self>) -> Router<()> {
|
||||
|
||||
Reference in New Issue
Block a user