diff --git a/integrations/actix/Cargo.toml b/integrations/actix/Cargo.toml index 3a8bfd88c5..8c21885a11 100644 --- a/integrations/actix/Cargo.toml +++ b/integrations/actix/Cargo.toml @@ -19,7 +19,7 @@ serde_json = "1" parking_lot = "0.12.1" regex = "1.7.0" tracing = "0.1.37" -tokio = { version = "1", features = ["rt"] } +tokio = { version = "1", features = ["rt", "fs"] } [features] nonce = ["leptos/nonce"] diff --git a/integrations/actix/src/lib.rs b/integrations/actix/src/lib.rs index 180cb97e13..af4b26359f 100644 --- a/integrations/actix/src/lib.rs +++ b/integrations/actix/src/lib.rs @@ -6,6 +6,7 @@ //! [`examples`](https://github.com/leptos-rs/leptos/tree/main/examples) //! directory in the Leptos repository. +use actix_http::header::{HeaderName, HeaderValue}; use actix_web::{ body::BoxBody, dev::{ServiceFactory, ServiceRequest}, @@ -26,7 +27,7 @@ use leptos_meta::*; use leptos_router::*; use parking_lot::RwLock; use regex::Regex; -use std::{fmt::Display, future::Future, sync::Arc}; +use std::{fmt::Display, future::Future, pin::Pin, sync::Arc}; #[cfg(debug_assertions)] use tracing::instrument; /// This struct lets you define headers and override the status of the Response from an Element or a Server Function @@ -869,12 +870,24 @@ async fn render_app_async_helper( /// create routes in Actix's App without having to use wildcard matching or fallbacks. Takes in your root app Element /// as an argument so it can walk you app tree. This version is tailored to generated Actix compatible paths. pub fn generate_route_list( - app_fn: impl FnOnce() -> IV + 'static, + app_fn: impl Fn() -> IV + 'static + Clone, ) -> Vec where IV: IntoView + 'static, { - generate_route_list_with_exclusions(app_fn, None) + generate_route_list_with_exclusions_and_ssg(app_fn, None).0 +} + +/// Generates a list of all routes defined in Leptos's Router in your app. We can then use this to automatically +/// create routes in Actix's App without having to use wildcard matching or fallbacks. Takes in your root app Element +/// as an argument so it can walk you app tree. This version is tailored to generated Actix compatible paths. +pub fn generate_route_list_with_ssg( + app_fn: impl Fn() -> IV + 'static + Clone, +) -> (Vec, StaticDataMap) +where + IV: IntoView + 'static, +{ + generate_route_list_with_exclusions_and_ssg(app_fn, None) } /// Generates a list of all routes defined in Leptos's Router in your app. We can then use this to automatically @@ -882,13 +895,28 @@ where /// as an argument so it can walk you app tree. This version is tailored to generated Actix compatible paths. Adding excluded_routes /// to this function will stop `.leptos_routes()` from generating a route for it, allowing a custom handler. These need to be in Actix path format pub fn generate_route_list_with_exclusions( - app_fn: impl FnOnce() -> IV + 'static, + app_fn: impl Fn() -> IV + 'static + Clone, excluded_routes: Option>, ) -> Vec where IV: IntoView + 'static, { - let mut routes = leptos_router::generate_route_list_inner(app_fn); + generate_route_list_with_exclusions_and_ssg(app_fn, excluded_routes).0 +} + +/// Generates a list of all routes defined in Leptos's Router in your app. We can then use this to automatically +/// create routes in Actix's App without having to use wildcard matching or fallbacks. Takes in your root app Element +/// as an argument so it can walk you app tree. This version is tailored to generated Actix compatible paths. Adding excluded_routes +/// to this function will stop `.leptos_routes()` from generating a route for it, allowing a custom handler. These need to be in Actix path format +pub fn generate_route_list_with_exclusions_and_ssg( + app_fn: impl Fn() -> IV + 'static + Clone, + excluded_routes: Option>, +) -> (Vec, StaticDataMap) +where + IV: IntoView + 'static, +{ + let (mut routes, static_data_map) = + leptos_router::generate_route_list_inner(app_fn); // Actix's Router doesn't follow Leptos's // Match `*` or `*someword` to replace with replace it with "/{tail.*} @@ -904,30 +932,54 @@ where if path.is_empty() { return RouteListing::new( "/".to_string(), + listing.path(), listing.mode(), listing.methods(), + listing.static_mode(), ); } - RouteListing::new(listing.path(), listing.mode(), listing.methods()) + RouteListing::new( + listing.path(), + listing.path(), + listing.mode(), + listing.methods(), + listing.static_mode(), + ) }) .map(|listing| { let path = wildcard_re .replace_all(listing.path(), "{tail:.*}") .to_string(); let path = capture_re.replace_all(&path, "{$1}").to_string(); - RouteListing::new(path, listing.mode(), listing.methods()) + RouteListing::new( + path, + listing.path(), + listing.mode(), + listing.methods(), + listing.static_mode(), + ) }) .collect::>(); - if routes.is_empty() { - vec![RouteListing::new("/", Default::default(), [Method::Get])] - } else { - // Routes to exclude from auto generation - if let Some(excluded_routes) = excluded_routes { - routes.retain(|p| !excluded_routes.iter().any(|e| e == p.path())) - } - routes - } + ( + if routes.is_empty() { + vec![RouteListing::new( + "/", + "", + Default::default(), + [Method::Get], + None, + )] + } else { + // Routes to exclude from auto generation + if let Some(excluded_routes) = excluded_routes { + routes + .retain(|p| !excluded_routes.iter().any(|e| e == p.path())) + } + routes + }, + static_data_map, + ) } pub enum DataResponse { @@ -935,6 +987,179 @@ pub enum DataResponse { Response(actix_web::dev::Response), } +fn handle_static_response<'a, IV>( + path: &'a str, + options: &'a LeptosOptions, + app_fn: &'a (impl Fn() -> IV + Clone + Send + 'static), + additional_context: &'a (impl Fn() + 'static + Clone + Send), + res: StaticResponse, +) -> Pin> + 'a>> +where + IV: IntoView + 'static, +{ + Box::pin(async move { + match res { + StaticResponse::ReturnResponse { + body, + status, + content_type, + } => { + let mut res = HttpResponse::new(match status { + StaticStatusCode::Ok => StatusCode::OK, + StaticStatusCode::NotFound => StatusCode::NOT_FOUND, + StaticStatusCode::InternalServerError => { + StatusCode::INTERNAL_SERVER_ERROR + } + }); + if let Some(v) = content_type { + res.headers_mut().insert( + HeaderName::from_static("content-type"), + HeaderValue::from_static(v), + ); + } + res.set_body(body) + } + StaticResponse::RenderDynamic => { + handle_static_response( + path, + options, + app_fn, + additional_context, + render_dynamic( + path, + options, + app_fn.clone(), + additional_context.clone(), + ) + .await, + ) + .await + } + StaticResponse::RenderNotFound => { + handle_static_response( + path, + options, + app_fn, + additional_context, + not_found_page( + tokio::fs::read_to_string(not_found_path(options)) + .await, + ), + ) + .await + } + StaticResponse::WriteFile { body, path } => { + if let Some(path) = path.parent() { + if let Err(e) = std::fs::create_dir_all(path) { + tracing::error!( + "encountered error {} writing directories {}", + e, + path.display() + ); + } + } + if let Err(e) = std::fs::write(&path, &body) { + tracing::error!( + "encountered error {} writing file {}", + e, + path.display() + ); + } + handle_static_response( + path.to_str().unwrap(), + options, + app_fn, + additional_context, + StaticResponse::ReturnResponse { + body, + status: StaticStatusCode::Ok, + content_type: Some("text/html"), + }, + ) + .await + } + } + }) +} + +fn static_route( + options: LeptosOptions, + app_fn: impl Fn() -> IV + Clone + Send + 'static, + additional_context: impl Fn() + 'static + Clone + Send, + method: Method, + mode: StaticMode, +) -> Route +where + IV: IntoView + 'static, +{ + match mode { + StaticMode::Incremental => { + let handler = move |req: HttpRequest| { + Box::pin({ + let options = options.clone(); + let app_fn = app_fn.clone(); + let additional_context = additional_context.clone(); + async move { + handle_static_response( + req.path(), + &options, + &app_fn, + &additional_context, + incremental_static_route( + tokio::fs::read_to_string(static_file_path( + &options, + req.path(), + )) + .await, + ), + ) + .await + } + }) + }; + match method { + Method::Get => web::get().to(handler), + Method::Post => web::post().to(handler), + Method::Put => web::put().to(handler), + Method::Delete => web::delete().to(handler), + Method::Patch => web::patch().to(handler), + } + } + StaticMode::Upfront => { + let handler = move |req: HttpRequest| { + Box::pin({ + let options = options.clone(); + let app_fn = app_fn.clone(); + let additional_context = additional_context.clone(); + async move { + handle_static_response( + req.path(), + &options, + &app_fn, + &additional_context, + upfront_static_route( + tokio::fs::read_to_string(static_file_path( + &options, + req.path(), + )) + .await, + ), + ) + .await + } + }) + }; + match method { + Method::Get => web::get().to(handler), + Method::Post => web::post().to(handler), + Method::Put => web::put().to(handler), + Method::Delete => web::delete().to(handler), + Method::Patch => web::patch().to(handler), + } + } + } +} + /// This trait allows one to pass a list of routes and a render function to Actix's router, letting us avoid /// having to use wildcards or manually define all routes in multiple places. pub trait LeptosRoutes { @@ -999,7 +1224,19 @@ where let mode = listing.mode(); for method in listing.methods() { - router = router.route( + router = if let Some(static_mode) = listing.static_mode() { + router.route( + path, + static_route( + options.clone(), + app_fn.clone(), + additional_context.clone(), + method, + static_mode, + ), + ) + } else { + router.route( path, match mode { SsrMode::OutOfOrder => { @@ -1034,7 +1271,8 @@ where method, ), }, - ); + ) + }; } } router diff --git a/integrations/axum/src/lib.rs b/integrations/axum/src/lib.rs index ee42001496..d2e0893fda 100644 --- a/integrations/axum/src/lib.rs +++ b/integrations/axum/src/lib.rs @@ -36,7 +36,6 @@ use leptos_router::*; use once_cell::sync::OnceCell; use parking_lot::RwLock; use std::{io, pin::Pin, sync::Arc, thread::available_parallelism}; -use tokio::task::LocalSet; use tokio_util::task::LocalPoolHandle; use tracing::Instrument; /// A struct to hold the parts of the incoming Request. Since `http::Request` isn't cloneable, we're forced @@ -1208,12 +1207,27 @@ where /// as an argument so it can walk you app tree. This version is tailored to generate Axum compatible paths. #[tracing::instrument(level = "trace", fields(error), skip_all)] pub async fn generate_route_list( - app_fn: impl FnOnce() -> IV + 'static, + app_fn: impl Fn() -> IV + 'static + Clone, ) -> Vec where IV: IntoView + 'static, { - generate_route_list_with_exclusions(app_fn, None).await + generate_route_list_with_exclusions_and_ssg(app_fn, None) + .await + .0 +} + +/// Generates a list of all routes defined in Leptos's Router in your app. We can then use this to automatically +/// create routes in Axum's Router without having to use wildcard matching or fallbacks. Takes in your root app Element +/// as an argument so it can walk you app tree. This version is tailored to generate Axum compatible paths. +#[tracing::instrument(level = "trace", fields(error), skip_all)] +pub async fn generate_route_list_with_ssg( + app_fn: impl Fn() -> IV + 'static + Clone, +) -> (Vec, StaticDataMap) +where + IV: IntoView + 'static, +{ + generate_route_list_with_exclusions_and_ssg(app_fn, None).await } /// Generates a list of all routes defined in Leptos's Router in your app. We can then use this to automatically @@ -1222,34 +1236,31 @@ where /// to this function will stop `.leptos_routes()` from generating a route for it, allowing a custom handler. These need to be in Axum path format #[tracing::instrument(level = "trace", fields(error), skip_all)] pub async fn generate_route_list_with_exclusions( - app_fn: impl FnOnce() -> IV + 'static, + app_fn: impl Fn() -> IV + 'static + Clone, excluded_routes: Option>, ) -> Vec where IV: IntoView + 'static, { - #[derive(Default, Clone, Debug)] - pub struct Routes(pub Arc>>); - - let routes = Routes::default(); - let routes_inner = routes.clone(); - - let local = LocalSet::new(); - // Run the local task set. - - local - .run_until(async move { - tokio::task::spawn_local(async move { - let routes = leptos_router::generate_route_list_inner(app_fn); - let mut writable = routes_inner.0.write(); - *writable = routes; - }) - .await - .unwrap(); - }) - .await; + generate_route_list_with_exclusions_and_ssg(app_fn, excluded_routes) + .await + .0 +} - let routes = routes.0.read().to_owned(); +/// Generates a list of all routes defined in Leptos's Router in your app. We can then use this to automatically +/// create routes in Axum's Router without having to use wildcard matching or fallbacks. Takes in your root app Element +/// as an argument so it can walk you app tree. This version is tailored to generate Axum compatible paths. Adding excluded_routes +/// to this function will stop `.leptos_routes()` from generating a route for it, allowing a custom handler. These need to be in Axum path format +#[tracing::instrument(level = "trace", fields(error), skip_all)] +pub async fn generate_route_list_with_exclusions_and_ssg( + app_fn: impl Fn() -> IV + 'static + Clone, + excluded_routes: Option>, +) -> (Vec, StaticDataMap) +where + IV: IntoView + 'static, +{ + let (routes, static_data_map) = + leptos_router::generate_route_list_inner(app_fn); // Axum's Router defines Root routes as "/" not "" let mut routes = routes .into_iter() @@ -1258,8 +1269,10 @@ where if path.is_empty() { RouteListing::new( "/".to_string(), + listing.path(), listing.mode(), listing.methods(), + listing.static_mode(), ) } else { listing @@ -1267,19 +1280,25 @@ where }) .collect::>(); - if routes.is_empty() { - vec![RouteListing::new( - "/", - Default::default(), - [leptos_router::Method::Get], - )] - } else { - // Routes to exclude from auto generation - if let Some(excluded_routes) = excluded_routes { - routes.retain(|p| !excluded_routes.iter().any(|e| e == p.path())) - } - routes - } + ( + if routes.is_empty() { + vec![RouteListing::new( + "/", + "", + Default::default(), + [leptos_router::Method::Get], + None, + )] + } else { + // Routes to exclude from auto generation + if let Some(excluded_routes) = excluded_routes { + routes + .retain(|p| !excluded_routes.iter().any(|e| e == p.path())) + } + routes + }, + static_data_map, + ) } /// This trait allows one to pass a list of routes and a render function to Axum's router, letting us avoid @@ -1317,6 +1336,208 @@ where T: 'static; } +fn handle_static_response( + path: String, + options: LeptosOptions, + app_fn: impl Fn() -> IV + Clone + Send + 'static, + additional_context: impl Fn() + Clone + Send + 'static, + res: StaticResponse, +) -> Pin> + 'static>> +where + IV: IntoView + 'static, +{ + Box::pin(async move { + match res { + StaticResponse::ReturnResponse { + body, + status, + content_type, + } => { + let mut res = Response::new(body); + if let Some(v) = content_type { + res.headers_mut().insert( + HeaderName::from_static("content-type"), + HeaderValue::from_static(v), + ); + } + *res.status_mut() = match status { + StaticStatusCode::Ok => StatusCode::OK, + StaticStatusCode::NotFound => StatusCode::NOT_FOUND, + StaticStatusCode::InternalServerError => { + StatusCode::INTERNAL_SERVER_ERROR + } + }; + res + } + StaticResponse::RenderDynamic => { + let res = render_dynamic( + &path, + &options, + app_fn.clone(), + additional_context.clone(), + ) + .await; + handle_static_response( + path, + options, + app_fn, + additional_context, + res, + ) + .await + } + StaticResponse::RenderNotFound => { + let res = not_found_page( + tokio::fs::read_to_string(not_found_path(&options)).await, + ); + handle_static_response( + path, + options, + app_fn, + additional_context, + res, + ) + .await + } + StaticResponse::WriteFile { body, path } => { + if let Some(path) = path.parent() { + if let Err(e) = std::fs::create_dir_all(path) { + tracing::error!( + "encountered error {} writing directories {}", + e, + path.display() + ); + } + } + if let Err(e) = std::fs::write(&path, &body) { + tracing::error!( + "encountered error {} writing file {}", + e, + path.display() + ); + } + handle_static_response( + path.to_str().unwrap().to_string(), + options, + app_fn, + additional_context, + StaticResponse::ReturnResponse { + body, + status: StaticStatusCode::Ok, + content_type: Some("text/html"), + }, + ) + .await + } + } + }) +} + +fn static_route( + router: axum::Router, + path: &str, + options: LeptosOptions, + app_fn: impl Fn() -> IV + Clone + Send + 'static, + additional_context: impl Fn() + Clone + Send + 'static, + method: leptos_router::Method, + mode: StaticMode, +) -> axum::Router +where + IV: IntoView + 'static, + S: Clone + Send + Sync + 'static, +{ + match mode { + StaticMode::Incremental => { + let handler = move |req: Request| { + Box::pin({ + let path = req.uri().path().to_string(); + let options = options.clone(); + let app_fn = app_fn.clone(); + let additional_context = additional_context.clone(); + + async move { + let (tx, rx) = futures::channel::oneshot::channel(); + let local_pool = get_leptos_pool(); + local_pool.spawn_pinned(move || async move { + let res = incremental_static_route( + tokio::fs::read_to_string(static_file_path( + &options, &path, + )) + .await, + ); + let res = handle_static_response( + path.clone(), + options, + app_fn, + additional_context, + res, + ) + .await; + + let _ = tx.send(res); + }); + rx.await.expect("to complete HTML rendering") + } + }) + }; + router.route( + path, + match method { + leptos_router::Method::Get => get(handler), + leptos_router::Method::Post => post(handler), + leptos_router::Method::Put => put(handler), + leptos_router::Method::Delete => delete(handler), + leptos_router::Method::Patch => patch(handler), + }, + ) + } + StaticMode::Upfront => { + let handler = move |req: Request| { + Box::pin({ + let path = req.uri().path().to_string(); + let options = options.clone(); + let app_fn = app_fn.clone(); + let additional_context = additional_context.clone(); + + async move { + let (tx, rx) = futures::channel::oneshot::channel(); + let local_pool = get_leptos_pool(); + local_pool.spawn_pinned(move || async move { + let res = upfront_static_route( + tokio::fs::read_to_string(static_file_path( + &options, &path, + )) + .await, + ); + let res = handle_static_response( + path.clone(), + options, + app_fn, + additional_context, + res, + ) + .await; + + let _ = tx.send(res); + }); + rx.await.expect("to complete HTML rendering") + } + }) + }; + router.route( + path, + match method { + leptos_router::Method::Get => get(handler), + leptos_router::Method::Post => post(handler), + leptos_router::Method::Put => put(handler), + leptos_router::Method::Delete => delete(handler), + leptos_router::Method::Patch => patch(handler), + }, + ) + } + } +} + /// The default implementation of `LeptosRoutes` which takes in a list of paths, and dispatches GET requests /// to those paths to Leptos's renderer. impl LeptosRoutes for axum::Router @@ -1353,7 +1574,18 @@ where let path = listing.path(); for method in listing.methods() { - router = router.route( + router = if let Some(static_mode) = listing.static_mode() { + static_route( + router, + path, + LeptosOptions::from_ref(options), + app_fn.clone(), + additional_context.clone(), + method, + static_mode, + ) + } else { + router.route( path, match listing.mode() { SsrMode::OutOfOrder => { @@ -1414,7 +1646,8 @@ where } } }, - ); + ) + }; } } router diff --git a/integrations/viz/src/lib.rs b/integrations/viz/src/lib.rs index 79d2d0c269..f4eb90ca48 100644 --- a/integrations/viz/src/lib.rs +++ b/integrations/viz/src/lib.rs @@ -23,7 +23,7 @@ use leptos_meta::{generate_head_metadata_separated, MetaContext}; use leptos_router::*; use parking_lot::RwLock; use std::{pin::Pin, sync::Arc}; -use tokio::task::{spawn_blocking, LocalSet}; +use tokio::task::spawn_blocking; use viz::{ headers::{HeaderMap, HeaderName, HeaderValue}, Body, Bytes, Error, Handler, IntoResponse, Request, RequestExt, Response, @@ -989,46 +989,54 @@ where /// create routes in Viz's Router without having to use wildcard matching or fallbacks. Takes in your root app Element /// as an argument so it can walk you app tree. This version is tailored to generate Viz compatible paths. pub async fn generate_route_list( - app_fn: impl FnOnce() -> IV + 'static, + app_fn: impl Fn() -> IV + 'static + Clone, ) -> Vec where IV: IntoView + 'static, { - generate_route_list_with_exclusions(app_fn, None).await + generate_route_list_with_exclusions_and_ssg(app_fn, None) + .await + .0 +} + +/// Generates a list of all routes defined in Leptos's Router in your app. We can then use this to automatically +/// create routes in Viz's Router without having to use wildcard matching or fallbacks. Takes in your root app Element +/// as an argument so it can walk you app tree. This version is tailored to generate Viz compatible paths. +pub async fn generate_route_list_with_ssg( + app_fn: impl Fn() -> IV + 'static + Clone, +) -> (Vec, StaticDataMap) +where + IV: IntoView + 'static, +{ + generate_route_list_with_exclusions_and_ssg(app_fn, None).await } /// Generates a list of all routes defined in Leptos's Router in your app. We can then use this to automatically /// create routes in Viz's Router without having to use wildcard matching or fallbacks. Takes in your root app Element /// as an argument so it can walk you app tree. This version is tailored to generate Viz compatible paths. pub async fn generate_route_list_with_exclusions( - app_fn: impl FnOnce() -> IV + 'static, + app_fn: impl Fn() -> IV + 'static + Clone, excluded_routes: Option>, ) -> Vec where IV: IntoView + 'static, { - #[derive(Default, Clone, Debug)] - pub struct Routes(pub Arc>>); - - let routes = Routes::default(); - let routes_inner = routes.clone(); - - let local = LocalSet::new(); - // Run the local task set. - - local - .run_until(async move { - tokio::task::spawn_local(async move { - let routes = leptos_router::generate_route_list_inner(app_fn); - let mut writable = routes_inner.0.write(); - *writable = routes; - }) - .await - .unwrap(); - }) - .await; - - let routes = routes.0.read().to_owned(); + generate_route_list_with_exclusions_and_ssg(app_fn, excluded_routes) + .await + .0 +} +/// Generates a list of all routes defined in Leptos's Router in your app. We can then use this to automatically +/// create routes in Viz's Router without having to use wildcard matching or fallbacks. Takes in your root app Element +/// as an argument so it can walk you app tree. This version is tailored to generate Viz compatible paths. +pub async fn generate_route_list_with_exclusions_and_ssg( + app_fn: impl Fn() -> IV + 'static + Clone, + excluded_routes: Option>, +) -> (Vec, StaticDataMap) +where + IV: IntoView + 'static, +{ + let (routes, static_data_map) = + leptos_router::generate_route_list_inner(app_fn); // Viz's Router defines Root routes as "/" not "" let mut routes = routes .into_iter() @@ -1037,8 +1045,10 @@ where if path.is_empty() { RouteListing::new( "/".to_string(), + listing.path(), listing.mode(), listing.methods(), + listing.static_mode(), ) } else { listing @@ -1046,17 +1056,260 @@ where }) .collect::>(); - if routes.is_empty() { - vec![RouteListing::new( - "/", - Default::default(), - [leptos_router::Method::Get], - )] - } else { - if let Some(excluded_routes) = excluded_routes { - routes.retain(|p| !excluded_routes.iter().any(|e| e == p.path())) + ( + if routes.is_empty() { + vec![RouteListing::new( + "/", + "", + Default::default(), + [leptos_router::Method::Get], + None, + )] + } else { + if let Some(excluded_routes) = excluded_routes { + routes + .retain(|p| !excluded_routes.iter().any(|e| e == p.path())) + } + routes + }, + static_data_map, + ) +} + +fn handle_static_response( + path: String, + options: LeptosOptions, + app_fn: impl Fn() -> IV + Clone + Send + Sync + 'static, + additional_context: impl Fn() + Clone + Send + Sync + 'static, + res: StaticResponse, +) -> Pin> + 'static>> +where + IV: IntoView + 'static, +{ + Box::pin(async move { + match res { + StaticResponse::ReturnResponse { + body, + status, + content_type, + } => { + let mut res = Response::html(body); + if let Some(v) = content_type { + res.headers_mut().insert( + HeaderName::from_static("content-type"), + HeaderValue::from_static(v), + ); + } + *res.status_mut() = match status { + StaticStatusCode::Ok => StatusCode::OK, + StaticStatusCode::NotFound => StatusCode::NOT_FOUND, + StaticStatusCode::InternalServerError => { + StatusCode::INTERNAL_SERVER_ERROR + } + }; + Ok(res) + } + StaticResponse::RenderDynamic => { + let res = render_dynamic( + &path, + &options, + app_fn.clone(), + additional_context.clone(), + ) + .await; + handle_static_response( + path, + options, + app_fn, + additional_context, + res, + ) + .await + } + StaticResponse::RenderNotFound => { + let res = not_found_page( + tokio::fs::read_to_string(not_found_path(&options)).await, + ); + handle_static_response( + path, + options, + app_fn, + additional_context, + res, + ) + .await + } + StaticResponse::WriteFile { body, path } => { + if let Some(path) = path.parent() { + if let Err(e) = std::fs::create_dir_all(path) { + tracing::error!( + "encountered error {} writing directories {}", + e, + path.display() + ); + } + } + if let Err(e) = std::fs::write(&path, &body) { + tracing::error!( + "encountered error {} writing file {}", + e, + path.display() + ); + } + handle_static_response( + path.to_str().unwrap().to_string(), + options, + app_fn, + additional_context, + StaticResponse::ReturnResponse { + body, + status: StaticStatusCode::Ok, + content_type: Some("text/html"), + }, + ) + .await + } + } + }) +} + +fn static_route( + router: Router, + path: &str, + options: LeptosOptions, + app_fn: impl Fn() -> IV + Clone + Send + Sync + 'static, + additional_context: impl Fn() + Clone + Send + Sync + 'static, + method: leptos_router::Method, + mode: StaticMode, +) -> Router +where + IV: IntoView + 'static, +{ + match mode { + StaticMode::Incremental => { + let handler = move |req: Request| { + Box::pin({ + let path = req.path().to_string(); + let options = options.clone(); + let app_fn = app_fn.clone(); + let additional_context = additional_context.clone(); + + async move { + let (tx, rx) = futures::channel::oneshot::channel(); + spawn_blocking(move || { + let path = path.clone(); + let options = options.clone(); + let app_fn = app_fn.clone(); + let additional_context = additional_context.clone(); + tokio::runtime::Runtime::new() + .expect("couldn't spawn runtime") + .block_on({ + let path = path.clone(); + let options = options.clone(); + let app_fn = app_fn.clone(); + let additional_context = + additional_context.clone(); + async move { + tokio::task::LocalSet::new().run_until(async { + let res = incremental_static_route( + tokio::fs::read_to_string( + static_file_path( + &options, + &path, + ), + ) + .await, + ); + let res = handle_static_response( + path.clone(), + options, + app_fn, + additional_context, + res, + ) + .await; + + let _ = tx.send(res); + }).await; + } + }) + }); + + rx.await.expect("to complete HTML rendering") + } + }) + }; + match method { + leptos_router::Method::Get => router.get(path, handler), + leptos_router::Method::Post => router.post(path, handler), + leptos_router::Method::Put => router.put(path, handler), + leptos_router::Method::Delete => router.delete(path, handler), + leptos_router::Method::Patch => router.patch(path, handler), + } + } + StaticMode::Upfront => { + let handler = move |req: Request| { + Box::pin({ + let path = req.path().to_string(); + let options = options.clone(); + let app_fn = app_fn.clone(); + let additional_context = additional_context.clone(); + + async move { + let (tx, rx) = futures::channel::oneshot::channel(); + spawn_blocking(move || { + let path = path.clone(); + let options = options.clone(); + let app_fn = app_fn.clone(); + let additional_context = additional_context.clone(); + tokio::runtime::Runtime::new() + .expect("couldn't spawn runtime") + .block_on({ + let path = path.clone(); + let options = options.clone(); + let app_fn = app_fn.clone(); + let additional_context = + additional_context.clone(); + async move { + tokio::task::LocalSet::new() + .run_until(async { + let res = upfront_static_route( + tokio::fs::read_to_string( + static_file_path( + &options, &path, + ), + ) + .await, + ); + let res = + handle_static_response( + path.clone(), + options, + app_fn, + additional_context, + res, + ) + .await; + + let _ = tx.send(res); + }) + .await; + } + }) + }); + + rx.await.expect("to complete HTML rendering") + } + }) + }; + match method { + leptos_router::Method::Get => router.get(path, handler), + leptos_router::Method::Post => router.post(path, handler), + leptos_router::Method::Put => router.put(path, handler), + leptos_router::Method::Delete => router.delete(path, handler), + leptos_router::Method::Patch => router.patch(path, handler), + } } - routes } } @@ -1120,63 +1373,117 @@ impl LeptosRoutes for Router { let path = listing.path(); let mode = listing.mode(); - listing.methods().fold(router, |router, method| match mode { - SsrMode::OutOfOrder => { - let s = render_app_to_stream_with_context( + listing.methods().fold(router, |router, method| { + if let Some(static_mode) = listing.static_mode() { + static_route( + router, + path, options.clone(), - additional_context.clone(), app_fn.clone(), - ); - match method { - leptos_router::Method::Get => router.get(path, s), - leptos_router::Method::Post => router.post(path, s), - leptos_router::Method::Put => router.put(path, s), - leptos_router::Method::Delete => router.delete(path, s), - leptos_router::Method::Patch => router.patch(path, s), - } - } - SsrMode::PartiallyBlocked => { - let s = + additional_context.clone(), + method, + static_mode, + ) + } else { + match mode { + SsrMode::OutOfOrder => { + let s = render_app_to_stream_with_context( + options.clone(), + additional_context.clone(), + app_fn.clone(), + ); + match method { + leptos_router::Method::Get => { + router.get(path, s) + } + leptos_router::Method::Post => { + router.post(path, s) + } + leptos_router::Method::Put => { + router.put(path, s) + } + leptos_router::Method::Delete => { + router.delete(path, s) + } + leptos_router::Method::Patch => { + router.patch(path, s) + } + } + } + SsrMode::PartiallyBlocked => { + let s = render_app_to_stream_with_context_and_replace_blocks( options.clone(), additional_context.clone(), app_fn.clone(), true, ); - match method { - leptos_router::Method::Get => router.get(path, s), - leptos_router::Method::Post => router.post(path, s), - leptos_router::Method::Put => router.put(path, s), - leptos_router::Method::Delete => router.delete(path, s), - leptos_router::Method::Patch => router.patch(path, s), - } - } - SsrMode::InOrder => { - let s = render_app_to_stream_in_order_with_context( - options.clone(), - additional_context.clone(), - app_fn.clone(), - ); - match method { - leptos_router::Method::Get => router.get(path, s), - leptos_router::Method::Post => router.post(path, s), - leptos_router::Method::Put => router.put(path, s), - leptos_router::Method::Delete => router.delete(path, s), - leptos_router::Method::Patch => router.patch(path, s), - } - } - SsrMode::Async => { - let s = render_app_async_with_context( - options.clone(), - additional_context.clone(), - app_fn.clone(), - ); - match method { - leptos_router::Method::Get => router.get(path, s), - leptos_router::Method::Post => router.post(path, s), - leptos_router::Method::Put => router.put(path, s), - leptos_router::Method::Delete => router.delete(path, s), - leptos_router::Method::Patch => router.patch(path, s), + match method { + leptos_router::Method::Get => { + router.get(path, s) + } + leptos_router::Method::Post => { + router.post(path, s) + } + leptos_router::Method::Put => { + router.put(path, s) + } + leptos_router::Method::Delete => { + router.delete(path, s) + } + leptos_router::Method::Patch => { + router.patch(path, s) + } + } + } + SsrMode::InOrder => { + let s = render_app_to_stream_in_order_with_context( + options.clone(), + additional_context.clone(), + app_fn.clone(), + ); + match method { + leptos_router::Method::Get => { + router.get(path, s) + } + leptos_router::Method::Post => { + router.post(path, s) + } + leptos_router::Method::Put => { + router.put(path, s) + } + leptos_router::Method::Delete => { + router.delete(path, s) + } + leptos_router::Method::Patch => { + router.patch(path, s) + } + } + } + SsrMode::Async => { + let s = render_app_async_with_context( + options.clone(), + additional_context.clone(), + app_fn.clone(), + ); + match method { + leptos_router::Method::Get => { + router.get(path, s) + } + leptos_router::Method::Post => { + router.post(path, s) + } + leptos_router::Method::Put => { + router.put(path, s) + } + leptos_router::Method::Delete => { + router.delete(path, s) + } + leptos_router::Method::Patch => { + router.patch(path, s) + } + } + } } } }) diff --git a/leptos_config/src/lib.rs b/leptos_config/src/lib.rs index 1a983756f6..3033088db5 100644 --- a/leptos_config/src/lib.rs +++ b/leptos_config/src/lib.rs @@ -66,6 +66,10 @@ pub struct LeptosOptions { #[builder(default)] #[serde(default)] pub reload_ws_protocol: ReloadWSProtocol, + /// The path of a custom 404 Not Found page to display when statically serving content, defaults to `site_root/404.html` + #[builder(default = default_not_found_path())] + #[serde(default = "default_not_found_path")] + pub not_found_path: String, } impl LeptosOptions { @@ -103,6 +107,7 @@ impl LeptosOptions { reload_ws_protocol: ws_from_str( env_w_default("LEPTOS_RELOAD_WS_PROTOCOL", "ws")?.as_str(), )?, + not_found_path: env_w_default("LEPTOS_NOT_FOUND_PATH", "/404")?, }) } } @@ -126,6 +131,11 @@ fn default_site_addr() -> SocketAddr { fn default_reload_port() -> u32 { 3001 } + +fn default_not_found_path() -> String { + "/404".to_string() +} + fn env_wo_default(key: &str) -> Result, LeptosConfigError> { match std::env::var(key) { Ok(val) => Ok(Some(val)), diff --git a/router/Cargo.toml b/router/Cargo.toml index 79b34e5136..dbd91bf0f5 100644 --- a/router/Cargo.toml +++ b/router/Cargo.toml @@ -10,6 +10,8 @@ description = "Router for the Leptos web framework." [dependencies] leptos = { workspace = true } +leptos_integration_utils = { workspace = true, optional = true } +leptos_meta = { workspace = true, optional = true } cached = { version = "0.45.0", optional = true } cfg-if = "1" common_macros = "0.1" @@ -59,7 +61,15 @@ features = [ default = [] csr = ["leptos/csr"] hydrate = ["leptos/hydrate"] -ssr = ["leptos/ssr", "dep:cached", "dep:lru", "dep:url", "dep:regex"] +ssr = [ + "leptos/ssr", + "dep:cached", + "dep:lru", + "dep:url", + "dep:regex", + "dep:leptos_integration_utils", + "dep:leptos_meta", +] nightly = ["leptos/nightly"] [package.metadata.cargo-all-features] diff --git a/router/src/components/mod.rs b/router/src/components/mod.rs index b316074033..c35e8cb9f9 100644 --- a/router/src/components/mod.rs +++ b/router/src/components/mod.rs @@ -6,6 +6,7 @@ mod redirect; mod route; mod router; mod routes; +mod static_render; pub use form::*; pub use link::*; @@ -15,3 +16,4 @@ pub use redirect::*; pub use route::*; pub use router::*; pub use routes::*; +pub use static_render::*; diff --git a/router/src/components/route.rs b/router/src/components/route.rs index 2a82339fc1..925ac619d2 100644 --- a/router/src/components/route.rs +++ b/router/src/components/route.rs @@ -1,12 +1,14 @@ use crate::{ matching::{resolve_path, PathMatch, RouteDefinition, RouteMatch}, - ParamsMap, RouterContext, SsrMode, + ParamsMap, RouterContext, SsrMode, StaticData, StaticMode, StaticParamsMap, }; use leptos::{leptos_dom::Transparent, *}; use std::{ any::Any, borrow::Cow, cell::{Cell, RefCell}, + future::Future, + pin::Pin, rc::Rc, }; @@ -78,6 +80,8 @@ where ssr, methods, data, + None, + None, ) } @@ -136,12 +140,64 @@ where ssr, methods, data, + None, + None, ) } + +/// Describes a portion of the nested layout of the app, specifying the route it should match, +/// the element it should display, and data that should be loaded alongside the route. +#[cfg_attr( + any(debug_assertions, feature = "ssr"), + tracing::instrument(level = "info", skip_all,) +)] +#[component(transparent)] +pub fn StaticRoute( + /// The path fragment that this route should match. This can be static (`users`), + /// include a parameter (`:id`) or an optional parameter (`:id?`), or match a + /// wildcard (`user/*any`). + path: P, + /// The view that should be shown when this route is matched. This can be any function + /// that returns a type that implements [IntoView] (like `|| view! {

