diff --git a/packages/router/Cargo.toml b/packages/router/Cargo.toml index fb92d0888..f0ec24757 100644 --- a/packages/router/Cargo.toml +++ b/packages/router/Cargo.toml @@ -23,9 +23,12 @@ wasm-bindgen = { version = "0.2.86", optional = true } 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 } +lru = { version = "0.10.0", optional = true } [features] -default = ["web"] +default = ["web", "ssr"] +ssr = ["dioxus-ssr", "lru"] wasm_test = [] serde = ["dep:serde", "gloo-utils/serde"] web = ["gloo", "web-sys", "wasm-bindgen", "gloo-utils", "js-sys"] diff --git a/packages/router/examples/static_generation.rs b/packages/router/examples/static_generation.rs index 249deb28b..f678080c6 100644 --- a/packages/router/examples/static_generation.rs +++ b/packages/router/examples/static_generation.rs @@ -2,74 +2,33 @@ use dioxus::prelude::*; use dioxus_router::prelude::*; -use std::io::prelude::*; -use std::{path::PathBuf, str::FromStr}; +use dioxus_router::ssr::{DefaultRenderer, IncrementalRendererConfig}; fn main() { - render_static_pages(); -} + let mut renderer = IncrementalRendererConfig::new(DefaultRenderer { + before_body: r#" + + + + + Dioxus Application + + "# + .to_string(), + after_body: r#" + "# + .to_string(), + }) + .static_dir("./static") + .memory_cache_limit(5) + .build(); -fn render_static_pages() { - for route in Route::SITE_MAP - .iter() - .flat_map(|seg| seg.flatten().into_iter()) - { - // check if this is a static segment - let mut file_path = PathBuf::from("./"); - let mut full_path = String::new(); - let mut is_static = true; - for segment in &route { - match segment { - SegmentType::Static(s) => { - file_path.push(s); - full_path += "/"; - full_path += s; - } - _ => { - // skip routes with any dynamic segments - is_static = false; - break; - } - } - } + renderer.pre_cache_static::(); - if is_static { - let route = Route::from_str(&full_path).unwrap(); - let mut vdom = VirtualDom::new_with_props(RenderPath, RenderPathProps { path: route }); - let _ = vdom.rebuild(); - - file_path.push("index.html"); - std::fs::create_dir_all(file_path.parent().unwrap()).unwrap(); - let mut file = std::fs::File::create(file_path).unwrap(); - - let body = dioxus_ssr::render(&vdom); - let html = format!( - r#" - - - - - - {} - - - {} - - -"#, - full_path, body - ); - file.write_all(html.as_bytes()).unwrap(); - } - } -} - -#[inline_props] -fn RenderPath(cx: Scope, path: Route) -> Element { - let path = path.clone(); - render! { - Router { - config: || RouterConfig::default().history(MemoryHistory::with_initial_path(path)) + for _ in 0..2 { + for id in 0..10 { + renderer.render(Route::Post { id }); } } } @@ -84,7 +43,16 @@ fn Blog(cx: Scope) -> Element { } #[inline_props] -fn Post(cx: Scope) -> Element { +fn Post(cx: Scope, id: usize) -> Element { + render! { + div { + "PostId: {id}" + } + } +} + +#[inline_props] +fn PostHome(cx: Scope) -> Element { render! { div { "Post" @@ -107,8 +75,12 @@ enum Route { #[nest("/blog")] #[route("/")] Blog {}, - #[route("/post")] - Post {}, + #[route("/post/index")] + PostHome {}, + #[route("/post/:id")] + Post { + id: usize, + }, #[end_nest] #[route("/")] Home {}, diff --git a/packages/router/src/lib.rs b/packages/router/src/lib.rs index 9db86db08..c145fad61 100644 --- a/packages/router/src/lib.rs +++ b/packages/router/src/lib.rs @@ -6,6 +6,9 @@ pub mod navigation; pub mod routable; +#[cfg(feature = "ssr")] +pub mod ssr; + /// Components interacting with the router. pub mod components { mod default_errors; diff --git a/packages/router/src/ssr.rs b/packages/router/src/ssr.rs new file mode 100644 index 000000000..eb9ad648b --- /dev/null +++ b/packages/router/src/ssr.rs @@ -0,0 +1,244 @@ +//! Incremental file based incremental rendering + +#![allow(non_snake_case)] + +use crate::prelude::*; +use dioxus::prelude::*; +use std::{ + io::{Read, Write}, + num::NonZeroUsize, + path::{Path, PathBuf}, + str::FromStr, +}; + +/// Something that can render a HTML page from a body. +pub trait RenderHTML { + /// Render a HTML page from a body. + fn render_html(&self, body: &str) -> String; +} + +/// The default page renderer +pub struct DefaultRenderer { + /// The HTML before the body. + pub before_body: String, + /// The HTML after the body. + pub after_body: String, +} + +impl Default for DefaultRenderer { + fn default() -> Self { + let before = r#" + + + + + Dioxus Application + + "#; + let after = r#" + "#; + Self { + before_body: before.to_string(), + after_body: after.to_string(), + } + } +} + +impl RenderHTML for DefaultRenderer { + fn render_html(&self, body: &str) -> String { + format!("{}{}{}", self.before_body, body, self.after_body) + } +} + +/// A configuration for the incremental renderer. +pub struct IncrementalRendererConfig { + static_dir: PathBuf, + memory_cache_limit: usize, + render: R, +} + +impl Default for IncrementalRendererConfig { + fn default() -> Self { + Self::new(DefaultRenderer::default()) + } +} + +impl IncrementalRendererConfig { + /// Create a new incremental renderer configuration. + pub fn new(render: R) -> Self { + Self { + static_dir: PathBuf::from("./static"), + memory_cache_limit: 100, + render, + } + } + + /// Set the static directory. + pub fn static_dir>(mut self, static_dir: P) -> Self { + self.static_dir = static_dir.as_ref().to_path_buf(); + self + } + + /// Set the memory cache limit. + pub const fn memory_cache_limit(mut self, memory_cache_limit: usize) -> Self { + self.memory_cache_limit = memory_cache_limit; + self + } + + /// Build the incremental renderer. + pub fn build(self) -> IncrementalRenderer { + IncrementalRenderer { + static_dir: self.static_dir, + memory_cache: NonZeroUsize::new(self.memory_cache_limit) + .map(|limit| lru::LruCache::new(limit)), + render: self.render, + } + } +} + +/// An incremental renderer. +pub struct IncrementalRenderer { + static_dir: PathBuf, + memory_cache: Option>, + render: R, +} + +impl IncrementalRenderer { + /// Create a new incremental renderer builder. + pub fn builder(renderer: R) -> IncrementalRendererConfig { + IncrementalRendererConfig::new(renderer) + } + + fn render_uncached(&self, route: Rt) -> String + where + Rt: Routable, + ::Err: std::fmt::Display, + { + let mut vdom = VirtualDom::new_with_props(RenderPath, RenderPathProps { path: route }); + let _ = vdom.rebuild(); + + let body = dioxus_ssr::render(&vdom); + + self.render.render_html(&body) + } + + fn add_to_cache(&mut self, route: String, html: String) { + let file_path = self.route_as_path(&route); + if let Some(parent) = file_path.parent() { + if !parent.exists() { + std::fs::create_dir_all(parent).unwrap(); + } + } + let file = std::fs::File::create(dbg!(file_path)).unwrap(); + let mut file = std::io::BufWriter::new(file); + file.write_all(html.as_bytes()).unwrap(); + if let Some(cache) = self.memory_cache.as_mut() { + cache.put(route, html); + } + } + + fn search_cache(&mut self, route: String) -> Option { + if let Some(cache_hit) = self + .memory_cache + .as_mut() + .and_then(|cache| cache.get(&route).cloned()) + { + println!("memory cache hit"); + Some(cache_hit) + } else { + let file_path = self.route_as_path(&route); + if let Ok(file) = dbg!(std::fs::File::open(file_path)) { + let mut file = std::io::BufReader::new(file); + let mut html = String::new(); + file.read_to_string(&mut html).ok()?; + println!("file cache hit"); + Some(html) + } else { + None + } + } + } + + /// Render a route or get it from cache. + pub fn render(&mut self, route: Rt) -> String + where + Rt: Routable, + ::Err: std::fmt::Display, + { + // check if this route is cached + if let Some(html) = self.search_cache(route.to_string()) { + return html; + } + + // if not, create it + println!("cache miss"); + let html = self.render_uncached(route.clone()); + self.add_to_cache(route.to_string(), html.clone()); + + html + } + + fn route_as_path(&self, route: &str) -> PathBuf { + let mut file_path = self.static_dir.clone(); + for segment in route.split('/') { + file_path.push(segment); + } + file_path.push("index"); + file_path.set_extension("html"); + file_path + } + + /// Pre-cache all static routes. + pub fn pre_cache_static(&mut self) + where + Rt: Routable, + ::Err: std::fmt::Display, + { + 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; + } + } + } + + if is_static { + match Rt::from_str(&full_path) { + Ok(route) => { + let _ = self.render(route); + } + Err(e) => { + log::error!("Error pre-caching static route: {}", e); + } + } + } + } + } +} + +#[inline_props] +fn RenderPath(cx: Scope, path: R) -> Element +where + R: Routable, + ::Err: std::fmt::Display, +{ + let path = path.clone(); + render! { + GenericRouter:: { + config: || RouterConfig::default().history(MemoryHistory::with_initial_path(path)) + } + } +}