Browse Source

add cache headers

Evan Almloff 2 years ago
parent
commit
a165e13564

+ 3 - 3
packages/fullstack/Cargo.toml

@@ -21,7 +21,7 @@ warp = { version = "0.3.3", optional = true }
 http-body = { version = "0.4.5", optional = true }
 
 # axum
-axum = { version = "0.6.1", features = ["ws"], optional = true }
+axum = { version = "0.6.1", features = ["ws", "macros"], optional = true }
 tower-http = { version = "0.4.0", optional = true, features = ["fs"] }
 axum-macros = "0.3.7"
 
@@ -36,7 +36,7 @@ hyper = { version = "0.14.25", optional = true }
 http = { version = "0.2.9", optional = true }
 
 # Router Intigration
-dioxus-router = { path = "../router", version = "^0.3.0", features = ["ssr"], optional = true }
+dioxus-router = { path = "../router", version = "^0.3.0", optional = true }
 
 log = "0.4.17"
 once_cell = "1.17.1"
@@ -65,7 +65,7 @@ hot-reload = ["serde_json", "tokio-stream", "futures-util"]
 warp = ["dep:warp", "http-body", "ssr"]
 axum = ["dep:axum", "tower-http", "ssr"]
 salvo = ["dep:salvo", "ssr"]
-ssr = ["server_fn/ssr", "tokio", "dioxus-ssr", "hyper", "http"]
+ssr = ["server_fn/ssr", "tokio", "dioxus-ssr", "hyper", "http", "dioxus-router/ssr"]
 default-tls = ["server_fn/default-tls"]
 rustls = ["server_fn/rustls"]
 

+ 2 - 1
packages/fullstack/examples/axum-hello-world/.gitignore

@@ -1,2 +1,3 @@
 dist
-target
+target
+static

+ 2 - 2
packages/fullstack/examples/axum-hello-world/src/main.rs

@@ -35,7 +35,7 @@ fn app(cx: Scope<AppProps>) -> Element {
                     }
                 }
             },
-            "Run a server function! testing1234"
+            "Run a server function!"
         }
         "Server said: {text}"
     })
@@ -60,6 +60,6 @@ async fn get_server_data() -> Result<String, ServerFnError> {
 fn main() {
     launch!(@([127, 0, 0, 1], 8080), app, {
         server_cfg: ServeConfigBuilder::new(app, (AppProps { count: 0 })),
-        incremental,
+        incremental: IncrementalRendererConfig::default().invalidate_after(std::time::Duration::from_secs(120)),
     });
 }

+ 2 - 1
packages/fullstack/examples/axum-router/.gitignore

@@ -1,2 +1,3 @@
 dist
-target
+target
+static

+ 2 - 1
packages/fullstack/examples/axum-router/src/main.rs

@@ -6,13 +6,14 @@
 //! ```
 
 #![allow(non_snake_case)]
+
 use dioxus::prelude::*;
 use dioxus_fullstack::prelude::*;
 use dioxus_router::prelude::*;
 
 fn main() {
     launch_router!(@([127, 0, 0, 1], 8080), Route, {
-        incremental,
+        incremental: IncrementalRendererConfig::default().invalidate_after(std::time::Duration::from_secs(120)),
     });
 }
 

+ 2 - 1
packages/fullstack/examples/salvo-hello-world/.gitignore

@@ -1,2 +1,3 @@
 dist
-target
+target
+static

+ 1 - 1
packages/fullstack/examples/salvo-hello-world/src/main.rs

@@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
 
 fn main() {
     launch!(@([127, 0, 0, 1], 8080), app, (AppProps { count: 5 }), {
-        incremental,
+        incremental: IncrementalRendererConfig::default().invalidate_after(std::time::Duration::from_secs(120)),
     });
 }
 

+ 2 - 1
packages/fullstack/examples/warp-hello-world/.gitignore

@@ -1,2 +1,3 @@
 dist
-target
+target
+static

+ 1 - 1
packages/fullstack/examples/warp-hello-world/src/main.rs

@@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
 
 fn main() {
     launch!(@([127, 0, 0, 1], 8080), app, (AppProps { count: 5 }), {
-        incremental,
+        incremental: IncrementalRendererConfig::default().invalidate_after(std::time::Duration::from_secs(120)),
     });
 }
 

+ 25 - 5
packages/fullstack/src/adapters/axum_adapter.rs

@@ -349,6 +349,17 @@ where
     }
 }
 
