diff --git a/examples/static_routing/.gitignore b/examples/static_routing/.gitignore new file mode 100644 index 000000000..8cdaa33de --- /dev/null +++ b/examples/static_routing/.gitignore @@ -0,0 +1,13 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ +pkg + +# These are backup files generated by rustfmt +**/*.rs.bk + +# node e2e test tools and outputs +node_modules/ +test-results/ +end2end/playwright-report/ +playwright/.cache/ diff --git a/examples/static_routing/Cargo.toml b/examples/static_routing/Cargo.toml new file mode 100644 index 000000000..7df933f9b --- /dev/null +++ b/examples/static_routing/Cargo.toml @@ -0,0 +1,115 @@ +[package] +name = "static_routing" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +console_error_panic_hook = "0.1.7" +console_log = "1.0" +leptos = { path = "../../leptos", features = [ + "hydration", +] } #"nightly", "hydration"] } +leptos_meta = { path = "../../meta" } +leptos_axum = { path = "../../integrations/axum", optional = true } +leptos_router = { path = "../../router" } +log = "0.4.22" +serde = { version = "1.0", features = ["derive"] } +thiserror = "1.0" +axum = { version = "0.7.5", optional = true } +tower = { version = "0.4.13", optional = true } +tower-http = { version = "0.5.2", features = ["fs"], optional = true } +tokio = { version = "1.39", features = [ + "fs", + "rt-multi-thread", + "macros", +], optional = true } +tokio-stream = { version = "0.1", features = ["fs"], optional = true } +futures = "0.3" +wasm-bindgen = "0.2.93" +notify = { version = "6", optional = true } +http = { version = "1", optional = true } + +[features] +hydrate = ["leptos/hydrate"] +ssr = [ + "dep:axum", + "dep:tower", + "dep:tower-http", + "dep:tokio", + "dep:tokio-stream", + "leptos/ssr", + "leptos_meta/ssr", + "dep:leptos_axum", + "leptos_router/ssr", + "dep:notify", + "dep:http" +] + +[profile.release] +panic = "abort" + +[profile.wasm-release] +inherits = "release" +opt-level = 'z' +lto = true +codegen-units = 1 +panic = "abort" + +[package.metadata.cargo-all-features] +denylist = ["axum", "tower", "tower-http", "tokio", "sqlx", "leptos_axum"] +skip_feature_sets = [["ssr", "hydrate"]] + +[package.metadata.leptos] +# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name +output-name = "ssr_modes" +# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup. +site-root = "target/site" +# The site-root relative folder where all compiled output (JS, WASM and CSS) is written +# Defaults to pkg +site-pkg-dir = "pkg" +# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to //app.css +style-file = "style/main.scss" +# Assets source dir. All files found here will be copied and synchronized to site-root. +# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir. +# +# Optional. Env: LEPTOS_ASSETS_DIR. +assets-dir = "assets" +# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup. +site-addr = "127.0.0.1:3007" +# The port to use for automatic reload monitoring +reload-port = 3001 +# [Optional] Command to use when running end2end tests. It will run in the end2end dir. +# [Windows] for non-WSL use "npx.cmd playwright test" +# This binary name can be checked in Powershell with Get-Command npx +end2end-cmd = "npx playwright test" +end2end-dir = "end2end" +# The browserlist query used for optimizing the CSS. +browserquery = "defaults" +# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head +watch = false +# The environment Leptos will run in, usually either "DEV" or "PROD" +env = "DEV" +# The features to use when compiling the bin target +# +# Optional. Can be over-ridden with the command line parameter --bin-features +bin-features = ["ssr"] + +# If the --no-default-features flag should be used when compiling the bin target +# +# Optional. Defaults to false. +bin-default-features = false + +# The features to use when compiling the lib target +# +# Optional. Can be over-ridden with the command line parameter --lib-features +lib-features = ["hydrate"] + +# If the --no-default-features flag should be used when compiling the lib target +# +# Optional. Defaults to false. +lib-default-features = false + +lib-profile-release = "wasm-release" diff --git a/examples/static_routing/LICENSE b/examples/static_routing/LICENSE new file mode 100644 index 000000000..e869ce3b4 --- /dev/null +++ b/examples/static_routing/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 henrik + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/static_routing/Makefile.toml b/examples/static_routing/Makefile.toml new file mode 100644 index 000000000..8f80e59c2 --- /dev/null +++ b/examples/static_routing/Makefile.toml @@ -0,0 +1,8 @@ +extend = [ + { path = "../cargo-make/main.toml" }, + { path = "../cargo-make/cargo-leptos.toml" }, +] + +[env] + +CLIENT_PROCESS_NAME = "ssr_modes_axum" diff --git a/examples/static_routing/README.md b/examples/static_routing/README.md new file mode 100644 index 000000000..ee989cda2 --- /dev/null +++ b/examples/static_routing/README.md @@ -0,0 +1,11 @@ +# Static Routing Example + +This example shows the static routing features, which can be used to generate the HTML content for some routes before a request. + +## Getting Started + +See the [Examples README](../README.md) for setup and run instructions. + +## Quick Start + +Run `cargo leptos watch` to run this example. diff --git a/examples/static_routing/assets/favicon.ico b/examples/static_routing/assets/favicon.ico new file mode 100644 index 000000000..2ba8527cb Binary files /dev/null and b/examples/static_routing/assets/favicon.ico differ diff --git a/examples/static_routing/posts/post1.md b/examples/static_routing/posts/post1.md new file mode 100644 index 000000000..944a831df --- /dev/null +++ b/examples/static_routing/posts/post1.md @@ -0,0 +1,3 @@ +# My first blog post + +Having a blog is *fun*. diff --git a/examples/static_routing/posts/post2.md b/examples/static_routing/posts/post2.md new file mode 100644 index 000000000..43ba01875 --- /dev/null +++ b/examples/static_routing/posts/post2.md @@ -0,0 +1,3 @@ +# My second blog post + +Coming up with content is hard. diff --git a/examples/static_routing/posts/post3.md b/examples/static_routing/posts/post3.md new file mode 100644 index 000000000..7b97534da --- /dev/null +++ b/examples/static_routing/posts/post3.md @@ -0,0 +1,3 @@ +# My third blog post + +Could I just have AI write this for me instead? diff --git a/examples/static_routing/posts/post4.md b/examples/static_routing/posts/post4.md new file mode 100644 index 000000000..8d0355966 --- /dev/null +++ b/examples/static_routing/posts/post4.md @@ -0,0 +1,3 @@ +# My fourth post + +Here is some content. It should regenerate the static page. diff --git a/examples/static_routing/rust-toolchain.toml b/examples/static_routing/rust-toolchain.toml new file mode 100644 index 000000000..ff2a4ff10 --- /dev/null +++ b/examples/static_routing/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable" # test change diff --git a/examples/static_routing/src/app.rs b/examples/static_routing/src/app.rs new file mode 100644 index 000000000..b47d01cb2 --- /dev/null +++ b/examples/static_routing/src/app.rs @@ -0,0 +1,323 @@ +use std::path::Path; + +use futures::{channel::mpsc, Stream}; +use leptos::prelude::*; +use leptos_meta::MetaTags; +use leptos_meta::*; +use leptos_router::{ + components::{FlatRoutes, Redirect, Route, Router}, + hooks::use_params, + params::Params, + path, + static_routes::StaticRoute, + SsrMode, +}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub fn shell(options: LeptosOptions) -> impl IntoView { + view! { + + + + + + + + + + + + + + } +} + +#[component] +pub fn App() -> impl IntoView { + // Provides context that manages stylesheets, titles, meta tags, etc. + provide_meta_context(); + let fallback = || view! { "Page not found." }.into_view(); + + view! { + + + <Meta name="color-scheme" content="dark light"/> + <Router> + <nav> + <a href="/">"Home"</a> + </nav> + <main> + <FlatRoutes fallback> + <Route + path=path!("/") + view=HomePage + ssr=SsrMode::Static( + StaticRoute::new().regenerate(|_| watch_path(Path::new("./posts"))), + ) + /> + + <Route + path=path!("/about") + view=move || view! { <Redirect path="/"/> } + ssr=SsrMode::Static(StaticRoute::new()) + /> + + <Route + path=path!("/post/:slug/") + view=Post + ssr=SsrMode::Static( + StaticRoute::new() + .prerender_params(|| async move { + [("slug".into(), list_slugs().await.unwrap_or_default())] + .into_iter() + .collect() + }) + .regenerate(|params| { + let slug = params.get("slug").unwrap(); + watch_path(Path::new(&format!("./posts/{slug}.md"))) + }), + ) + /> + + </FlatRoutes> + </main> + </Router> + } +} + +#[component] +fn HomePage() -> impl IntoView { + // load the posts + let posts = Resource::new(|| (), |_| list_posts()); + let posts = move || { + posts + .get() + .map(|n| n.unwrap_or_default()) + .unwrap_or_default() + }; + + view! { + <h1>"My Great Blog"</h1> + <Suspense fallback=move || view! { <p>"Loading posts..."</p> }> + <ul> + <For each=posts key=|post| post.slug.clone() let:post> + <li> + <a href=format!("/post/{}/", post.slug)>{post.title.clone()}</a> + </li> + </For> + </ul> + </Suspense> + } +} + +#[derive(Params, Clone, Debug, PartialEq, Eq)] +pub struct PostParams { + slug: Option<String>, +} + +#[component] +fn Post() -> impl IntoView { + let query = use_params::<PostParams>(); + let slug = move || { + query + .get() + .map(|q| q.slug.unwrap_or_default()) + .map_err(|_| PostError::InvalidId) + }; + let post_resource = Resource::new_blocking(slug, |slug| async move { + match slug { + Err(e) => Err(e), + Ok(slug) => get_post(slug) + .await + .map(|data| data.ok_or(PostError::PostNotFound)) + .map_err(|e| PostError::ServerError(e.to_string())), + } + }); + + let post_view = move || { + Suspend::new(async move { + match post_resource.await { + Ok(Ok(post)) => { + Ok(view! { + <h1>{post.title.clone()}</h1> + <p>{post.content.clone()}</p> + + // since we're using async rendering for this page, + // this metadata should be included in the actual HTML <head> + // when it's first served + <Title text=post.title/> + <Meta name="description" content=post.content/> + }) + } + Ok(Err(e)) | Err(e) => { + Err(PostError::ServerError(e.to_string())) + } + } + }) + }; + + view! { + <em>"The world's best content."</em> + <Suspense fallback=move || view! { <p>"Loading post..."</p> }> + <ErrorBoundary fallback=|errors| { + #[cfg(feature = "ssr")] + expect_context::<leptos_axum::ResponseOptions>() + .set_status(http::StatusCode::NOT_FOUND); + view! { + <div class="error"> + <h1>"Something went wrong."</h1> + <ul> + {move || { + errors + .get() + .into_iter() + .map(|(_, error)| view! { <li>{error.to_string()}</li> }) + .collect::<Vec<_>>() + }} + + </ul> + </div> + } + }>{post_view}</ErrorBoundary> + </Suspense> + } +} + +#[derive(Error, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PostError { + #[error("Invalid post ID.")] + InvalidId, + #[error("Post not found.")] + PostNotFound, + #[error("Server error: {0}.")] + ServerError(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Post { + slug: String, + title: String, + content: String, +} + +#[server] +pub async fn list_slugs() -> Result<Vec<String>, ServerFnError> { + use tokio::fs; + use tokio_stream::wrappers::ReadDirStream; + use tokio_stream::StreamExt; + + let files = ReadDirStream::new(fs::read_dir("./posts").await?); + Ok(files + .filter_map(|entry| { + let entry = entry.ok()?; + let path = entry.path(); + if !path.is_file() { + return None; + } + let extension = path.extension()?; + if extension != "md" { + return None; + } + + let slug = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default() + .replace(".md", ""); + Some(slug) + }) + .collect() + .await) +} + +#[server] +pub async fn list_posts() -> Result<Vec<Post>, ServerFnError> { + println!("calling list_posts"); + + use futures::TryStreamExt; + use tokio::fs; + use tokio_stream::wrappers::ReadDirStream; + + let files = ReadDirStream::new(fs::read_dir("./posts").await?); + files + .try_filter_map(|entry| async move { + let path = entry.path(); + if !path.is_file() { + return Ok(None); + } + let Some(extension) = path.extension() else { + return Ok(None); + }; + if extension != "md" { + return Ok(None); + } + + let slug = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default() + .replace(".md", ""); + let content = fs::read_to_string(path).await?; + // world's worst Markdown frontmatter parser + let title = content.lines().next().unwrap().replace("# ", ""); + + Ok(Some(Post { + slug, + title, + content, + })) + }) + .try_collect() + .await + .map_err(ServerFnError::from) +} + +#[server] +pub async fn get_post(slug: String) -> Result<Option<Post>, ServerFnError> { + println!("reading ./posts/{slug}.md"); + let content = + tokio::fs::read_to_string(&format!("./posts/{slug}.md")).await?; + // world's worst Markdown frontmatter parser + let title = content.lines().next().unwrap().replace("# ", ""); + + Ok(Some(Post { + slug, + title, + content, + })) +} + +#[allow(unused)] // path is not used in non-SSR +fn watch_path(path: &Path) -> impl Stream<Item = ()> { + #[allow(unused)] + let (mut tx, rx) = mpsc::channel(0); + + #[cfg(feature = "ssr")] + { + use notify::RecursiveMode; + use notify::Watcher; + + let mut watcher = + notify::recommended_watcher(move |res: Result<_, _>| { + if res.is_ok() { + // if this fails, it's because the buffer is full + // this means we've already notified before it's regenerated, + // so this page will be queued for regeneration already + _ = tx.try_send(()); + } + }) + .expect("could not create watcher"); + + // Add a path to be watched. All files and directories at that path and + // below will be monitored for changes. + watcher + .watch(path, RecursiveMode::NonRecursive) + .expect("could not watch path"); + + // we want this to run as long as the server is alive + std::mem::forget(watcher); + } + + rx +} diff --git a/examples/static_routing/src/lib.rs b/examples/static_routing/src/lib.rs new file mode 100644 index 000000000..1fd98109f --- /dev/null +++ b/examples/static_routing/src/lib.rs @@ -0,0 +1,9 @@ +pub mod app; + +#[cfg(feature = "hydrate")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn hydrate() { + use app::*; + console_error_panic_hook::set_once(); + leptos::mount::hydrate_body(App); +} diff --git a/examples/static_routing/src/main.rs b/examples/static_routing/src/main.rs new file mode 100644 index 000000000..7821f72c1 --- /dev/null +++ b/examples/static_routing/src/main.rs @@ -0,0 +1,42 @@ +#[cfg(feature = "ssr")] +#[tokio::main] +async fn main() { + use axum::Router; + use leptos::prelude::*; + use leptos_axum::{generate_route_list_with_ssg, LeptosRoutes}; + use static_routing::app::*; + + let conf = get_configuration(None).unwrap(); + let addr = conf.leptos_options.site_addr; + let leptos_options = conf.leptos_options; + // Generate the list of routes in your Leptos App + let (routes, static_routes) = generate_route_list_with_ssg({ + let leptos_options = leptos_options.clone(); + move || shell(leptos_options.clone()) + }); + + static_routes.generate(&leptos_options).await; + + let app = Router::new() + .leptos_routes(&leptos_options, routes, { + let leptos_options = leptos_options.clone(); + move || shell(leptos_options.clone()) + }) + .fallback(leptos_axum::file_and_error_handler(shell)) + .with_state(leptos_options); + + // run our app with hyper + // `axum::Server` is a re-export of `hyper::Server` + log!("listening on http://{}", &addr); + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app.into_make_service()) + .await + .unwrap(); +} + +#[cfg(not(feature = "ssr"))] +pub fn main() { + // no client-side main function + // unless we want this to work with e.g., Trunk for pure client-side testing + // see lib.rs for hydration function instead +} diff --git a/examples/static_routing/style/main.scss b/examples/static_routing/style/main.scss new file mode 100644 index 000000000..a12f75925 --- /dev/null +++ b/examples/static_routing/style/main.scss @@ -0,0 +1,3 @@ +body { + font-family: sans-serif; +} \ No newline at end of file diff --git a/integrations/actix/Cargo.toml b/integrations/actix/Cargo.toml index 824e73d87..8b14fef6c 100644 --- a/integrations/actix/Cargo.toml +++ b/integrations/actix/Cargo.toml @@ -10,6 +10,7 @@ edition.workspace = true [dependencies] actix-http = "3.8" +actix-files = "0.6" actix-web = "4.8" futures = "0.3.30" any_spawner = { workspace = true, features = ["tokio"] } @@ -25,6 +26,8 @@ parking_lot = "0.12.3" tracing = { version = "0.1", optional = true } tokio = { version = "1.39", features = ["rt", "fs"] } send_wrapper = "0.6.0" +dashmap = "6" +once_cell = "1" [package.metadata.docs.rs] rustdoc-args = ["--generate-link-to-definition"] diff --git a/integrations/actix/src/lib.rs b/integrations/actix/src/lib.rs index f30687af4..c460203d4 100644 --- a/integrations/actix/src/lib.rs +++ b/integrations/actix/src/lib.rs @@ -6,30 +6,38 @@ //! [`examples`](https://github.com/leptos-rs/leptos/tree/main/examples) //! directory in the Leptos repository. +use actix_files::NamedFile; use actix_http::header::{HeaderName, HeaderValue, ACCEPT, LOCATION, REFERER}; use actix_web::{ body::BoxBody, dev::{ServiceFactory, ServiceRequest}, http::header, - web::{Payload, ServiceConfig}, + test, + web::{Data, Payload, ServiceConfig}, *, }; +use dashmap::DashMap; use futures::{stream::once, Stream, StreamExt}; use http::StatusCode; use hydration_context::SsrSharedContext; use leptos::{ + config::LeptosOptions, context::{provide_context, use_context}, + prelude::expect_context, reactive_graph::{computed::ScopedFuture, owner::Owner}, - IntoView, *, + IntoView, }; use leptos_integration_utils::{ BoxedFnOnce, ExtendResponse, PinnedFuture, PinnedStream, }; use leptos_meta::ServerMetaContext; use leptos_router::{ - components::provide_server_redirect, location::RequestUrl, PathSegment, - RouteList, RouteListing, SsrMode, StaticDataMap, StaticMode, *, + components::provide_server_redirect, + location::RequestUrl, + static_routes::{RegenerationFn, ResolvedStaticPath}, + Method, PathSegment, RouteList, RouteListing, SsrMode, }; +use once_cell::sync::Lazy; use parking_lot::RwLock; use send_wrapper::SendWrapper; use server_fn::{ @@ -37,7 +45,9 @@ use server_fn::{ }; use std::{ fmt::{Debug, Display}, + future::Future, ops::{Deref, DerefMut}, + path::Path, sync::Arc, }; @@ -728,13 +738,25 @@ pub fn render_app_async_with_context<IV>( where IV: IntoView + 'static, { - handle_response(method, additional_context, app_fn, |app, chunks| { - Box::pin(async move { - let app = app.to_html_stream_in_order().collect::<String>().await; - let chunks = chunks(); - Box::pin(once(async move { app }).chain(chunks)) - as PinnedStream<String> - }) + handle_response(method, additional_context, app_fn, async_stream_builder) +} + +fn async_stream_builder<IV>( + app: IV, + chunks: BoxedFnOnce<PinnedStream<String>>, +) -> PinnedFuture<PinnedStream<String>> +where + IV: IntoView + 'static, +{ + Box::pin(async move { + let app = if cfg!(feature = "islands-router") { + app.to_html_stream_in_order_branching() + } else { + app.to_html_stream_in_order() + }; + let app = app.collect::<String>().await; + let chunks = chunks(); + Box::pin(once(async move { app }).chain(chunks)) as PinnedStream<String> }) } @@ -822,7 +844,7 @@ where /// 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<IV>( - app_fn: impl Fn() -> IV + 'static + Clone, + app_fn: impl Fn() -> IV + 'static + Send + Clone, ) -> Vec<ActixRouteListing> where IV: IntoView + 'static, @@ -834,8 +856,8 @@ where /// 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<IV>( - app_fn: impl Fn() -> IV + 'static + Clone, -) -> (Vec<ActixRouteListing>, StaticDataMap) + app_fn: impl Fn() -> IV + 'static + Send + Clone, +) -> (Vec<ActixRouteListing>, StaticRouteGenerator) where IV: IntoView + 'static, { @@ -847,7 +869,7 @@ 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<IV>( - app_fn: impl Fn() -> IV + 'static + Clone, + app_fn: impl Fn() -> IV + 'static + Send + Clone, excluded_routes: Option<Vec<String>>, ) -> Vec<ActixRouteListing> where @@ -861,9 +883,9 @@ 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_and_ssg<IV>( - app_fn: impl Fn() -> IV + 'static + Clone, + app_fn: impl Fn() -> IV + 'static + Send + Clone, excluded_routes: Option<Vec<String>>, -) -> (Vec<ActixRouteListing>, StaticDataMap) +) -> (Vec<ActixRouteListing>, StaticRouteGenerator) where IV: IntoView + 'static, { @@ -912,7 +934,7 @@ pub struct ActixRouteListing { path: String, mode: SsrMode, methods: Vec<leptos_router::Method>, - static_mode: Option<(StaticMode, StaticDataMap)>, + regenerate: Vec<RegenerationFn>, } impl From<RouteListing> for ActixRouteListing { @@ -925,12 +947,12 @@ impl From<RouteListing> for ActixRouteListing { }; let mode = value.mode(); let methods = value.methods().collect(); - let static_mode = value.into_static_parts(); + let regenerate = value.regenerate().into(); Self { path, - mode, + mode: mode.clone(), methods, - static_mode, + regenerate, } } } @@ -941,13 +963,13 @@ impl ActixRouteListing { path: String, mode: SsrMode, methods: impl IntoIterator<Item = leptos_router::Method>, - static_mode: Option<(StaticMode, StaticDataMap)>, + regenerate: impl Into<Vec<RegenerationFn>>, ) -> Self { Self { path, mode, methods: methods.into_iter().collect(), - static_mode, + regenerate: regenerate.into(), } } @@ -958,19 +980,13 @@ impl ActixRouteListing { /// The rendering mode for this path. pub fn mode(&self) -> SsrMode { - self.mode + self.mode.clone() } /// The HTTP request methods this path can handle. pub fn methods(&self) -> impl Iterator<Item = leptos_router::Method> + '_ { self.methods.iter().copied() } - - /// Whether this route is statically rendered. - #[inline(always)] - pub fn static_mode(&self) -> Option<StaticMode> { - self.static_mode.as_ref().map(|n| n.0) - } } /// Generates a list of all routes defined in Leptos's Router in your app. We can then use this to automatically @@ -979,10 +995,10 @@ impl ActixRouteListing { /// 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. /// Additional context will be provided to the app Element. pub fn generate_route_list_with_exclusions_and_ssg_and_context<IV>( - app_fn: impl Fn() -> IV + 'static + Clone, + app_fn: impl Fn() -> IV + 'static + Send + Clone, excluded_routes: Option<Vec<String>>, - additional_context: impl Fn() + 'static + Clone, -) -> (Vec<ActixRouteListing>, StaticDataMap) + additional_context: impl Fn() + 'static + Send + Clone, +) -> (Vec<ActixRouteListing>, StaticRouteGenerator) where IV: IntoView + 'static, { @@ -1001,6 +1017,12 @@ where }) .unwrap_or_default(); + let generator = StaticRouteGenerator::new( + &routes, + app_fn.clone(), + additional_context.clone(), + ); + // Axum's Router defines Root routes as "/" not "" let mut routes = routes .into_inner() @@ -1014,7 +1036,7 @@ where "/".to_string(), Default::default(), [leptos_router::Method::Get], - None, + vec![], )] } else { // Routes to exclude from auto generation @@ -1024,192 +1046,251 @@ where } routes }, - StaticDataMap::new(), // TODO - //static_data_map, + generator, ) } +/// Allows generating any prerendered routes. +#[allow(clippy::type_complexity)] +pub struct StaticRouteGenerator( + Box<dyn FnOnce(&LeptosOptions) -> PinnedFuture<()> + Send>, +); + +impl StaticRouteGenerator { + fn render_route<IV: IntoView + 'static>( + path: String, + app_fn: impl Fn() -> IV + Clone + Send + 'static, + additional_context: impl Fn() + Clone + Send + 'static, + ) -> impl Future<Output = (Owner, String)> { + let (meta_context, meta_output) = ServerMetaContext::new(); + let additional_context = { + let add_context = additional_context.clone(); + move || { + let mock_req = test::TestRequest::with_uri(&path) + .insert_header(("Accept", "text/html")) + .to_http_request(); + let res_options = ResponseOptions::default(); + provide_contexts( + Request::new(&mock_req), + &meta_context, + &res_options, + ); + add_context(); + } + }; + + let (owner, stream) = leptos_integration_utils::build_response( + app_fn.clone(), + additional_context, + async_stream_builder, + ); + + let sc = owner.shared_context().unwrap(); + + async move { + let stream = stream.await; + while let Some(pending) = sc.await_deferred() { + pending.await; + } + + let html = meta_output + .inject_meta_context(stream) + .await + .collect::<String>() + .await; + (owner, html) + } + } + + /// Creates a new static route generator from the given list of route definitions. + pub fn new<IV>( + routes: &RouteList, + app_fn: impl Fn() -> IV + Clone + Send + 'static, + additional_context: impl Fn() + Clone + Send + 'static, + ) -> Self + where + IV: IntoView + 'static, + { + Self({ + let routes = routes.clone(); + Box::new(move |options| { + let options = options.clone(); + let app_fn = app_fn.clone(); + let additional_context = additional_context.clone(); + + Box::pin(routes.generate_static_files( + move |path: &ResolvedStaticPath| { + Self::render_route( + path.to_string(), + app_fn.clone(), + additional_context.clone(), + ) + }, + move |path: &ResolvedStaticPath, + owner: &Owner, + html: String| { + let options = options.clone(); + let path = path.to_owned(); + let response_options = owner.with(use_context); + async move { + write_static_route( + &options, + response_options, + path.as_ref(), + &html, + ) + .await + } + }, + was_404, + )) + }) + }) + } + + /// Generates the routes. + pub async fn generate(self, options: &LeptosOptions) { + (self.0)(options).await + } +} + +static STATIC_HEADERS: Lazy<DashMap<String, ResponseOptions>> = + Lazy::new(DashMap::new); + +fn was_404(owner: &Owner) -> bool { + let resp = owner.with(|| expect_context::<ResponseOptions>()); + let status = resp.0.read().status; + + if let Some(status) = status { + return status == StatusCode::NOT_FOUND; + } + + false +} + +fn static_path(options: &LeptosOptions, path: &str) -> String { + use leptos_integration_utils::static_file_path; + + // If the path ends with a trailing slash, we generate the path + // as a directory with a index.html file inside. + if path != "/" && path.ends_with("/") { + static_file_path(options, &format!("{}index", path)) + } else { + static_file_path(options, path) + } +} + +async fn write_static_route( + options: &LeptosOptions, + response_options: Option<ResponseOptions>, + path: &str, + html: &str, +) -> Result<(), std::io::Error> { + if let Some(options) = response_options { + STATIC_HEADERS.insert(path.to_string(), options); + } + + let path = static_path(options, path); + let path = Path::new(&path); + if let Some(path) = path.parent() { + tokio::fs::create_dir_all(path).await?; + } + tokio::fs::write(path, &html).await?; + + Ok(()) +} + +fn handle_static_route<IV>( + additional_context: impl Fn() + 'static + Clone + Send, + app_fn: impl Fn() -> IV + Clone + Send + 'static, + regenerate: Vec<RegenerationFn>, +) -> Route +where + IV: IntoView + 'static, +{ + let handler = move |req: HttpRequest, data: Data<LeptosOptions>| { + Box::pin({ + let app_fn = app_fn.clone(); + let additional_context = additional_context.clone(); + let regenerate = regenerate.clone(); + async move { + let options = data.into_inner(); + let orig_path = req.uri().path(); + let path = static_path(&options, orig_path); + let path = Path::new(&path); + let exists = tokio::fs::try_exists(path).await.unwrap_or(false); + + let (response_options, html) = if !exists { + let path = ResolvedStaticPath::new(orig_path); + + let (owner, html) = path + .build( + move |path: &ResolvedStaticPath| { + StaticRouteGenerator::render_route( + path.to_string(), + app_fn.clone(), + additional_context.clone(), + ) + }, + move |path: &ResolvedStaticPath, + owner: &Owner, + html: String| { + let options = options.clone(); + let path = path.to_owned(); + let response_options = owner.with(use_context); + async move { + write_static_route( + &options, + response_options, + path.as_ref(), + &html, + ) + .await + } + }, + was_404, + regenerate, + ) + .await; + (owner.with(use_context::<ResponseOptions>), html) + } else { + let headers = + STATIC_HEADERS.get(orig_path).map(|v| v.clone()); + (headers, None) + }; + + // if html is Some(_), it means that `was_error_response` is true and we're not + // actually going to cache this route, just return it as HTML + // + // this if for thing like 404s, where we do not want to cache an endless series of + // typos (or malicious requests) + let mut res = ActixResponse(match html { + Some(html) => { + HttpResponse::Ok().content_type("text/html").body(html) + } + None => match NamedFile::open(path) { + Ok(res) => res.into_response(&req), + Err(err) => HttpResponse::InternalServerError() + .body(err.to_string()), + }, + }); + + if let Some(options) = response_options { + res.extend_response(&options); + } + + res.0 + } + }) + }; + web::get().to(handler) +} + pub enum DataResponse<T> { Data(T), Response(actix_web::dev::Response<BoxBody>), } -// TODO static 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<Box<dyn Future<Output = HttpResponse<String>> + '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<IV>( - 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 { @@ -1290,19 +1371,15 @@ where provide_context(method); additional_context(); }; - router = if let Some(static_mode) = listing.static_mode() { - _ = static_mode; - todo!() /* - router.route( - path, - static_route( - app_fn.clone(), - additional_context_and_method.clone(), - method, - static_mode, - ), - ) - */ + router = if matches!(listing.mode(), SsrMode::Static(_)) { + router.route( + path, + handle_static_route( + additional_context_and_method.clone(), + app_fn.clone(), + listing.regenerate.clone(), + ), + ) } else { router.route( path, @@ -1334,6 +1411,7 @@ where app_fn.clone(), method, ), + _ => unreachable!() }, ) }; @@ -1390,7 +1468,17 @@ impl LeptosRoutes for &mut ServiceConfig { let mode = listing.mode(); for method in listing.methods() { - router = router.route( + if matches!(listing.mode(), SsrMode::Static(_)) { + router = router.route( + path, + handle_static_route( + additional_context.clone(), + app_fn.clone(), + listing.regenerate.clone(), + ), + ) + } else { + router = router.route( path, match mode { SsrMode::OutOfOrder => { @@ -1420,8 +1508,10 @@ impl LeptosRoutes for &mut ServiceConfig { app_fn.clone(), method, ), + _ => unreachable!() }, ); + } } } diff --git a/integrations/axum/Cargo.toml b/integrations/axum/Cargo.toml index 90bce7b6e..956743f76 100644 --- a/integrations/axum/Cargo.toml +++ b/integrations/axum/Cargo.toml @@ -14,6 +14,7 @@ hydration_context = { workspace = true } axum = { version = "0.7.5", default-features = false, features = [ "matched-path", ] } +dashmap = "6" futures = "0.3.30" http = "1.1" http-body-util = "0.1.2" @@ -23,6 +24,7 @@ leptos_macro = { workspace = true, features = ["axum"] } leptos_meta = { workspace = true, features = ["ssr"] } leptos_router = { workspace = true, features = ["ssr"] } leptos_integration_utils = { workspace = true } +once_cell = "1" parking_lot = "0.12.3" serde_json = "1.0" tokio = { version = "1.39", default-features = false } diff --git a/integrations/axum/src/lib.rs b/integrations/axum/src/lib.rs index b530d0f4c..08e189d47 100644 --- a/integrations/axum/src/lib.rs +++ b/integrations/axum/src/lib.rs @@ -32,9 +32,11 @@ //! [`examples`](https://github.com/leptos-rs/leptos/tree/main/examples) //! directory in the Leptos repository. +#[cfg(feature = "default")] +use axum::http::Uri; use axum::{ body::{Body, Bytes}, - extract::{FromRequestParts, MatchedPath}, + extract::{FromRef, FromRequestParts, MatchedPath, State}, http::{ header::{self, HeaderName, HeaderValue, ACCEPT, LOCATION, REFERER}, request::Parts, @@ -44,10 +46,7 @@ use axum::{ routing::{delete, get, patch, post, put}, }; #[cfg(feature = "default")] -use axum::{ - extract::{FromRef, State}, - http::Uri, -}; +use dashmap::DashMap; use futures::{stream::once, Future, Stream, StreamExt}; use hydration_context::SsrSharedContext; use leptos::{ @@ -61,12 +60,20 @@ use leptos_integration_utils::{ BoxedFnOnce, ExtendResponse, PinnedFuture, PinnedStream, }; use leptos_meta::ServerMetaContext; +#[cfg(feature = "default")] +use leptos_router::static_routes::ResolvedStaticPath; use leptos_router::{ - components::provide_server_redirect, location::RequestUrl, PathSegment, - RouteList, RouteListing, SsrMode, StaticDataMap, StaticMode, + components::provide_server_redirect, + location::RequestUrl, + static_routes::{RegenerationFn, StaticParamsMap}, + PathSegment, RouteList, RouteListing, SsrMode, }; +#[cfg(feature = "default")] +use once_cell::sync::Lazy; use parking_lot::RwLock; use server_fn::{redirect::REDIRECT_HEADER, ServerFnError}; +#[cfg(feature = "default")] +use std::path::Path; use std::{fmt::Debug, io, pin::Pin, sync::Arc}; #[cfg(feature = "default")] use tower::ServiceExt; @@ -236,14 +243,20 @@ pub fn redirect(path: &str) { ); } } else { - let msg = "Couldn't retrieve either Parts or ResponseOptions while \ - trying to redirect()."; - #[cfg(feature = "tracing")] - tracing::warn!("{}", &msg); - + { + tracing::warn!( + "Couldn't retrieve either Parts or ResponseOptions while \ + trying to redirect()." + ); + } #[cfg(not(feature = "tracing"))] - eprintln!("{}", &msg); + { + eprintln!( + "Couldn't retrieve either Parts or ResponseOptions while \ + trying to redirect()." + ); + } } } @@ -497,10 +510,11 @@ where feature = "tracing", tracing::instrument(level = "trace", fields(error), skip_all) )] -pub fn render_route<IV>( +pub fn render_route<S, IV>( paths: Vec<AxumRouteListing>, app_fn: impl Fn() -> IV + Clone + Send + 'static, ) -> impl Fn( + State<S>, Request<Body>, ) -> Pin<Box<dyn Future<Output = Response<Body>> + Send + 'static>> + Clone @@ -508,6 +522,8 @@ pub fn render_route<IV>( + 'static where IV: IntoView + 'static, + LeptosOptions: FromRef<S>, + S: Send + 'static, { render_route_with_context(paths, || {}, app_fn) } @@ -648,11 +664,12 @@ where feature = "tracing", tracing::instrument(level = "trace", fields(error), skip_all) )] -pub fn render_route_with_context<IV>( +pub fn render_route_with_context<S, IV>( paths: Vec<AxumRouteListing>, additional_context: impl Fn() + 'static + Clone + Send, app_fn: impl Fn() -> IV + Clone + Send + 'static, ) -> impl Fn( + State<S>, Request<Body>, ) -> Pin<Box<dyn Future<Output = Response<Body>> + Send + 'static>> + Clone @@ -660,6 +677,8 @@ pub fn render_route_with_context<IV>( + 'static where IV: IntoView + 'static, + LeptosOptions: FromRef<S>, + S: Send + 'static, { let ooo = render_app_to_stream_with_context( additional_context.clone(), @@ -679,7 +698,7 @@ where app_fn.clone(), ); - move |req| { + move |state, req| { // 1. Process route to match the values in routeListing let path = req .extensions() @@ -702,6 +721,25 @@ where SsrMode::PartiallyBlocked => pb(req), SsrMode::InOrder => io(req), SsrMode::Async => asyn(req), + SsrMode::Static(_) => { + #[cfg(feature = "default")] + { + let regenerate = listing.regenerate.clone(); + handle_static_route( + additional_context.clone(), + app_fn.clone(), + regenerate, + )(state, req) + } + #[cfg(not(feature = "default"))] + { + _ = state; + panic!( + "Static routes are not currently supported on WASM32 \ + server targets." + ); + } + } } } } @@ -1097,18 +1135,25 @@ pub fn render_app_async_with_context<IV>( where IV: IntoView + 'static, { - handle_response(additional_context, app_fn, |app, chunks| { - Box::pin(async move { - let app = if cfg!(feature = "islands-router") { - app.to_html_stream_in_order_branching() - } else { - app.to_html_stream_in_order() - }; - let app = app.collect::<String>().await; - let chunks = chunks(); - Box::pin(once(async move { app }).chain(chunks)) - as PinnedStream<String> - }) + handle_response(additional_context, app_fn, async_stream_builder) +} + +fn async_stream_builder<IV>( + app: IV, + chunks: BoxedFnOnce<PinnedStream<String>>, +) -> PinnedFuture<PinnedStream<String>> +where + IV: IntoView + 'static, +{ + Box::pin(async move { + let app = if cfg!(feature = "islands-router") { + app.to_html_stream_in_order_branching() + } else { + app.to_html_stream_in_order() + }; + let app = app.collect::<String>().await; + let chunks = chunks(); + Box::pin(once(async move { app }).chain(chunks)) as PinnedStream<String> }) } @@ -1120,7 +1165,7 @@ where tracing::instrument(level = "trace", fields(error), skip_all) )] pub fn generate_route_list<IV>( - app_fn: impl Fn() -> IV + 'static + Clone, + app_fn: impl Fn() -> IV + 'static + Clone + Send, ) -> Vec<AxumRouteListing> where IV: IntoView + 'static, @@ -1128,7 +1173,7 @@ where 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 +/// Generates a list of all routes defined in Leptos's Router in your app. We can then use t.clone()his 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. #[cfg_attr( @@ -1136,8 +1181,8 @@ where tracing::instrument(level = "trace", fields(error), skip_all) )] pub fn generate_route_list_with_ssg<IV>( - app_fn: impl Fn() -> IV + 'static + Clone, -) -> (Vec<AxumRouteListing>, StaticDataMap) + app_fn: impl Fn() -> IV + 'static + Clone + Send, +) -> (Vec<AxumRouteListing>, StaticRouteGenerator) where IV: IntoView + 'static, { @@ -1153,7 +1198,7 @@ where tracing::instrument(level = "trace", fields(error), skip_all) )] pub fn generate_route_list_with_exclusions<IV>( - app_fn: impl Fn() -> IV + 'static + Clone, + app_fn: impl Fn() -> IV + 'static + Clone + Send, excluded_routes: Option<Vec<String>>, ) -> Vec<AxumRouteListing> where @@ -1162,13 +1207,13 @@ where generate_route_list_with_exclusions_and_ssg(app_fn, excluded_routes).0 } -/// TODO docs +/// Builds all routes that have been defined using [`StaticRoute`]. #[allow(unused)] pub async fn build_static_routes<IV>( options: &LeptosOptions, app_fn: impl Fn() -> IV + 'static + Send + Clone, routes: &[RouteListing], - static_data_map: StaticDataMap, + static_data_map: StaticParamsMap, ) where IV: IntoView + 'static, { @@ -1197,9 +1242,9 @@ pub async fn build_static_routes<IV>( tracing::instrument(level = "trace", fields(error), skip_all) )] pub fn generate_route_list_with_exclusions_and_ssg<IV>( - app_fn: impl Fn() -> IV + 'static + Clone, + app_fn: impl Fn() -> IV + 'static + Clone + Send, excluded_routes: Option<Vec<String>>, -) -> (Vec<AxumRouteListing>, StaticDataMap) +) -> (Vec<AxumRouteListing>, StaticRouteGenerator) where IV: IntoView + 'static, { @@ -1216,7 +1261,8 @@ pub struct AxumRouteListing { path: String, mode: SsrMode, methods: Vec<leptos_router::Method>, - static_mode: Option<(StaticMode, StaticDataMap)>, + #[allow(unused)] + regenerate: Vec<RegenerationFn>, } impl From<RouteListing> for AxumRouteListing { @@ -1229,12 +1275,12 @@ impl From<RouteListing> for AxumRouteListing { }; let mode = value.mode(); let methods = value.methods().collect(); - let static_mode = value.into_static_parts(); + let regenerate = value.regenerate().into(); Self { path, - mode, + mode: mode.clone(), methods, - static_mode, + regenerate, } } } @@ -1245,13 +1291,13 @@ impl AxumRouteListing { path: String, mode: SsrMode, methods: impl IntoIterator<Item = leptos_router::Method>, - static_mode: Option<(StaticMode, StaticDataMap)>, + regenerate: impl Into<Vec<RegenerationFn>>, ) -> Self { Self { path, mode, methods: methods.into_iter().collect(), - static_mode, + regenerate: regenerate.into(), } } @@ -1261,20 +1307,14 @@ impl AxumRouteListing { } /// The rendering mode for this path. - pub fn mode(&self) -> SsrMode { - self.mode + pub fn mode(&self) -> &SsrMode { + &self.mode } /// The HTTP request methods this path can handle. pub fn methods(&self) -> impl Iterator<Item = leptos_router::Method> + '_ { self.methods.iter().copied() } - - /// Whether this route is statically rendered. - #[inline(always)] - pub fn static_mode(&self) -> Option<StaticMode> { - self.static_mode.as_ref().map(|n| n.0) - } } /// Generates a list of all routes defined in Leptos's Router in your app. We can then use this to automatically @@ -1287,16 +1327,17 @@ impl AxumRouteListing { tracing::instrument(level = "trace", fields(error), skip_all) )] pub fn generate_route_list_with_exclusions_and_ssg_and_context<IV>( - app_fn: impl Fn() -> IV + 'static + Clone, + app_fn: impl Fn() -> IV + Clone + Send + 'static, excluded_routes: Option<Vec<String>>, - additional_context: impl Fn() + 'static + Clone, -) -> (Vec<AxumRouteListing>, StaticDataMap) + additional_context: impl Fn() + Clone + Send + 'static, +) -> (Vec<AxumRouteListing>, StaticRouteGenerator) where IV: IntoView + 'static, { + // do some basic reactive setup init_executor(); - let owner = Owner::new_root(Some(Arc::new(SsrSharedContext::new()))); + let routes = owner .with(|| { // stub out a path for now @@ -1310,6 +1351,12 @@ where }) .unwrap_or_default(); + let generator = StaticRouteGenerator::new( + &routes, + app_fn.clone(), + additional_context.clone(), + ); + // Axum's Router defines Root routes as "/" not "" let mut routes = routes .into_inner() @@ -1323,7 +1370,7 @@ where "/".to_string(), Default::default(), [leptos_router::Method::Get], - None, + vec![], )] } else { // Routes to exclude from auto generation @@ -1333,16 +1380,284 @@ where } routes }, - StaticDataMap::new(), // TODO - //static_data_map, + generator, ) } +/// Allows generating any prerendered routes. +#[allow(clippy::type_complexity)] +pub struct StaticRouteGenerator( + Box<dyn FnOnce(&LeptosOptions) -> PinnedFuture<()> + Send>, +); + +impl StaticRouteGenerator { + #[cfg(feature = "default")] + fn render_route<IV: IntoView + 'static>( + path: String, + app_fn: impl Fn() -> IV + Clone + Send + 'static, + additional_context: impl Fn() + Clone + Send + 'static, + ) -> impl Future<Output = (Owner, String)> { + let (meta_context, meta_output) = ServerMetaContext::new(); + let additional_context = { + let add_context = additional_context.clone(); + move || { + let full_path = format!("http://leptos.dev{path}"); + let mock_req = http::Request::builder() + .method(http::Method::GET) + .header("Accept", "text/html") + .body(Body::empty()) + .unwrap(); + let (mock_parts, _) = mock_req.into_parts(); + let res_options = ResponseOptions::default(); + provide_contexts( + &full_path, + &meta_context, + mock_parts, + res_options, + ); + add_context(); + } + }; + + let (owner, stream) = leptos_integration_utils::build_response( + app_fn.clone(), + additional_context, + async_stream_builder, + ); + + let sc = owner.shared_context().unwrap(); + + async move { + let stream = stream.await; + while let Some(pending) = sc.await_deferred() { + pending.await; + } + + let html = meta_output + .inject_meta_context(stream) + .await + .collect::<String>() + .await; + (owner, html) + } + } + + /// Creates a new static route generator from the given list of route definitions. + pub fn new<IV>( + routes: &RouteList, + app_fn: impl Fn() -> IV + Clone + Send + 'static, + additional_context: impl Fn() + Clone + Send + 'static, + ) -> Self + where + IV: IntoView + 'static, + { + #[cfg(feature = "default")] + { + Self({ + let routes = routes.clone(); + Box::new(move |options| { + let options = options.clone(); + let app_fn = app_fn.clone(); + let additional_context = additional_context.clone(); + + Box::pin(routes.generate_static_files( + move |path: &ResolvedStaticPath| { + Self::render_route( + path.to_string(), + app_fn.clone(), + additional_context.clone(), + ) + }, + move |path: &ResolvedStaticPath, + owner: &Owner, + html: String| { + let options = options.clone(); + let path = path.to_owned(); + let response_options = owner.with(use_context); + async move { + write_static_route( + &options, + response_options, + path.as_ref(), + &html, + ) + .await + } + }, + was_404, + )) + }) + }) + } + + #[cfg(not(feature = "default"))] + { + _ = routes; + _ = app_fn; + _ = additional_context; + panic!( + "Static routes are not currently supported on WASM32 server \ + targets." + ); + } + } + + /// Generates the routes. + pub async fn generate(self, options: &LeptosOptions) { + (self.0)(options).await + } +} + +#[cfg(feature = "default")] +static STATIC_HEADERS: Lazy<DashMap<String, ResponseOptions>> = + Lazy::new(DashMap::new); + +#[cfg(feature = "default")] +fn was_404(owner: &Owner) -> bool { + let resp = owner.with(|| expect_context::<ResponseOptions>()); + let status = resp.0.read().status; + + if let Some(status) = status { + return status == StatusCode::NOT_FOUND; + } + + false +} + +#[cfg(feature = "default")] +fn static_path(options: &LeptosOptions, path: &str) -> String { + use leptos_integration_utils::static_file_path; + + // If the path ends with a trailing slash, we generate the path + // as a directory with a index.html file inside. + if path != "/" && path.ends_with("/") { + static_file_path(options, &format!("{}index", path)) + } else { + static_file_path(options, path) + } +} + +#[cfg(feature = "default")] +async fn write_static_route( + options: &LeptosOptions, + response_options: Option<ResponseOptions>, + path: &str, + html: &str, +) -> Result<(), std::io::Error> { + if let Some(options) = response_options { + STATIC_HEADERS.insert(path.to_string(), options); + } + + let path = static_path(options, path); + let path = Path::new(&path); + if let Some(path) = path.parent() { + tokio::fs::create_dir_all(path).await?; + } + tokio::fs::write(path, &html).await?; + + Ok(()) +} + +#[cfg(feature = "default")] +fn handle_static_route<S, IV>( + additional_context: impl Fn() + 'static + Clone + Send, + app_fn: impl Fn() -> IV + Clone + Send + 'static, + regenerate: Vec<RegenerationFn>, +) -> impl Fn( + State<S>, + Request<Body>, +) -> Pin<Box<dyn Future<Output = Response<Body>> + Send + 'static>> + + Clone + + Send + + 'static +where + LeptosOptions: FromRef<S>, + S: Send + 'static, + IV: IntoView + 'static, +{ + use tower_http::services::ServeFile; + + move |state, req| { + let app_fn = app_fn.clone(); + let additional_context = additional_context.clone(); + let regenerate = regenerate.clone(); + Box::pin(async move { + let options = LeptosOptions::from_ref(&state); + let orig_path = req.uri().path(); + let path = static_path(&options, orig_path); + let path = Path::new(&path); + let exists = tokio::fs::try_exists(path).await.unwrap_or(false); + + let (response_options, html) = if !exists { + let path = ResolvedStaticPath::new(orig_path); + + let (owner, html) = path + .build( + move |path: &ResolvedStaticPath| { + StaticRouteGenerator::render_route( + path.to_string(), + app_fn.clone(), + additional_context.clone(), + ) + }, + move |path: &ResolvedStaticPath, + owner: &Owner, + html: String| { + let options = options.clone(); + let path = path.to_owned(); + let response_options = owner.with(use_context); + async move { + write_static_route( + &options, + response_options, + path.as_ref(), + &html, + ) + .await + } + }, + was_404, + regenerate, + ) + .await; + (owner.with(use_context::<ResponseOptions>), html) + } else { + let headers = STATIC_HEADERS.get(orig_path).map(|v| v.clone()); + (headers, None) + }; + + // if html is Some(_), it means that `was_error_response` is true and we're not + // actually going to cache this route, just return it as HTML + // + // this if for thing like 404s, where we do not want to cache an endless series of + // typos (or malicious requests) + let mut res = AxumResponse(match html { + Some(html) => axum::response::Html(html).into_response(), + None => match ServeFile::new(path).oneshot(req).await { + Ok(res) => res.into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Something went wrong: {err}"), + ) + .into_response(), + }, + }); + + if let Some(options) = response_options { + res.extend_response(&options); + } + + res.0 + }) + } +} + /// This trait allows one to pass a list of routes and a render function to Axum's router, letting us avoid /// having to use wildcards or manually define all routes in multiple places. pub trait LeptosRoutes<S> where S: Clone + Send + Sync + 'static, + LeptosOptions: FromRef<S>, { fn leptos_routes<IV>( self, @@ -1372,209 +1687,6 @@ where H: axum::handler::Handler<T, S>, T: 'static; } -/* -#[cfg(feature = "default")] -fn handle_static_response<IV>( - path: String, - options: LeptosOptions, - app_fn: impl Fn() -> IV + Clone + Send + 'static, - additional_context: impl Fn() + Clone + Send + 'static, - res: StaticResponse, -) -> Pin<Box<dyn Future<Output = Response<String>> + '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 - } - } - }) -}*/ - -#[allow(unused)] // TODO -#[cfg(feature = "default")] -fn static_route<IV, S>( - router: axum::Router<S>, - path: &str, - app_fn: impl Fn() -> IV + Clone + Send + 'static, - additional_context: impl Fn() + Clone + Send + 'static, - method: leptos_router::Method, - mode: StaticMode, -) -> axum::Router<S> -where - IV: IntoView + 'static, - S: Clone + Send + Sync + 'static, -{ - todo!() - /*match mode { - StaticMode::Incremental => { - let handler = move |req: Request<Body>| { - 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(); - spawn_task!(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<Body>| { - 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(); - spawn_task!(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), - }, - ) - } - }*/ -} trait AxumPath { fn to_axum_path(&self) -> String; @@ -1611,6 +1723,7 @@ impl AxumPath for &[PathSegment] { impl<S> LeptosRoutes<S> for axum::Router<S> where S: Clone + Send + Sync + 'static, + LeptosOptions: FromRef<S>, { #[cfg_attr( feature = "tracing", @@ -1688,25 +1801,24 @@ where provide_context(method); cx_with_state(); }; - router = if let Some(static_mode) = listing.static_mode() { + router = if matches!(listing.mode(), SsrMode::Static(_)) { #[cfg(feature = "default")] { - static_route( - router, + router.route( path, - app_fn.clone(), - cx_with_state_and_method.clone(), - method, - static_mode, + get(handle_static_route( + cx_with_state_and_method.clone(), + app_fn.clone(), + listing.regenerate.clone(), + )), ) } #[cfg(not(feature = "default"))] { - _ = static_mode; panic!( - "Static site generation is not currently \ - supported on WASM32 server targets." - ) + "Static routes are not currently supported on \ + WASM32 server targets." + ); } } else { router.route( @@ -1765,6 +1877,7 @@ where leptos_router::Method::Patch => patch(s), } } + _ => unreachable!() }, ) }; diff --git a/integrations/utils/src/lib.rs b/integrations/utils/src/lib.rs index 64e91e4bd..5bf1e61f5 100644 --- a/integrations/utils/src/lib.rs +++ b/integrations/utils/src/lib.rs @@ -5,6 +5,7 @@ use leptos::{ reactive_graph::owner::{Owner, Sandboxed}, IntoView, }; +use leptos_config::LeptosOptions; use leptos_meta::ServerMetaContextOutput; use std::{future::Future, pin::Pin, sync::Arc}; @@ -132,3 +133,13 @@ where })); (owner, stream) } + +pub fn static_file_path(options: &LeptosOptions, path: &str) -> String { + let trimmed_path = path.trim_start_matches('/'); + let path = if trimmed_path.is_empty() { + "index" + } else { + trimmed_path + }; + format!("{}/{}.html", options.site_root, path) +} diff --git a/leptos/src/suspense_component.rs b/leptos/src/suspense_component.rs index 2d3c113bd..6acd6ab18 100644 --- a/leptos/src/suspense_component.rs +++ b/leptos/src/suspense_component.rs @@ -264,7 +264,6 @@ where { buf.next_id(); let suspense_context = use_context::<SuspenseContext>().unwrap(); - let owner = Owner::current().unwrap(); // we need to wait for one of two things: either @@ -277,6 +276,16 @@ where futures::channel::oneshot::channel::<()>(); let mut tasks_tx = Some(tasks_tx); + + // now, create listener for local resources + let (local_tx, mut local_rx) = + futures::channel::oneshot::channel::<()>(); + provide_context(LocalResourceNotifier::from(local_tx)); + + // walk over the tree of children once to make sure that all resource loads are registered + self.children.dry_resolve(); + + // check the set of tasks to see if it is empty, now or later let eff = reactive_graph::effect::RenderEffect::new_isomorphic({ move |_| { tasks.track(); @@ -290,14 +299,6 @@ where } }); - // now, create listener for local resources - let (local_tx, mut local_rx) = - futures::channel::oneshot::channel::<()>(); - provide_context(LocalResourceNotifier::from(local_tx)); - - // walk over the tree of children once to make sure that all resource loads are registered - self.children.dry_resolve(); - let mut fut = Box::pin(ScopedFuture::new(ErrorHookFuture::new( async move { // race the local resource notifier against the set of tasks diff --git a/reactive_graph/src/owner/context.rs b/reactive_graph/src/owner/context.rs index ec84237e2..18ba55e65 100644 --- a/reactive_graph/src/owner/context.rs +++ b/reactive_graph/src/owner/context.rs @@ -1,6 +1,9 @@ use crate::owner::Owner; use or_poisoned::OrPoisoned; -use std::any::{Any, TypeId}; +use std::{ + any::{Any, TypeId}, + collections::VecDeque, +}; impl Owner { fn provide_context<T: Send + Sync + 'static>(&self, value: T) { @@ -60,6 +63,35 @@ impl Owner { None } } + + /// Searches for items stored in context in either direction, either among parents or among + /// descendants. + pub fn use_context_bidirectional<T: Clone + 'static>(&self) -> Option<T> { + self.use_context() + .unwrap_or_else(|| self.find_context_in_children()) + } + + fn find_context_in_children<T: Clone + 'static>(&self) -> Option<T> { + let ty = TypeId::of::<T>(); + let inner = self.inner.read().or_poisoned(); + let mut to_search = VecDeque::new(); + to_search.extend(inner.children.clone()); + drop(inner); + + while let Some(next) = to_search.pop_front() { + if let Some(child) = next.upgrade() { + let child = child.read().or_poisoned(); + let contexts = &child.contexts; + if let Some(context) = contexts.get(&ty) { + return context.downcast_ref::<T>().cloned(); + } + + to_search.extend(child.children.clone()); + } + } + + None + } } /// Provides a context value of type `T` to the current reactive [`Owner`] diff --git a/router/Cargo.toml b/router/Cargo.toml index 1eae19287..8468e7e2c 100644 --- a/router/Cargo.toml +++ b/router/Cargo.toml @@ -28,6 +28,7 @@ send_wrapper = "0.6.0" thiserror = "1.0" percent-encoding = { version = "2.3", optional = true } gloo-net = "0.6.0" +serde = { version = "1", features = ["derive"] } [dependencies.web-sys] version = "0.3.70" diff --git a/router/src/components.rs b/router/src/components.rs index 28f2ebef1..6fb2505a0 100644 --- a/router/src/components.rs +++ b/router/src/components.rs @@ -450,6 +450,12 @@ pub fn Redirect<P>( "Calling <Redirect/> without a ServerRedirectFunction \ provided, in SSR mode." ); + + #[cfg(not(feature = "tracing"))] + eprintln!( + "Calling <Redirect/> without a ServerRedirectFunction \ + provided, in SSR mode." + ); return; } let navigate = use_navigate(); diff --git a/router/src/flat_router.rs b/router/src/flat_router.rs index 99aa72a9c..53b5cf304 100644 --- a/router/src/flat_router.rs +++ b/router/src/flat_router.rs @@ -2,8 +2,8 @@ use crate::{ location::{LocationProvider, Url}, matching::Routes, params::ParamsMap, - ChooseView, MatchInterface, MatchNestedRoutes, MatchParams, Method, - PathSegment, RouteList, RouteListing, RouteMatchId, + ChooseView, MatchInterface, MatchNestedRoutes, MatchParams, PathSegment, + RouteList, RouteListing, RouteMatchId, }; use any_spawner::Executor; use either_of::{Either, EitherOf3}; @@ -511,10 +511,8 @@ where RouteListing::new( path, data.ssr_mode, - // TODO methods - [Method::Get], - // TODO static data - None, + data.methods, + data.regenerate, ) }) .collect::<Vec<_>>(); diff --git a/router/src/generate_route_list.rs b/router/src/generate_route_list.rs index fa3292045..ac305f2ec 100644 --- a/router/src/generate_route_list.rs +++ b/router/src/generate_route_list.rs @@ -1,9 +1,17 @@ use crate::{ - matching::PathSegment, Method, SsrMode, StaticDataMap, StaticMode, + matching::PathSegment, + static_routes::{ + RegenerationFn, ResolvedStaticPath, StaticPath, StaticRoute, + }, + Method, SsrMode, }; +use futures::future::join_all; +use reactive_graph::owner::Owner; use std::{ cell::{Cell, RefCell}, collections::HashSet, + future::Future, + mem, }; use tachys::{renderer::Renderer, view::RenderHtml}; @@ -13,7 +21,7 @@ pub struct RouteListing { path: Vec<PathSegment>, mode: SsrMode, methods: HashSet<Method>, - static_mode: Option<(StaticMode, StaticDataMap)>, + regenerate: Vec<RegenerationFn>, } impl RouteListing { @@ -22,19 +30,19 @@ impl RouteListing { path: impl IntoIterator<Item = PathSegment>, mode: SsrMode, methods: impl IntoIterator<Item = Method>, - static_mode: Option<(StaticMode, StaticDataMap)>, + regenerate: impl IntoIterator<Item = RegenerationFn>, ) -> Self { Self { path: path.into_iter().collect(), mode, methods: methods.into_iter().collect(), - static_mode, + regenerate: regenerate.into_iter().collect(), } } /// Create a route listing from a path, with the other fields set to default values. pub fn from_path(path: impl IntoIterator<Item = PathSegment>) -> Self { - Self::new(path, SsrMode::Async, [], None) + Self::new(path, SsrMode::Async, [], []) } /// The path this route handles. @@ -43,8 +51,8 @@ impl RouteListing { } /// The rendering mode for this path. - pub fn mode(&self) -> SsrMode { - self.mode + pub fn mode(&self) -> &SsrMode { + &self.mode } /// The HTTP request methods this path can handle. @@ -52,56 +60,95 @@ impl RouteListing { self.methods.iter().copied() } - /// Whether this route is statically rendered. - #[inline(always)] - pub fn static_mode(&self) -> Option<StaticMode> { - self.static_mode.as_ref().map(|n| n.0) + /// The set of regeneration functions that should be applied to this route, if it is statically + /// generated (either up front or incrementally). + pub fn regenerate(&self) -> &[RegenerationFn] { + &self.regenerate } /// Whether this route is statically rendered. #[inline(always)] - pub fn static_data_map(&self) -> Option<&StaticDataMap> { - self.static_mode.as_ref().map(|n| &n.1) + pub fn static_route(&self) -> Option<&StaticRoute> { + match self.mode { + SsrMode::Static(ref route) => Some(route), + _ => None, + } } - pub fn into_static_parts(self) -> Option<(StaticMode, StaticDataMap)> { - self.static_mode + pub async fn into_static_paths(self) -> Option<Vec<ResolvedStaticPath>> { + let params = self.static_route()?.to_prerendered_params().await; + Some(StaticPath::new(self.path).into_paths(params)) + } + + pub async fn generate_static_files<Fut, WriterFut>( + mut self, + render_fn: impl Fn(&ResolvedStaticPath) -> Fut + Send + Clone + 'static, + writer: impl Fn(&ResolvedStaticPath, &Owner, String) -> WriterFut + + Send + + Clone + + 'static, + was_404: impl Fn(&Owner) -> bool + Send + Clone + 'static, + ) where + Fut: Future<Output = (Owner, String)> + Send + 'static, + WriterFut: Future<Output = Result<(), std::io::Error>> + Send + 'static, + { + if let SsrMode::Static(_) = self.mode() { + let (all_initial_tx, all_initial_rx) = std::sync::mpsc::channel(); + + let render_fn = render_fn.clone(); + let regenerate = mem::take(&mut self.regenerate); + let paths = self.into_static_paths().await.unwrap_or_default(); + + for path in paths { + // Err(_) here would just mean they've dropped the rx and are no longer awaiting + // it; we're only using it to notify them it's done so it doesn't matter in that + // case + _ = all_initial_tx.send(path.build( + render_fn.clone(), + writer.clone(), + was_404.clone(), + regenerate.clone(), + )); + } + + join_all(all_initial_rx.try_iter()).await; + } } /* - /// 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<IV>( - &self, - options: &LeptosOptions, - app_fn: impl Fn() -> IV + Send + 'static + Clone, - additional_context: impl Fn() + Send + 'static + Clone, - params: &StaticParamsMap, - ) -> Result<bool, std::io::Error> - 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?; + /// 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<IV>( + &self, + options: &LeptosOptions, + app_fn: impl Fn() -> IV + Send + 'static + Clone, + additional_context: impl Fn() + Send + 'static + Clone, + params: &StaticParamsMap, + ) -> Result<bool, std::io::Error> + where + IV: IntoView + 'static, + { + match self.mode { + SsrMode::Static(route) => { + let mut path = StaticPath::new(self.path.clone()); + for path in path.into_paths(params) { + /*path.write( + options, + app_fn.clone(), + additional_context.clone(), + ) + .await?;*/ println!() + } + Ok(true) } - Ok(true) + _ => Ok(false), } } - }*/ + */ } -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] pub struct RouteList(Vec<RouteListing>); impl From<Vec<RouteListing>> for RouteList { @@ -124,6 +171,45 @@ impl RouteList { pub fn into_inner(self) -> Vec<RouteListing> { self.0 } + + pub fn iter(&self) -> impl Iterator<Item = &RouteListing> { + self.0.iter() + } + + pub async fn into_static_paths(self) -> Vec<ResolvedStaticPath> { + futures::future::join_all( + self.into_inner() + .into_iter() + .map(|route_listing| route_listing.into_static_paths()), + ) + .await + .into_iter() + .flatten() + .flatten() + .collect::<Vec<_>>() + } + + pub async fn generate_static_files<Fut, WriterFut>( + self, + render_fn: impl Fn(&ResolvedStaticPath) -> Fut + Send + Clone + 'static, + writer: impl Fn(&ResolvedStaticPath, &Owner, String) -> WriterFut + + Send + + Clone + + 'static, + was_404: impl Fn(&Owner) -> bool + Send + Clone + 'static, + ) where + Fut: Future<Output = (Owner, String)> + Send + 'static, + WriterFut: Future<Output = Result<(), std::io::Error>> + Send + 'static, + { + join_all(self.into_inner().into_iter().map(|route| { + route.generate_static_files( + render_fn.clone(), + writer.clone(), + was_404.clone(), + ) + })) + .await; + } } impl RouteList { diff --git a/router/src/hooks.rs b/router/src/hooks.rs index 320afd431..e19bc7d9b 100644 --- a/router/src/hooks.rs +++ b/router/src/hooks.rs @@ -155,8 +155,10 @@ pub fn use_location() -> Location { location } +pub(crate) type RawParamsMap = ArcMemo<ParamsMap>; + #[track_caller] -fn use_params_raw() -> ArcMemo<ParamsMap> { +fn use_params_raw() -> RawParamsMap { use_context().expect( "Tried to access params outside the context of a matched <Route>.", ) diff --git a/router/src/lib.rs b/router/src/lib.rs index 743321a70..fa0476dda 100644 --- a/router/src/lib.rs +++ b/router/src/lib.rs @@ -16,7 +16,7 @@ pub mod nested_router; pub mod params; //mod router; mod ssr_mode; -mod static_route; +pub mod static_routes; pub use generate_route_list::*; #[doc(inline)] @@ -26,4 +26,3 @@ pub use method::*; pub use navigate::*; //pub use router::*; pub use ssr_mode::*; -pub use static_route::*; diff --git a/router/src/location/mod.rs b/router/src/location/mod.rs index fda2540c4..acea48917 100644 --- a/router/src/location/mod.rs +++ b/router/src/location/mod.rs @@ -201,10 +201,29 @@ where } } +#[cfg(feature = "ssr")] pub(crate) fn unescape(s: &str) -> String { + percent_encoding::percent_decode_str(s) + .decode_utf8() + .unwrap() + .to_string() +} + +#[cfg(not(feature = "ssr"))] +pub(crate) fn unescape(s: &str) -> String { + js_sys::decode_uri_component(s).unwrap().into() +} + +#[cfg(not(feature = "ssr"))] +pub(crate) fn unescape_minimal(s: &str) -> String { js_sys::decode_uri(s).unwrap().into() } +#[cfg(feature = "ssr")] +pub(crate) fn unescape_minimal(s: &str) -> String { + unescape(s) +} + pub(crate) fn handle_anchor_click<NavFn, NavFut>( router_base: Option<Cow<'static, str>>, parse_with_base: fn(&str, &str) -> Result<Url, JsValue>, @@ -259,7 +278,7 @@ where } let url = parse_with_base(href.as_str(), &origin).unwrap(); - let path_name = unescape(&url.path); + let path_name = unescape_minimal(&url.path); // let browser handle this event if it leaves our domain // or our base path diff --git a/router/src/matching/mod.rs b/router/src/matching/mod.rs index 81a06f036..76183ae79 100644 --- a/router/src/matching/mod.rs +++ b/router/src/matching/mod.rs @@ -6,10 +6,10 @@ pub use path_segment::*; mod horizontal; mod nested; mod vertical; -use crate::SsrMode; +use crate::{static_routes::RegenerationFn, Method, SsrMode}; pub use horizontal::*; pub use nested::*; -use std::{borrow::Cow, marker::PhantomData}; +use std::{borrow::Cow, collections::HashSet, marker::PhantomData}; use tachys::{ renderer::Renderer, view::{Render, RenderHtml}, @@ -145,6 +145,8 @@ where pub struct GeneratedRouteData { pub segments: Vec<PathSegment>, pub ssr_mode: SsrMode, + pub methods: HashSet<Method>, + pub regenerate: Vec<RegenerationFn>, } #[cfg(test)] diff --git a/router/src/matching/nested/mod.rs b/router/src/matching/nested/mod.rs index 8f4af44e6..f3b4a8d2b 100644 --- a/router/src/matching/nested/mod.rs +++ b/router/src/matching/nested/mod.rs @@ -2,11 +2,12 @@ use super::{ MatchInterface, MatchNestedRoutes, PartialPathMatch, PathSegment, PossibleRouteMatch, RouteMatchId, }; -use crate::{ChooseView, GeneratedRouteData, MatchParams, SsrMode}; +use crate::{ChooseView, GeneratedRouteData, MatchParams, Method, SsrMode}; use core::{fmt, iter}; use either_of::Either; use std::{ borrow::Cow, + collections::HashSet, marker::PhantomData, sync::atomic::{AtomicU16, Ordering}, }; @@ -19,7 +20,7 @@ mod tuples; static ROUTE_ID: AtomicU16 = AtomicU16::new(1); -#[derive(Debug, Copy, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq)] pub struct NestedRoute<Segments, Children, Data, View, R> { id: u16, segments: Segments, @@ -27,6 +28,7 @@ pub struct NestedRoute<Segments, Children, Data, View, R> { data: Data, view: View, rndr: PhantomData<R>, + methods: HashSet<Method>, ssr_mode: SsrMode, } @@ -46,7 +48,8 @@ where data: self.data.clone(), view: self.view.clone(), rndr: PhantomData, - ssr_mode: self.ssr_mode, + methods: self.methods.clone(), + ssr_mode: self.ssr_mode.clone(), } } } @@ -64,6 +67,7 @@ impl<Segments, View, R> NestedRoute<Segments, (), (), View, R> { data: (), view, rndr: PhantomData, + methods: [Method::Get].into(), ssr_mode: Default::default(), } } @@ -81,6 +85,7 @@ impl<Segments, Data, View, R> NestedRoute<Segments, (), Data, View, R> { view, rndr, ssr_mode, + methods, .. } = self; NestedRoute { @@ -90,6 +95,7 @@ impl<Segments, Data, View, R> NestedRoute<Segments, (), Data, View, R> { data, view, ssr_mode, + methods, rndr, } } @@ -249,25 +255,44 @@ where let mut segment_routes = Vec::new(); self.segments.generate_path(&mut segment_routes); let children = self.children.as_ref(); - let ssr_mode = self.ssr_mode; + let ssr_mode = self.ssr_mode.clone(); + let methods = self.methods.clone(); + let regenerate = match &ssr_mode { + SsrMode::Static(data) => match data.regenerate.as_ref() { + None => vec![], + Some(regenerate) => vec![regenerate.clone()] + } + _ => vec![] + }; match children { None => Either::Left(iter::once(GeneratedRouteData { segments: segment_routes, - ssr_mode + ssr_mode, + methods, + regenerate })), Some(children) => { Either::Right(children.generate_routes().into_iter().map(move |child| { + // extend this route's segments with child segments let segments = segment_routes.clone().into_iter().chain(child.segments).collect(); + + let mut methods = methods.clone(); + methods.extend(child.methods); + + let mut regenerate = regenerate.clone(); + regenerate.extend(child.regenerate); + if child.ssr_mode > ssr_mode { GeneratedRouteData { segments, ssr_mode: child.ssr_mode, + methods, regenerate } } else { GeneratedRouteData { segments, - ssr_mode, + ssr_mode: ssr_mode.clone(), methods, regenerate } } })) diff --git a/router/src/nested_router.rs b/router/src/nested_router.rs index a99f33870..5fa5602a5 100644 --- a/router/src/nested_router.rs +++ b/router/src/nested_router.rs @@ -3,8 +3,8 @@ use crate::{ location::{LocationProvider, Url}, matching::Routes, params::ParamsMap, - ChooseView, MatchInterface, MatchNestedRoutes, MatchParams, Method, - PathSegment, RouteList, RouteListing, RouteMatchId, + ChooseView, MatchInterface, MatchNestedRoutes, MatchParams, PathSegment, + RouteList, RouteListing, RouteMatchId, }; use any_spawner::Executor; use either_of::{Either, EitherOf3}; @@ -272,10 +272,8 @@ where RouteListing::new( path, data.ssr_mode, - // TODO methods - [Method::Get], - // TODO static data - None, + data.methods, + data.regenerate, ) }) .collect::<Vec<_>>(); diff --git a/router/src/ssr_mode.rs b/router/src/ssr_mode.rs index 59a882673..8ab78e826 100644 --- a/router/src/ssr_mode.rs +++ b/router/src/ssr_mode.rs @@ -1,3 +1,5 @@ +use crate::static_routes::StaticRoute; + /// Indicates which rendering mode should be used for this route during server-side rendering. /// /// Leptos supports the following ways of rendering HTML that contains `async` data loaded @@ -18,15 +20,17 @@ /// 5. **`Async`**: Load all resources on the server. Wait until all data are loaded, and render HTML in one sweep. /// - *Pros*: Better handling for meta tags (because you know async data even before you render the `<head>`). Faster complete load than **synchronous** because async resources begin loading on server. /// - *Cons*: Slower load time/TTFB: you need to wait for all async resources to load before displaying anything on the client. +/// 6. **`Static`**: /// /// The mode defaults to out-of-order streaming. For a path that includes multiple nested routes, the most /// restrictive mode will be used: i.e., if even a single nested route asks for `Async` rendering, the whole initial /// request will be rendered `Async`. (`Async` is the most restricted requirement, followed by `InOrder`, `PartiallyBlocked`, and `OutOfOrder`.) -#[derive(Default, Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive(Default, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum SsrMode { #[default] OutOfOrder, PartiallyBlocked, InOrder, Async, + Static(StaticRoute), } diff --git a/router/src/static_route.rs b/router/src/static_route.rs deleted file mode 100644 index 7f559c064..000000000 --- a/router/src/static_route.rs +++ /dev/null @@ -1,21 +0,0 @@ -/// 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, -} - -// TODO -#[derive(Debug, Clone)] -pub struct StaticDataMap; - -impl StaticDataMap { - #[allow(clippy::new_without_default)] // TODO - pub fn new() -> Self { - Self - } -} diff --git a/router/src/static_routes.rs b/router/src/static_routes.rs new file mode 100644 index 000000000..5e0acdf58 --- /dev/null +++ b/router/src/static_routes.rs @@ -0,0 +1,363 @@ +use crate::{hooks::RawParamsMap, params::ParamsMap, PathSegment}; +use futures::{channel::oneshot, stream, Stream, StreamExt}; +use leptos::spawn::spawn; +use reactive_graph::{owner::Owner, traits::GetUntracked}; +use std::{ + fmt::{Debug, Display}, + future::Future, + ops::Deref, + pin::Pin, + sync::Arc, +}; + +type PinnedFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>; +type PinnedStream<T> = Pin<Box<dyn Stream<Item = T> + Send>>; + +pub type StaticParams = Arc<StaticParamsFn>; +pub type StaticParamsFn = + dyn Fn() -> PinnedFuture<StaticParamsMap> + Send + Sync + 'static; + +#[derive(Clone)] +#[allow(clippy::type_complexity)] +pub struct RegenerationFn( + Arc<dyn Fn(&ParamsMap) -> PinnedStream<()> + Send + Sync>, +); + +impl Debug for RegenerationFn { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RegenerationFn").finish_non_exhaustive() + } +} + +impl Deref for RegenerationFn { + type Target = dyn Fn(&ParamsMap) -> PinnedStream<()> + Send + Sync; + + fn deref(&self) -> &Self::Target { + &*self.0 + } +} + +impl PartialEq for RegenerationFn { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +#[derive(Clone, Default)] +pub struct StaticRoute { + pub(crate) prerender_params: Option<StaticParams>, + pub(crate) regenerate: Option<RegenerationFn>, +} + +impl StaticRoute { + pub fn new() -> Self { + Self::default() + } + + pub fn prerender_params<Fut>( + mut self, + params: impl Fn() -> Fut + Send + Sync + 'static, + ) -> Self + where + Fut: Future<Output = StaticParamsMap> + Send + 'static, + { + self.prerender_params = Some(Arc::new(move || Box::pin(params()))); + self + } + + pub fn regenerate<St>( + mut self, + invalidate: impl Fn(&ParamsMap) -> St + Send + Sync + 'static, + ) -> Self + where + St: Stream<Item = ()> + Send + 'static, + { + self.regenerate = Some(RegenerationFn(Arc::new(move |params| { + Box::pin(invalidate(params)) + }))); + self + } + + pub async fn to_prerendered_params(&self) -> Option<StaticParamsMap> { + match &self.prerender_params { + None => None, + Some(params) => Some(params().await), + } + } +} + +impl Debug for StaticRoute { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StaticRoute").finish_non_exhaustive() + } +} + +impl PartialOrd for StaticRoute { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + Some(self.cmp(other)) + } +} + +impl Ord for StaticRoute { + fn cmp(&self, _other: &Self) -> std::cmp::Ordering { + std::cmp::Ordering::Equal + } +} + +impl PartialEq for StaticRoute { + fn eq(&self, other: &Self) -> bool { + let prerender = match (&self.prerender_params, &other.prerender_params) + { + (None, None) => true, + (None, Some(_)) | (Some(_), None) => false, + (Some(this), Some(that)) => Arc::ptr_eq(this, that), + }; + prerender && (self.regenerate == other.regenerate) + } +} + +impl Eq for StaticRoute {} + +#[derive(Debug, Clone, Default)] +pub struct StaticParamsMap(pub Vec<(String, Vec<String>)>); + +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<String>) { + let key = key.to_string(); + for item in self.0.iter_mut() { + if item.0 == key { + item.1 = value; + return; + } + } + self.0.push((key, value)); + } + + /// Get a value from the map. + #[inline] + pub fn get(&self, key: &str) -> Option<&Vec<String>> { + self.0 + .iter() + .find_map(|entry| (entry.0 == key).then_some(&entry.1)) + } +} + +impl IntoIterator for StaticParamsMap { + type Item = (String, Vec<String>); + type IntoIter = StaticParamsIter; + + fn into_iter(self) -> Self::IntoIter { + StaticParamsIter(self.0.into_iter()) + } +} + +#[derive(Debug)] +pub struct StaticParamsIter( + <Vec<(String, Vec<String>)> as IntoIterator>::IntoIter, +); + +impl Iterator for StaticParamsIter { + type Item = (String, Vec<String>); + + fn next(&mut self) -> Option<Self::Item> { + self.0.next() + } +} + +impl<A> FromIterator<A> for StaticParamsMap +where + A: Into<(String, Vec<String>)>, +{ + fn from_iter<T: IntoIterator<Item = A>>(iter: T) -> Self { + Self(iter.into_iter().map(Into::into).collect()) + } +} + +#[doc(hidden)] +#[derive(Debug)] +pub struct StaticPath { + segments: Vec<PathSegment>, +} + +impl StaticPath { + pub fn new(segments: Vec<PathSegment>) -> StaticPath { + Self { segments } + } + + pub fn into_paths( + self, + params: Option<StaticParamsMap>, + ) -> Vec<ResolvedStaticPath> { + use PathSegment::*; + let mut paths = vec![ResolvedStaticPath { + path: String::new(), + }]; + + for segment in &self.segments { + match segment { + Unit => {} + Static(s) => { + paths = paths + .into_iter() + .map(|p| { + if s.starts_with("/") { + ResolvedStaticPath { + path: format!("{}{s}", p.path), + } + } else { + ResolvedStaticPath { + path: format!("{}/{s}", p.path), + } + } + }) + .collect::<Vec<_>>(); + } + Param(name) | Splat(name) => { + let mut new_paths = vec![]; + if let Some(params) = params.as_ref() { + for path in paths { + if let Some(params) = params.get(name) { + for val in params.iter() { + new_paths.push(if val.starts_with("/") { + ResolvedStaticPath { + path: format!( + "{}{}", + path.path, val + ), + } + } else { + ResolvedStaticPath { + path: format!( + "{}/{}", + path.path, val + ), + } + }); + } + } + } + } + paths = new_paths; + } + } + } + paths + } +} + +#[derive(Debug, Clone)] +pub struct ResolvedStaticPath { + pub(crate) path: String, +} + +impl ResolvedStaticPath { + pub fn new(path: impl Into<String>) -> Self { + Self { path: path.into() } + } +} + +impl AsRef<str> for ResolvedStaticPath { + fn as_ref(&self) -> &str { + self.path.as_ref() + } +} + +impl Display for ResolvedStaticPath { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + Display::fmt(&self.path, f) + } +} + +impl ResolvedStaticPath { + pub async fn build<Fut, WriterFut>( + self, + render_fn: impl Fn(&ResolvedStaticPath) -> Fut + Send + Clone + 'static, + writer: impl Fn(&ResolvedStaticPath, &Owner, String) -> WriterFut + + Send + + Clone + + 'static, + was_404: impl Fn(&Owner) -> bool + Send + Clone + 'static, + regenerate: Vec<RegenerationFn>, + ) -> (Owner, Option<String>) + where + Fut: Future<Output = (Owner, String)> + Send + 'static, + WriterFut: Future<Output = Result<(), std::io::Error>> + Send + 'static, + { + let (tx, rx) = oneshot::channel(); + + // spawns a separate task for each path it's rendering + // this allows us to parallelize all static site rendering, + // and also to create long-lived tasks + spawn({ + let render_fn = render_fn.clone(); + let writer = writer.clone(); + let was_error = was_404.clone(); + async move { + // render and write the initial page + let (owner, html) = render_fn(&self).await; + + // if rendering this page resulted in an error (404, 500, etc.) + // then we should not cache it: the `was_error` function can handle notifying + // the user that there was an error, and the server can give a dynamic response + // that will include the 404 or 500 + if was_error(&owner) { + // can ignore errors from channel here, because it just means we're not + // awaiting the Future + _ = tx.send((owner.clone(), Some(html))); + } else { + _ = tx.send((owner.clone(), None)); + if let Err(e) = writer(&self, &owner, html).await { + #[cfg(feature = "tracing")] + tracing::warn!("{e}"); + + #[cfg(not(feature = "tracing"))] + eprintln!("{e}"); + } + } + + // if there's a regeneration function, keep looping + let params = if regenerate.is_empty() { + None + } else { + Some( + owner + .use_context_bidirectional::<RawParamsMap>() + .expect( + "using static routing, but couldn't find \ + ParamsMap", + ) + .get_untracked(), + ) + }; + let mut regenerate = stream::select_all( + regenerate + .into_iter() + .map(|r| owner.with(|| r(params.as_ref().unwrap()))), + ); + while regenerate.next().await.is_some() { + let (owner, html) = render_fn(&self).await; + if !was_error(&owner) { + if let Err(e) = writer(&self, &owner, html).await { + #[cfg(feature = "tracing")] + tracing::warn!("{e}"); + + #[cfg(not(feature = "tracing"))] + eprintln!("{e}"); + } + } + drop(owner); + } + } + }); + + rx.await.unwrap() + } +} diff --git a/router_macro/src/lib.rs b/router_macro/src/lib.rs index 604203f8f..2c26c82fe 100644 --- a/router_macro/src/lib.rs +++ b/router_macro/src/lib.rs @@ -75,6 +75,9 @@ impl SegmentParser { lit.trim_start_matches(['"', '/']) .trim_end_matches(['"', '/']), ); + if lit.ends_with(r#"/""#) && lit != r#""/""# { + self.segments.push(Segment::Static("/".to_string())); + } } TokenTree::Group(_) => unimplemented!(), TokenTree::Ident(_) => unimplemented!(), @@ -102,13 +105,14 @@ impl SegmentParser { impl Segment { fn is_valid(segment: &str) -> bool { - segment.chars().all(|c| { - c.is_ascii_digit() - || c.is_ascii_lowercase() - || c.is_ascii_uppercase() - || RFC3986_UNRESERVED.contains(&c) - || RFC3986_PCHAR_OTHER.contains(&c) - }) + segment == "/" + || segment.chars().all(|c| { + c.is_ascii_digit() + || c.is_ascii_lowercase() + || c.is_ascii_uppercase() + || RFC3986_UNRESERVED.contains(&c) + || RFC3986_PCHAR_OTHER.contains(&c) + }) } fn ensure_valid(&self) { diff --git a/router_macro/tests/path.rs b/router_macro/tests/path.rs index 125f77611..2110629f8 100644 --- a/router_macro/tests/path.rs +++ b/router_macro/tests/path.rs @@ -64,14 +64,14 @@ fn parses_no_slashes() { #[test] fn parses_no_leading_slash() { - let output = path!("home/"); + let output = path!("home"); assert_eq!(output, (StaticSegment("home"),)); } #[test] fn parses_trailing_slash() { let output = path!("/home/"); - assert_eq!(output, (StaticSegment("home"),)); + assert_eq!(output, (StaticSegment("home"), StaticSegment("/"))); } #[test] @@ -105,6 +105,19 @@ fn parses_mixed_segment_types() { ); } +#[test] +fn parses_trailing_slash_after_param() { + let output = path!("/foo/:bar/"); + assert_eq!( + output, + ( + StaticSegment("foo"), + ParamSegment("bar"), + StaticSegment("/") + ) + ); +} + #[test] fn parses_consecutive_static() { let output = path!("/foo/bar/baz");