"Show this"

})` + /// or `|| view! { ` } or even, for a component with no props, `MyComponent`). + view: F, + /// Creates a map of the params that should be built for a particular route. + #[prop(optional)] + static_params: Option, + /// The static route mode + #[prop(optional)] + mode: StaticMode, + /// A data-loading function that will be called when the route is matched. Its results can be + /// accessed with [`use_route_data`](crate::use_route_data). + #[prop(optional, into)] + data: Option, + /// `children` may be empty or include nested routes. + #[prop(optional)] + children: Option, +) -> impl IntoView +where + E: IntoView, + F: Fn() -> E + 'static, + P: std::fmt::Display, + S: Fn() -> Pin>> + 'static, +{ + define_route( + children, + path.to_string(), + Rc::new(move || view().into_view()), + SsrMode::default(), + &[Method::Get], + data, + Some(mode), + static_params.map(|s| Rc::new(s) as _), + ) +} + #[cfg_attr( any(debug_assertions, feature = "ssr"), tracing::instrument(level = "info", skip_all,) )] +#[allow(clippy::too_many_arguments)] pub(crate) fn define_route( children: Option, path: String, @@ -149,6 +205,8 @@ pub(crate) fn define_route( ssr_mode: SsrMode, methods: &'static [Method], data: Option, + static_mode: Option, + static_params: Option, ) -> RouteDefinition { let children = children .map(|children| { @@ -179,6 +237,8 @@ pub(crate) fn define_route( ssr_mode, methods, data, + static_mode, + static_params, } } diff --git a/router/src/components/routes.rs b/router/src/components/routes.rs index 32529f8c51..39f0a162cb 100644 --- a/router/src/components/routes.rs +++ b/router/src/components/routes.rs @@ -313,6 +313,8 @@ impl Branches { base, &mut Vec::new(), &mut branches, + true, + base, ); current.insert(base.to_string(), branches); } @@ -570,9 +572,16 @@ fn create_branches( base: &str, stack: &mut Vec, branches: &mut Vec, + static_valid: bool, + parents_path: &str, ) { for def in route_defs { - let routes = create_routes(def, base); + let routes = create_routes( + def, + base, + static_valid && def.static_mode.is_some(), + parents_path, + ); for route in routes { stack.push(route.clone()); @@ -580,7 +589,14 @@ fn create_branches( let branch = create_branch(stack, branches.len()); branches.push(branch); } else { - create_branches(&def.children, &route.pattern, stack, branches); + create_branches( + &def.children, + &route.pattern, + stack, + branches, + static_valid && route.key.static_mode.is_some(), + &format!("{}{}", parents_path, def.path), + ); } stack.pop(); @@ -603,9 +619,21 @@ pub(crate) fn create_branch(routes: &[RouteData], index: usize) -> Branch { any(debug_assertions, feature = "ssr"), tracing::instrument(level = "info", skip_all,) )] -fn create_routes(route_def: &RouteDefinition, base: &str) -> Vec { +fn create_routes( + route_def: &RouteDefinition, + base: &str, + static_valid: bool, + parents_path: &str, +) -> Vec { let RouteDefinition { children, .. } = route_def; let is_leaf = children.is_empty(); + if is_leaf && route_def.static_mode.is_some() && !static_valid { + panic!( + "Static rendering is not valid for route '{}{}', all parent \ + routes must also be statically renderable.", + parents_path, route_def.path + ); + } let mut acc = Vec::new(); for original_path in expand_optionals(&route_def.path) { let path = join_paths(base, &original_path); diff --git a/router/src/components/static_render.rs b/router/src/components/static_render.rs new file mode 100644 index 0000000000..e23c087e42 --- /dev/null +++ b/router/src/components/static_render.rs @@ -0,0 +1,447 @@ +#[cfg(feature = "ssr")] +use crate::{RouteListing, RouterIntegrationContext, ServerIntegration}; +#[cfg(feature = "ssr")] +use leptos::{provide_context, IntoView, LeptosOptions}; +#[cfg(feature = "ssr")] +use leptos_meta::MetaContext; +use linear_map::LinearMap; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "ssr")] +use std::path::Path; +use std::{ + collections::HashMap, + fmt::Display, + future::Future, + hash::{Hash, Hasher}, + path::PathBuf, + pin::Pin, + rc::Rc, +}; + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct StaticParamsMap(pub LinearMap>); + +impl StaticParamsMap { + /// Create a new empty `StaticParamsMap`. + #[inline] + pub fn new() -> Self { + Self::default() + } + + /// Insert a value into the map. + #[inline] + pub fn insert(&mut self, key: impl ToString, value: Vec) { + self.0.insert(key.to_string(), value); + } + + /// Get a value from the map. + #[inline] + pub fn get(&self, key: &str) -> Option<&Vec> { + self.0.get(key) + } +} + +#[doc(hidden)] +#[derive(Debug)] +pub struct StaticPath<'b, 'a: 'b> { + path: &'a str, + segments: Vec>, + params: LinearMap<&'a str, &'b Vec>, +} + +#[doc(hidden)] +#[derive(Debug)] +enum StaticPathSegment<'a> { + Static(&'a str), + Param(&'a str), + Wildcard(&'a str), +} + +impl<'b, 'a: 'b> StaticPath<'b, 'a> { + pub fn new(path: &'a str) -> StaticPath<'b, 'a> { + use StaticPathSegment::*; + Self { + path, + segments: path + .split('/') + .filter(|s| !s.is_empty()) + .map(|s| match s.chars().next() { + Some(':') => Param(&s[1..]), + Some('*') => Wildcard(&s[1..]), + _ => Static(s), + }) + .collect::>(), + params: LinearMap::new(), + } + } + + pub fn add_params(&mut self, params: &'b StaticParamsMap) { + use StaticPathSegment::*; + for segment in self.segments.iter() { + match segment { + Param(name) | Wildcard(name) => { + if let Some(value) = params.get(name) { + self.params.insert(name, value); + } + } + _ => {} + } + } + } + + pub fn into_paths(self) -> Vec { + use StaticPathSegment::*; + let mut paths = vec![ResolvedStaticPath(String::new())]; + + for segment in self.segments { + match segment { + Static(s) => { + paths = paths + .into_iter() + .map(|p| ResolvedStaticPath(format!("{}/{}", p, s))) + .collect::>(); + } + Param(name) | Wildcard(name) => { + let mut new_paths = vec![]; + for path in paths { + let Some(params) = self.params.get(name) else { + panic!( + "missing param {} for path: {}", + name, self.path + ); + }; + for val in params.iter() { + new_paths.push(ResolvedStaticPath(format!( + "{}/{}", + path, val + ))); + } + } + paths = new_paths; + } + } + } + paths + } + + pub fn parent(&self) -> Option> { + if self.path == "/" || self.path.is_empty() { + return None; + } + self.path + .rfind('/') + .map(|i| StaticPath::new(&self.path[..i])) + } + + pub fn parents(&self) -> Vec> { + let mut parents = vec![]; + let mut parent = self.parent(); + while let Some(p) = parent { + parent = p.parent(); + parents.push(p); + } + parents + } + + pub fn path(&self) -> &'a str { + self.path + } +} + +impl Hash for StaticPath<'_, '_> { + fn hash(&self, state: &mut H) { + self.path.hash(state); + } +} + +impl StaticPath<'_, '_> {} + +#[doc(hidden)] +#[repr(transparent)] +pub struct ResolvedStaticPath(pub String); + +impl Display for ResolvedStaticPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl ResolvedStaticPath { + #[cfg(feature = "ssr")] + pub async fn build( + &self, + options: &LeptosOptions, + app_fn: impl Fn() -> IV + 'static + Clone, + additional_context: impl Fn() + 'static + Clone, + ) -> String + where + IV: IntoView + 'static, + { + let url = format!("http://leptos{}", self); + let app = { + let app_fn = app_fn.clone(); + move || { + provide_context(RouterIntegrationContext::new( + ServerIntegration { path: url }, + )); + provide_context(MetaContext::new()); + (app_fn)().into_view() + } + }; + let (stream, runtime) = leptos::ssr::render_to_stream_in_order_with_prefix_undisposed_with_context(app, move || "".into(), additional_context.clone()); + leptos_integration_utils::build_async_response(stream, options, runtime) + .await + } + + #[cfg(feature = "ssr")] + pub async fn write( + &self, + options: &LeptosOptions, + app_fn: impl Fn() -> IV + 'static + Clone, + additional_context: impl Fn() + 'static + Clone, + ) -> Result + where + IV: IntoView + 'static, + { + let html = self.build(options, app_fn, additional_context).await; + let path = Path::new(&options.site_root) + .join(format!("{}.static.html", self.0.trim_start_matches('/'))); + + if let Some(path) = path.parent() { + std::fs::create_dir_all(path)? + } + std::fs::write(path, &html)?; + Ok(html) + } +} + +#[cfg(feature = "ssr")] +pub async fn build_static_routes( + options: &LeptosOptions, + app_fn: impl Fn() -> IV + 'static + Clone, + routes: &[RouteListing], + static_data_map: &StaticDataMap, +) -> Result<(), std::io::Error> +where + IV: IntoView + 'static, +{ + build_static_routes_with_additional_context( + options, + app_fn, + || {}, + routes, + static_data_map, + ) + .await +} + +#[cfg(feature = "ssr")] +pub async fn build_static_routes_with_additional_context( + options: &LeptosOptions, + app_fn: impl Fn() -> IV + 'static + Clone, + additional_context: impl Fn() + 'static + Clone, + routes: &[RouteListing], + static_data_map: &StaticDataMap, +) -> Result<(), std::io::Error> +where + IV: IntoView + 'static, +{ + let mut static_data: HashMap<&str, StaticParamsMap> = HashMap::new(); + for (key, value) in static_data_map { + match value { + Some(value) => static_data.insert(key, value.as_ref()().await), + None => static_data.insert(key, StaticParamsMap::default()), + }; + } + let static_routes = routes + .iter() + .filter(|route| route.static_mode().is_some()) + .collect::>(); + // TODO: maybe make this concurrent in some capacity + for route in static_routes { + let mut path = StaticPath::new(route.leptos_path()); + for p in path.parents().into_iter().rev() { + if let Some(data) = static_data.get(p.path()) { + path.add_params(data); + } + } + if let Some(data) = static_data.get(path.path()) { + path.add_params(data); + } + for path in path.into_paths() { + println!("building static route: {}", path); + path.write(options, app_fn.clone(), additional_context.clone()) + .await?; + } + } + Ok(()) +} + +#[doc(hidden)] +#[cfg(feature = "ssr")] +pub fn purge_dir_of_static_files(path: PathBuf) -> Result<(), std::io::Error> { + for entry in path.read_dir()? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + purge_dir_of_static_files(path)?; + } else if path.is_file() { + if let Some(name) = path.file_name().and_then(|i| i.to_str()) { + if name.ends_with(".static.html") { + std::fs::remove_file(path)?; + } + } + } + } + Ok(()) +} + +/// Purge all statically generated route files +#[cfg(feature = "ssr")] +pub fn purge_all_static_routes( + options: &LeptosOptions, +) -> Result<(), std::io::Error> { + purge_dir_of_static_files(Path::new(&options.site_root).to_path_buf()) +} + +pub type StaticData = Rc; + +pub type StaticDataFn = + dyn Fn() -> Pin>> + 'static; + +pub type StaticDataMap = HashMap>; + +/// The mode to use when rendering the route statically. +/// On mode `Upfront`, the route will be built with the server is started using the provided static +/// data. On mode `Incremental`, the route will be built on the first request to it and then cached +/// and returned statically for subsequent requests. +#[derive(Default, Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum StaticMode { + #[default] + Upfront, + Incremental, +} + +#[doc(hidden)] +pub enum StaticStatusCode { + Ok, + NotFound, + InternalServerError, +} + +#[doc(hidden)] +pub enum StaticResponse { + ReturnResponse { + body: String, + status: StaticStatusCode, + content_type: Option<&'static str>, + }, + RenderDynamic, + RenderNotFound, + WriteFile { + body: String, + path: PathBuf, + }, +} + +#[doc(hidden)] +#[inline(always)] +#[cfg(feature = "ssr")] +pub fn static_file_path(options: &LeptosOptions, path: &str) -> String { + format!("{}{}.static.html", options.site_root, path) +} + +#[doc(hidden)] +#[inline(always)] +#[cfg(feature = "ssr")] +pub fn not_found_path(options: &LeptosOptions) -> String { + format!( + "{}{}.static.html", + options.site_root, options.not_found_path + ) +} + +#[doc(hidden)] +#[inline(always)] +pub fn upfront_static_route( + res: Result, +) -> StaticResponse { + match res { + Ok(body) => StaticResponse::ReturnResponse { + body, + status: StaticStatusCode::Ok, + content_type: Some("text/html"), + }, + Err(e) => match e.kind() { + std::io::ErrorKind::NotFound => StaticResponse::RenderNotFound, + _ => { + tracing::error!("error reading file: {}", e); + StaticResponse::ReturnResponse { + body: "Internal Server Error".into(), + status: StaticStatusCode::InternalServerError, + content_type: None, + } + } + }, + } +} + +#[doc(hidden)] +#[inline(always)] +pub fn not_found_page(res: Result) -> StaticResponse { + match res { + Ok(body) => StaticResponse::ReturnResponse { + body, + status: StaticStatusCode::NotFound, + content_type: Some("text/html"), + }, + Err(e) => match e.kind() { + std::io::ErrorKind::NotFound => StaticResponse::ReturnResponse { + body: "Not Found".into(), + status: StaticStatusCode::Ok, + content_type: None, + }, + _ => { + tracing::error!("error reading not found file: {}", e); + StaticResponse::ReturnResponse { + body: "Internal Server Error".into(), + status: StaticStatusCode::InternalServerError, + content_type: None, + } + } + }, + } +} + +#[doc(hidden)] +pub fn incremental_static_route( + res: Result, +) -> StaticResponse { + match res { + Ok(body) => StaticResponse::ReturnResponse { + body, + status: StaticStatusCode::Ok, + content_type: Some("text/html"), + }, + Err(_) => StaticResponse::RenderDynamic, + } +} + +#[doc(hidden)] +#[cfg(feature = "ssr")] +pub async fn render_dynamic( + path: &str, + options: &LeptosOptions, + app_fn: impl Fn() -> IV + Clone + Send + 'static, + additional_context: impl Fn() + 'static + Clone + Send, +) -> StaticResponse +where + IV: IntoView + 'static, +{ + let body = ResolvedStaticPath(path.into()) + .build(options, app_fn, additional_context) + .await; + let path = Path::new(&options.site_root) + .join(format!("{}.static.html", path.trim_start_matches('/'))); + StaticResponse::WriteFile { body, path } +} diff --git a/router/src/extract_routes.rs b/router/src/extract_routes.rs index 6029f37ecd..b85aff1e1d 100644 --- a/router/src/extract_routes.rs +++ b/router/src/extract_routes.rs @@ -1,8 +1,13 @@ use crate::{ Branch, Method, RouterIntegrationContext, ServerIntegration, SsrMode, + StaticDataMap, StaticMode, StaticParamsMap, StaticPath, }; use leptos::*; -use std::{cell::RefCell, collections::HashSet, rc::Rc}; +use std::{ + cell::RefCell, + collections::{HashMap, HashSet}, + rc::Rc, +}; /// Context to contain all possible routes. #[derive(Clone, Default, Debug)] @@ -12,21 +17,27 @@ pub struct PossibleBranchContext(pub(crate) Rc>>); /// A route that this application can serve. pub struct RouteListing { path: String, + leptos_path: String, mode: SsrMode, methods: HashSet, + static_mode: Option, } impl RouteListing { /// Create a route listing from its parts. pub fn new( path: impl ToString, + leptos_path: impl ToString, mode: SsrMode, methods: impl IntoIterator, + static_mode: Option, ) -> Self { Self { path: path.to_string(), + leptos_path: leptos_path.to_string(), mode, methods: methods.into_iter().collect(), + static_mode, } } @@ -35,6 +46,11 @@ impl RouteListing { &self.path } + /// The leptos-formatted path this route handles. + pub fn leptos_path(&self) -> &str { + &self.leptos_path + } + /// The rendering mode for this path. pub fn mode(&self) -> SsrMode { self.mode @@ -44,6 +60,43 @@ impl RouteListing { pub fn methods(&self) -> impl Iterator + '_ { self.methods.iter().copied() } + + /// Whether this route is statically rendered. + #[inline(always)] + pub fn static_mode(&self) -> Option { + self.static_mode + } + + /// Build a route statically, will return `Ok(true)` on success or `Ok(false)` when the route + /// is not marked as statically rendered. All route parameters to use when resolving all paths + /// to render should be passed in the `params` argument. + pub async fn build_static( + &self, + options: &LeptosOptions, + app_fn: impl Fn() -> IV + Send + 'static + Clone, + additional_context: impl Fn() + Send + 'static + Clone, + params: &StaticParamsMap, + ) -> Result + where + IV: IntoView + 'static, + { + match self.static_mode { + None => Ok(false), + Some(_) => { + let mut path = StaticPath::new(&self.leptos_path); + path.add_params(params); + for path in path.into_paths() { + path.write( + options, + app_fn.clone(), + additional_context.clone(), + ) + .await?; + } + Ok(true) + } + } + } } /// Generates a list of all routes this application could possibly serve. This returns the raw routes in the leptos_router @@ -54,8 +107,8 @@ impl RouteListing { /// [`axum`]: /// [`viz`]: pub fn generate_route_list_inner( - app_fn: impl FnOnce() -> IV + 'static, -) -> Vec + app_fn: impl Fn() -> IV + 'static + Clone, +) -> (Vec, StaticDataMap) where IV: IntoView + 'static, { @@ -74,6 +127,7 @@ where leptos::suppress_resource_load(false); let branches = branches.0.borrow(); + let mut static_data_map: StaticDataMap = HashMap::new(); let routes = branches .iter() .flat_map(|branch| { @@ -89,15 +143,26 @@ where .flat_map(|route| route.key.methods) .copied() .collect::>(); - let pattern = - branch.routes.last().map(|route| route.pattern.clone()); - pattern.map(|path| RouteListing { + let route = branch + .routes + .last() + .map(|route| (route.key.static_mode, route.pattern.clone())); + for route in branch.routes.iter() { + static_data_map.insert( + route.pattern.to_string(), + route.key.static_params.clone(), + ); + } + route.map(|(static_mode, path)| RouteListing { + leptos_path: path.clone(), path, mode, methods: methods.clone(), + static_mode, }) }) - .collect(); + .collect::>(); + runtime.dispose(); - routes + (routes, static_data_map) } diff --git a/router/src/matching/route.rs b/router/src/matching/route.rs index c6cccce96f..fe178a72fc 100644 --- a/router/src/matching/route.rs +++ b/router/src/matching/route.rs @@ -1,4 +1,4 @@ -use crate::{Loader, Method, SsrMode}; +use crate::{Loader, Method, SsrMode, StaticData, StaticMode}; use leptos::leptos_dom::View; use std::rc::Rc; @@ -21,6 +21,10 @@ pub struct RouteDefinition { pub methods: &'static [Method], /// A data loader function that will be called when this route is matched. pub(crate) data: Option, + /// The route's preferred mode of static generation, if any + pub static_mode: Option, + /// The data required to fill any dynamic segments in the path during static rendering. + pub static_params: Option, } impl std::fmt::Debug for RouteDefinition { @@ -29,6 +33,7 @@ impl std::fmt::Debug for RouteDefinition { .field("path", &self.path) .field("children", &self.children) .field("ssr_mode", &self.ssr_mode) + .field("static_render", &self.static_mode) .finish() } }