+fn apply_request_parts_to_response<B>(
+    parts: &RequestParts,
+    response: &mut axum::response::Response<B>,
+) {
+    let headers = response.headers_mut();
+    for (key, value) in parts.headers.iter() {
+        headers.insert(key, value.clone());
+    }
+}
+
+#[axum::debug_handler]
 async fn render_handler<P: Clone + serde::Serialize + Send + Sync + 'static>(
     State((cfg, ssr_state)): State<(ServeConfig<P>, SSRState)>,
     request: Request<Body>,
@@ -356,12 +367,21 @@ async fn render_handler<P: Clone + serde::Serialize + Send + Sync + 'static>(
     let (parts, _) = request.into_parts();
     let parts: Arc<RequestParts> = Arc::new(parts.into());
     let url = parts.uri.path_and_query().unwrap().to_string();
-    let server_context = DioxusServerContext::new(parts);
+    let server_context = DioxusServerContext::new(parts.clone());
 
-    match ssr_state.render(url, &cfg, |vdom| {
-        vdom.base_scope().provide_context(server_context);
-    }) {
-        Ok(html) => Full::from(html).into_response(),
+    match ssr_state
+        .render(url, &cfg, |vdom| {
+            vdom.base_scope().provide_context(server_context);
+        })
+        .await
+    {
+        Ok(rendered) => {
+            let crate::render::RenderResponse { html, freshness } = rendered;
+            let mut response = axum::response::Html::from(html).into_response();
+            freshness.write(response.headers_mut());
+            apply_request_parts_to_response(&parts, &mut response);
+            response
+        }
         Err(e) => {
             log::error!("Failed to render page: {}", e);
             report_err(e).into_response()

+ 18 - 9
packages/fullstack/src/adapters/salvo_adapter.rs

@@ -332,16 +332,25 @@ impl<P: Clone + serde::Serialize + Send + Sync + 'static> Handler for SSRHandler
         let route = parts.uri.path().to_string();
         let server_context = DioxusServerContext::new(parts);
 
-        res.write_body(
-            renderer_pool
-                .render(route, &self.cfg, |vdom| {
-                    vdom.base_scope().provide_context(server_context.clone());
-                })
-                .unwrap(),
-        )
-        .unwrap();
+        match renderer_pool
+            .render(route, &self.cfg, |vdom| {
+                vdom.base_scope().provide_context(server_context.clone());
+            })
+            .await
+        {
+            Ok(rendered) => {
+                let crate::render::RenderResponse { html, freshness } = rendered;
 
-        *res.headers_mut() = server_context.take_response_headers();
+                res.write_body(html).unwrap();
+
+                *res.headers_mut() = server_context.take_response_headers();
+                freshness.write(res.headers_mut());
+            }
+            Err(err) => {
+                log::error!("Error rendering SSR: {}", err);
+                res.write_body("Error rendering SSR").unwrap();
+            }
+        };
     }
 }
 

+ 31 - 14
packages/fullstack/src/adapters/warp_adapter.rs

@@ -185,24 +185,42 @@ pub fn render_ssr<P: Clone + serde::Serialize + Send + Sync + 'static>(
     warp::get()
         .and(request_parts())
         .and(with_ssr_state(&cfg))
-        .map(move |parts: RequestParts, renderer: SSRState| {
+        .then(move |parts: RequestParts, renderer: SSRState| {
             let route = parts.uri.path().to_string();
             let parts = Arc::new(parts);
+            let cfg = cfg.clone();
+            async move {
+                let server_context = DioxusServerContext::new(parts);
 
-            let server_context = DioxusServerContext::new(parts);
-
-            let html = renderer.render(route, &cfg, |vdom| {
-                vdom.base_scope().provide_context(server_context.clone());
-            });
+                match renderer
+                    .render(route, &cfg, |vdom| {
+                        vdom.base_scope().provide_context(server_context.clone());
+                    })
+                    .await
+                {
+                    Ok(rendered) => {
+                        let crate::render::RenderResponse { html, freshness } = rendered;
 
-            let mut res = Response::builder();
+                        let mut res = Response::builder()
+                            .header("Content-Type", "text/html")
+                            .body(html)
+                            .unwrap();
 
-            *res.headers_mut().expect("empty request should be valid") =
-                server_context.take_response_headers();
+                        let headers_mut = res.headers_mut();
+                        *headers_mut = server_context.take_response_headers();
+                        freshness.write(headers_mut);
 
-            res.header("Content-Type", "text/html")
-                .body(Bytes::from(html.unwrap()))
-                .unwrap()
+                        res
+                    }
+                    Err(err) => {
+                        log::error!("Failed to render ssr: {}", err);
+                        Response::builder()
+                            .status(500)
+                            .body("Failed to render ssr".into())
+                            .unwrap()
+                    }
+                }
+            }
         })
 }
 
@@ -364,9 +382,8 @@ pub fn connect_hot_reload() -> impl Filter<Extract = (impl Reply,), Error = warp
     #[cfg(not(all(debug_assertions, feature = "hot-reload", feature = "ssr")))]
     {
         warp::path!("_dioxus" / "hot_reload")
-            .and(warp::ws())
             .map(warp::reply)
-            .map(|reply| warp::reply::with_status(reply, warp::http::StatusCode::NOT_FOUND));
+            .map(|reply| warp::reply::with_status(reply, warp::http::StatusCode::NOT_FOUND))
     }
     #[cfg(all(debug_assertions, feature = "hot-reload", feature = "ssr"))]
     {

+ 58 - 27
packages/fullstack/src/render.rs

@@ -3,7 +3,10 @@
 use std::sync::Arc;
 
 use dioxus::prelude::VirtualDom;
-use dioxus_ssr::{incremental::IncrementalRendererConfig, Renderer};
+use dioxus_ssr::{
+    incremental::{IncrementalRendererConfig, RenderFreshness},
+    Renderer,
+};
 
 use crate::prelude::*;
 use dioxus::prelude::*;
@@ -20,7 +23,7 @@ enum SsrRendererPool {
 }
 
 impl SsrRendererPool {
-    fn render_to<P: Clone + 'static>(
+    async fn render_to<P: Clone + 'static>(
         &self,
         cfg: &ServeConfig<P>,
         route: String,
@@ -28,7 +31,7 @@ impl SsrRendererPool {
         props: P,
         to: &mut String,
         modify_vdom: impl FnOnce(&mut VirtualDom),
-    ) -> Result<(), dioxus_ssr::incremental::IncrementalRendererError> {
+    ) -> Result<RenderFreshness, dioxus_ssr::incremental::IncrementalRendererError> {
         match self {
             Self::Renderer(pool) => {
                 let mut vdom = VirtualDom::new_with_props(component, props);
@@ -37,13 +40,16 @@ impl SsrRendererPool {
                 let _ = vdom.rebuild();
                 let mut renderer = pool.pull(pre_renderer);
                 renderer.render_to(to, &vdom)?;
+
+                Ok(RenderFreshness::now(None))
             }
             Self::Incremental(pool) => {
                 let mut renderer = pool.pull(|| incremental_pre_renderer(cfg));
-                renderer.render_to_string(route, component, props, to, modify_vdom)?;
+                Ok(renderer
+                    .render_to_string(route, component, props, to, modify_vdom)
+                    .await?)
             }
         }
-        Ok(())
     }
 }
 
@@ -74,30 +80,36 @@ impl SSRState {
     }
 
     /// Render the application to HTML.
-    pub fn render<P: 'static + Clone + serde::Serialize>(
-        &self,
+    pub fn render<'a, P: 'static + Clone + serde::Serialize + Send + Sync>(
+        &'a self,
         route: String,
-        cfg: &ServeConfig<P>,
-        modify_vdom: impl FnOnce(&mut VirtualDom),
-    ) -> Result<String, dioxus_ssr::incremental::IncrementalRendererError> {
-        let ServeConfig { app, props, .. } = cfg;
+        cfg: &'a ServeConfig<P>,
+        modify_vdom: impl FnOnce(&mut VirtualDom) + Send + 'a,
+    ) -> impl std::future::Future<
+        Output = Result<RenderResponse, dioxus_ssr::incremental::IncrementalRendererError>,
+    > + Send
+           + 'a {
+        async move {
+            let ServeConfig { app, props, .. } = cfg;
 
-        let ServeConfig { index, .. } = cfg;
+            let ServeConfig { index, .. } = cfg;
 
-        let mut html = String::new();
+            let mut html = String::new();
 
-        html += &index.pre_main;
+            html += &index.pre_main;
 
-        self.renderers
-            .render_to(cfg, route, *app, props.clone(), &mut html, modify_vdom)?;
+            let freshness = self
+                .renderers
+                .render_to(cfg, route, *app, props.clone(), &mut html, modify_vdom)
+                .await?;
 
-        // serialize the props
-        let _ = crate::props_html::serialize_props::encode_in_element(&cfg.props, &mut html);
+            // serialize the props
+            let _ = crate::props_html::serialize_props::encode_in_element(&cfg.props, &mut html);
 
-        #[cfg(all(debug_assertions, feature = "hot-reload"))]
-        {
-            // In debug mode, we need to add a script to the page that will reload the page if the websocket disconnects to make full recompile hot reloads work
-            let disconnect_js = r#"(function () {
+            #[cfg(all(debug_assertions, feature = "hot-reload"))]
+            {
+                // In debug mode, we need to add a script to the page that will reload the page if the websocket disconnects to make full recompile hot reloads work
+                let disconnect_js = r#"(function () {
     const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
     const url = protocol + '//' + window.location.host + '/_dioxus/disconnect';
     const poll_interval = 1000;
@@ -123,14 +135,33 @@ impl SSRState {
     ws.onclose = reload_upon_connect;
 })()"#;
 
-            html += r#"<script>"#;
-            html += disconnect_js;
-            html += r#"</script>"#;
+                html += r#"<script>"#;
+                html += disconnect_js;
+                html += r#"</script>"#;
+            }
+
+            html += &index.post_main;
+
+            Ok(RenderResponse { html, freshness })
         }
+    }
+}
 
-        html += &index.post_main;
+/// A rendered response from the server.
+pub struct RenderResponse {
+    pub(crate) html: String,
+    pub(crate) freshness: RenderFreshness,
+}
+
+impl RenderResponse {
+    /// Get the rendered HTML.
+    pub fn html(&self) -> &str {
+        &self.html
+    }
 
-        Ok(html)
+    /// Get the freshness of the rendered HTML.
+    pub fn freshness(&self) -> RenderFreshness {
+        self.freshness
     }
 }
 

+ 3 - 2
packages/router/Cargo.toml

@@ -24,10 +24,11 @@ web-sys = { version = "0.3.60", optional = true, features = ["ScrollRestoration"
 js-sys = { version = "0.3.63", optional = true }
 gloo-utils = { version = "0.1.6", optional = true }
 dioxus-ssr = { path = "../ssr", optional = true }
+tokio = { version = "1.28", features = ["full"], optional = true }
 
 [features]
-default = ["web", "ssr"]
-ssr = ["dioxus-ssr"]
+default = ["web"]
+ssr = ["dioxus-ssr", "tokio"]
 wasm_test = []
 serde = ["dep:serde", "gloo-utils/serde"]
 web = ["gloo", "web-sys", "wasm-bindgen", "gloo-utils", "js-sys"]

+ 62 - 76
packages/router/src/incremental.rs

@@ -2,103 +2,89 @@
 use std::str::FromStr;
 
 use dioxus::prelude::*;
-use dioxus_ssr::incremental::{IncrementalRenderer, IncrementalRendererError, RenderHTML};
+use dioxus_ssr::incremental::{
+    IncrementalRenderer, IncrementalRendererError, RenderFreshness, RenderHTML,
+};
 
 use crate::prelude::*;
 
-trait IncrementalRendererRouterExt {
-    /// Pre-cache all static routes.
-    fn pre_cache_static_routes<Rt>(&mut self) -> Result<(), IncrementalRendererError>
-    where
-        Rt: Routable,
-        <Rt as FromStr>::Err: std::fmt::Display;
-
-    /// Render a route to a writer.
-    fn render_route<Rt, W, F: FnOnce(&mut VirtualDom)>(
-        &mut self,
-        route: Rt,
-        writer: &mut W,
-        modify_vdom: F,
-    ) -> Result<(), IncrementalRendererError>
-    where
-        Rt: Routable,
-        <Rt as FromStr>::Err: std::fmt::Display,
-        W: std::io::Write;
-}
-
-impl<R: RenderHTML> IncrementalRendererRouterExt for IncrementalRenderer<R> {
-    fn pre_cache_static_routes<Rt>(&mut self) -> Result<(), IncrementalRendererError>
-    where
-        Rt: Routable,
-        <Rt as FromStr>::Err: std::fmt::Display,
+/// Pre-cache all static routes.
+pub async fn pre_cache_static_routes<R: RenderHTML + Send, Rt>(
+    renderer: &mut IncrementalRenderer<R>,
+) -> Result<(), IncrementalRendererError>
+where
+    Rt: Routable,
+    <Rt as FromStr>::Err: std::fmt::Display,
+{
+    for route in Rt::SITE_MAP
+        .iter()
+        .flat_map(|seg| seg.flatten().into_iter())
     {
-        for route in Rt::SITE_MAP
-            .iter()
-            .flat_map(|seg| seg.flatten().into_iter())
-        {
-            // check if this is a static segment
-            let mut is_static = true;
-            let mut full_path = String::new();
-            for segment in &route {
-                match segment {
-                    SegmentType::Static(s) => {
-                        full_path += "/";
-                        full_path += s;
-                    }
-                    _ => {
-                        // skip routes with any dynamic segments
-                        is_static = false;
-                        break;
-                    }
+        // check if this is a static segment
+        let mut is_static = true;
+        let mut full_path = String::new();
+        for segment in &route {
+            match segment {
+                SegmentType::Static(s) => {
+                    full_path += "/";
+                    full_path += s;
+                }
+                _ => {
+                    // skip routes with any dynamic segments
+                    is_static = false;
+                    break;
                 }
             }
+        }
 
-            if is_static {
-                match Rt::from_str(&full_path) {
-                    Ok(route) => {
-                        self.render_route(route, &mut std::io::sink(), |_| {})?;
-                    }
-                    Err(e) => {
-                        log::error!("Error pre-caching static route: {}", e);
-                    }
+        if is_static {
+            match Rt::from_str(&full_path) {
+                Ok(route) => {
+                    render_route(renderer, route, &mut tokio::io::sink(), |_| {}).await?;
+                }
+                Err(e) => {
+                    log::error!("Error pre-caching static route: {}", e);
                 }
             }
         }
-
-        Ok(())
     }
 
-    fn render_route<Rt, W, F: FnOnce(&mut VirtualDom)>(
-        &mut self,
-        route: Rt,
-        writer: &mut W,
-        modify_vdom: F,
-    ) -> Result<(), IncrementalRendererError>
+    Ok(())
+}
+
+/// Render a route to a writer.
+pub async fn render_route<R: RenderHTML + Send, Rt, W, F: FnOnce(&mut VirtualDom)>(
+    renderer: &mut IncrementalRenderer<R>,
+    route: Rt,
+    writer: &mut W,
+    modify_vdom: F,
+) -> Result<RenderFreshness, IncrementalRendererError>
+where
+    Rt: Routable,
+    <Rt as FromStr>::Err: std::fmt::Display,
+    W: tokio::io::AsyncWrite + Unpin + Send,
+{
+    #[inline_props]
+    fn RenderPath<R>(cx: Scope, path: R) -> Element
     where
-        Rt: Routable,
-        <Rt as FromStr>::Err: std::fmt::Display,
-        W: std::io::Write,
+        R: Routable,
+        <R as FromStr>::Err: std::fmt::Display,
     {
-        #[inline_props]
-        fn RenderPath<R>(cx: Scope, path: R) -> Element
-        where
-            R: Routable,
-            <R as FromStr>::Err: std::fmt::Display,
-        {
-            let path = path.clone();
-            render! {
-                GenericRouter::<R> {
-                    config: || RouterConfig::default().history(MemoryHistory::with_initial_path(path))
-                }
+        let path = path.clone();
+        render! {
+            GenericRouter::<R> {
+                config: || RouterConfig::default().history(MemoryHistory::with_initial_path(path))
             }
         }
+    }
 
-        self.render(
+    renderer
+        .render(
             route.to_string(),
             RenderPath,
             RenderPathProps { path: route },
             writer,
             modify_vdom,
         )
-    }
+        .await
 }

+ 2 - 0
packages/ssr/Cargo.toml

@@ -19,6 +19,8 @@ thiserror = "1.0.23"
 rustc-hash = "1.1.0"
 lru = "0.10.0"
 log = "0.4.13"
+http = "0.2.9"
+tokio = { version = "1.28", features = ["full"] }
 
 [dev-dependencies]
 dioxus = { path = "../dioxus", version = "0.3.0" }

+ 136 - 51
packages/ssr/src/incremental.rs

@@ -8,9 +8,11 @@ use std::{
     hash::BuildHasherDefault,
     io::Write,
     num::NonZeroUsize,
+    ops::{Deref, DerefMut},
     path::{Path, PathBuf},
     time::{Duration, SystemTime},
 };
+use tokio::io::{AsyncWrite, AsyncWriteExt, BufReader};
 
 /// Something that can render a HTML page from a body.
 pub trait RenderHTML {
@@ -127,7 +129,7 @@ pub struct IncrementalRenderer<R: RenderHTML> {
     render: R,
 }
 
-impl<R: RenderHTML> IncrementalRenderer<R> {
+impl<R: RenderHTML + std::marker::Send> IncrementalRenderer<R> {
     /// Get the inner renderer.
     pub fn renderer(&self) -> &crate::Renderer {
         &self.ssr_renderer
@@ -166,34 +168,43 @@ impl<R: RenderHTML> IncrementalRenderer<R> {
         self.invalidate_after.is_some()
     }
 
-    fn render_and_cache<P: 'static>(
-        &mut self,
+    fn render_and_cache<'a, P: 'static>(
+        &'a mut self,
         route: String,
         comp: fn(Scope<P>) -> Element,
         props: P,
-        output: &mut impl Write,
+        output: &'a mut (impl AsyncWrite + Unpin + Send),
         modify_vdom: impl FnOnce(&mut VirtualDom),
-    ) -> Result<(), IncrementalRendererError> {
-        let mut vdom = VirtualDom::new_with_props(comp, props);
-        modify_vdom(&mut vdom);
-        let _ = vdom.rebuild();
-
+    ) -> impl std::future::Future<Output = Result<RenderFreshness, IncrementalRendererError>> + 'a + Send
+    {
         let mut html_buffer = WriteBuffer { buffer: Vec::new() };
-        self.render.render_before_body(&mut html_buffer)?;
-        self.ssr_renderer.render_to(&mut html_buffer, &vdom)?;
-        self.render.render_after_body(&mut html_buffer)?;
-        let html_buffer = html_buffer.buffer;
+        let result_1;
+        let result2;
+        {
+            let mut vdom = VirtualDom::new_with_props(comp, props);
+            modify_vdom(&mut vdom);
+            let _ = vdom.rebuild();
+
+            result_1 = self.render.render_before_body(&mut *html_buffer);
+            result2 = self.ssr_renderer.render_to(&mut html_buffer, &vdom);
+        }
+        async move {
+            result_1?;
+            result2?;
+            self.render.render_after_body(&mut *html_buffer)?;
+            let html_buffer = html_buffer.buffer;
 
-        output.write_all(&html_buffer)?;
+            output.write_all(&*html_buffer).await?;
 
-        self.add_to_cache(route, html_buffer)
+            self.add_to_cache(route, html_buffer)
+        }
     }
 
     fn add_to_cache(
         &mut self,
         route: String,
         html: Vec<u8>,
-    ) -> Result<(), IncrementalRendererError> {
+    ) -> Result<RenderFreshness, IncrementalRendererError> {
         let file_path = self.route_as_path(&route);
         if let Some(parent) = file_path.parent() {
             if !parent.exists() {
@@ -204,7 +215,7 @@ impl<R: RenderHTML> IncrementalRenderer<R> {
         let mut file = std::io::BufWriter::new(file);
         file.write_all(&html)?;
         self.add_to_memory_cache(route, html);
-        Ok(())
+        Ok(RenderFreshness::now(self.invalidate_after))
     }
 
     fn add_to_memory_cache(&mut self, route: String, html: Vec<u8>) {
@@ -219,73 +230,83 @@ impl<R: RenderHTML> IncrementalRenderer<R> {
         }
     }
 
-    fn search_cache(
+    async fn search_cache(
         &mut self,
         route: String,
-        output: &mut impl Write,
-    ) -> Result<bool, IncrementalRendererError> {
+        output: &mut (impl AsyncWrite + Unpin + std::marker::Send),
+    ) -> Result<Option<RenderFreshness>, IncrementalRendererError> {
+        // check the memory cache
         if let Some((timestamp, cache_hit)) = self
             .memory_cache
             .as_mut()
             .and_then(|cache| cache.get(&route))
         {
-            if let (Ok(elapsed), Some(invalidate_after)) =
-                (timestamp.elapsed(), self.invalidate_after)
-            {
-                if elapsed < invalidate_after {
+            if let Ok(elapsed) = timestamp.elapsed() {
+                let age = elapsed.as_secs();
+                if let Some(invalidate_after) = self.invalidate_after {
+                    if elapsed < invalidate_after {
+                        log::trace!("memory cache hit {:?}", route);
+                        output.write_all(cache_hit).await?;
+                        let max_age = invalidate_after.as_secs();
+                        return Ok(Some(RenderFreshness::new(age, max_age)));
+                    }
+                } else {
                     log::trace!("memory cache hit {:?}", route);
-                    output.write_all(cache_hit)?;
-                    return Ok(true);
+                    output.write_all(cache_hit).await?;
+                    return Ok(Some(RenderFreshness::new_age(age)));
                 }
-            } else {
-                log::trace!("memory cache hit {:?}", route);
-                output.write_all(cache_hit)?;
-                return Ok(true);
             }
         }
+        // check the file cache
         if let Some(file_path) = self.find_file(&route) {
-            if let Ok(file) = std::fs::File::open(file_path.full_path) {
-                let mut file = std::io::BufReader::new(file);
-                std::io::copy(&mut file, output)?;
-                log::trace!("file cache hit {:?}", route);
-                self.promote_memory_cache(&route);
-                return Ok(true);
+            if let Some(freshness) = file_path.freshness(self.invalidate_after) {
+                if let Ok(file) = tokio::fs::File::open(file_path.full_path).await {
+                    let mut file = BufReader::new(file);
+                    tokio::io::copy_buf(&mut file, output).await?;
+                    log::trace!("file cache hit {:?}", route);
+                    self.promote_memory_cache(&route);
+                    return Ok(Some(freshness));
+                }
             }
         }
-        Ok(false)
+        Ok(None)
     }
 
     /// Render a route or get it from cache.
-    pub fn render<P: 'static>(
+    pub async fn render<P: 'static>(
         &mut self,
         route: String,
         component: fn(Scope<P>) -> Element,
         props: P,
-        output: &mut impl Write,
+        output: &mut (impl AsyncWrite + Unpin + std::marker::Send),
         modify_vdom: impl FnOnce(&mut VirtualDom),
-    ) -> Result<(), IncrementalRendererError> {
+    ) -> Result<RenderFreshness, IncrementalRendererError> {
         // check if this route is cached
-        if !self.search_cache(route.to_string(), output)? {
+        if let Some(freshness) = self.search_cache(route.to_string(), output).await? {
+            Ok(freshness)
+        } else {
             // if not, create it
-            self.render_and_cache(route, component, props, output, modify_vdom)?;
+            let freshness = self
+                .render_and_cache(route, component, props, output, modify_vdom)
+                .await?;
             log::trace!("cache miss");
+            Ok(freshness)
         }
-
-        Ok(())
     }
 
     /// Render a route or get it from cache to a string.
-    pub fn render_to_string<P: 'static>(
+    pub async fn render_to_string<P: 'static>(
         &mut self,
         route: String,
         component: fn(Scope<P>) -> Element,
         props: P,
         output: &mut String,
         modify_vdom: impl FnOnce(&mut VirtualDom),
-    ) -> Result<(), IncrementalRendererError> {
+    ) -> Result<RenderFreshness, IncrementalRendererError> {
         unsafe {
             // SAFETY: The renderer will only write utf8 to the buffer
             self.render(route, component, props, output.as_mut_vec(), modify_vdom)
+                .await
         }
     }
 
@@ -344,6 +365,60 @@ impl<R: RenderHTML> IncrementalRenderer<R> {
     }
 }
 
+/// Information about the freshness of a rendered response
+#[derive(Debug, Clone, Copy)]
+pub struct RenderFreshness {
+    /// The age of the rendered response
+    age: u64,
+    /// The maximum age of the rendered response
+    max_age: Option<u64>,
+}
+
+impl RenderFreshness {
+    /// Create new freshness information
+    pub fn new(age: u64, max_age: u64) -> Self {
+        Self {
+            age,
+            max_age: Some(max_age),
+        }
+    }
+
+    /// Create new freshness information with only the age
+    pub fn new_age(age: u64) -> Self {
+        Self { age, max_age: None }
+    }
+
+    /// Create new freshness information at the current time
+    pub fn now(max_age: Option<Duration>) -> Self {
+        Self {
+            age: 0,
+            max_age: max_age.map(|d| d.as_secs()),
+        }
+    }
+
+    /// Get the age of the rendered response in seconds
+    pub fn age(&self) -> u64 {
+        self.age
+    }
+
+    /// Get the maximum age of the rendered response in seconds
+    pub fn max_age(&self) -> Option<u64> {
+        self.max_age
+    }
+
+    /// Write the freshness to the response headers.
+    pub fn write(&self, headers: &mut http::HeaderMap<http::HeaderValue>) {
+        let age = self.age();
+        headers.insert(http::header::AGE, age.into());
+        if let Some(max_age) = self.max_age() {
+            headers.insert(
+                http::header::CACHE_CONTROL,
+                http::HeaderValue::from_str(&format!("max-age={}", max_age)).unwrap(),
+            );
+        }
+    }
+}
+
 struct WriteBuffer {
     buffer: Vec<u8>,
 }
@@ -355,13 +430,17 @@ impl std::fmt::Write for WriteBuffer {
     }
 }
 
-impl Write for WriteBuffer {
-    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
-        self.buffer.write(buf)
+impl Deref for WriteBuffer {
+    type Target = Vec<u8>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.buffer
     }
+}
 
-    fn flush(&mut self) -> std::io::Result<()> {
-        self.buffer.flush()
+impl DerefMut for WriteBuffer {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.buffer
     }
 }
 
@@ -382,6 +461,12 @@ impl ValidCachedPath {
             timestamp,
         })
     }
+
+    fn freshness(&self, max_age: Option<std::time::Duration>) -> Option<RenderFreshness> {
+        let age = self.timestamp.elapsed().ok()?.as_secs();
+        let max_age = max_age.map(|max_age| max_age.as_secs());
+        Some(RenderFreshness::new(age, max_age?))
+    }
 }
 
 fn decode_timestamp(timestamp: &str) -> Option<std::time::SystemTime> {