diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac23abd..6b8e6fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,8 +52,6 @@ jobs: buildandtest: name: "Build and test" runs-on: ubuntu-latest - permissions: - contents: write steps: - uses: actions/checkout@v4 @@ -71,3 +69,6 @@ jobs: - name: Test run: cargo test-all-features -- --release + + - name: Publish + run: cargo publish --dry-run \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index d056a09..c3ab127 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1274,7 +1274,7 @@ dependencies = [ [[package]] name = "servable" -version = "0.0.2" +version = "0.0.3" dependencies = [ "axum", "chrono", diff --git a/Cargo.toml b/Cargo.toml index aa30e32..3c2739e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] rust-version = "1.90.0" edition = "2024" -version = "0.0.2" +version = "0.0.3" license = "GPL-3.0" repository = "https://github.com/rm-dr/servable" readme = "README.md" @@ -70,16 +70,16 @@ cargo_common_metadata = "deny" [workspace.dependencies] -axum = "0.8.7" -chrono = "0.4.42" -image = "0.25.9" -maud = "0.27.0" -rand = "0.9.0" -serde = { version = "1.0.228", features = ["derive"] } -serde_urlencoded = "0.7.1" -strum = { version = "0.27.2", features = ["derive"] } -thiserror = "2.0.17" -tokio = "1.48.0" -tower = "0.5.2" -tower-http = { version = "0.6.7", features = ["compression-full"] } -tracing = "0.1.41" +axum = "0.8" +chrono = "0.4" +image = "0.25" +maud = "0.27" +rand = "0.9" +serde = { version = "1.0", features = ["derive"] } +serde_urlencoded = "0.7" +strum = { version = "0.27", features = ["derive"] } +thiserror = "2.0" +tokio = "1.48" +tower = "0.5" +tower-http = { version = "0.6", features = ["compression-full"] } +tracing = "0.1" diff --git a/README.md b/README.md deleted file mode 100644 index 47b0323..0000000 --- a/README.md +++ /dev/null @@ -1,179 +0,0 @@ -# Servable: a simple web framework - -[![CI](https://github.com/rm-dr/servable/workflows/CI/badge.svg)](https://github.com/rm-dr/servable/actions) -[![Cargo](https://img.shields.io/crates/v/servable.svg)](https://crates.io/crates/servable) -[![API reference](https://docs.rs/servable/badge.svg)](https://docs.rs/servable/) - -A tiny, convenient web micro-framework built around [htmx](https://htmx.org), [Axum](https://github.com/tokio-rs/axum), and [Maud](https://maud.lambda.xyz). -Inspired by the "MASH" stack described [here](https://yree.io/mash) and [here](https://emschwartz.me/building-a-fast-website-with-the-mash-stack-in-rust). - - - -## Features - -`servable` provides abstractions that implement common utilities needed by an http server. \ - -- response headers and cache-busting utilities -- client device detection (mobile / desktop) -- server-side image optimization (see the `image` feature below) -- ergonomic [htmx](https://htmx.org) integration (see `htmx-*` features below) - - -------------------- - - -## Quick Start - -```rust,ignore -use servable::{ServableRouter, servable::StaticAsset, mime::MimeType}; - -#[tokio::main] -async fn main() { - let route = ServableRouter::new() - .add_page( - "/hello", - StaticAsset { - bytes: b"Hello, World!", - mime: MimeType::Text, - }, - ); - - // usual axum startup routine - let app = route.into_router(); - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") - .await - .unwrap(); - - axum::serve(listener, app).await.unwrap(); -} -``` - -# Core Concepts - -## The `Servable` trait - -The `Servable` trait is the foundation of this stack. \ -`servable` provides implementations for a few common servables: - - -- `StaticAsset`, for static files like CSS, JavaScript, images, or plain bytes: - ```rust - use servable::{StaticAsset, mime::MimeType}; - - let asset = StaticAsset { - bytes: b"body { color: red; }", - mime: MimeType::Css, - }; - ``` - -- `Redirect`, for simple http redirects: - ```rust - use servable::Redirect; - - let redirect = Redirect::new("/new-location").unwrap(); - ``` - -- `HtmlPage`, for dynamically-rendered HTML pages - ```rust - use servable::{HtmlPage, PageMetadata}; - use maud::html; - use std::pin::Pin; - - let page = HtmlPage::default() - .with_meta(PageMetadata { - title: "My Page".into(), - description: Some("A great page".into()), - ..Default::default() - }) - .with_render(|_page, ctx| { - Box::pin(async move { - html! { - h1 { "Welcome!" } - p { "Route: " (ctx.route) } - } - }) - }); - ``` - `HtmlPage` automatically generates a `` and wraps its rendered html in ``. - - - -## `ServableRouter` - -A `ServableRouter` exposes a collection of `Servable`s under different routes. It implements `tower`'s `Service` trait, and can be easily be converted into an Axum `Router`. Construct one as follows: - -```rust -# use servable::{ServableRouter, StaticAsset, mime::MimeType}; -# let home_page = StaticAsset { bytes: b"home", mime: MimeType::Html }; -# let about_page = StaticAsset { bytes: b"about", mime: MimeType::Html }; -# let stylesheet = StaticAsset { bytes: b"css", mime: MimeType::Css }; -# let custom_404_page = StaticAsset { bytes: b"404", mime: MimeType::Html }; -let route = ServableRouter::new() - .add_page("/", home_page) - .add_page("/about", about_page) - .add_page("/style.css", stylesheet) - .with_404(custom_404_page); // override default 404 -``` - -# Features -- `image`: enable image transformation via query parameters. This makes `tokio` a dependency. \ - When this is enabled, all `StaticAssets` with a valid mimetype can take an optional `t=` query parameter. \ - See the `TransformerEnum` in this crate's documentation for details. - - When `image` is enabled, the image below... - ```rust - # use servable::{ServableRouter, StaticAsset, mime::MimeType}; - let route = ServableRouter::new() - .add_page( - "/image.png", - StaticAsset { - bytes: b"fake image data", - mime: MimeType::Png, - } - ); - ``` - ...may be accessed as follows: - - ```r - # Original image - GET /image.png - - # Resize to max 800px on longest side - GET /image.png?t=maxdim(800) - - # Crop to a 400x400 square at the center of the image - GET /image.png?t=crop(400,400,c) - - # Chain transformations and transcode - GET /image.png?t=maxdim(800);crop(400,400);format(webp) - ``` - - -- `htmx-2.0.8`: Include htmx sources in the compiled executable. \ - Use as follows: - ```rust - # use servable::ServableRouter; - # #[cfg(feature = "htmx-2.0.8")] - let route = ServableRouter::new() - .add_page("/htmx.js", servable::HTMX_2_0_8) - .add_page("/htmx-json-enc.js", servable::EXT_JSON_1_19_12); - ``` - - - -## Caching and cache-busting - -Control caching behavior per servable: - -```rust -use chrono::TimeDelta; -use servable::HtmlPage; - -let page = HtmlPage::default() - .with_ttl(Some(TimeDelta::hours(1))) - .with_immutable(false); -``` - -Headers are automatically generated: -- `Cache-Control: public, max-age=3600` -- `Cache-Control: immutable, public, max-age=31536000` (for immutable assets) diff --git a/README.md b/README.md new file mode 120000 index 0000000..74ef51f --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +crates/servable/README.md \ No newline at end of file diff --git a/crates/servable/README.md b/crates/servable/README.md index 47b0323..975c715 100644 --- a/crates/servable/README.md +++ b/crates/servable/README.md @@ -1,17 +1,19 @@ # Servable: a simple web framework +### TODO: +- cache-bust fonts in css (dynamic replace in css (fonts)) + + [![CI](https://github.com/rm-dr/servable/workflows/CI/badge.svg)](https://github.com/rm-dr/servable/actions) [![Cargo](https://img.shields.io/crates/v/servable.svg)](https://crates.io/crates/servable) [![API reference](https://docs.rs/servable/badge.svg)](https://docs.rs/servable/) -A tiny, convenient web micro-framework built around [htmx](https://htmx.org), [Axum](https://github.com/tokio-rs/axum), and [Maud](https://maud.lambda.xyz). -Inspired by the "MASH" stack described [here](https://yree.io/mash) and [here](https://emschwartz.me/building-a-fast-website-with-the-mash-stack-in-rust). - - +A minimal, convenient web micro-framework built around [htmx](https://htmx.org), [Axum](https://github.com/tokio-rs/axum), and [Maud](https://maud.lambda.xyz). \ +This powers [my homepage](https://betalupi.com). See example usage [here](https://git.betalupi.com/Mark/webpage/src/branch/main/crates/service/service-webpage/src/routes/mod.rs). ## Features -`servable` provides abstractions that implement common utilities needed by an http server. \ +`servable` provides abstractions that implement common utilities needed by an http server. - response headers and cache-busting utilities - client device detection (mobile / desktop) @@ -63,6 +65,7 @@ The `Servable` trait is the foundation of this stack. \ let asset = StaticAsset { bytes: b"body { color: red; }", mime: MimeType::Css, + ttl: StaticAsset::DEFAULT_TTL }; ``` @@ -104,10 +107,10 @@ A `ServableRouter` exposes a collection of `Servable`s under different routes. I ```rust # use servable::{ServableRouter, StaticAsset, mime::MimeType}; -# let home_page = StaticAsset { bytes: b"home", mime: MimeType::Html }; -# let about_page = StaticAsset { bytes: b"about", mime: MimeType::Html }; -# let stylesheet = StaticAsset { bytes: b"css", mime: MimeType::Css }; -# let custom_404_page = StaticAsset { bytes: b"404", mime: MimeType::Html }; +# let home_page = StaticAsset { bytes: b"home", mime: MimeType::Html, ttl: StaticAsset::DEFAULT_TTL}; +# let about_page = StaticAsset { bytes: b"about", mime: MimeType::Html, ttl: StaticAsset::DEFAULT_TTL }; +# let stylesheet = StaticAsset { bytes: b"css", mime: MimeType::Css, ttl: StaticAsset::DEFAULT_TTL }; +# let custom_404_page = StaticAsset { bytes: b"404", mime: MimeType::Html, ttl: StaticAsset::DEFAULT_TTL }; let route = ServableRouter::new() .add_page("/", home_page) .add_page("/about", about_page) @@ -129,6 +132,7 @@ let route = ServableRouter::new() StaticAsset { bytes: b"fake image data", mime: MimeType::Png, + ttl: StaticAsset::DEFAULT_TTL } ); ``` @@ -139,13 +143,13 @@ let route = ServableRouter::new() GET /image.png # Resize to max 800px on longest side - GET /image.png?t=maxdim(800) + GET /image.png?t=maxdim(800,800) # Crop to a 400x400 square at the center of the image GET /image.png?t=crop(400,400,c) # Chain transformations and transcode - GET /image.png?t=maxdim(800);crop(400,400);format(webp) + GET /image.png?t=maxdim(800,800);crop(400,400);format(webp) ``` @@ -171,9 +175,34 @@ use servable::HtmlPage; let page = HtmlPage::default() .with_ttl(Some(TimeDelta::hours(1))) - .with_immutable(false); + .with_private(false); ``` Headers are automatically generated: -- `Cache-Control: public, max-age=3600` -- `Cache-Control: immutable, public, max-age=31536000` (for immutable assets) +- `Cache-Control: public, max-age=3600` (default) +- `Cache-Control: private, max-age=31536000` (if `private` is true) + +We also provide a static `CACHE_BUST_STR`, which may be formatted into urls to force cache refresh +whenever the server is restarted: + +```rust +use chrono::TimeDelta; +use servable::{HtmlPage, CACHE_BUST_STR, ServableWithRoute, StaticAsset, ServableRouter}; +use servable::mime::MimeType; + +pub static HTMX: ServableWithRoute = ServableWithRoute::new( + || format!("/{}/main.css", *CACHE_BUST_STR), + StaticAsset { + bytes: "div{}".as_bytes(), + mime: MimeType::Css, + ttl: StaticAsset::DEFAULT_TTL, + }, +); + + +let route = HTMX.route(); +println!("Css is at {route}"); + +let router = ServableRouter::new() + .add_page_with_route(&HTMX); +``` diff --git a/crates/servable/src/lib.rs b/crates/servable/src/lib.rs index 8604905..a4ae06f 100644 --- a/crates/servable/src/lib.rs +++ b/crates/servable/src/lib.rs @@ -1,4 +1,8 @@ #![doc = include_str!("../README.md")] +// readme is symlinked to the root of this repo +// because `cargo publish` works from a different dir, +// and needs a different relative path than cargo build. +// https://github.com/rust-lang/cargo/issues/13309 pub mod mime; @@ -44,6 +48,7 @@ pub static CACHE_BUST_STR: std::sync::LazyLock = std::sync::LazyLock::ne pub const HTMX_2_0_8: servable::StaticAsset = servable::StaticAsset { bytes: include_str!("../htmx/htmx-2.0.8.min.js").as_bytes(), mime: mime::MimeType::Javascript, + ttl: StaticAsset::DEFAULT_TTL, }; /// HTMX json extension, 1.19.2. @@ -53,4 +58,5 @@ pub const HTMX_2_0_8: servable::StaticAsset = servable::StaticAsset { pub const EXT_JSON_1_19_12: servable::StaticAsset = servable::StaticAsset { bytes: include_str!("../htmx/json-enc-1.9.12.js").as_bytes(), mime: mime::MimeType::Javascript, + ttl: StaticAsset::DEFAULT_TTL, }; diff --git a/crates/servable/src/mime.rs b/crates/servable/src/mime.rs index 476f569..13958f9 100644 --- a/crates/servable/src/mime.rs +++ b/crates/servable/src/mime.rs @@ -225,6 +225,13 @@ impl<'de> Deserialize<'de> for MimeType { impl Default for MimeType { fn default() -> Self { + Self::const_default() + } +} + +impl MimeType { + /// [Default::default], but const + pub const fn const_default() -> Self { Self::Blob } } diff --git a/crates/servable/src/router.rs b/crates/servable/src/router.rs index ba47349..3a57a89 100644 --- a/crates/servable/src/router.rs +++ b/crates/servable/src/router.rs @@ -35,9 +35,9 @@ impl Servable for Default404 { code: StatusCode::NOT_FOUND, body: (), ttl: Some(TimeDelta::days(1)), - immutable: true, headers: HeaderMap::new(), mime: Some(MimeType::Html), + private: false, }; }) } @@ -73,6 +73,7 @@ impl Servable for Default404 { /// StaticAsset { /// bytes: "I am a page".as_bytes(), /// mime: MimeType::Text, +/// ttl: StaticAsset::DEFAULT_TTL /// }, /// ); /// @@ -243,14 +244,15 @@ impl Service> for ServableRouter { // Tweak headers { if !rend.headers.contains_key(header::CACHE_CONTROL) { - let max_age = rend.ttl.map(|x| x.num_seconds()).unwrap_or(1).max(1); + let max_age = rend.ttl.map(|x| x.num_seconds()).unwrap_or(0).max(0); let mut value = String::new(); - if rend.immutable { - value.push_str("immutable, "); - } - value.push_str("public, "); + value.push_str(match rend.private { + true => "private, ", + false => "public, ", + }); + value.push_str(&format!("max-age={}, ", max_age)); #[expect(clippy::unwrap_used)] diff --git a/crates/servable/src/servable/asset.rs b/crates/servable/src/servable/asset.rs index 1596afe..a629bfe 100644 --- a/crates/servable/src/servable/asset.rs +++ b/crates/servable/src/servable/asset.rs @@ -4,8 +4,6 @@ use std::pin::Pin; use crate::{RenderContext, Rendered, RenderedBody, mime::MimeType, servable::Servable}; -const TTL: Option = Some(TimeDelta::days(1)); - /// A static blob of bytes pub struct StaticAsset { /// The data to return @@ -13,6 +11,21 @@ pub struct StaticAsset { /// The type of `bytes` pub mime: MimeType, + + /// How long to cache this response. + /// If None, never cache + pub ttl: Option, +} + +impl StaticAsset { + /// Default ttl of a [StaticAsset] + pub const DEFAULT_TTL: Option = Some(TimeDelta::days(14)); + + /// Set `self.ttl` + pub const fn with_ttl(mut self, ttl: Option) -> Self { + self.ttl = ttl; + self + } } #[cfg(feature = "image")] @@ -36,8 +49,8 @@ impl Servable for StaticAsset { return Rendered { code: StatusCode::BAD_REQUEST, body: (), - ttl: TTL, - immutable: true, + ttl: self.ttl, + private: false, headers: HeaderMap::new(), mime: None, @@ -51,8 +64,8 @@ impl Servable for StaticAsset { return Rendered { code: StatusCode::OK, body: (), - ttl: TTL, - immutable: true, + ttl: self.ttl, + private: false, headers: HeaderMap::new(), mime: Some( @@ -67,8 +80,8 @@ impl Servable for StaticAsset { return Rendered { code: StatusCode::OK, body: (), - ttl: TTL, - immutable: true, + ttl: self.ttl, + private: false, headers: HeaderMap::new(), mime: Some(self.mime.clone()), @@ -99,8 +112,8 @@ impl Servable for StaticAsset { return Rendered { code: StatusCode::BAD_REQUEST, body: RenderedBody::String(err), - ttl: TTL, - immutable: true, + ttl: self.ttl, + private: false, headers: HeaderMap::new(), mime: None, @@ -131,7 +144,7 @@ impl Servable for StaticAsset { "Error while transforming image: {error:?}" )), ttl: None, - immutable: true, + private: false, headers: HeaderMap::new(), mime: None, @@ -144,8 +157,8 @@ impl Servable for StaticAsset { return Rendered { code: StatusCode::OK, body: RenderedBody::Bytes(bytes), - ttl: TTL, - immutable: true, + ttl: self.ttl, + private: false, headers: HeaderMap::new(), mime: Some(mime), @@ -156,8 +169,8 @@ impl Servable for StaticAsset { return Rendered { code: StatusCode::INTERNAL_SERVER_ERROR, body: RenderedBody::String(format!("{err}")), - ttl: TTL, - immutable: true, + ttl: self.ttl, + private: false, headers: HeaderMap::new(), mime: None, @@ -170,8 +183,9 @@ impl Servable for StaticAsset { return Rendered { code: StatusCode::OK, body: RenderedBody::Static(self.bytes), - ttl: TTL, - immutable: true, + ttl: self.ttl, + private: false, + headers: HeaderMap::new(), mime: Some(self.mime.clone()), }; @@ -191,8 +205,8 @@ impl Servable for StaticAsset { return Rendered { code: StatusCode::OK, body: (), - ttl: TTL, - immutable: true, + ttl: self.ttl, + private: false, headers: HeaderMap::new(), mime: Some(self.mime.clone()), diff --git a/crates/servable/src/servable/html.rs b/crates/servable/src/servable/html.rs index 544beea..70a9f4b 100644 --- a/crates/servable/src/servable/html.rs +++ b/crates/servable/src/servable/html.rs @@ -52,7 +52,7 @@ pub struct HtmlPage { pub meta: PageMetadata, /// If true, the contents of this page never change - pub immutable: bool, + pub private: bool, /// How long this page's html may be cached. /// This controls the maximum age of a page shown to the user. @@ -94,7 +94,7 @@ impl Default for HtmlPage { HtmlPage { // No cache by default ttl: None, - immutable: false, + private: false, meta: Default::default(), render: Arc::new(|_, _| Box::pin(async { html!() })), @@ -132,10 +132,10 @@ impl HtmlPage { self } - /// Set `self.immutable` + /// Set `self.private` #[inline(always)] - pub fn with_immutable(mut self, immutable: bool) -> Self { - self.immutable = immutable; + pub fn with_private(mut self, private: bool) -> Self { + self.private = private; self } @@ -223,7 +223,7 @@ impl Servable for HtmlPage { code: self.response_code, body: (), ttl: self.ttl, - immutable: self.immutable, + private: self.private, headers: HeaderMap::new(), mime: Some(MimeType::Html), }; diff --git a/crates/servable/src/servable/redirect.rs b/crates/servable/src/servable/redirect.rs index 35a7870..81f4471 100644 --- a/crates/servable/src/servable/redirect.rs +++ b/crates/servable/src/servable/redirect.rs @@ -60,7 +60,7 @@ impl Servable for Redirect { headers, body: (), ttl: None, - immutable: true, + private: false, mime: None, }; }) diff --git a/crates/servable/src/types.rs b/crates/servable/src/types.rs index c4231a6..f9ae86a 100644 --- a/crates/servable/src/types.rs +++ b/crates/servable/src/types.rs @@ -58,8 +58,8 @@ pub struct Rendered { /// If none, don't cache. pub ttl: Option, - /// If true, the data at this route will never change. - pub immutable: bool, + /// If true, this response sets `Cache-Control: private` + pub private: bool, } impl Rendered<()> { @@ -71,7 +71,7 @@ impl Rendered<()> { body, mime: self.mime, ttl: self.ttl, - immutable: self.immutable, + private: self.private, } } }