codify "platform" into resolution logic, server-fn/fullstack overhaul (#4596)
overhaul dioxus fullstack using a new implementation of server_fn This PR is a complete rewrite of dioxus fullstack designed to improve compatibility with axum and to fix a number of compilation errors based on feature flag unification plumbing through macros.
This commit is contained in:
1 parent
8421c3aef0
commit
f2b1c6d97f
424 files changed
+17284
-6624
No files matched your search
@@ -0,0 +1,517 @@
|
||||
//! Configuration for how to serve a Dioxus application
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use dioxus_core::LaunchConfig;
|
||||
use std::any::Any;
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::IncrementalRendererConfig;
|
||||
|
||||
#[allow(unused)]
|
||||
pub(crate) type ContextProviders =
|
||||
Arc<Vec<Box<dyn Fn() -> Box<dyn std::any::Any> + Send + Sync + 'static>>>;
|
||||
|
||||
/// A ServeConfig is used to configure how to serve a Dioxus application. It contains information about how to serve static assets, and what content to render with [`dioxus-ssr`].
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ServeConfigBuilder {
|
||||
pub(crate) root_id: Option<&'static str>,
|
||||
pub(crate) index_html: Option<String>,
|
||||
pub(crate) index_path: Option<PathBuf>,
|
||||
pub(crate) incremental: Option<IncrementalRendererConfig>,
|
||||
pub(crate) context_providers:
|
||||
Vec<Arc<dyn Fn() -> Box<dyn std::any::Any> + Send + Sync + 'static>>,
|
||||
pub(crate) streaming_mode: StreamingMode,
|
||||
}
|
||||
|
||||
impl LaunchConfig for ServeConfigBuilder {}
|
||||
|
||||
impl ServeConfigBuilder {
|
||||
/// Create a new ServeConfigBuilder with incremental static generation disabled and the default index.html settings
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
root_id: None,
|
||||
index_html: None,
|
||||
index_path: None,
|
||||
incremental: None,
|
||||
context_providers: Default::default(),
|
||||
streaming_mode: StreamingMode::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable incremental static generation. Incremental static generation caches the
|
||||
/// rendered html in memory and/or the file system. It can be used to improve performance of heavy routes.
|
||||
///
|
||||
/// ```rust, no_run
|
||||
/// # fn app() -> Element { unimplemented!() }
|
||||
/// use dioxus::prelude::*;
|
||||
///
|
||||
/// // Finally, launch the app with the config
|
||||
/// LaunchBuilder::new()
|
||||
/// // Only set the server config if the server feature is enabled
|
||||
/// .with_cfg(server_only!(dioxus_server::ServeConfigBuilder::default().incremental(dioxus_server::IncrementalRendererConfig::default())))
|
||||
/// .launch(app);
|
||||
/// ```
|
||||
pub fn incremental(mut self, cfg: IncrementalRendererConfig) -> Self {
|
||||
self.incremental = Some(cfg);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the contents of the index.html file to be served. (precedence over index_path)
|
||||
pub fn index_html(mut self, index_html: String) -> Self {
|
||||
self.index_html = Some(index_html);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the path of the index.html file to be served. (defaults to {assets_path}/index.html)
|
||||
pub fn index_path(mut self, index_path: PathBuf) -> Self {
|
||||
self.index_path = Some(index_path);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the id of the root element in the index.html file to place the prerendered content into. (defaults to main)
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// If your index.html file looks like this:
|
||||
/// ```html
|
||||
/// <!DOCTYPE html>
|
||||
/// <html>
|
||||
/// <head>
|
||||
/// <title>My App</title>
|
||||
/// </head>
|
||||
/// <body>
|
||||
/// <div id="my-custom-root"></div>
|
||||
/// </body>
|
||||
/// </html>
|
||||
/// ```
|
||||
///
|
||||
/// You can set the root id to `"my-custom-root"` to render the app into that element:
|
||||
///
|
||||
/// ```rust, no_run
|
||||
/// # fn app() -> Element { unimplemented!() }
|
||||
/// use dioxus::prelude::*;
|
||||
///
|
||||
/// // Finally, launch the app with the config
|
||||
/// LaunchBuilder::new()
|
||||
/// // Only set the server config if the server feature is enabled
|
||||
/// .with_cfg(server_only! {
|
||||
/// dioxus_server::ServeConfigBuilder::default().root_id("app")
|
||||
/// })
|
||||
/// // You also need to set the root id in your web config
|
||||
/// .with_cfg(web! {
|
||||
/// dioxus::web::Config::default().rootname("app")
|
||||
/// })
|
||||
/// // And desktop config
|
||||
/// .with_cfg(desktop! {
|
||||
/// dioxus::desktop::Config::default().with_root_name("app")
|
||||
/// })
|
||||
/// .launch(app);
|
||||
/// ```
|
||||
pub fn root_id(mut self, root_id: &'static str) -> Self {
|
||||
self.root_id = Some(root_id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Provide context to the root and server functions. You can use this context while rendering with [`consume_context`](dioxus_core::consume_context).
|
||||
///
|
||||
///
|
||||
/// The context providers passed into this method will be called when the context type is requested which may happen many times in the lifecycle of the application.
|
||||
///
|
||||
///
|
||||
/// Context will be forwarded from the LaunchBuilder if it is provided.
|
||||
///
|
||||
/// ```rust, no_run
|
||||
/// #![allow(non_snake_case)]
|
||||
/// use dioxus::prelude::*;
|
||||
/// use std::sync::Arc;
|
||||
/// use std::any::Any;
|
||||
///
|
||||
/// fn main() {
|
||||
/// #[cfg(feature = "web")]
|
||||
/// // Hydrate the application on the client
|
||||
/// dioxus::launch(app);
|
||||
/// #[cfg(feature = "server")]
|
||||
/// {
|
||||
/// tokio::runtime::Runtime::new()
|
||||
/// .unwrap()
|
||||
/// .block_on(async move {
|
||||
/// let address = dioxus_cli_config::fullstack_address_or_localhost();
|
||||
/// let listener = tokio::net::TcpListener::bind(address)
|
||||
/// .await
|
||||
/// .unwrap();
|
||||
/// let config = ServeConfigBuilder::default()
|
||||
/// // You can provide context to your whole app on the server (including server functions) with the `context_provider` method on the launch builder
|
||||
/// .context_providers(Arc::new(vec![Box::new(|| Box::new(1234u32) as Box<dyn Any>) as Box<dyn Fn() -> Box<dyn std::any::Any> + Send + Sync>]));
|
||||
/// axum::serve(
|
||||
/// listener,
|
||||
/// axum::Router::new()
|
||||
/// .serve_dioxus_application(config, app)
|
||||
/// .into_make_service(),
|
||||
/// )
|
||||
/// .await
|
||||
/// .unwrap();
|
||||
/// });
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// #[server]
|
||||
/// async fn read_context() -> ServerFnResult<u32> {
|
||||
/// Ok(123)
|
||||
/// }
|
||||
///
|
||||
/// fn app() -> Element {
|
||||
/// let future = use_resource(read_context);
|
||||
/// rsx! {
|
||||
/// h1 { "{future:?}" }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub fn context_providers(mut self, state: ContextProviders) -> Self {
|
||||
// This API should probably accept Vec<Box<dyn Fn() -> Box<dyn Any> + Send + Sync + 'static>> instead of Arc so we can
|
||||
// continue adding to the context list after calling this method. Changing the type is a breaking change so it cannot
|
||||
// be done until 0.7 is released.
|
||||
let context_providers = (0..state.len()).map(|i| {
|
||||
let state = state.clone();
|
||||
Arc::new(move || state[i]()) as Arc<dyn Fn() -> Box<dyn std::any::Any> + Send + Sync>
|
||||
});
|
||||
self.context_providers.extend(context_providers);
|
||||
self
|
||||
}
|
||||
|
||||
/// Provide context to the root and server functions. You can use this context
|
||||
/// while rendering with [`consume_context`](dioxus_core::consume_context).
|
||||
///
|
||||
///
|
||||
/// The context providers passed into this method will be called when the context type is requested which may happen many times in the lifecycle of the application.
|
||||
///
|
||||
///
|
||||
/// Context will be forwarded from the LaunchBuilder if it is provided.
|
||||
///
|
||||
/// ```rust, no_run
|
||||
/// #![allow(non_snake_case)]
|
||||
/// use dioxus::prelude::*;
|
||||
///
|
||||
/// fn main() {
|
||||
/// #[cfg(feature = "web")]
|
||||
/// // Hydrate the application on the client
|
||||
/// dioxus::launch(app);
|
||||
/// #[cfg(feature = "server")]
|
||||
/// {
|
||||
/// tokio::runtime::Runtime::new()
|
||||
/// .unwrap()
|
||||
/// .block_on(async move {
|
||||
/// let address = dioxus_cli_config::fullstack_address_or_localhost();
|
||||
/// let listener = tokio::net::TcpListener::bind(address)
|
||||
/// .await
|
||||
/// .unwrap();
|
||||
/// let config = ServeConfigBuilder::default()
|
||||
/// // You can provide context to your whole app on the server (including server functions) with the `context_provider` method on the launch builder
|
||||
/// .context_provider(move || 1234u32);
|
||||
/// axum::serve(
|
||||
/// listener,
|
||||
/// axum::Router::new()
|
||||
/// .serve_dioxus_application(config, app)
|
||||
/// .into_make_service(),
|
||||
/// )
|
||||
/// .await
|
||||
/// .unwrap();
|
||||
/// });
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// #[server]
|
||||
/// async fn read_context() -> ServerFnResult<u32> {
|
||||
/// Ok(123)
|
||||
/// }
|
||||
///
|
||||
/// fn app() -> Element {
|
||||
/// let future = use_resource(read_context);
|
||||
/// rsx! {
|
||||
/// h1 { "{future:?}" }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub fn context_provider<C: 'static>(
|
||||
mut self,
|
||||
state: impl Fn() -> C + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
self.context_providers
|
||||
.push(Arc::new(move || Box::new(state())));
|
||||
self
|
||||
}
|
||||
|
||||
/// Provide context to the root and server functions. You can use this context while rendering with [`consume_context`](dioxus_core::consume_context).
|
||||
///
|
||||
/// Context will be forwarded from the LaunchBuilder if it is provided.
|
||||
///
|
||||
/// ```rust, no_run
|
||||
/// #![allow(non_snake_case)]
|
||||
/// use dioxus::prelude::*;
|
||||
///
|
||||
/// fn main() {
|
||||
/// #[cfg(feature = "web")]
|
||||
/// // Hydrate the application on the client
|
||||
/// dioxus::launch(app);
|
||||
/// #[cfg(feature = "server")]
|
||||
/// {
|
||||
/// tokio::runtime::Runtime::new()
|
||||
/// .unwrap()
|
||||
/// .block_on(async move {
|
||||
/// let address = dioxus_cli_config::fullstack_address_or_localhost();
|
||||
/// let listener = tokio::net::TcpListener::bind(address)
|
||||
/// .await
|
||||
/// .unwrap();
|
||||
/// let config = ServeConfigBuilder::default()
|
||||
/// // You can provide context to your whole app on the server (including server functions) with the `context_provider` method on the launch builder
|
||||
/// .context(1234u32);
|
||||
/// axum::serve(
|
||||
/// listener,
|
||||
/// axum::Router::new()
|
||||
/// .serve_dioxus_application(config, app)
|
||||
/// .into_make_service(),
|
||||
/// )
|
||||
/// .await
|
||||
/// .unwrap();
|
||||
/// });
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// #[server]
|
||||
/// async fn read_context() -> ServerFnResult<u32> {
|
||||
/// Ok(123)
|
||||
/// }
|
||||
///
|
||||
/// fn app() -> Element {
|
||||
/// let future = use_resource(read_context);
|
||||
/// rsx! {
|
||||
/// h1 { "{future:?}" }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub fn context(mut self, state: impl Any + Clone + Send + Sync + 'static) -> Self {
|
||||
self.context_providers
|
||||
.push(Arc::new(move || Box::new(state.clone())));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the streaming mode for the server. By default, streaming is disabled.
|
||||
///
|
||||
/// ```rust, no_run
|
||||
/// # use dioxus::prelude::*;
|
||||
/// # fn app() -> Element { unimplemented!() }
|
||||
/// dioxus::LaunchBuilder::new()
|
||||
/// .with_context(server_only! {
|
||||
/// dioxus::server::ServeConfig::builder().streaming_mode(dioxus::server::StreamingMode::OutOfOrder)
|
||||
/// })
|
||||
/// .launch(app);
|
||||
/// ```
|
||||
pub fn streaming_mode(mut self, mode: StreamingMode) -> Self {
|
||||
self.streaming_mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable out of order streaming. This will cause server futures to be resolved out of order and streamed to the client as they resolve.
|
||||
///
|
||||
/// It is equivalent to calling `streaming_mode(StreamingMode::OutOfOrder)`
|
||||
///
|
||||
/// ```rust, no_run
|
||||
/// # use dioxus::prelude::*;
|
||||
/// # fn app() -> Element { unimplemented!() }
|
||||
/// dioxus::LaunchBuilder::new()
|
||||
/// .with_context(server_only! {
|
||||
/// dioxus::server::ServeConfig::builder().enable_out_of_order_streaming()
|
||||
/// })
|
||||
/// .launch(app);
|
||||
/// ```
|
||||
pub fn enable_out_of_order_streaming(mut self) -> Self {
|
||||
self.streaming_mode = StreamingMode::OutOfOrder;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the ServeConfig. This may fail if the index.html file is not found.
|
||||
///
|
||||
/// ## WASM compatibility
|
||||
/// In the context of WASM the file system normally can't be read so this always requires
|
||||
/// the `index_html` field to be set.
|
||||
pub fn build(self) -> Result<ServeConfig, UnableToLoadIndex> {
|
||||
let root_id = self.root_id.unwrap_or("main");
|
||||
|
||||
let index_html = match self.index_html {
|
||||
Some(index) => index,
|
||||
None => {
|
||||
// The CLI always bundles static assets into the exe/public directory
|
||||
let public_path = public_path();
|
||||
|
||||
let index_path = self
|
||||
.index_path
|
||||
.unwrap_or_else(|| public_path.join("index.html"));
|
||||
load_index_path(index_path).unwrap_or_else(|_| {
|
||||
r#"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head> </head>
|
||||
<body>
|
||||
<div id="main"></div>
|
||||
</body>
|
||||
</html>
|
||||
"#
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let index = load_index_html(index_html, root_id);
|
||||
let context_providers = Arc::new(
|
||||
self.context_providers
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
Box::new(move || f()) as Box<dyn Fn() -> Box<dyn std::any::Any> + Send + Sync>
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
Ok(ServeConfig {
|
||||
index,
|
||||
incremental: self.incremental,
|
||||
context_providers,
|
||||
streaming_mode: self.streaming_mode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryInto<ServeConfig> for ServeConfigBuilder {
|
||||
type Error = UnableToLoadIndex;
|
||||
|
||||
fn try_into(self) -> Result<ServeConfig, Self::Error> {
|
||||
self.build()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the path to the public assets directory to serve static files from
|
||||
pub(crate) fn public_path() -> PathBuf {
|
||||
if let Ok(path) = std::env::var("DIOXUS_PUBLIC_PATH") {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
|
||||
// The CLI always bundles static assets into the exe/public directory
|
||||
std::env::current_exe()
|
||||
.expect("Failed to get current executable path")
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("public")
|
||||
}
|
||||
|
||||
/// An error that can occur when loading the index.html file
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnableToLoadIndex(PathBuf);
|
||||
|
||||
impl std::fmt::Display for UnableToLoadIndex {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Failed to find index.html. Make sure the index_path is set correctly and the WASM application has been built. Tried to open file at path: {:?}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for UnableToLoadIndex {}
|
||||
|
||||
fn load_index_path(path: PathBuf) -> Result<String, UnableToLoadIndex> {
|
||||
let mut file = File::open(&path).map_err(|_| UnableToLoadIndex(path))?;
|
||||
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)
|
||||
.expect("Failed to read index.html");
|
||||
Ok(contents)
|
||||
}
|
||||
|
||||
fn load_index_html(contents: String, root_id: &'static str) -> IndexHtml {
|
||||
let (pre_main, post_main) = contents.split_once(&format!("id=\"{root_id}\"")).unwrap_or_else(|| panic!("Failed to find id=\"{root_id}\" in index.html. The id is used to inject the application into the page."));
|
||||
|
||||
let post_main = post_main.split_once('>').unwrap_or_else(|| {
|
||||
panic!("Failed to find closing > after id=\"{root_id}\" in index.html.")
|
||||
});
|
||||
|
||||
let (pre_main, post_main) = (
|
||||
pre_main.to_string() + &format!("id=\"{root_id}\"") + post_main.0 + ">",
|
||||
post_main.1.to_string(),
|
||||
);
|
||||
|
||||
let (head, close_head) = pre_main.split_once("</head>").unwrap_or_else(|| {
|
||||
panic!("Failed to find closing </head> tag after id=\"{root_id}\" in index.html.")
|
||||
});
|
||||
let (head, close_head) = (head.to_string(), "</head>".to_string() + close_head);
|
||||
|
||||
let (post_main, after_closing_body_tag) =
|
||||
post_main.split_once("</body>").unwrap_or_else(|| {
|
||||
panic!("Failed to find closing </body> tag after id=\"{root_id}\" in index.html.")
|
||||
});
|
||||
|
||||
// Strip out the head if it exists
|
||||
let mut head_before_title = String::new();
|
||||
let mut head_after_title = head;
|
||||
let mut title = String::new();
|
||||
if let Some((new_head_before_title, new_title)) = head_after_title.split_once("<title>") {
|
||||
let (new_title, new_head_after_title) = new_title
|
||||
.split_once("</title>")
|
||||
.expect("Failed to find closing </title> tag after <title> in index.html.");
|
||||
title = format!("<title>{new_title}</title>");
|
||||
head_before_title = new_head_before_title.to_string();
|
||||
head_after_title = new_head_after_title.to_string();
|
||||
}
|
||||
|
||||
IndexHtml {
|
||||
head_before_title,
|
||||
head_after_title,
|
||||
title,
|
||||
close_head,
|
||||
post_main: post_main.to_string(),
|
||||
after_closing_body_tag: "</body>".to_string() + after_closing_body_tag,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct IndexHtml {
|
||||
pub(crate) head_before_title: String,
|
||||
pub(crate) head_after_title: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) close_head: String,
|
||||
pub(crate) post_main: String,
|
||||
pub(crate) after_closing_body_tag: String,
|
||||
}
|
||||
|
||||
/// The streaming mode to use while rendering the page
|
||||
#[derive(Clone, Copy, Default, PartialEq)]
|
||||
pub enum StreamingMode {
|
||||
/// Streaming is disabled; all server futures should be resolved before hydrating the page on the client
|
||||
#[default]
|
||||
Disabled,
|
||||
/// Out of order streaming is enabled; server futures are resolved out of order and streamed to the client
|
||||
/// as they resolve
|
||||
OutOfOrder,
|
||||
}
|
||||
|
||||
/// Used to configure how to serve a Dioxus application. It contains information about how to serve static assets, and what content to render with [`dioxus-ssr`].
|
||||
/// See [`ServeConfigBuilder`] to create a ServeConfig
|
||||
#[derive(Clone)]
|
||||
pub struct ServeConfig {
|
||||
pub(crate) index: IndexHtml,
|
||||
pub(crate) incremental: Option<IncrementalRendererConfig>,
|
||||
pub(crate) context_providers: ContextProviders,
|
||||
pub(crate) streaming_mode: StreamingMode,
|
||||
}
|
||||
|
||||
impl LaunchConfig for ServeConfig {}
|
||||
|
||||
impl ServeConfig {
|
||||
/// Create a new ServeConfig
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn new() -> Result<Self, UnableToLoadIndex> {
|
||||
ServeConfigBuilder::new().build()
|
||||
}
|
||||
|
||||
/// Create a new builder for a ServeConfig
|
||||
pub fn builder() -> ServeConfigBuilder {
|
||||
ServeConfigBuilder::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! On the server, we collect any elements that should be rendered into the head in the first frame of SSR.
|
||||
//! After the first frame, we have already sent down the head, so we can't modify it in place. The web client
|
||||
//! will hydrate the head with the correct contents once it loads.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use dioxus_core::Element;
|
||||
use dioxus_core_macro::rsx;
|
||||
use dioxus_document::{
|
||||
Document, Eval, LinkProps, MetaProps, NoOpDocument, ScriptProps, StyleProps,
|
||||
};
|
||||
use dioxus_html as dioxus_elements;
|
||||
use dioxus_ssr::Renderer;
|
||||
use parking_lot::RwLock;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
static RENDERER: LazyLock<RwLock<Renderer>> = LazyLock::new(|| RwLock::new(Renderer::new()));
|
||||
|
||||
/// Reset the static renderer to a fresh state, clearing its cache.
|
||||
pub(crate) fn reset_renderer() {
|
||||
RENDERER.write().clear();
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ServerDocumentInner {
|
||||
streaming: bool,
|
||||
title: Option<String>,
|
||||
meta: Vec<Element>,
|
||||
link: Vec<Element>,
|
||||
script: Vec<Element>,
|
||||
}
|
||||
|
||||
/// A Document provider that collects all contents injected into the head for SSR rendering.
|
||||
#[derive(Default)]
|
||||
pub struct ServerDocument(RefCell<ServerDocumentInner>);
|
||||
|
||||
impl ServerDocument {
|
||||
pub(crate) fn title(&self) -> Option<String> {
|
||||
let myself = self.0.borrow();
|
||||
myself.title.as_ref().map(|title| {
|
||||
RENDERER
|
||||
.write()
|
||||
.render_element(rsx! { title { "{title}" } })
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render(&self, to: &mut impl std::fmt::Write) -> std::fmt::Result {
|
||||
let myself = self.0.borrow();
|
||||
let element = rsx! {
|
||||
{myself.meta.iter().map(|m| rsx! { {m} })}
|
||||
{myself.link.iter().map(|l| rsx! { {l} })}
|
||||
{myself.script.iter().map(|s| rsx! { {s} })}
|
||||
};
|
||||
|
||||
RENDERER.write().render_element_to(to, element)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn start_streaming(&self) {
|
||||
self.0.borrow_mut().streaming = true;
|
||||
}
|
||||
|
||||
pub(crate) fn warn_if_streaming(&self) {
|
||||
if self.0.borrow().streaming {
|
||||
tracing::warn!("Attempted to insert content into the head after the initial streaming frame. Inserting content into the head only works during the initial render of SSR outside before resolving any suspense boundaries.");
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the head element into the serialized context for hydration
|
||||
/// We write true if the head element was written to the DOM during server side rendering
|
||||
#[track_caller]
|
||||
pub(crate) fn serialize_for_hydration(&self) {
|
||||
// We only serialize the head elements if the web document feature is enabled
|
||||
#[cfg(feature = "document")]
|
||||
{
|
||||
dioxus_fullstack_core::head_element_hydration_entry()
|
||||
.insert(&!self.0.borrow().streaming, std::panic::Location::caller());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Document for ServerDocument {
|
||||
fn eval(&self, js: String) -> Eval {
|
||||
NoOpDocument.eval(js)
|
||||
}
|
||||
|
||||
fn set_title(&self, title: String) {
|
||||
self.warn_if_streaming();
|
||||
self.0.borrow_mut().title = Some(title);
|
||||
}
|
||||
|
||||
fn create_meta(&self, props: MetaProps) {
|
||||
self.0.borrow_mut().meta.push(rsx! {
|
||||
meta {
|
||||
name: props.name,
|
||||
charset: props.charset,
|
||||
http_equiv: props.http_equiv,
|
||||
content: props.content,
|
||||
property: props.property,
|
||||
"data": props.data,
|
||||
..props.additional_attributes,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn create_script(&self, props: ScriptProps) {
|
||||
let children = props.script_contents().ok();
|
||||
self.0.borrow_mut().script.push(rsx! {
|
||||
script {
|
||||
src: props.src,
|
||||
defer: props.defer,
|
||||
crossorigin: props.crossorigin,
|
||||
fetchpriority: props.fetchpriority,
|
||||
integrity: props.integrity,
|
||||
nomodule: props.nomodule,
|
||||
nonce: props.nonce,
|
||||
referrerpolicy: props.referrerpolicy,
|
||||
r#type: props.r#type,
|
||||
..props.additional_attributes,
|
||||
{children}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn create_style(&self, props: StyleProps) {
|
||||
let contents = props.style_contents().ok();
|
||||
self.0.borrow_mut().script.push(rsx! {
|
||||
style {
|
||||
media: props.media,
|
||||
nonce: props.nonce,
|
||||
title: props.title,
|
||||
..props.additional_attributes,
|
||||
{contents}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create_link(&self, props: LinkProps) {
|
||||
self.0.borrow_mut().link.push(rsx! {
|
||||
link {
|
||||
rel: props.rel,
|
||||
media: props.media,
|
||||
title: props.title,
|
||||
disabled: props.disabled,
|
||||
r#as: props.r#as,
|
||||
sizes: props.sizes,
|
||||
href: props.href,
|
||||
crossorigin: props.crossorigin,
|
||||
referrerpolicy: props.referrerpolicy,
|
||||
fetchpriority: props.fetchpriority,
|
||||
hreflang: props.hreflang,
|
||||
integrity: props.integrity,
|
||||
r#type: props.r#type,
|
||||
blocking: props.blocking,
|
||||
..props.additional_attributes,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn create_head_component(&self) -> bool {
|
||||
self.warn_if_streaming();
|
||||
self.serialize_for_hydration();
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::isrg::fs_cache::PathMapFn;
|
||||
|
||||
use crate::isrg::memory_cache::InMemoryCache;
|
||||
use crate::IncrementalRenderer;
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
/// A configuration for the incremental renderer.
|
||||
#[derive(Clone)]
|
||||
pub struct IncrementalRendererConfig {
|
||||
static_dir: PathBuf,
|
||||
memory_cache_limit: usize,
|
||||
invalidate_after: Option<Duration>,
|
||||
clear_cache: bool,
|
||||
pre_render: bool,
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
map_path: Option<PathMapFn>,
|
||||
}
|
||||
|
||||
impl Default for IncrementalRendererConfig {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl IncrementalRendererConfig {
|
||||
/// Create a new incremental renderer configuration.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
static_dir: PathBuf::from("./static"),
|
||||
memory_cache_limit: 10000,
|
||||
invalidate_after: None,
|
||||
clear_cache: false,
|
||||
pre_render: false,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
map_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the cache on startup (default: true)
|
||||
pub fn clear_cache(mut self, clear_cache: bool) -> Self {
|
||||
self.clear_cache = clear_cache;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set a mapping from the route to the file path. This will override the default mapping configured with `static_dir`.
|
||||
/// The function should return the path to the folder to store the index.html file in.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn map_path<F: Fn(&str) -> PathBuf + Send + Sync + 'static>(mut self, map_path: F) -> Self {
|
||||
self.map_path = Some(std::sync::Arc::new(map_path));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the static directory.
|
||||
pub fn static_dir<P: AsRef<Path>>(mut self, static_dir: P) -> Self {
|
||||
self.static_dir = static_dir.as_ref().to_path_buf();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the memory cache limit.
|
||||
pub const fn memory_cache_limit(mut self, memory_cache_limit: usize) -> Self {
|
||||
self.memory_cache_limit = memory_cache_limit;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the invalidation time.
|
||||
pub fn invalidate_after(mut self, invalidate_after: Duration) -> Self {
|
||||
self.invalidate_after = Some(invalidate_after);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether to include hydration ids in the pre-rendered html.
|
||||
pub fn pre_render(mut self, pre_render: bool) -> Self {
|
||||
self.pre_render = pre_render;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the incremental renderer.
|
||||
pub fn build(self) -> IncrementalRenderer {
|
||||
let mut renderer = IncrementalRenderer {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
file_system_cache: super::fs_cache::FileSystemCache::new(
|
||||
self.static_dir.clone(),
|
||||
self.map_path,
|
||||
self.invalidate_after,
|
||||
),
|
||||
memory_cache: InMemoryCache::new(self.memory_cache_limit, self.invalidate_after),
|
||||
invalidate_after: self.invalidate_after,
|
||||
};
|
||||
|
||||
if self.clear_cache {
|
||||
renderer.invalidate_all();
|
||||
}
|
||||
|
||||
renderer
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Information about the freshness of a rendered response
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct RenderFreshness {
|
||||
/// The age of the rendered response
|
||||
age: u64,
|
||||
/// The maximum age of the rendered response
|
||||
max_age: Option<u64>,
|
||||
/// The time the response was rendered
|
||||
timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl RenderFreshness {
|
||||
/// Create new freshness information
|
||||
pub(crate) fn new(age: u64, max_age: u64, timestamp: DateTime<Utc>) -> Self {
|
||||
Self {
|
||||
age,
|
||||
max_age: Some(max_age),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create new freshness information with only the age
|
||||
pub(crate) fn new_age(age: u64, timestamp: DateTime<Utc>) -> Self {
|
||||
Self {
|
||||
age,
|
||||
max_age: None,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create new freshness information from a timestamp
|
||||
pub(crate) fn created_at(timestamp: DateTime<Utc>, max_age: Option<Duration>) -> Self {
|
||||
Self {
|
||||
age: timestamp
|
||||
.signed_duration_since(Utc::now())
|
||||
.num_seconds()
|
||||
.unsigned_abs(),
|
||||
max_age: max_age.map(|d| d.as_secs()),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create new freshness information at the current time
|
||||
pub fn now(max_age: Option<Duration>) -> Self {
|
||||
Self {
|
||||
age: 0,
|
||||
max_age: max_age.map(|d| d.as_secs()),
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the age of the rendered response in seconds
|
||||
pub fn age(&self) -> u64 {
|
||||
self.age
|
||||
}
|
||||
|
||||
/// Get the maximum age of the rendered response in seconds
|
||||
pub fn max_age(&self) -> Option<u64> {
|
||||
self.max_age
|
||||
}
|
||||
|
||||
/// Get the time the response was rendered
|
||||
pub fn timestamp(&self) -> DateTime<Utc> {
|
||||
self.timestamp
|
||||
}
|
||||
|
||||
/// Write the freshness to the response headers.
|
||||
pub fn write(&self, headers: &mut http::HeaderMap<http::HeaderValue>) {
|
||||
let age = self.age();
|
||||
headers.insert(http::header::AGE, age.into());
|
||||
if let Some(max_age) = self.max_age() {
|
||||
headers.insert(
|
||||
http::header::CACHE_CONTROL,
|
||||
http::HeaderValue::from_str(&format!("max-age={}", max_age)).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::{IncrementalRendererError, RenderFreshness};
|
||||
use std::{path::PathBuf, sync::Arc, time::SystemTime};
|
||||
|
||||
pub(crate) type PathMapFn = Arc<dyn Fn(&str) -> PathBuf + Send + Sync>;
|
||||
|
||||
pub(crate) struct FileSystemCache {
|
||||
static_dir: PathBuf,
|
||||
map_path: PathMapFn,
|
||||
invalidate_after: Option<std::time::Duration>,
|
||||
}
|
||||
|
||||
impl FileSystemCache {
|
||||
pub fn new(
|
||||
static_dir: PathBuf,
|
||||
map_path: Option<PathMapFn>,
|
||||
invalidate_after: Option<std::time::Duration>,
|
||||
) -> Self {
|
||||
Self {
|
||||
static_dir: static_dir.clone(),
|
||||
map_path: map_path.unwrap_or_else(move || {
|
||||
Arc::new(move |route: &str| {
|
||||
let (before_query, _) = route.split_once('?').unwrap_or((route, ""));
|
||||
let mut path = static_dir.clone();
|
||||
for segment in before_query.split('/') {
|
||||
path.push(segment);
|
||||
}
|
||||
path
|
||||
})
|
||||
}),
|
||||
invalidate_after,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn put(
|
||||
&mut self,
|
||||
route: String,
|
||||
timestamp: DateTime<Utc>,
|
||||
data: Vec<u8>,
|
||||
) -> Result<(), IncrementalRendererError> {
|
||||
use std::io::Write;
|
||||
let file_path = self.route_as_path(&route, timestamp);
|
||||
if let Some(parent) = file_path.parent() {
|
||||
if !parent.exists() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
let file = std::fs::File::create(file_path)?;
|
||||
let mut file = std::io::BufWriter::new(file);
|
||||
file.write_all(&data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
// clear the static directory of index.html files contained within folders
|
||||
for entry in std::fs::read_dir(&self.static_dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
{
|
||||
if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
|
||||
for entry in walkdir::WalkDir::new(entry.path()).into_iter().flatten() {
|
||||
if entry.file_type().is_file() {
|
||||
if let Some(fnmae) = entry.file_name().to_str() {
|
||||
if fnmae.ends_with(".html") {
|
||||
if let Err(err) = std::fs::remove_file(entry.path()) {
|
||||
tracing::error!("Failed to remove file: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalidate(&mut self, route: &str) {
|
||||
let file_path = self.find_file(route).unwrap().full_path;
|
||||
if let Err(err) = std::fs::remove_file(file_path) {
|
||||
tracing::error!("Failed to remove file: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(
|
||||
&self,
|
||||
route: &str,
|
||||
) -> Result<Option<(RenderFreshness, Vec<u8>)>, IncrementalRendererError> {
|
||||
if let Some(file_path) = self.find_file(route) {
|
||||
if let Some(freshness) = file_path.freshness(self.invalidate_after) {
|
||||
if let Ok(file) = std::fs::File::open(file_path.full_path) {
|
||||
let mut file = std::io::BufReader::new(file);
|
||||
let mut cache_hit = Vec::new();
|
||||
std::io::copy(&mut file, &mut cache_hit)?;
|
||||
tracing::trace!("file cache hit {:?}", route);
|
||||
return Ok(Some((freshness, cache_hit)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn find_file(&self, route: &str) -> Option<ValidCachedPath> {
|
||||
let mut file_path = (self.map_path)(route);
|
||||
if let Some(deadline) = self.invalidate_after {
|
||||
// find the first file that matches the route and is a html file
|
||||
file_path.push("index");
|
||||
if let Ok(dir) = std::fs::read_dir(file_path) {
|
||||
for entry in dir.flatten() {
|
||||
if let Some(cached_path) = ValidCachedPath::try_from_path(entry.path()) {
|
||||
if let Ok(elapsed) = cached_path.timestamp.elapsed() {
|
||||
if elapsed < deadline {
|
||||
// The timestamp is valid, return the file
|
||||
return Some(cached_path);
|
||||
}
|
||||
}
|
||||
// if the timestamp is invalid or passed, delete the file
|
||||
if let Err(err) = std::fs::remove_file(entry.path()) {
|
||||
tracing::error!("Failed to remove file: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
file_path.push("index.html");
|
||||
file_path.exists().then_some({
|
||||
ValidCachedPath {
|
||||
full_path: file_path,
|
||||
timestamp: SystemTime::now(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn route_as_path(&self, route: &str, timestamp: DateTime<Utc>) -> PathBuf {
|
||||
let mut file_path = (self.map_path)(route);
|
||||
if self.track_timestamps() {
|
||||
file_path.push("index");
|
||||
file_path.push(timestamp_to_string(timestamp));
|
||||
} else {
|
||||
file_path.push("index");
|
||||
}
|
||||
file_path.set_extension("html");
|
||||
file_path
|
||||
}
|
||||
|
||||
fn track_timestamps(&self) -> bool {
|
||||
self.invalidate_after.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ValidCachedPath {
|
||||
pub(crate) full_path: PathBuf,
|
||||
pub(crate) timestamp: std::time::SystemTime,
|
||||
}
|
||||
|
||||
impl ValidCachedPath {
|
||||
pub fn try_from_path(value: PathBuf) -> Option<Self> {
|
||||
if value.extension() != Some(std::ffi::OsStr::new("html")) {
|
||||
return None;
|
||||
}
|
||||
let timestamp = decode_timestamp(value.file_stem()?.to_str()?)?;
|
||||
let full_path = value;
|
||||
Some(Self {
|
||||
full_path,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn freshness(&self, max_age: Option<std::time::Duration>) -> Option<RenderFreshness> {
|
||||
let age = self.timestamp.elapsed().ok()?.as_secs();
|
||||
let max_age = max_age.map(|max_age| max_age.as_secs());
|
||||
Some(RenderFreshness::new(age, max_age?, self.timestamp.into()))
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_timestamp(timestamp: &str) -> Option<std::time::SystemTime> {
|
||||
let timestamp = u64::from_str_radix(timestamp, 16).ok()?;
|
||||
Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(timestamp))
|
||||
}
|
||||
|
||||
pub(crate) fn timestamp_to_string(timestamp: DateTime<Utc>) -> String {
|
||||
let timestamp = timestamp
|
||||
.signed_duration_since(DateTime::<Utc>::from(std::time::UNIX_EPOCH))
|
||||
.num_seconds();
|
||||
format!("{:x}", timestamp)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Incremental file based incremental rendering
|
||||
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use chrono::offset::Utc;
|
||||
use chrono::DateTime;
|
||||
use rustc_hash::FxHasher;
|
||||
use std::{hash::BuildHasherDefault, num::NonZeroUsize};
|
||||
|
||||
use super::freshness::RenderFreshness;
|
||||
|
||||
pub(crate) struct InMemoryCache {
|
||||
#[allow(clippy::type_complexity)]
|
||||
lru: Option<lru::LruCache<String, (DateTime<Utc>, Vec<u8>), BuildHasherDefault<FxHasher>>>,
|
||||
invalidate_after: Option<std::time::Duration>,
|
||||
}
|
||||
|
||||
impl InMemoryCache {
|
||||
pub fn new(memory_cache_limit: usize, invalidate_after: Option<std::time::Duration>) -> Self {
|
||||
Self {
|
||||
lru: NonZeroUsize::new(memory_cache_limit)
|
||||
.map(|limit| lru::LruCache::with_hasher(limit, Default::default())),
|
||||
invalidate_after,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
if let Some(cache) = &mut self.lru {
|
||||
cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn put(&mut self, route: String, timestamp: DateTime<Utc>, data: Vec<u8>) {
|
||||
if let Some(cache) = &mut self.lru {
|
||||
cache.put(route, (timestamp, data));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalidate(&mut self, route: &str) {
|
||||
if let Some(cache) = &mut self.lru {
|
||||
cache.pop(route);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_get_or_insert<'a, F: FnOnce() -> Result<(DateTime<Utc>, Vec<u8>), E>, E>(
|
||||
&'a mut self,
|
||||
route: &str,
|
||||
or_insert: F,
|
||||
) -> Result<Option<(RenderFreshness, &'a [u8])>, E> {
|
||||
if let Some(memory_cache) = self.lru.as_mut() {
|
||||
let (timestamp, _) = memory_cache.try_get_or_insert(route.to_string(), or_insert)?;
|
||||
|
||||
let now = Utc::now();
|
||||
let elapsed = timestamp.signed_duration_since(now);
|
||||
let age = elapsed.num_seconds();
|
||||
// The cache entry is out of date, so we need to remove it.
|
||||
if let Some(invalidate_after) = self.invalidate_after {
|
||||
// If we can't convert to a std duration, the duration is negative and hasn't elapsed yet.
|
||||
if let Ok(std_elapsed) = elapsed.to_std() {
|
||||
if std_elapsed > invalidate_after {
|
||||
tracing::trace!("memory cache out of date");
|
||||
memory_cache.pop(route);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We need to reborrow because we may have invalidated the lifetime if the route was removed.
|
||||
// We know it wasn't because we returned... but rust doesn't understand that.
|
||||
let (timestamp, cache_hit) = memory_cache.get(route).unwrap();
|
||||
|
||||
return match self.invalidate_after {
|
||||
Some(invalidate_after) => {
|
||||
tracing::trace!("memory cache hit");
|
||||
let max_age = invalidate_after.as_secs();
|
||||
let freshness = RenderFreshness::new(age as u64, max_age, *timestamp);
|
||||
Ok(Some((freshness, cache_hit)))
|
||||
}
|
||||
None => {
|
||||
tracing::trace!("memory cache hit");
|
||||
let freshness = RenderFreshness::new_age(age as u64, *timestamp);
|
||||
Ok(Some((freshness, cache_hit)))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Incremental file based incremental rendering
|
||||
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
mod config;
|
||||
mod freshness;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod fs_cache;
|
||||
mod memory_cache;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
pub use config::*;
|
||||
pub use freshness::*;
|
||||
|
||||
use self::memory_cache::InMemoryCache;
|
||||
|
||||
/// A render that was cached from a previous render.
|
||||
pub struct CachedRender<'a> {
|
||||
/// The route that was rendered
|
||||
pub route: String,
|
||||
/// The freshness information for the rendered response
|
||||
pub freshness: RenderFreshness,
|
||||
/// The rendered response
|
||||
pub response: &'a [u8],
|
||||
}
|
||||
|
||||
/// An incremental renderer.
|
||||
pub struct IncrementalRenderer {
|
||||
pub(crate) memory_cache: InMemoryCache,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) file_system_cache: fs_cache::FileSystemCache,
|
||||
invalidate_after: Option<Duration>,
|
||||
}
|
||||
|
||||
impl IncrementalRenderer {
|
||||
/// Create a new incremental renderer builder.
|
||||
pub fn builder() -> IncrementalRendererConfig {
|
||||
IncrementalRendererConfig::new()
|
||||
}
|
||||
|
||||
/// Remove a route from the cache.
|
||||
pub fn invalidate(&mut self, route: &str) {
|
||||
self.memory_cache.invalidate(route);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.file_system_cache.invalidate(route);
|
||||
}
|
||||
|
||||
/// Remove all routes from the cache.
|
||||
pub fn invalidate_all(&mut self) {
|
||||
self.memory_cache.clear();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.file_system_cache.clear();
|
||||
}
|
||||
|
||||
/// Cache a rendered response.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use dioxus_server::IncrementalRenderer;
|
||||
/// # let mut renderer = IncrementalRenderer::builder().build();
|
||||
/// let route = "/index".to_string();
|
||||
/// let response = b"<html><body>Hello world</body></html>";
|
||||
/// renderer.cache(route, response).unwrap();
|
||||
/// ```
|
||||
pub fn cache(
|
||||
&mut self,
|
||||
route: String,
|
||||
html: impl Into<Vec<u8>>,
|
||||
) -> Result<RenderFreshness, IncrementalRendererError> {
|
||||
let timestamp = Utc::now();
|
||||
let html = html.into();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.file_system_cache
|
||||
.put(route.clone(), timestamp, html.clone())?;
|
||||
self.memory_cache.put(route, timestamp, html);
|
||||
Ok(RenderFreshness::created_at(
|
||||
timestamp,
|
||||
self.invalidate_after,
|
||||
))
|
||||
}
|
||||
|
||||
/// Try to get a cached response for a route.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use dioxus_server::IncrementalRenderer;
|
||||
/// # let mut renderer = IncrementalRenderer::builder().build();
|
||||
/// # let route = "/index".to_string();
|
||||
/// # let response = b"<html><body>Hello world</body></html>";
|
||||
/// # renderer.cache(route, response).unwrap();
|
||||
/// let route = "/index";
|
||||
/// let response = renderer.get(route).unwrap();
|
||||
/// assert_eq!(response.unwrap().response, b"<html><body>Hello world</body></html>");
|
||||
/// ```
|
||||
///
|
||||
/// If the route is not cached, `None` is returned.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use dioxus_server::IncrementalRenderer;
|
||||
/// # let mut renderer = IncrementalRenderer::builder().build();
|
||||
/// let route = "/index";
|
||||
/// let response = renderer.get(route).unwrap();
|
||||
/// assert!(response.is_none());
|
||||
/// ```
|
||||
pub fn get<'a>(
|
||||
&'a mut self,
|
||||
route: &str,
|
||||
) -> Result<Option<CachedRender<'a>>, IncrementalRendererError> {
|
||||
let Self {
|
||||
memory_cache,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
file_system_cache,
|
||||
..
|
||||
} = self;
|
||||
|
||||
#[allow(unused)]
|
||||
enum FsGetError {
|
||||
NotPresent,
|
||||
Error(IncrementalRendererError),
|
||||
}
|
||||
|
||||
// The borrow checker prevents us from simply using a match/if and returning early. Instead we need to use the more complex closure API
|
||||
// non lexical lifetimes will make this possible (it works with polonius)
|
||||
let or_insert = || {
|
||||
// check the file cache
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return match file_system_cache.get(route) {
|
||||
Ok(Some((freshness, bytes))) => Ok((freshness.timestamp(), bytes)),
|
||||
Ok(None) => Err(FsGetError::NotPresent),
|
||||
Err(e) => Err(FsGetError::Error(e)),
|
||||
};
|
||||
|
||||
#[allow(unreachable_code)]
|
||||
Err(FsGetError::NotPresent)
|
||||
};
|
||||
|
||||
match memory_cache.try_get_or_insert(route, or_insert) {
|
||||
Ok(Some((freshness, bytes))) => Ok(Some(CachedRender {
|
||||
route: route.to_string(),
|
||||
freshness,
|
||||
response: bytes,
|
||||
})),
|
||||
Err(FsGetError::NotPresent) | Ok(None) => Ok(None),
|
||||
Err(FsGetError::Error(e)) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An error that can occur while rendering a route or retrieving a cached route.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum IncrementalRendererError {
|
||||
/// An formatting error occurred while rendering a route.
|
||||
#[error("RenderError: {0}")]
|
||||
RenderError(#[from] std::fmt::Error),
|
||||
|
||||
/// An IO error occurred while rendering a route.
|
||||
#[error("IoError: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
|
||||
/// An error occurred while rendering a route.
|
||||
#[error("Unknown error: {0}")]
|
||||
Other(#[from] dioxus_core::CapturedError),
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! A launch function that creates an axum router for the LaunchBuilder
|
||||
|
||||
use crate::{server::DioxusRouterExt, RenderHandleState, ServeConfig, ServeConfigBuilder};
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Request, State},
|
||||
response::IntoResponse,
|
||||
routing::IntoMakeService,
|
||||
};
|
||||
use dioxus_cli_config::base_path;
|
||||
use dioxus_core::Element;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use dioxus_core::{RenderError, VNode};
|
||||
use dioxus_devtools::DevserverMsg;
|
||||
use futures_util::{stream::FusedStream, StreamExt};
|
||||
use hyper::body::Incoming;
|
||||
use hyper_util::server::conn::auto::Builder as HyperBuilder;
|
||||
use hyper_util::{
|
||||
rt::{TokioExecutor, TokioIo},
|
||||
service::TowerToHyperService,
|
||||
};
|
||||
use std::{any::Any, collections::HashMap, net::SocketAddr, prelude::rust_2024::Future};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_util::task::LocalPoolHandle;
|
||||
use tower::Service;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
type ContextList = Vec<Box<dyn Fn() -> Box<dyn Any> + Send + Sync>>;
|
||||
|
||||
type BaseComp = fn() -> Element;
|
||||
|
||||
/// Launch a fullstack app with the given root component.
|
||||
pub fn launch(root: BaseComp) -> ! {
|
||||
launch_cfg(root, vec![], vec![])
|
||||
}
|
||||
|
||||
/// Launch a fullstack app with the given root component, contexts, and config.
|
||||
#[allow(unused)]
|
||||
pub fn launch_cfg(root: BaseComp, contexts: ContextList, platform_config: Vec<Box<dyn Any>>) -> ! {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(async move { serve_server(root, contexts, platform_config).await });
|
||||
|
||||
unreachable!("Launching a fullstack app should never return")
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
async fn serve_server(
|
||||
original_root: fn() -> Result<VNode, RenderError>,
|
||||
contexts: Vec<Box<dyn Fn() -> Box<dyn Any> + Send + Sync>>,
|
||||
platform_config: Vec<Box<dyn Any>>,
|
||||
) {
|
||||
let (devtools_tx, mut devtools_rx) = futures_channel::mpsc::unbounded();
|
||||
|
||||
dioxus_devtools::connect(move |msg| _ = devtools_tx.unbounded_send(msg));
|
||||
|
||||
let platform_config = platform_config
|
||||
.into_iter()
|
||||
.find_map(|cfg| {
|
||||
cfg.downcast::<ServeConfig>()
|
||||
.map(|cfg| Result::Ok(*cfg))
|
||||
.or_else(|cfg| {
|
||||
cfg.downcast::<ServeConfigBuilder>()
|
||||
.map(|builder| builder.build())
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.unwrap_or_else(ServeConfig::new);
|
||||
|
||||
// Extend the config's context providers with the context providers from the launch builder
|
||||
let cfg = platform_config
|
||||
.map(|mut cfg| {
|
||||
let mut contexts = contexts;
|
||||
let cfg_context_providers = cfg.context_providers.clone();
|
||||
for i in 0..cfg_context_providers.len() {
|
||||
contexts.push(Box::new({
|
||||
let cfg_context_providers = cfg_context_providers.clone();
|
||||
move || (cfg_context_providers[i])()
|
||||
}));
|
||||
}
|
||||
cfg.context_providers = std::sync::Arc::new(contexts);
|
||||
cfg
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Get the address the server should run on. If the CLI is running, the CLI proxies fullstack into the main address
|
||||
// and we use the generated address the CLI gives us
|
||||
let address = dioxus_cli_config::fullstack_address_or_localhost();
|
||||
|
||||
// Create the router and register the server functions under the basepath.
|
||||
let router = apply_base_path(
|
||||
axum::Router::new().serve_dioxus_application(cfg.clone(), original_root),
|
||||
original_root,
|
||||
cfg.clone(),
|
||||
base_path().map(|s| s.to_string()),
|
||||
);
|
||||
|
||||
let task_pool = LocalPoolHandle::new(
|
||||
std::thread::available_parallelism()
|
||||
.map(usize::from)
|
||||
.unwrap_or(1),
|
||||
);
|
||||
let mut make_service = router.into_make_service();
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(address).await.unwrap();
|
||||
|
||||
enum Msg {
|
||||
TcpStream(std::io::Result<(TcpStream, SocketAddr)>),
|
||||
Devtools(DevserverMsg),
|
||||
}
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(0);
|
||||
let mut hr_idx = 0;
|
||||
|
||||
// Manually loop on accepting connections so we can also respond to devtools messages
|
||||
loop {
|
||||
let res = tokio::select! {
|
||||
res = listener.accept() => Msg::TcpStream(res),
|
||||
msg = devtools_rx.next(), if !devtools_rx.is_terminated() => {
|
||||
if let Some(msg) = msg {
|
||||
Msg::Devtools(msg)
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match res {
|
||||
Msg::TcpStream(Ok((tcp_stream, _remote_addr))) => {
|
||||
let this_hr_index = hr_idx;
|
||||
let mut make_service = make_service.clone();
|
||||
let mut shutdown_rx = shutdown_rx.clone();
|
||||
|
||||
task_pool.spawn_pinned(move || async move {
|
||||
let tcp_stream = TokioIo::new(tcp_stream);
|
||||
|
||||
std::future::poll_fn(|cx| {
|
||||
<IntoMakeService<axum::Router> as tower::Service<Request>>::poll_ready(
|
||||
&mut make_service,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tower_service = make_service
|
||||
.call(())
|
||||
.await
|
||||
.unwrap()
|
||||
.map_request(|req: Request<Incoming>| req.map(Body::new));
|
||||
|
||||
// upgrades needed for websockets
|
||||
let builder = HyperBuilder::new(TokioExecutor::new());
|
||||
let connection = builder.serve_connection_with_upgrades(
|
||||
tcp_stream,
|
||||
TowerToHyperService::new(tower_service),
|
||||
);
|
||||
|
||||
tokio::select! {
|
||||
res = connection => {
|
||||
if let Err(_err) = res {
|
||||
// This error only appears when the client doesn't send a request and
|
||||
// terminate the connection.
|
||||
//
|
||||
// If client sends one request then terminate connection whenever, it doesn't
|
||||
// appear.
|
||||
}
|
||||
}
|
||||
_res = shutdown_rx.wait_for(|i| *i == this_hr_index + 1) => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
Msg::TcpStream(Err(_)) => {}
|
||||
// We need to delete our old router and build a new one
|
||||
//
|
||||
// one challenge is that the server functions are sitting in the dlopened lib and no longer
|
||||
// accessible by us (the original process)
|
||||
//
|
||||
// We need to somehow get them out... ?
|
||||
//
|
||||
// for now we just support editing existing server functions
|
||||
Msg::Devtools(devserver_msg) => {
|
||||
match devserver_msg {
|
||||
DevserverMsg::HotReload(hot_reload_msg) => {
|
||||
if hot_reload_msg.for_build_id == Some(dioxus_cli_config::build_id()) {
|
||||
if let Some(table) = hot_reload_msg.jump_table {
|
||||
use crate::ServerFunction;
|
||||
|
||||
unsafe { dioxus_devtools::subsecond::apply_patch(table).unwrap() };
|
||||
|
||||
let mut new_router = axum::Router::new().serve_static_assets();
|
||||
let new_cfg = ServeConfig::new().unwrap();
|
||||
|
||||
let server_fn_iter = ServerFunction::collect();
|
||||
|
||||
// de-duplicate iteratively by preferring the most recent (first, since it's linked)
|
||||
let mut server_fn_map: HashMap<_, _> = HashMap::new();
|
||||
for f in server_fn_iter.into_iter().rev() {
|
||||
server_fn_map.insert(f.path(), f);
|
||||
}
|
||||
|
||||
for (_, fn_) in server_fn_map {
|
||||
tracing::trace!(
|
||||
"Registering server function: {:?} {:?}",
|
||||
fn_.path(),
|
||||
fn_.method()
|
||||
);
|
||||
new_router = fn_.register_server_fn_on_router(new_router);
|
||||
}
|
||||
|
||||
let hot_root = subsecond::HotFn::current(original_root);
|
||||
let new_root_addr = hot_root.ptr_address().0 as usize as *const ();
|
||||
let new_root = unsafe {
|
||||
std::mem::transmute::<*const (), fn() -> Element>(new_root_addr)
|
||||
};
|
||||
|
||||
crate::document::reset_renderer();
|
||||
|
||||
let state = RenderHandleState::new(new_cfg.clone(), new_root);
|
||||
|
||||
let fallback_handler =
|
||||
axum::routing::get(RenderHandleState::render_handler)
|
||||
.with_state(state);
|
||||
|
||||
make_service = apply_base_path(
|
||||
new_router.fallback(fallback_handler),
|
||||
new_root,
|
||||
new_cfg.clone(),
|
||||
base_path().map(|s| s.to_string()),
|
||||
)
|
||||
.into_make_service();
|
||||
|
||||
shutdown_tx.send_modify(|i| {
|
||||
*i += 1;
|
||||
hr_idx += 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
DevserverMsg::FullReloadStart => {}
|
||||
DevserverMsg::FullReloadFailed => {}
|
||||
DevserverMsg::FullReloadCommand => {}
|
||||
DevserverMsg::Shutdown => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_base_path(
|
||||
mut router: axum::Router,
|
||||
root: fn() -> Result<VNode, RenderError>,
|
||||
cfg: ServeConfig,
|
||||
base_path: Option<String>,
|
||||
) -> axum::Router {
|
||||
if let Some(base_path) = base_path {
|
||||
let base_path = base_path.trim_matches('/');
|
||||
// If there is a base path, nest the router under it and serve the root route manually
|
||||
// Nesting a route in axum only serves /base_path or /base_path/ not both
|
||||
router = axum::Router::new().nest(&format!("/{base_path}/"), router);
|
||||
|
||||
async fn root_render_handler(
|
||||
state: State<RenderHandleState>,
|
||||
mut request: Request<Body>,
|
||||
) -> impl IntoResponse {
|
||||
// The root of the base path always looks like the root from dioxus fullstack
|
||||
*request.uri_mut() = "/".parse().unwrap();
|
||||
RenderHandleState::render_handler(state, request).await
|
||||
}
|
||||
|
||||
router = router.route(
|
||||
&format!("/{base_path}"),
|
||||
axum::routing::method_routing::get(root_render_handler)
|
||||
.with_state(RenderHandleState::new(cfg, root)),
|
||||
)
|
||||
}
|
||||
|
||||
router
|
||||
}
|
||||
|
||||
pub fn serve<F>(mut serve_it: impl FnMut() -> F)
|
||||
where
|
||||
F: Future<Output = Result<axum::Router, anyhow::Error>>,
|
||||
{
|
||||
dioxus_logger::initialize_default();
|
||||
|
||||
tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(async move {
|
||||
let router = serve_it().await.unwrap();
|
||||
|
||||
let address = dioxus_cli_config::fullstack_address_or_localhost();
|
||||
let listener = tokio::net::TcpListener::bind(address).await.unwrap();
|
||||
|
||||
tracing::trace!("Listening on {address}");
|
||||
|
||||
axum::serve::serve(listener, router.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// unreachable!("Serving a fullstack app should never return")
|
||||
}
|
||||
|
||||
/// Create a router that serves the dioxus application at the appropriate base path.
|
||||
///
|
||||
/// This method automatically setups up:
|
||||
/// - Static asset serving
|
||||
/// - Mapping of base paths
|
||||
/// - Automatic registration of server functions
|
||||
/// - Handler to render the dioxus application
|
||||
/// - WebSocket handling for live reload and devtools
|
||||
/// - Hot-reloading
|
||||
/// - Async Runtime
|
||||
/// - Logging
|
||||
pub fn router(app: fn() -> Element) -> axum::Router {
|
||||
let cfg = ServeConfig::new().unwrap();
|
||||
apply_base_path(
|
||||
axum::Router::new().serve_dioxus_application(cfg.clone(), app),
|
||||
app,
|
||||
cfg,
|
||||
base_path().map(|s| s.to_string()),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
#![doc(html_logo_url = "https://avatars.githubusercontent.com/u/79236386")]
|
||||
#![doc(html_favicon_url = "https://avatars.githubusercontent.com/u/79236386")]
|
||||
// #![warn(missing_docs)]
|
||||
#![cfg_attr(docsrs, feature(doc_cfg))]
|
||||
#![forbid(unexpected_cfgs)]
|
||||
|
||||
// re-exported to make it possible to implement a custom Client without adding a separate
|
||||
// dependency on `bytes`
|
||||
pub use bytes::Bytes;
|
||||
pub use dioxus_fullstack_core::{ServerFnError, ServerFnResult};
|
||||
|
||||
pub use axum;
|
||||
pub(crate) use config::*;
|
||||
pub use config::*;
|
||||
pub use config::{ServeConfig, ServeConfigBuilder};
|
||||
pub use document::ServerDocument;
|
||||
pub use server::*;
|
||||
|
||||
pub mod redirect;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod launch;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use launch::{launch, launch_cfg};
|
||||
|
||||
/// Implementations of the server side of the server function call.
|
||||
pub mod server;
|
||||
|
||||
/// Types and traits for HTTP responses.
|
||||
// pub mod response;
|
||||
pub mod config;
|
||||
|
||||
pub(crate) mod document;
|
||||
pub(crate) mod ssr;
|
||||
pub(crate) mod streaming;
|
||||
|
||||
pub use launch::router;
|
||||
pub use launch::serve;
|
||||
|
||||
pub mod serverfn;
|
||||
pub use serverfn::*;
|
||||
|
||||
pub mod isrg;
|
||||
pub use isrg::*;
|
||||
@@ -0,0 +1,31 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// A custom header that can be set with any value to indicate
|
||||
/// that the server function client should redirect to a new route.
|
||||
///
|
||||
/// This is useful because it allows returning a value from the request,
|
||||
/// while also indicating that a redirect should follow. This cannot be
|
||||
/// done with an HTTP `3xx` status code, because the browser will follow
|
||||
/// that redirect rather than returning the desired data.
|
||||
pub const REDIRECT_HEADER: &str = "serverfnredirect";
|
||||
|
||||
/// A function that will be called if a server function returns a `3xx` status
|
||||
/// or the [`REDIRECT_HEADER`].
|
||||
pub type RedirectHook = Box<dyn Fn(&str) + Send + Sync>;
|
||||
|
||||
// allowed: not in a public API, and pretty straightforward
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(crate) static REDIRECT_HOOK: OnceLock<RedirectHook> = OnceLock::new();
|
||||
|
||||
/// Sets a function that will be called if a server function returns a `3xx` status
|
||||
/// or the [`REDIRECT_HEADER`]. Returns `Err(_)` if the hook has already been set.
|
||||
pub fn set_redirect_hook(hook: impl Fn(&str) + Send + Sync + 'static) -> Result<(), RedirectHook> {
|
||||
REDIRECT_HOOK.set(Box::new(hook))
|
||||
}
|
||||
|
||||
/// Calls the hook that has been set by [`set_redirect_hook`] to redirect to `loc`.
|
||||
pub fn call_redirect_hook(loc: &str) {
|
||||
if let Some(hook) = REDIRECT_HOOK.get() {
|
||||
hook(loc)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
use crate::{
|
||||
ssr::{SSRError, SsrRendererPool},
|
||||
ServeConfig, ServerFunction,
|
||||
};
|
||||
use crate::{IncrementalRendererError, RenderFreshness};
|
||||
use axum::{
|
||||
body,
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{Request, Response, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::*,
|
||||
};
|
||||
use dioxus_core::{Element, VirtualDom};
|
||||
use futures::Stream;
|
||||
use http::header::*;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tower::util::MapResponse;
|
||||
use tower::ServiceExt;
|
||||
use tower_http::services::fs::ServeFileSystemResponseBody;
|
||||
|
||||
/// SSR renderer handler for Axum with added context injection.
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,no_run
|
||||
/// #![allow(non_snake_case)]
|
||||
/// use std::sync::{Arc, Mutex};
|
||||
///
|
||||
/// use axum::routing::get;
|
||||
/// use dioxus::prelude::*;
|
||||
/// use dioxus_server::{RenderHandleState, render_handler, ServeConfig};
|
||||
///
|
||||
/// fn app() -> Element {
|
||||
/// rsx! {
|
||||
/// "hello!"
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let addr = dioxus::cli_config::fullstack_address_or_localhost();
|
||||
/// let router = axum::Router::new()
|
||||
/// // Register server functions, etc.
|
||||
/// // Note you can use `register_server_functions_with_context`
|
||||
/// // to inject the context into server functions running outside
|
||||
/// // of an SSR render context.
|
||||
/// .fallback(get(render_handler)
|
||||
/// .with_state(RenderHandleState::new(ServeConfig::new().unwrap(), app))
|
||||
/// )
|
||||
/// .into_make_service();
|
||||
/// let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
/// axum::serve(listener, router).await.unwrap();
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn render_handler(
|
||||
State(state): State<RenderHandleState>,
|
||||
request: Request<Body>,
|
||||
) -> impl IntoResponse {
|
||||
RenderHandleState::render_handler(State(state), request).await
|
||||
}
|
||||
|
||||
/// A extension trait with utilities for integrating Dioxus with your Axum router.
|
||||
pub trait DioxusRouterExt<S>: DioxusRouterFnExt<S> {
|
||||
/// Serves the static WASM for your Dioxus application (except the generated index.html).
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust, no_run
|
||||
/// # #![allow(non_snake_case)]
|
||||
/// # use dioxus::prelude::*;
|
||||
/// use dioxus_server::{DioxusRouterExt, DioxusRouterFnExt};
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() -> anyhow::Result<()> {
|
||||
/// let addr = dioxus::cli_config::fullstack_address_or_localhost();
|
||||
/// let router = axum::Router::new()
|
||||
/// // Server side render the application, serve static assets, and register server functions
|
||||
/// .serve_static_assets()
|
||||
/// // Server render the application
|
||||
/// // ...
|
||||
/// .into_make_service();
|
||||
/// let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
/// axum::serve(listener, router).await?;
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
fn serve_static_assets(self) -> Self
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
/// Serves the Dioxus application. This will serve a complete server side rendered application.
|
||||
/// This will serve static assets, server render the application, register server functions, and integrate with hot reloading.
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust, no_run
|
||||
/// # #![allow(non_snake_case)]
|
||||
/// # use dioxus::prelude::*;
|
||||
/// use dioxus_server::{DioxusRouterExt, DioxusRouterFnExt, ServeConfig};
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let addr = dioxus::cli_config::fullstack_address_or_localhost();
|
||||
/// let router = axum::Router::new()
|
||||
/// // Server side render the application, serve static assets, and register server functions
|
||||
/// .serve_dioxus_application(ServeConfig::new().unwrap(), app)
|
||||
/// .into_make_service();
|
||||
/// let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
/// axum::serve(listener, router).await.unwrap();
|
||||
/// }
|
||||
///
|
||||
/// fn app() -> Element {
|
||||
/// rsx! { "Hello World" }
|
||||
/// }
|
||||
/// ```
|
||||
fn serve_dioxus_application(self, cfg: ServeConfig, app: fn() -> Element) -> Self
|
||||
where
|
||||
Self: Sized;
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl<S> DioxusRouterExt<S> for Router<S>
|
||||
where
|
||||
S: Send + Sync + Clone + 'static,
|
||||
{
|
||||
fn serve_static_assets(self) -> Self {
|
||||
let public_path = crate::public_path();
|
||||
|
||||
if !public_path.exists() {
|
||||
return self;
|
||||
}
|
||||
|
||||
// Serve all files in public folder except index.html
|
||||
serve_dir_cached(self, &public_path, &public_path)
|
||||
}
|
||||
|
||||
fn serve_dioxus_application(self, cfg: ServeConfig, app: fn() -> Element) -> Self {
|
||||
self.register_server_functions()
|
||||
.serve_static_assets()
|
||||
.fallback(
|
||||
get(RenderHandleState::render_handler).with_state(RenderHandleState::new(cfg, app)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A extension trait with server function utilities for integrating Dioxus with your Axum router.
|
||||
pub trait DioxusRouterFnExt<S> {
|
||||
/// Registers server functions with the default handler.
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust, no_run
|
||||
/// # use dioxus::prelude::*;
|
||||
/// # use dioxus_server::DioxusRouterFnExt;
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let addr = dioxus::cli_config::fullstack_address_or_localhost();
|
||||
/// let router = axum::Router::new()
|
||||
/// // Register server functions routes with the default handler
|
||||
/// .register_server_functions()
|
||||
/// .into_make_service();
|
||||
/// let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
/// axum::serve(listener, router).await.unwrap();
|
||||
/// }
|
||||
/// ```
|
||||
#[allow(dead_code)]
|
||||
fn register_server_functions(self) -> Self
|
||||
where
|
||||
Self: Sized;
|
||||
/// Serves a Dioxus application without static assets.
|
||||
/// Sets up server function routes and rendering endpoints only.
|
||||
///
|
||||
/// Useful for WebAssembly environments or when static assets
|
||||
/// are served by another system.
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust, no_run
|
||||
/// # use dioxus::prelude::*;
|
||||
/// # use dioxus_server::{DioxusRouterFnExt, ServeConfig};
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let router = axum::Router::new()
|
||||
/// .serve_api_application(ServeConfig::new().unwrap(), app)
|
||||
/// .into_make_service();
|
||||
/// // ...
|
||||
/// }
|
||||
///
|
||||
/// fn app() -> Element {
|
||||
/// rsx! { "Hello World" }
|
||||
/// }
|
||||
/// ```
|
||||
fn serve_api_application(self, cfg: ServeConfig, app: fn() -> Element) -> Self
|
||||
where
|
||||
Self: Sized;
|
||||
}
|
||||
|
||||
impl<S: Send + Sync + Clone + 'static> DioxusRouterFnExt<S> for Router<S> {
|
||||
fn register_server_functions(mut self) -> Self {
|
||||
for f in ServerFunction::collect() {
|
||||
self = f.register_server_fn_on_router(self);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn serve_api_application(self, cfg: ServeConfig, app: fn() -> Element) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
self.register_server_functions().fallback(
|
||||
get(RenderHandleState::render_handler).with_state(RenderHandleState::new(cfg, app)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// State used by [`RenderHandleState::render_handler`] to render a dioxus component with axum
|
||||
#[derive(Clone)]
|
||||
pub struct RenderHandleState {
|
||||
config: ServeConfig,
|
||||
build_virtual_dom: Arc<dyn Fn() -> VirtualDom + Send + Sync>,
|
||||
renderers: Arc<SsrRendererPool>,
|
||||
}
|
||||
|
||||
impl RenderHandleState {
|
||||
/// Create a new [`RenderHandleState`]
|
||||
pub fn new(config: ServeConfig, root: fn() -> Element) -> Self {
|
||||
Self {
|
||||
renderers: Arc::new(SsrRendererPool::new(4, config.incremental.clone())),
|
||||
build_virtual_dom: Arc::new(move || VirtualDom::new(root)),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new [`RenderHandleState`] with a custom [`VirtualDom`] factory. This method can be used to pass context into the root component of your application.
|
||||
pub fn new_with_virtual_dom_factory(
|
||||
config: ServeConfig,
|
||||
build_virtual_dom: impl Fn() -> VirtualDom + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
renderers: Arc::new(SsrRendererPool::new(4, config.incremental.clone())),
|
||||
config,
|
||||
build_virtual_dom: Arc::new(build_virtual_dom),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the [`ServeConfig`] for this [`RenderHandleState`]
|
||||
pub fn with_config(mut self, config: ServeConfig) -> Self {
|
||||
self.config = config;
|
||||
self
|
||||
}
|
||||
|
||||
/// SSR renderer handler for Axum with added context injection.
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,no_run
|
||||
/// #![allow(non_snake_case)]
|
||||
/// use std::sync::{Arc, Mutex};
|
||||
///
|
||||
/// use axum::routing::get;
|
||||
/// use dioxus::prelude::*;
|
||||
/// use dioxus_server::{RenderHandleState, render_handler, ServeConfig};
|
||||
///
|
||||
/// fn app() -> Element {
|
||||
/// rsx! {
|
||||
/// "hello!"
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let addr = dioxus::cli_config::fullstack_address_or_localhost();
|
||||
/// let router = axum::Router::new()
|
||||
/// // Register server functions, etc.
|
||||
/// // Note you can use `register_server_functions_with_context`
|
||||
/// // to inject the context into server functions running outside
|
||||
/// // of an SSR render context.
|
||||
/// .fallback(get(render_handler)
|
||||
/// .with_state(RenderHandleState::new(ServeConfig::new().unwrap(), app))
|
||||
/// )
|
||||
/// .into_make_service();
|
||||
/// let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
/// axum::serve(listener, router).await.unwrap();
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn render_handler(
|
||||
State(state): State<Self>,
|
||||
request: Request<Body>,
|
||||
) -> impl IntoResponse {
|
||||
let cfg = &state.config;
|
||||
let build_virtual_dom = {
|
||||
let build_virtual_dom = state.build_virtual_dom.clone();
|
||||
let context_providers = state.config.context_providers.clone();
|
||||
move || {
|
||||
let mut vdom = build_virtual_dom();
|
||||
for state in context_providers.as_slice() {
|
||||
vdom.insert_any_root_context(state());
|
||||
}
|
||||
vdom
|
||||
}
|
||||
};
|
||||
|
||||
let (parts, _) = request.into_parts();
|
||||
let url = parts
|
||||
.uri
|
||||
.path_and_query()
|
||||
.ok_or(StatusCode::BAD_REQUEST)?
|
||||
.to_string();
|
||||
|
||||
match state.render(url, cfg, build_virtual_dom).await {
|
||||
Ok((freshness, rx)) => {
|
||||
let mut response =
|
||||
axum::response::Html::from(Body::from_stream(rx)).into_response();
|
||||
freshness.write(response.headers_mut());
|
||||
Result::<http::Response<axum::body::Body>, StatusCode>::Ok(response)
|
||||
}
|
||||
|
||||
Err(SSRError::Incremental(e)) => {
|
||||
tracing::error!("Failed to render page: {}", e);
|
||||
Ok(Self::err_to_axum_response(e).into_response())
|
||||
}
|
||||
|
||||
Err(SSRError::HttpError { status, message }) => Ok(Response::builder()
|
||||
.status(status)
|
||||
.body(Body::from(message.unwrap_or_else(|| {
|
||||
status
|
||||
.canonical_reason()
|
||||
.unwrap_or("An unknown error occurred")
|
||||
.to_string()
|
||||
})))
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
fn err_to_axum_response<E: std::fmt::Display>(e: E) -> Response<axum::body::Body> {
|
||||
Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(body::Body::new(format!("Error: {}", e)))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Render the application to HTML.
|
||||
pub async fn render<'a>(
|
||||
&'a self,
|
||||
route: String,
|
||||
cfg: &'a ServeConfig,
|
||||
virtual_dom_factory: impl FnOnce() -> VirtualDom + Send + Sync + 'static,
|
||||
) -> Result<
|
||||
(
|
||||
RenderFreshness,
|
||||
impl Stream<Item = Result<String, IncrementalRendererError>>,
|
||||
),
|
||||
SSRError,
|
||||
> {
|
||||
self.renderers
|
||||
.clone()
|
||||
.render_to(cfg, route, virtual_dom_factory)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_dir_cached<S>(
|
||||
mut router: Router<S>,
|
||||
public_path: &std::path::Path,
|
||||
directory: &std::path::Path,
|
||||
) -> Router<S>
|
||||
where
|
||||
S: Send + Sync + Clone + 'static,
|
||||
{
|
||||
use tower_http::services::ServeFile;
|
||||
|
||||
let dir = std::fs::read_dir(directory)
|
||||
.unwrap_or_else(|e| panic!("Couldn't read public directory at {:?}: {}", &directory, e));
|
||||
|
||||
for entry in dir.flatten() {
|
||||
let path = entry.path();
|
||||
// Don't serve the index.html file. The SSR handler will generate it.
|
||||
if path == public_path.join("index.html") {
|
||||
continue;
|
||||
}
|
||||
let route = path.strip_prefix(public_path).unwrap();
|
||||
let route = path_components_to_route_lossy(route);
|
||||
|
||||
if path.is_dir() {
|
||||
router = serve_dir_cached(router, public_path, &path);
|
||||
} else {
|
||||
let serve_file = ServeFile::new(&path).precompressed_br();
|
||||
// All cached assets are served at the root of the asset directory. If we know an asset
|
||||
// is hashed for cache busting, we can cache the response on the client side forever. If
|
||||
// the asset changes, the hash in the path will also change and the client will refetch it.
|
||||
if file_name_looks_immutable(&route) {
|
||||
router = router.nest_service(&route, cache_response_forever(serve_file))
|
||||
} else {
|
||||
router = router.nest_service(&route, serve_file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
router
|
||||
}
|
||||
|
||||
fn path_components_to_route_lossy(path: &Path) -> String {
|
||||
let route = path
|
||||
.iter()
|
||||
.map(|segment| segment.to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
format!("/{}", route)
|
||||
}
|
||||
|
||||
type MappedAxumService<S> = MapResponse<
|
||||
S,
|
||||
fn(Response<ServeFileSystemResponseBody>) -> Response<ServeFileSystemResponseBody>,
|
||||
>;
|
||||
|
||||
fn cache_response_forever<S>(service: S) -> MappedAxumService<S>
|
||||
where
|
||||
S: ServiceExt<Request<Body>, Response = Response<ServeFileSystemResponseBody>>,
|
||||
{
|
||||
service.map_response(|mut response: Response<ServeFileSystemResponseBody>| {
|
||||
response.headers_mut().insert(
|
||||
CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||
);
|
||||
response
|
||||
})
|
||||
}
|
||||
|
||||
fn file_name_looks_immutable(file_name: &str) -> bool {
|
||||
// Check if the file name looks like a hash (e.g., "main-dxh12345678.js")
|
||||
file_name.rsplit_once("-dxh").is_some_and(|(_, hash)| {
|
||||
hash.chars()
|
||||
.take_while(|c| *c != '.')
|
||||
.all(|c| c.is_ascii_hexdigit())
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_name_looks_immutable() {
|
||||
assert!(file_name_looks_immutable("main-dxh12345678.js"));
|
||||
assert!(file_name_looks_immutable("style-dxhabcdef.css"));
|
||||
assert!(!file_name_looks_immutable("index.html"));
|
||||
assert!(!file_name_looks_immutable("script.js"));
|
||||
assert!(!file_name_looks_immutable("main-dxh1234wyz.js"));
|
||||
assert!(!file_name_looks_immutable("main-dxh12345678-invalid.js"));
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
use axum::body::Body;
|
||||
use axum::handler::Handler;
|
||||
use axum::routing::MethodRouter;
|
||||
use axum::Router; // both req/res // both req/res // req only
|
||||
use dashmap::DashMap;
|
||||
use dioxus_fullstack_core::DioxusServerState;
|
||||
use http::Method;
|
||||
use std::{marker::PhantomData, sync::LazyLock};
|
||||
|
||||
pub type AxumRequest = http::Request<Body>;
|
||||
pub type AxumResponse = http::Response<Body>;
|
||||
|
||||
/// A function endpoint that can be called from the client.
|
||||
#[derive(Clone)]
|
||||
pub struct ServerFunction<Caller = ()> {
|
||||
path: &'static str,
|
||||
method: Method,
|
||||
handler: fn() -> MethodRouter<DioxusServerState>,
|
||||
_phantom: PhantomData<Caller>,
|
||||
}
|
||||
|
||||
pub struct MakeRequest<T> {
|
||||
_phantom: PhantomData<T>,
|
||||
}
|
||||
|
||||
impl ServerFunction {
|
||||
/// Create a new server function object.
|
||||
pub const fn new(
|
||||
method: Method,
|
||||
path: &'static str,
|
||||
handler: fn() -> MethodRouter<DioxusServerState>,
|
||||
) -> Self {
|
||||
Self {
|
||||
path,
|
||||
method,
|
||||
handler,
|
||||
_phantom: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
/// The path of the server function.
|
||||
pub fn path(&self) -> &'static str {
|
||||
self.path
|
||||
}
|
||||
|
||||
/// The HTTP method the server function expects.
|
||||
pub fn method(&self) -> Method {
|
||||
self.method.clone()
|
||||
}
|
||||
|
||||
pub fn collect() -> Vec<&'static ServerFunction> {
|
||||
inventory::iter::<ServerFunction>().collect()
|
||||
}
|
||||
|
||||
pub fn register_server_fn_on_router<S>(&'static self, router: Router<S>) -> Router<S>
|
||||
where
|
||||
S: Send + Sync + Clone + 'static,
|
||||
{
|
||||
tracing::info!(
|
||||
"Registering server function: {} {}",
|
||||
self.method(),
|
||||
self.path()
|
||||
);
|
||||
use http::method::Method;
|
||||
let path = self.path();
|
||||
let method = self.method();
|
||||
let handler = move |req| self.handle_server_fns_inner(req);
|
||||
match method {
|
||||
Method::GET => router.route(path, axum::routing::get(handler)),
|
||||
Method::POST => router.route(path, axum::routing::post(handler)),
|
||||
Method::PUT => router.route(path, axum::routing::put(handler)),
|
||||
Method::DELETE => router.route(path, axum::routing::delete(handler)),
|
||||
Method::PATCH => router.route(path, axum::routing::patch(handler)),
|
||||
Method::HEAD => router.route(path, axum::routing::head(handler)),
|
||||
Method::OPTIONS => router.route(path, axum::routing::options(handler)),
|
||||
Method::CONNECT => router.route(path, axum::routing::connect(handler)),
|
||||
Method::TRACE => router.route(path, axum::routing::trace(handler)),
|
||||
_ => unimplemented!("Unsupported server function method: {}", method),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_server_fns_inner(&self, req: http::Request<Body>) -> http::Response<Body> {
|
||||
// todo: jon
|
||||
// - bring back middleware to serverfns. the new layer system is fine but isn't a full replacement
|
||||
//
|
||||
// use axum::body;
|
||||
// use axum::extract::State;
|
||||
// use axum::routing::*;
|
||||
// use axum::{
|
||||
// body::Body,
|
||||
// http::{Request, Response, StatusCode},
|
||||
// response::IntoResponse,
|
||||
// };
|
||||
// use http::header::*;
|
||||
|
||||
// let (parts, body) = req.into_parts();
|
||||
// let req = Request::from_parts(parts.clone(), body);
|
||||
|
||||
// // Create the server context with info from the request
|
||||
// let server_context = DioxusServerContext::new(parts);
|
||||
|
||||
// // Provide additional context from the render state
|
||||
// server_context.add_server_context(&additional_context);
|
||||
|
||||
// // store Accepts and Referrer in case we need them for redirect (below)
|
||||
// let referrer = req.headers().get(REFERER).cloned();
|
||||
// let accepts_html = req
|
||||
// .headers()
|
||||
// .get(ACCEPT)
|
||||
// .and_then(|v| v.to_str().ok())
|
||||
// .map(|v| v.contains("text/html"))
|
||||
// .unwrap_or(false);
|
||||
|
||||
// let mthd: MethodRouter<()> = axum::routing::get(self.handler);
|
||||
|
||||
// // this is taken from server_fn source...
|
||||
// //
|
||||
// // [`server_fn::axum::get_server_fn_service`]
|
||||
// let mut service = {
|
||||
// let middleware = self.middleware();
|
||||
// let mut service = self.clone().boxed();
|
||||
// for middleware in middleware {
|
||||
// service = middleware.layer(service);
|
||||
// }
|
||||
// service
|
||||
// };
|
||||
|
||||
// actually run the server fn (which may use the server context)
|
||||
// let fut = crate::with_server_context(server_context.clone(), || service.run(req));
|
||||
// let fut = crate::with_server_context(server_context.clone(), || service.run(req));
|
||||
|
||||
// let res = ProvideServerContext::new(fut, server_context.clone()).await;
|
||||
// let mut res = res.res;
|
||||
|
||||
// // it it accepts text/html (i.e., is a plain form post) and doesn't already have a
|
||||
// // Location set, then redirect to Referer
|
||||
// if accepts_html {
|
||||
// if let Some(referrer) = referrer {
|
||||
// let has_location = res.headers().get(LOCATION).is_some();
|
||||
// if !has_location {
|
||||
// *res.status_mut() = StatusCode::FOUND;
|
||||
// res.headers_mut().insert(LOCATION, referrer);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// // apply the response parts from the server context to the response
|
||||
// server_context.send_response(&mut res);
|
||||
|
||||
let mthd: MethodRouter<DioxusServerState> =
|
||||
(self.handler)().with_state(DioxusServerState {});
|
||||
|
||||
mthd.call(req, DioxusServerState {}).await
|
||||
}
|
||||
}
|
||||
|
||||
impl inventory::Collect for ServerFunction {
|
||||
#[inline]
|
||||
fn registry() -> &'static inventory::Registry {
|
||||
static REGISTRY: inventory::Registry = inventory::Registry::new();
|
||||
®ISTRY
|
||||
}
|
||||
}
|
||||
|
||||
/// The set of all registered server function paths.
|
||||
pub fn server_fn_paths() -> impl Iterator<Item = (&'static str, Method)> {
|
||||
REGISTERED_SERVER_FUNCTIONS
|
||||
.iter()
|
||||
.map(|item| (item.path(), item.method()))
|
||||
}
|
||||
|
||||
type LazyServerFnMap = LazyLock<DashMap<(String, Method), ServerFunction>>;
|
||||
static REGISTERED_SERVER_FUNCTIONS: LazyServerFnMap = std::sync::LazyLock::new(|| {
|
||||
inventory::iter::<ServerFunction>
|
||||
.into_iter()
|
||||
.map(|obj| ((obj.path().to_string(), obj.method()), obj.clone()))
|
||||
.collect()
|
||||
});
|
||||
@@ -0,0 +1,707 @@
|
||||
//! A shared pool of renderers for efficient server side rendering.
|
||||
use crate::isrg::{
|
||||
CachedRender, IncrementalRenderer, IncrementalRendererConfig, IncrementalRendererError,
|
||||
RenderFreshness,
|
||||
};
|
||||
use crate::streaming::{Mount, StreamingRenderer};
|
||||
use crate::{document::ServerDocument, ServeConfig};
|
||||
use dioxus_cli_config::base_path;
|
||||
use dioxus_core::{
|
||||
has_context, DynamicNode, ErrorContext, ScopeId, SuspenseContext, VNode, VirtualDom,
|
||||
};
|
||||
use dioxus_fullstack_core::{history::provide_fullstack_history_context, HttpError, ServerFnError};
|
||||
use dioxus_fullstack_core::{HydrationContext, SerializedHydrationData};
|
||||
use dioxus_fullstack_core::{StreamingContext, StreamingStatus};
|
||||
use dioxus_router::ParseRouteError;
|
||||
use dioxus_ssr::Renderer;
|
||||
use futures_channel::mpsc::Sender;
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use http::StatusCode;
|
||||
use std::{collections::HashMap, fmt::Write, future::Future, rc::Rc, sync::Arc, sync::RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::StreamingMode;
|
||||
|
||||
/// Errors that can occur during server side rendering before the initial chunk is sent down
|
||||
pub enum SSRError {
|
||||
/// An error from the incremental renderer. This should result in a 500 code
|
||||
Incremental(IncrementalRendererError),
|
||||
|
||||
HttpError {
|
||||
status: StatusCode,
|
||||
message: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A suspense boundary that is pending with a placeholder in the client
|
||||
struct PendingSuspenseBoundary {
|
||||
mount: Mount,
|
||||
children: Vec<ScopeId>,
|
||||
}
|
||||
|
||||
pub(crate) struct SsrRendererPool {
|
||||
renderers: RwLock<Vec<Renderer>>,
|
||||
incremental_cache: Option<RwLock<IncrementalRenderer>>,
|
||||
}
|
||||
|
||||
impl SsrRendererPool {
|
||||
pub(crate) fn new(initial_size: usize, incremental: Option<IncrementalRendererConfig>) -> Self {
|
||||
let renderers = RwLock::new((0..initial_size).map(|_| Self::pre_renderer()).collect());
|
||||
Self {
|
||||
renderers,
|
||||
incremental_cache: incremental.map(|cache| RwLock::new(cache.build())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Look for a cached route in the incremental cache and send it into the render channel if it exists
|
||||
fn check_cached_route(
|
||||
&self,
|
||||
route: &str,
|
||||
render_into: &mut Sender<Result<String, IncrementalRendererError>>,
|
||||
) -> Option<RenderFreshness> {
|
||||
if let Some(incremental) = &self.incremental_cache {
|
||||
if let Ok(mut incremental) = incremental.write() {
|
||||
match incremental.get(route) {
|
||||
Ok(Some(cached_render)) => {
|
||||
let CachedRender {
|
||||
freshness,
|
||||
response,
|
||||
..
|
||||
} = cached_render;
|
||||
_ = render_into.start_send(
|
||||
String::from_utf8(response.to_vec())
|
||||
.map_err(|err| IncrementalRendererError::Other(err.into())),
|
||||
);
|
||||
return Some(freshness);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to get route \"{route}\" from incremental cache: {e}"
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Render a virtual dom into a stream. This method will return immediately and continue streaming the result in the background
|
||||
/// The streaming is canceled when the stream the function returns is dropped
|
||||
pub(crate) async fn render_to(
|
||||
self: Arc<Self>,
|
||||
cfg: &ServeConfig,
|
||||
route: String,
|
||||
virtual_dom_factory: impl FnOnce() -> VirtualDom + Send + Sync + 'static,
|
||||
) -> Result<
|
||||
(
|
||||
RenderFreshness,
|
||||
impl Stream<Item = Result<String, IncrementalRendererError>>,
|
||||
),
|
||||
SSRError,
|
||||
> {
|
||||
struct ReceiverWithDrop {
|
||||
receiver: futures_channel::mpsc::Receiver<Result<String, IncrementalRendererError>>,
|
||||
cancel_task: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Stream for ReceiverWithDrop {
|
||||
type Item = Result<String, IncrementalRendererError>;
|
||||
|
||||
fn poll_next(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Option<Self::Item>> {
|
||||
self.receiver.poll_next_unpin(cx)
|
||||
}
|
||||
}
|
||||
|
||||
// When we drop the stream, we need to cancel the task that is feeding values to the stream
|
||||
impl Drop for ReceiverWithDrop {
|
||||
fn drop(&mut self) {
|
||||
if let Some(cancel_task) = self.cancel_task.take() {
|
||||
cancel_task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (mut into, rx) =
|
||||
futures_channel::mpsc::channel::<Result<String, IncrementalRendererError>>(1000);
|
||||
|
||||
let (initial_result_tx, initial_result_rx) = futures_channel::oneshot::channel();
|
||||
|
||||
// before we even spawn anything, we can check synchronously if we have the route cached
|
||||
if let Some(freshness) = self.check_cached_route(&route, &mut into) {
|
||||
return Ok((
|
||||
freshness,
|
||||
ReceiverWithDrop {
|
||||
receiver: rx,
|
||||
cancel_task: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let mut renderer = self
|
||||
.renderers
|
||||
.write()
|
||||
.unwrap()
|
||||
.pop()
|
||||
.unwrap_or_else(Self::pre_renderer);
|
||||
|
||||
let myself = self.clone();
|
||||
let streaming_mode = cfg.streaming_mode;
|
||||
|
||||
let cfg = cfg.clone();
|
||||
let create_render_future = move || async move {
|
||||
let mut virtual_dom = virtual_dom_factory();
|
||||
let document = Rc::new(ServerDocument::default());
|
||||
virtual_dom.provide_root_context(document.clone());
|
||||
|
||||
// If there is a base path, trim the base path from the route and add the base path formatting to the
|
||||
// history provider
|
||||
let history = if let Some(base_path) = base_path() {
|
||||
let base_path = base_path.trim_matches('/');
|
||||
let base_path = format!("/{base_path}");
|
||||
let route = route.strip_prefix(&base_path).unwrap_or(&route);
|
||||
dioxus_history::MemoryHistory::with_initial_path(route).with_prefix(base_path)
|
||||
} else {
|
||||
dioxus_history::MemoryHistory::with_initial_path(&route)
|
||||
};
|
||||
|
||||
// Provide the document and streaming context to the root of the app
|
||||
let streaming_context = virtual_dom.in_scope(ScopeId::ROOT, StreamingContext::new);
|
||||
virtual_dom.provide_root_context(document.clone() as Rc<dyn dioxus_document::Document>);
|
||||
virtual_dom.provide_root_context(streaming_context);
|
||||
|
||||
virtual_dom.in_scope(ScopeId::ROOT, || {
|
||||
// Wrap the memory history in a fullstack history provider to provide the initial route for hydration
|
||||
provide_fullstack_history_context(history);
|
||||
// Provide a hydration compatible error boundary that serializes errors for the client
|
||||
dioxus_core::provide_create_error_boundary(
|
||||
dioxus_fullstack_core::init_error_boundary,
|
||||
);
|
||||
});
|
||||
|
||||
// rebuild the virtual dom
|
||||
virtual_dom.rebuild_in_place();
|
||||
|
||||
// If streaming is disabled, wait for the virtual dom to finish all suspense work
|
||||
// before rendering anything
|
||||
if streaming_mode == StreamingMode::Disabled {
|
||||
virtual_dom.wait_for_suspense().await;
|
||||
} else {
|
||||
// Otherwise, just wait for the streaming context to signal the initial chunk is ready
|
||||
loop {
|
||||
// Check if the router has finished and set the streaming context to finished
|
||||
let streaming_context_finished = virtual_dom
|
||||
.in_scope(ScopeId::ROOT, || streaming_context.current_status())
|
||||
== StreamingStatus::InitialChunkCommitted;
|
||||
// Or if this app isn't using the router and has finished suspense
|
||||
let suspense_finished = !virtual_dom.suspended_tasks_remaining();
|
||||
if streaming_context_finished || suspense_finished {
|
||||
break;
|
||||
}
|
||||
|
||||
// Wait for new async work that runs during suspense (mainly use_server_futures)
|
||||
virtual_dom.wait_for_suspense_work().await;
|
||||
|
||||
// Do that async work
|
||||
virtual_dom.render_suspense_immediate().await;
|
||||
}
|
||||
}
|
||||
|
||||
// check if there are any errors
|
||||
let error = virtual_dom.in_runtime(|| {
|
||||
ScopeId::ROOT_ERROR_BOUNDARY
|
||||
.consume_context::<ErrorContext>()
|
||||
.expect("The root should be under an error boundary")
|
||||
.error()
|
||||
});
|
||||
|
||||
if let Some(error) = error {
|
||||
let mut status_code = None;
|
||||
let mut out_message = None;
|
||||
|
||||
// If the errors include an `HttpError` or `StatusCode` or `ServerFnError`, we need
|
||||
// to try and return the appropriate status code
|
||||
if let Some(error) = error.downcast_ref::<HttpError>() {
|
||||
status_code = Some(error.status);
|
||||
out_message = error.message.clone();
|
||||
}
|
||||
|
||||
if let Some(error) = error.downcast_ref::<StatusCode>() {
|
||||
status_code = Some(*error);
|
||||
}
|
||||
|
||||
// todo - the user is allowed to return anything that impls `From<ServerFnError>`
|
||||
// we need to eventually be able to downcast that and get the status code from it
|
||||
if let Some(ServerFnError::ServerError { message, code, .. }) = error.downcast_ref()
|
||||
{
|
||||
if let Some(code) = code {
|
||||
status_code = Some(
|
||||
(*code)
|
||||
.try_into()
|
||||
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
);
|
||||
} else {
|
||||
status_code = Some(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
out_message = Some(message.clone());
|
||||
}
|
||||
|
||||
// If there was an error while routing, return the error with a 404 status
|
||||
// Return a routing error if any of the errors were a routing error
|
||||
if let Some(routing_error) = error.downcast_ref::<ParseRouteError>().cloned() {
|
||||
status_code = Some(StatusCode::NOT_FOUND);
|
||||
out_message = Some(routing_error.to_string());
|
||||
}
|
||||
|
||||
// If we captured anything that produces a status code, we should return that status code.
|
||||
if let Some(status_code) = status_code {
|
||||
_ = initial_result_tx.send(Err(SSRError::HttpError {
|
||||
status: status_code,
|
||||
message: out_message,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
_ = initial_result_tx.send(Err(SSRError::Incremental(
|
||||
IncrementalRendererError::Other(error),
|
||||
)));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Now that we handled any errors from rendering, we can send the initial ok result
|
||||
_ = initial_result_tx.send(Ok(()));
|
||||
|
||||
// Wait long enough to assemble the `<head>` of the document before starting to stream
|
||||
let mut pre_body = String::new();
|
||||
if let Err(err) = Self::render_head(&cfg, &mut pre_body, &virtual_dom) {
|
||||
_ = into.start_send(Err(err));
|
||||
return;
|
||||
}
|
||||
|
||||
let stream = Arc::new(StreamingRenderer::new(pre_body, into));
|
||||
let scope_to_mount_mapping = Arc::new(RwLock::new(HashMap::new()));
|
||||
|
||||
renderer.pre_render = true;
|
||||
{
|
||||
let scope_to_mount_mapping = scope_to_mount_mapping.clone();
|
||||
let stream = stream.clone();
|
||||
renderer.set_render_components(Self::streaming_render_component_callback(
|
||||
stream,
|
||||
scope_to_mount_mapping,
|
||||
));
|
||||
}
|
||||
|
||||
macro_rules! throw_error {
|
||||
($e:expr) => {
|
||||
stream.close_with_error($e);
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
// Render the initial frame with loading placeholders
|
||||
let mut initial_frame = renderer.render(&virtual_dom);
|
||||
|
||||
// Along with the initial frame, we render the html after the main element, but before the body tag closes. This should include the script that starts loading the wasm bundle.
|
||||
if let Err(err) = Self::render_after_main(&cfg, &mut initial_frame, &virtual_dom) {
|
||||
throw_error!(err);
|
||||
}
|
||||
stream.render(initial_frame);
|
||||
|
||||
// After the initial render, we need to resolve suspense
|
||||
while virtual_dom.suspended_tasks_remaining() {
|
||||
virtual_dom.wait_for_suspense_work().await;
|
||||
let resolved_suspense_nodes = virtual_dom.render_suspense_immediate().await;
|
||||
|
||||
// Just rerender the resolved nodes
|
||||
for scope in resolved_suspense_nodes {
|
||||
let pending_suspense_boundary = {
|
||||
let mut lock = scope_to_mount_mapping.write().unwrap();
|
||||
lock.remove(&scope)
|
||||
};
|
||||
// If the suspense boundary was immediately removed, it may not have a mount. We can just skip resolving it
|
||||
if let Some(pending_suspense_boundary) = pending_suspense_boundary {
|
||||
let mut resolved_chunk = String::new();
|
||||
// After we replace the placeholder in the dom with javascript, we need to send down the resolved data so that the client can hydrate the node
|
||||
let render_suspense = |into: &mut String| {
|
||||
renderer.reset_hydration();
|
||||
renderer.render_scope(into, &virtual_dom, scope)
|
||||
};
|
||||
let resolved_data = Self::serialize_server_data(&virtual_dom, scope);
|
||||
if let Err(err) = stream.replace_placeholder(
|
||||
pending_suspense_boundary.mount,
|
||||
render_suspense,
|
||||
resolved_data,
|
||||
&mut resolved_chunk,
|
||||
) {
|
||||
throw_error!(IncrementalRendererError::RenderError(err));
|
||||
}
|
||||
|
||||
stream.render(resolved_chunk);
|
||||
// Freeze the suspense boundary to prevent future reruns of any child nodes of the suspense boundary
|
||||
if let Some(suspense) =
|
||||
SuspenseContext::downcast_suspense_boundary_from_scope(
|
||||
&virtual_dom.runtime(),
|
||||
scope,
|
||||
)
|
||||
{
|
||||
suspense.freeze();
|
||||
// Go to every child suspense boundary and add an error boundary. Since we cannot rerun any nodes above the child suspense boundary,
|
||||
// we need to capture the errors and send them to the client as it resolves
|
||||
virtual_dom.in_runtime(|| {
|
||||
for &suspense_scope in pending_suspense_boundary.children.iter() {
|
||||
Self::start_capturing_errors(suspense_scope);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After suspense is done, we render the html after the body
|
||||
let mut post_streaming = String::new();
|
||||
|
||||
if let Err(err) = Self::render_after_body(&cfg, &mut post_streaming) {
|
||||
throw_error!(err);
|
||||
}
|
||||
|
||||
// If incremental rendering is enabled, add the new render to the cache without the streaming bits
|
||||
if let Some(incremental) = &self.incremental_cache {
|
||||
let mut cached_render = String::new();
|
||||
if let Err(err) = Self::render_head(&cfg, &mut cached_render, &virtual_dom) {
|
||||
throw_error!(err);
|
||||
}
|
||||
renderer.reset_hydration();
|
||||
if let Err(err) = renderer.render_to(&mut cached_render, &virtual_dom) {
|
||||
throw_error!(IncrementalRendererError::RenderError(err));
|
||||
}
|
||||
if let Err(err) = Self::render_after_main(&cfg, &mut cached_render, &virtual_dom) {
|
||||
throw_error!(err);
|
||||
}
|
||||
cached_render.push_str(&post_streaming);
|
||||
|
||||
if let Ok(mut incremental) = incremental.write() {
|
||||
let _ = incremental.cache(route, cached_render);
|
||||
}
|
||||
}
|
||||
|
||||
stream.render(post_streaming);
|
||||
|
||||
renderer.reset_render_components();
|
||||
myself.renderers.write().unwrap().push(renderer);
|
||||
};
|
||||
|
||||
let join_handle = Self::spawn_platform(create_render_future);
|
||||
|
||||
// Wait for the initial result which determines the status code
|
||||
initial_result_rx
|
||||
.await
|
||||
.map_err(|err| SSRError::Incremental(IncrementalRendererError::Other(err.into())))??;
|
||||
|
||||
Ok((
|
||||
RenderFreshness::now(None),
|
||||
ReceiverWithDrop {
|
||||
receiver: rx,
|
||||
cancel_task: Some(join_handle),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn pre_renderer() -> Renderer {
|
||||
let mut renderer = Renderer::default();
|
||||
renderer.pre_render = true;
|
||||
renderer
|
||||
}
|
||||
|
||||
/// Create the streaming render component callback. It will keep track of what scopes are mounted to what pending
|
||||
/// suspense boundaries in the DOM.
|
||||
///
|
||||
/// This mapping is used to replace the DOM mount with the resolved contents once the suspense boundary is finished.
|
||||
fn streaming_render_component_callback(
|
||||
stream: Arc<StreamingRenderer<IncrementalRendererError>>,
|
||||
scope_to_mount_mapping: Arc<RwLock<HashMap<ScopeId, PendingSuspenseBoundary>>>,
|
||||
) -> impl Fn(&mut Renderer, &mut dyn Write, &VirtualDom, ScopeId) -> std::fmt::Result
|
||||
+ Send
|
||||
+ Sync
|
||||
+ 'static {
|
||||
// We use a stack to keep track of what suspense boundaries we are nested in to add children to the correct boundary
|
||||
// The stack starts with the root scope because the root is a suspense boundary
|
||||
let pending_suspense_boundaries_stack = RwLock::new(vec![]);
|
||||
move |renderer, to, vdom, scope| {
|
||||
let is_suspense_boundary =
|
||||
SuspenseContext::downcast_suspense_boundary_from_scope(&vdom.runtime(), scope)
|
||||
.filter(|s| s.has_suspended_tasks())
|
||||
.is_some();
|
||||
if is_suspense_boundary {
|
||||
let mount = stream.render_placeholder(
|
||||
|to| {
|
||||
{
|
||||
pending_suspense_boundaries_stack
|
||||
.write()
|
||||
.unwrap()
|
||||
.push(scope);
|
||||
}
|
||||
let out = renderer.render_scope(to, vdom, scope);
|
||||
{
|
||||
pending_suspense_boundaries_stack.write().unwrap().pop();
|
||||
}
|
||||
out
|
||||
},
|
||||
&mut *to,
|
||||
)?;
|
||||
// Add the suspense boundary to the list of pending suspense boundaries
|
||||
// We will replace the mount with the resolved contents later once the suspense boundary is resolved
|
||||
let mut scope_to_mount_mapping_write = scope_to_mount_mapping.write().unwrap();
|
||||
scope_to_mount_mapping_write.insert(
|
||||
scope,
|
||||
PendingSuspenseBoundary {
|
||||
mount,
|
||||
children: vec![],
|
||||
},
|
||||
);
|
||||
// Add the scope to the list of children of the parent suspense boundary
|
||||
let pending_suspense_boundaries_stack =
|
||||
pending_suspense_boundaries_stack.read().unwrap();
|
||||
// If there is a parent suspense boundary, add the scope to the list of children
|
||||
// This suspense boundary will start capturing errors when the parent is resolved
|
||||
if let Some(parent) = pending_suspense_boundaries_stack.last() {
|
||||
let parent = scope_to_mount_mapping_write.get_mut(parent).unwrap();
|
||||
parent.children.push(scope);
|
||||
}
|
||||
// Otherwise this is a root suspense boundary, so we need to start capturing errors immediately
|
||||
else {
|
||||
vdom.in_runtime(|| {
|
||||
Self::start_capturing_errors(scope);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
renderer.render_scope(to, vdom, scope)?
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Start capturing errors at a suspense boundary. If the parent suspense boundary is frozen, we need to capture the errors in the suspense boundary
|
||||
/// and send them to the client to continue bubbling up
|
||||
fn start_capturing_errors(suspense_scope: ScopeId) {
|
||||
// Add an error boundary to the scope. We serialize the suspense error boundary separately so we can use
|
||||
// the normal in memory ErrorContext here
|
||||
suspense_scope.in_runtime(|| dioxus_core::provide_context(ErrorContext::new(None)));
|
||||
}
|
||||
|
||||
fn serialize_server_data(virtual_dom: &VirtualDom, scope: ScopeId) -> SerializedHydrationData {
|
||||
// After we replace the placeholder in the dom with javascript, we need to send down the resolved data so that the client can hydrate the node
|
||||
// Extract any data we serialized for hydration (from server futures)
|
||||
let html_data = Self::extract_from_suspense_boundary(virtual_dom, scope);
|
||||
|
||||
// serialize the server state into a base64 string
|
||||
html_data.serialized()
|
||||
}
|
||||
|
||||
/// Walks through the suspense boundary in a depth first order and extracts the data from the context API.
|
||||
/// We use depth first order instead of relying on the order the hooks are called in because during suspense on the server, the order that futures are run in may be non deterministic.
|
||||
pub(crate) fn extract_from_suspense_boundary(
|
||||
vdom: &VirtualDom,
|
||||
scope: ScopeId,
|
||||
) -> HydrationContext {
|
||||
let data = HydrationContext::default();
|
||||
Self::serialize_errors(&data, vdom, scope);
|
||||
Self::take_from_scope(&data, vdom, scope);
|
||||
data
|
||||
}
|
||||
|
||||
/// Get the errors from the suspense boundary
|
||||
fn serialize_errors(context: &HydrationContext, vdom: &VirtualDom, scope: ScopeId) {
|
||||
// If there is an error boundary on the suspense boundary, grab the error from the context API
|
||||
// and throw it on the client so that it bubbles up to the nearest error boundary
|
||||
let error = vdom.in_runtime(|| {
|
||||
scope
|
||||
.consume_context::<ErrorContext>()
|
||||
.and_then(|error_context| error_context.error())
|
||||
});
|
||||
context
|
||||
.error_entry()
|
||||
.insert(&error, std::panic::Location::caller());
|
||||
}
|
||||
|
||||
fn take_from_scope(context: &HydrationContext, vdom: &VirtualDom, scope: ScopeId) {
|
||||
vdom.in_runtime(|| {
|
||||
scope.in_runtime(|| {
|
||||
// Grab any serializable server context from this scope
|
||||
let other: Option<HydrationContext> = has_context();
|
||||
if let Some(other) = other {
|
||||
context.extend(&other);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// then continue to any children
|
||||
if let Some(scope) = vdom.get_scope(scope) {
|
||||
// If this is a suspense boundary, move into the children first (even if they are suspended) because that will be run first on the client
|
||||
if let Some(suspense_boundary) =
|
||||
SuspenseContext::downcast_suspense_boundary_from_scope(&vdom.runtime(), scope.id())
|
||||
{
|
||||
if let Some(node) = suspense_boundary.suspended_nodes() {
|
||||
Self::take_from_vnode(context, vdom, &node);
|
||||
}
|
||||
}
|
||||
if let Some(node) = scope.try_root_node() {
|
||||
Self::take_from_vnode(context, vdom, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn take_from_vnode(context: &HydrationContext, vdom: &VirtualDom, vnode: &VNode) {
|
||||
for (dynamic_node_index, dyn_node) in vnode.dynamic_nodes.iter().enumerate() {
|
||||
match dyn_node {
|
||||
DynamicNode::Component(comp) => {
|
||||
if let Some(scope) = comp.mounted_scope(dynamic_node_index, vnode, vdom) {
|
||||
Self::take_from_scope(context, vdom, scope.id());
|
||||
}
|
||||
}
|
||||
DynamicNode::Fragment(nodes) => {
|
||||
for node in nodes {
|
||||
Self::take_from_vnode(context, vdom, node);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render any content before the head of the page.
|
||||
pub fn render_head<R: std::fmt::Write>(
|
||||
cfg: &ServeConfig,
|
||||
to: &mut R,
|
||||
virtual_dom: &VirtualDom,
|
||||
) -> Result<(), IncrementalRendererError> {
|
||||
let ServeConfig { index, .. } = cfg;
|
||||
|
||||
let title = {
|
||||
let document: Option<Rc<ServerDocument>> =
|
||||
virtual_dom.in_runtime(|| ScopeId::ROOT.consume_context());
|
||||
// Collect any head content from the document provider and inject that into the head
|
||||
document.and_then(|document| document.title())
|
||||
};
|
||||
|
||||
to.write_str(&index.head_before_title)?;
|
||||
if let Some(title) = title {
|
||||
to.write_str(&title)?;
|
||||
} else {
|
||||
to.write_str(&index.title)?;
|
||||
}
|
||||
to.write_str(&index.head_after_title)?;
|
||||
|
||||
let document: Option<Rc<ServerDocument>> =
|
||||
virtual_dom.in_runtime(|| ScopeId::ROOT.consume_context());
|
||||
if let Some(document) = document {
|
||||
// Collect any head content from the document provider and inject that into the head
|
||||
document.render(to)?;
|
||||
|
||||
// Enable a warning when inserting contents into the head during streaming
|
||||
document.start_streaming();
|
||||
}
|
||||
|
||||
Self::render_before_body(cfg, to)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render any content before the body of the page.
|
||||
fn render_before_body<R: std::fmt::Write>(
|
||||
cfg: &ServeConfig,
|
||||
to: &mut R,
|
||||
) -> Result<(), IncrementalRendererError> {
|
||||
let ServeConfig { index, .. } = cfg;
|
||||
|
||||
to.write_str(&index.close_head)?;
|
||||
|
||||
// // #[cfg(feature = "document")]
|
||||
// {
|
||||
use dioxus_interpreter_js::INITIALIZE_STREAMING_JS;
|
||||
write!(to, "<script>{INITIALIZE_STREAMING_JS}</script>")?;
|
||||
// }
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render all content after the main element of the page.
|
||||
pub fn render_after_main<R: std::fmt::Write>(
|
||||
cfg: &ServeConfig,
|
||||
to: &mut R,
|
||||
virtual_dom: &VirtualDom,
|
||||
) -> Result<(), IncrementalRendererError> {
|
||||
let ServeConfig { index, .. } = cfg;
|
||||
|
||||
// Collect the initial server data from the root node. For most apps, no use_server_futures will be resolved initially, so this will be full on `None`s.
|
||||
// Sending down those Nones are still important to tell the client not to run the use_server_futures that are already running on the backend
|
||||
let resolved_data = SsrRendererPool::serialize_server_data(virtual_dom, ScopeId::ROOT);
|
||||
// We always send down the data required to hydrate components on the client
|
||||
let raw_data = resolved_data.data;
|
||||
write!(
|
||||
to,
|
||||
r#"<script>window.initial_dioxus_hydration_data="{raw_data}";"#,
|
||||
)?;
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
// In debug mode, we also send down the type names and locations of the serialized data
|
||||
let debug_types = &resolved_data.debug_types;
|
||||
let debug_locations = &resolved_data.debug_locations;
|
||||
write!(
|
||||
to,
|
||||
r#"window.initial_dioxus_hydration_debug_types={debug_types};"#,
|
||||
)?;
|
||||
write!(
|
||||
to,
|
||||
r#"window.initial_dioxus_hydration_debug_locations={debug_locations};"#,
|
||||
)?;
|
||||
}
|
||||
write!(to, r#"</script>"#,)?;
|
||||
to.write_str(&index.post_main)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render all content after the body of the page.
|
||||
pub fn render_after_body<R: std::fmt::Write>(
|
||||
cfg: &ServeConfig,
|
||||
to: &mut R,
|
||||
) -> Result<(), IncrementalRendererError> {
|
||||
let ServeConfig { index, .. } = cfg;
|
||||
|
||||
to.write_str(&index.after_closing_body_tag)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Spawn a task in the background. If wasm is enabled, this will use the single threaded tokio runtime
|
||||
fn spawn_platform<Fut>(f: impl FnOnce() -> Fut + Send + 'static) -> JoinHandle<Fut::Output>
|
||||
where
|
||||
Fut: Future + 'static,
|
||||
Fut::Output: Send + 'static,
|
||||
{
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use tokio_util::task::LocalPoolHandle;
|
||||
static TASK_POOL: std::sync::OnceLock<LocalPoolHandle> = std::sync::OnceLock::new();
|
||||
|
||||
let pool = TASK_POOL.get_or_init(|| {
|
||||
LocalPoolHandle::new(
|
||||
std::thread::available_parallelism()
|
||||
.map(usize::from)
|
||||
.unwrap_or(1),
|
||||
)
|
||||
});
|
||||
|
||||
pool.spawn_pinned(f)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
tokio::task::spawn_local(f())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! There are two common ways to render suspense:
|
||||
//! 1. Stream the HTML in order - this will work even if javascript is disabled, but if there is something slow at the top of your page, and fast at the bottom, nothing will render until the slow part is done
|
||||
//! 2. Render placeholders and stream the HTML out of order - this will only work if javascript is enabled. This lets you render any parts of your page that resolve quickly, and then render the rest of the page as it becomes available
|
||||
//!
|
||||
//! Dioxus currently uses a the second out of order streaming approach which requires javascript. The rendering structure is as follows:
|
||||
//! ```html
|
||||
//! // Initial content is sent down with placeholders
|
||||
//! <div>
|
||||
//! Header
|
||||
//! <div class="flex flex-col">
|
||||
//! // If we reach a suspense placeholder that may be replaced later, we insert a template node with a unique id to replace later
|
||||
//! <div>Loading user info...</div>
|
||||
//! </div>
|
||||
//! Footer
|
||||
//! </div>
|
||||
//! // After the initial render is done, we insert divs that are hidden with new content.
|
||||
//! // We use divs instead of templates for better SEO
|
||||
//! <script>
|
||||
//! // Code to hook up hydration replacement
|
||||
//! </script>
|
||||
//! <div hidden id="ds-1-r">
|
||||
//! <div>Final HTML</div>
|
||||
//! </div>
|
||||
//! <script>
|
||||
//! window.dx_hydrate(2, "suspenseboundarydata");
|
||||
//! </script>
|
||||
//! ```
|
||||
|
||||
use dioxus_fullstack_core::SerializedHydrationData;
|
||||
use futures_channel::mpsc::Sender;
|
||||
|
||||
use std::{
|
||||
fmt::{Display, Write},
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
/// Sections are identified by a unique id based on the suspense path. We only track the path of suspense boundaries because the client may render different components than the server.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct MountPath {
|
||||
parent: Option<Arc<MountPath>>,
|
||||
id: usize,
|
||||
}
|
||||
|
||||
impl MountPath {
|
||||
fn child(&self) -> Self {
|
||||
Self {
|
||||
parent: Some(Arc::new(self.clone())),
|
||||
id: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for MountPath {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if let Some(parent) = &self.parent {
|
||||
write!(f, "{},", parent)?;
|
||||
}
|
||||
write!(f, "{}", self.id)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct StreamingRenderer<E = std::convert::Infallible> {
|
||||
channel: RwLock<Sender<Result<String, E>>>,
|
||||
current_path: RwLock<MountPath>,
|
||||
}
|
||||
|
||||
impl<E> StreamingRenderer<E> {
|
||||
/// Create a new streaming renderer with the given head that renders into a channel
|
||||
pub(crate) fn new(
|
||||
before_body: impl Display,
|
||||
mut render_into: Sender<Result<String, E>>,
|
||||
) -> Self {
|
||||
let start_html = before_body.to_string();
|
||||
_ = render_into.start_send(Ok(start_html));
|
||||
|
||||
Self {
|
||||
channel: render_into.into(),
|
||||
current_path: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a new chunk of html that will never change
|
||||
pub(crate) fn render(&self, html: impl Display) {
|
||||
_ = self
|
||||
.channel
|
||||
.write()
|
||||
.unwrap()
|
||||
.start_send(Ok(html.to_string()));
|
||||
}
|
||||
|
||||
/// Render a new chunk of html that may change
|
||||
pub(crate) fn render_placeholder<W: Write + ?Sized>(
|
||||
&self,
|
||||
html: impl FnOnce(&mut W) -> std::fmt::Result,
|
||||
into: &mut W,
|
||||
) -> Result<Mount, std::fmt::Error> {
|
||||
let id = self.current_path.read().unwrap().clone();
|
||||
// Increment the id for the next placeholder
|
||||
self.current_path.write().unwrap().id += 1;
|
||||
// While we are inside the placeholder, set the suspense path to the suspense boundary that we are rendering
|
||||
let old_path = std::mem::replace(&mut *self.current_path.write().unwrap(), id.child());
|
||||
html(into)?;
|
||||
// Restore the old path
|
||||
*self.current_path.write().unwrap() = old_path;
|
||||
Ok(Mount { id })
|
||||
}
|
||||
|
||||
/// Replace a placeholder that was rendered previously
|
||||
pub(crate) fn replace_placeholder<W: Write + ?Sized>(
|
||||
&self,
|
||||
id: Mount,
|
||||
html: impl FnOnce(&mut W) -> std::fmt::Result,
|
||||
resolved_data: SerializedHydrationData,
|
||||
into: &mut W,
|
||||
) -> std::fmt::Result {
|
||||
// Then replace the suspense placeholder with the new content
|
||||
write!(into, r#"<div id="ds-{id}-r" hidden>"#)?;
|
||||
// While we are inside the placeholder, set the suspense path to the suspense boundary that we are rendering
|
||||
let old_path = std::mem::replace(&mut *self.current_path.write().unwrap(), id.id.child());
|
||||
html(into)?;
|
||||
// Restore the old path
|
||||
*self.current_path.write().unwrap() = old_path;
|
||||
// dx_hydrate accepts 2-4 arguments. The first two are required, the rest are optional
|
||||
// The arguments are:
|
||||
// 1. The id of the nodes we are hydrating under
|
||||
// 2. The serialized data required to hydrate those components
|
||||
// 3. (in debug mode) The type names of the serialized data
|
||||
// 4. (in debug mode) The locations of the serialized data
|
||||
write!(
|
||||
into,
|
||||
r#"</div><script>window.dx_hydrate([{id}], "{}""#,
|
||||
resolved_data.data
|
||||
)?;
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
// In debug mode, we also send down the type names and locations of the serialized data
|
||||
let debug_types = &resolved_data.debug_types;
|
||||
let debug_locations = &resolved_data.debug_locations;
|
||||
write!(into, r#", {debug_types}, {debug_locations}"#,)?;
|
||||
}
|
||||
write!(into, r#")</script>"#)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close the stream with an error
|
||||
pub(crate) fn close_with_error(&self, error: E) {
|
||||
_ = self.channel.write().unwrap().start_send(Err(error));
|
||||
}
|
||||
}
|
||||
|
||||
/// A mounted placeholder in the dom that may change in the future
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Mount {
|
||||
id: MountPath,
|
||||
}
|
||||
|
||||
impl Display for Mount {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.id)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user