feat: add support for static routing and incremental static regeneration (#2875)

This commit is contained in:
Greg Johnston 2024-08-31 10:33:12 -04:00 committed by GitHub
parent 9fc26e609c
commit e7bb859cd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1916 additions and 606 deletions

13
examples/static_routing/.gitignore vendored Normal file
View file

@ -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/

View file

@ -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 <site-root>/<site-pkg>/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"

View file

@ -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.

View file

@ -0,0 +1,8 @@
extend = [
{ path = "../cargo-make/main.toml" },
{ path = "../cargo-make/cargo-leptos.toml" },
]
[env]
CLIENT_PROCESS_NAME = "ssr_modes_axum"

View file

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,3 @@
# My first blog post
Having a blog is *fun*.

View file

@ -0,0 +1,3 @@
# My second blog post
Coming up with content is hard.

View file

@ -0,0 +1,3 @@
# My third blog post
Could I just have AI write this for me instead?

View file

@ -0,0 +1,3 @@
# My fourth post
Here is some content. It should regenerate the static page.

View file

@ -0,0 +1,2 @@
[toolchain]
channel = "stable" # test change

View file

@ -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! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<AutoReload options=options.clone()/>
<HydrationScripts options/>
<MetaTags/>
</head>
<body>
<App/>
</body>
</html>
}
}
#[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! {
<Stylesheet id="leptos" href="/pkg/ssr_modes.css"/>
<Title text="Welcome to Leptos"/>
<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
}

View file

@ -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);
}

View file

@ -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
}

View file

@ -0,0 +1,3 @@
body {
font-family: sans-serif;
}

View file

@ -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"]

View file

@ -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| {
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 = app.to_html_stream_in_order().collect::<String>().await;
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>
})
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 = if matches!(listing.mode(), SsrMode::Static(_)) {
router.route(
path,
static_route(
app_fn.clone(),
handle_static_route(
additional_context_and_method.clone(),
method,
static_mode,
app_fn.clone(),
listing.regenerate.clone(),
),
)
*/
} else {
router.route(
path,
@ -1334,6 +1411,7 @@ where
app_fn.clone(),
method,
),
_ => unreachable!()
},
)
};
@ -1390,6 +1468,16 @@ impl LeptosRoutes for &mut ServiceConfig {
let mode = listing.mode();
for method in listing.methods() {
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 {
@ -1420,10 +1508,12 @@ impl LeptosRoutes for &mut ServiceConfig {
app_fn.clone(),
method,
),
_ => unreachable!()
},
);
}
}
}
router
}

View file

@ -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 }

View file

@ -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,7 +1135,16 @@ pub fn render_app_async_with_context<IV>(
where
IV: IntoView + 'static,
{
handle_response(additional_context, app_fn, |app, chunks| {
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()
@ -1106,9 +1153,7 @@ where
};
let app = app.collect::<String>().await;
let chunks = chunks();
Box::pin(once(async move { app }).chain(chunks))
as PinnedStream<String>
})
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(),
get(handle_static_route(
cx_with_state_and_method.clone(),
method,
static_mode,
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!()
},
)
};

View file

@ -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)
}

View file

@ -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

View file

@ -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`]

View file

@ -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"

View file

@ -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();

View file

@ -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<_>>();

View file

@ -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,20 +60,59 @@ 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;
}
}
/*
@ -82,26 +129,26 @@ impl RouteListing {
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(
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?;
.await?;*/ println!()
}
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 {

View file

@ -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>.",
)

View file

@ -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::*;

View file

@ -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

View file

@ -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)]

View file

@ -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
}
}
}))

View file

@ -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<_>>();

View file

@ -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),
}

View file

@ -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
}
}

363
router/src/static_routes.rs Normal file
View file

@ -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()
}
}

View file

@ -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,7 +105,8 @@ impl SegmentParser {
impl Segment {
fn is_valid(segment: &str) -> bool {
segment.chars().all(|c| {
segment == "/"
|| segment.chars().all(|c| {
c.is_ascii_digit()
|| c.is_ascii_lowercase()
|| c.is_ascii_uppercase()

View file

@ -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");