multiple fullstack nits/cleanups before release (#4852)

* feat: `:arg` syntax for serverfn

* add better decode/encode verify message

* allow catch-all query args

* add rebind

* implement websocket frames for `Streaming`

* fix lazy init

* wip... serverfnstate

* wip...

* wip: serverfns require Send/Sync, let's fix that

* wip...

* small cleanup

* fix hang by actually spawning the tokio task

* clippy

* fix parameter catchall binding

* fix lychee

* doc tests
This commit is contained in:
Jonathan Kelley authored and GitHub committed 2025-10-30 18:52:47 -07:00
1 parent 43373f0be7
commit 5559e463c8
27 files changed
+1395 -1097

No files matched your search

+1 -1
View File
@@ -38,5 +38,5 @@ jobs:
- name: Run lychee - name: Run lychee
uses: lycheeverse/lychee-action@v2 uses: lycheeverse/lychee-action@v2
with: with:
args: --base . --config ./lychee.toml './**/*.md' args: --config ./lychee.toml './**/*.md'
fail: true fail: true
+5
View File
@@ -703,6 +703,11 @@ name = "flat_router"
path = "examples/06-routing/flat_router.rs" path = "examples/06-routing/flat_router.rs"
doc-scrape-examples = true doc-scrape-examples = true
[[example]]
name = "query_params"
path = "examples/07-fullstack/query_params.rs"
doc-scrape-examples = true
[[example]] [[example]]
name = "server_functions" name = "server_functions"
path = "examples/07-fullstack/server_functions.rs" path = "examples/07-fullstack/server_functions.rs"
@@ -18,7 +18,7 @@ fn main() {
}); });
} }
#[get("/api/{name}/?age")] #[get("/api/:name/?age")]
async fn get_message(name: String, age: i32) -> Result<String> { async fn get_message(name: String, age: i32) -> Result<String> {
Ok(format!("Hello {}, you are {} years old!", name, age)) Ok(format!("Hello {}, you are {} years old!", name, age))
} }
+55
View File
@@ -0,0 +1,55 @@
//! An example showcasing query parameters in Dioxus Fullstack server functions.
//!
//! The query parameter syntax mostly follows axum, but with a few extra conveniences.
//! - can rename parameters in the function signature with `?age=age_in_years` where `age_in_years` is Rust variable name
//! - can absorb all query params with `?{object}` directly into a struct implementing `Deserialize`
use dioxus::prelude::*;
fn main() {
dioxus::launch(|| {
let mut message = use_action(get_message);
let mut message_rebind = use_action(get_message_rebind);
let mut message_all = use_action(get_message_all);
rsx! {
h1 { "Server says: "}
div {
button { onclick: move |_| message.call(22), "Single" }
pre { "{message:?}"}
}
div {
button { onclick: move |_| message_rebind.call(25), "Rebind" }
pre { "{message_rebind:?}"}
}
div {
button { onclick: move |_| message_all.call(Params { age: 30, name: "world".into() }), "Bind all" }
pre { "{message_all:?}"}
}
}
});
}
#[get("/api/message/?age")]
async fn get_message(age: i32) -> Result<String> {
Ok(format!("You are {} years old!", age))
}
#[get("/api/rebind/?age=age_in_years")]
async fn get_message_rebind(age_in_years: i32) -> Result<String> {
Ok(format!("You are {} years old!", age_in_years))
}
#[derive(serde::Deserialize, serde::Serialize, Debug)]
struct Params {
age: i32,
name: String,
}
#[get("/api/all/?{query}")]
async fn get_message_all(query: Params) -> Result<String> {
Ok(format!(
"Hello {}, you are {} years old!",
query.name, query.age
))
}
+2 -2
View File
@@ -92,8 +92,8 @@
//! take a `State<T>` extractor cannot be automatically added to the router since the dioxus router //! take a `State<T>` extractor cannot be automatically added to the router since the dioxus router
//! type does not know how to construct the `T` type. //! type does not know how to construct the `T` type.
//! //!
//! These server functions will be registered once the `ServerFnState<T>` layer is added to the app with //! These server functions will be registered once the `ServerState<T>` layer is added to the app with
//! `router = router.layer(ServerFnState::new(your_state))`. //! `router = router.layer(ServerState::new(your_state))`.
//! //!
//! ## Middleware //! ## Middleware
//! //!
+73 -1
View File
@@ -1,6 +1,13 @@
//! This example shows how to use global state to maintain state between server functions. //! This example shows how to use global state to maintain state between server functions.
use dioxus::prelude::*; use std::rc::Rc;
use axum_core::extract::{FromRef, FromRequest};
use dioxus::{
fullstack::{FullstackContext, extract::State},
prelude::*,
};
use reqwest::header::HeaderMap;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use { use {
@@ -77,7 +84,68 @@ type BroadcastExtension = axum::Extension<tokio::sync::broadcast::Sender<String>
#[post("/api/broadcast", ext: BroadcastExtension)] #[post("/api/broadcast", ext: BroadcastExtension)]
async fn broadcast_message() -> Result<()> { async fn broadcast_message() -> Result<()> {
let rt = Rc::new("asdasd".to_string());
ext.send("New broadcast message".to_string())?; ext.send("New broadcast message".to_string())?;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
println!("rt: {}", rt);
Ok(())
}
/*
Option 4:
You can use Axum's `State` extractor to provide custom application state to your server functions.
All ServerFunctions pull in `FullstackContext`, so you need to implement `FromRef<FullstackContext>` for your
custom state type. To add your state to your app, you can use `.register_server_functions()` on a router
for a given state type, which will automatically add your state into the `FullstackContext` used by your server functions.
There are two details to note here:
- You need to implement `FromRef<FullstackContext>` for your custom state type.
- Custom extractors need to implement `FromRequest<S>` where `S` is the state type that implements `FromRef<FullstackContext>`.
*/
#[derive(Clone)]
struct MyAppState {
abc: i32,
}
impl FromRef<FullstackContext> for MyAppState {
fn from_ref(state: &FullstackContext) -> Self {
state.extension::<MyAppState>().unwrap()
}
}
struct CustomExtractor {
abc: i32,
headermap: HeaderMap,
}
impl<S> FromRequest<S> for CustomExtractor
where
MyAppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = ();
async fn from_request(
_req: axum::extract::Request,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
let state = MyAppState::from_ref(state);
Ok(CustomExtractor {
abc: state.abc,
headermap: HeaderMap::new(),
})
}
}
#[post("/api/stateful", state: State<MyAppState>, ex: CustomExtractor)]
async fn app_state() -> Result<()> {
println!("abc: {}", state.abc);
println!("state abc: {:?}", ex.abc);
println!("headermap: {:?}", ex.headermap);
Ok(()) Ok(())
} }
@@ -95,6 +163,10 @@ fn main() {
let router = dioxus::server::router(app) let router = dioxus::server::router(app)
.layer(Extension(tokio::sync::broadcast::channel::<String>(16).0)); .layer(Extension(tokio::sync::broadcast::channel::<String>(16).0));
// To use our custom app state with `State<MyAppState>`, we need to register it
// as an extension since our `FromRef<FullstackContext>` implementation relies on it.
let router = router.layer(Extension(MyAppState { abc: 42 }));
Ok(router) Ok(router)
}); });
} }
+1 -3
View File
@@ -211,9 +211,7 @@ pub mod prelude {
#[cfg(feature = "server")] #[cfg(feature = "server")]
#[cfg_attr(docsrs, doc(cfg(feature = "server")))] #[cfg_attr(docsrs, doc(cfg(feature = "server")))]
#[doc(inline)] #[doc(inline)]
pub use dioxus_server::{ pub use dioxus_server::{self, serve, DioxusRouterExt, ServeConfig, ServerFunction};
self, serve, DioxusRouterExt, DioxusRouterFnExt, ServeConfig, ServerFunction,
};
#[cfg(feature = "router")] #[cfg(feature = "router")]
#[cfg_attr(docsrs, doc(cfg(feature = "router")))] #[cfg_attr(docsrs, doc(cfg(feature = "router")))]
+14
View File
@@ -162,6 +162,20 @@ impl From<RequestError> for ServerFnError {
} }
} }
impl From<ServerFnError> for HttpError {
fn from(value: ServerFnError) -> Self {
let status = StatusCode::from_u16(match &value {
ServerFnError::ServerError { code, .. } => *code,
_ => 500,
})
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
HttpError {
status,
message: Some(value.to_string()),
}
}
}
impl From<HttpError> for ServerFnError { impl From<HttpError> for ServerFnError {
fn from(value: HttpError) -> Self { fn from(value: HttpError) -> Self {
ServerFnError::ServerError { ServerFnError::ServerError {
-3
View File
@@ -25,6 +25,3 @@ pub use error::*;
pub mod httperror; pub mod httperror;
pub use httperror::*; pub use httperror::*;
#[derive(Clone, Default)]
pub struct DioxusServerState {}
+18 -7
View File
@@ -9,10 +9,6 @@ use std::collections::HashSet;
use std::fmt::Debug; use std::fmt::Debug;
use std::sync::Arc; use std::sync::Arc;
tokio::task_local! {
static FULLSTACK_CONTEXT: FullstackContext;
}
/// The context provided by dioxus fullstack for server-side rendering. /// The context provided by dioxus fullstack for server-side rendering.
/// ///
/// This context will only be set on the server during the initial streaming response /// This context will only be set on the server during the initial streaming response
@@ -21,10 +17,18 @@ tokio::task_local! {
pub struct FullstackContext { pub struct FullstackContext {
// We expose the lock for request headers directly so it needs to be in a separate lock // We expose the lock for request headers directly so it needs to be in a separate lock
request_headers: Arc<RwLock<http::request::Parts>>, request_headers: Arc<RwLock<http::request::Parts>>,
// The rest of the fields are only held internally, so we can group them together // The rest of the fields are only held internally, so we can group them together
lock: Arc<RwLock<FullstackContextInner>>, lock: Arc<RwLock<FullstackContextInner>>,
} }
// `FullstackContext` is always set when either
// 1. rendering the app via SSR
// 2. handling a server function request
tokio::task_local! {
static FULLSTACK_CONTEXT: FullstackContext;
}
pub struct FullstackContextInner { pub struct FullstackContextInner {
current_status: StreamingStatus, current_status: StreamingStatus,
current_status_subscribers: HashSet<ReactiveContext>, current_status_subscribers: HashSet<ReactiveContext>,
@@ -78,6 +82,7 @@ impl FullstackContext {
pub fn commit_initial_chunk(&mut self) { pub fn commit_initial_chunk(&mut self) {
let mut lock = self.lock.write(); let mut lock = self.lock.write();
lock.current_status = StreamingStatus::InitialChunkCommitted; lock.current_status = StreamingStatus::InitialChunkCommitted;
// The key type is mutable, but the hash is stable through mutations because we hash by pointer // The key type is mutable, but the hash is stable through mutations because we hash by pointer
#[allow(clippy::mutable_key_type)] #[allow(clippy::mutable_key_type)]
let subscribers = std::mem::take(&mut lock.current_status_subscribers); let subscribers = std::mem::take(&mut lock.current_status_subscribers);
@@ -110,17 +115,23 @@ impl FullstackContext {
FULLSTACK_CONTEXT.scope(self, fut).await FULLSTACK_CONTEXT.scope(self, fut).await
} }
/// Extract an extension from the current request.
pub fn extension<T: Clone + Send + Sync + 'static>(&self) -> Option<T> {
let lock = self.request_headers.read();
lock.extensions.get::<T>().cloned()
}
/// Extract an axum extractor from the current request. /// Extract an axum extractor from the current request.
/// ///
/// The body of the request is always empty when using this method, as the body can only be consumed once in the server /// The body of the request is always empty when using this method, as the body can only be consumed once in the server
/// function extractors. /// function extractors.
pub async fn extract<T: FromRequest<(), M>, M>() -> Result<T, ServerFnError> { pub async fn extract<T: FromRequest<Self, M>, M>() -> Result<T, ServerFnError> {
let this = Self::current() let this = Self::current()
.ok_or_else(|| ServerFnError::new("No FullstackContext found".to_string()))?; .ok_or_else(|| ServerFnError::new("No FullstackContext found".to_string()))?;
let parts = this.request_headers.read().clone(); let parts = this.request_headers.read().clone();
let request = axum_core::extract::Request::from_parts(parts, Default::default()); let request = axum_core::extract::Request::from_parts(parts, Default::default());
match T::from_request(request, &()).await { match T::from_request(request, &this).await {
Ok(res) => Ok(res), Ok(res) => Ok(res),
Err(err) => { Err(err) => {
let resp = err.into_response(); let resp = err.into_response();
@@ -255,7 +266,7 @@ pub fn commit_initial_chunk() {
/// Extract an axum extractor from the current request. /// Extract an axum extractor from the current request.
#[deprecated(note = "Use FullstackContext::extract instead", since = "0.7.0")] #[deprecated(note = "Use FullstackContext::extract instead", since = "0.7.0")]
pub fn extract<T: FromRequest<(), M>, M>( pub fn extract<T: FromRequest<FullstackContext, M>, M>(
) -> impl std::future::Future<Output = Result<T, ServerFnError>> { ) -> impl std::future::Future<Output = Result<T, ServerFnError>> {
FullstackContext::extract::<T, M>() FullstackContext::extract::<T, M>()
} }
File diff suppressed because it is too large. Load diff
+211 -242
View File
@@ -1,6 +1,6 @@
//! A launch function that creates an axum router for the LaunchBuilder //! A launch function that creates an axum router for the LaunchBuilder
use crate::{server::DioxusRouterExt, RenderHandleState, ServeConfig}; use crate::{server::DioxusRouterExt, FullstackState, ServeConfig};
use anyhow::Context; use anyhow::Context;
use axum::{ use axum::{
body::Body, body::Body,
@@ -9,10 +9,9 @@ use axum::{
Router, Router,
}; };
use dioxus_cli_config::base_path; use dioxus_cli_config::base_path;
use dioxus_core::Element; use dioxus_core::{ComponentFunction, Element};
#[cfg(not(target_arch = "wasm32"))]
use dioxus_core::{RenderError, VNode}; use dioxus_devtools::{DevserverMsg, HotReloadMsg};
use dioxus_devtools::DevserverMsg;
use futures_util::{stream::FusedStream, StreamExt}; use futures_util::{stream::FusedStream, StreamExt};
use hyper::body::Incoming; use hyper::body::Incoming;
use hyper_util::server::conn::auto::Builder as HyperBuilder; use hyper_util::server::conn::auto::Builder as HyperBuilder;
@@ -20,14 +19,19 @@ use hyper_util::{
rt::{TokioExecutor, TokioIo}, rt::{TokioExecutor, TokioIo},
service::TowerToHyperService, service::TowerToHyperService,
}; };
use std::sync::Arc; use std::{any::Any, net::SocketAddr, prelude::rust_2024::Future};
use std::{any::Any, collections::HashMap, net::SocketAddr, prelude::rust_2024::Future}; use std::{pin::Pin, sync::Arc};
use tokio::net::TcpStream; use subsecond::HotFn;
use tokio_util::task::LocalPoolHandle; use tokio_util::either::Either;
use tower::{Service, ServiceExt as _}; use tower::{Service, ServiceExt as _};
type ContextList = Vec<Box<dyn Fn() -> Box<dyn Any> + Send + Sync>>; #[cfg(not(target_arch = "wasm32"))]
use {
dioxus_core::{RenderError, VNode},
tokio::net::TcpListener,
};
type ContextList = Vec<Box<dyn Fn() -> Box<dyn Any> + Send + Sync>>;
type BaseComp = fn() -> Element; type BaseComp = fn() -> Element;
/// Launch a fullstack app with the given root component. /// Launch a fullstack app with the given root component.
@@ -52,10 +56,6 @@ async fn serve_server(
contexts: Vec<Box<dyn Fn() -> Box<dyn Any> + Send + Sync>>, contexts: Vec<Box<dyn Fn() -> Box<dyn Any> + Send + Sync>>,
platform_config: Vec<Box<dyn Any>>, 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 mut cfg = platform_config let mut cfg = platform_config
.into_iter() .into_iter()
.find_map(|cfg| cfg.downcast::<ServeConfig>().ok().map(|b| *b)) .find_map(|cfg| cfg.downcast::<ServeConfig>().ok().map(|b| *b))
@@ -67,236 +67,19 @@ async fn serve_server(
cfg.context_providers.push(arced); cfg.context_providers.push(arced);
} }
// Get the address the server should run on. If the CLI is running, the CLI proxies fullstack into the main address let cb = move || {
// and we use the generated address the CLI gives us let cfg = cfg.clone();
let address = dioxus_cli_config::fullstack_address_or_localhost(); Box::pin(async move {
Ok(apply_base_path(
// Create the router and register the server functions under the basepath. Router::new().serve_dioxus_application(cfg.clone(), original_root),
let router = apply_base_path( original_root,
Router::new().serve_dioxus_application(cfg.clone(), original_root), cfg.clone(),
original_root, base_path().map(|s| s.to_string()),
cfg.clone(), ))
base_path().map(|s| s.to_string()), }) as _
);
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<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 = Router::new().serve_static_assets();
let new_cfg = ServeConfig::new();
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: Router,
root: fn() -> Element,
cfg: ServeConfig,
base_path: Option<String>,
) -> 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 = Router::new().nest(&format!("/{base_path}/"), router).route(
&format!("/{base_path}"),
axum::routing::method_routing::get(
|state: State<RenderHandleState>, mut request: Request<Body>| async move {
// 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
},
)
.with_state(RenderHandleState::new(cfg, root)),
)
}
router
}
/// Serve a fullstack dioxus application with a custom axum router.
///
/// This function sets up an async runtime, enables the default dioxus logger, runs the provided initializer,
/// and then starts an axum server with the returned router.
///
/// The axum router will be bound to the address specified by the `IP` and `PORT` environment variables,
/// defaulting to `127.0.0.1:8080` if not set.
pub fn serve<F>(mut serve_it: impl FnMut() -> F) -> !
where
F: Future<Output = Result<Router, anyhow::Error>>,
{
dioxus_logger::initialize_default();
let app_future = async move {
let router = serve_it().await.expect("Failed to create axum router");
let address = dioxus_cli_config::fullstack_address_or_localhost();
let listener = tokio::net::TcpListener::bind(address)
.await
.with_context(|| format!("Failed to bind app to given address: {address}"))
.unwrap();
tracing::trace!("Listening on {address}");
axum::serve::serve(listener, router)
.await
.expect("Failed to serve axum app");
}; };
if let Ok(handle) = tokio::runtime::Handle::try_current() { serve_router(cb, dioxus_cli_config::fullstack_address_or_localhost()).await;
handle.block_on(app_future);
} else {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(app_future);
}
unreachable!("Serving a fullstack app should never return")
} }
/// Create a router that serves the dioxus application at the appropriate base path. /// Create a router that serves the dioxus application at the appropriate base path.
@@ -319,3 +102,189 @@ pub fn router(app: fn() -> Element) -> Router {
base_path().map(|s| s.to_string()), base_path().map(|s| s.to_string()),
) )
} }
/// Serve a fullstack dioxus application with a custom axum router.
///
/// This function sets up an async runtime, enables the default dioxus logger, runs the provided initializer,
/// and then starts an axum server with the returned router.
///
/// The axum router will be bound to the address specified by the `IP` and `PORT` environment variables,
/// defaulting to `127.0.0.1:8080` if not set.
///
/// This function uses axum to block on serving the application, and will not return.
pub fn serve<F>(mut serve_it: impl FnMut() -> F) -> !
where
F: Future<Output = Result<Router, anyhow::Error>> + 'static,
{
let cb = move || Box::pin(serve_it()) as _;
block_on(
async move { serve_router(cb, dioxus_cli_config::fullstack_address_or_localhost()).await },
);
unreachable!("Serving a fullstack app should never return")
}
/// Serve a fullstack dioxus application with a custom axum router.
///
/// This function enables the dioxus logger and then serves the axum server with hot-reloading support.
///
/// To enable hot-reloading of the router, the provided `serve_callback` should return a new `Router`
/// each time it is called.
pub async fn serve_router(
mut serve_callback: impl FnMut() -> Pin<Box<dyn Future<Output = Result<Router, anyhow::Error>>>>,
addr: SocketAddr,
) {
dioxus_logger::initialize_default();
let listener = TcpListener::bind(addr)
.await
.with_context(|| format!("Failed to bind to address {addr}"))
.unwrap();
// If we're not in debug mode, just serve the app normally
if !cfg!(debug_assertions) {
axum::serve(listener, serve_callback().await.unwrap())
.await
.unwrap();
return;
}
// Wire up the devtools connection. The sender only sends messages in dev.
let (devtools_tx, mut devtools_rx) = futures_channel::mpsc::unbounded();
dioxus_devtools::connect(move |msg| _ = devtools_tx.unbounded_send(msg));
let mut hot_serve_callback = HotFn::current(serve_callback);
let mut make_service = hot_serve_callback
.call(())
.await
.map(|router| router.into_make_service())
.unwrap();
let (shutdown_tx, _) = tokio::sync::broadcast::channel(1);
let our_build_id = Some(dioxus_cli_config::build_id());
// Manually loop on accepting connections so we can also respond to devtools messages
loop {
let res = tokio::select! {
res = listener.accept() => Either::Left(res),
Some(msg) = devtools_rx.next(), if !devtools_rx.is_terminated() => Either::Right(msg),
else => continue
};
match res {
Either::Left(Ok((tcp_stream, _remote_addr))) => {
let mut make_service = make_service.clone();
let mut shutdown_rx = shutdown_tx.subscribe();
tokio::task::spawn(async move {
let tcp_stream = TokioIo::new(tcp_stream);
std::future::poll_fn(|cx| {
<IntoMakeService<Router> as tower::Service<Request>>::poll_ready(
&mut make_service,
cx,
)
})
.await
.expect("Infallible");
// upgrades needed for websockets
let builder = HyperBuilder::new(TokioExecutor::new());
let connection = builder.serve_connection_with_upgrades(
tcp_stream,
TowerToHyperService::new(
make_service
.call(())
.await
.unwrap()
.map_request(|req: Request<Incoming>| req.map(Body::new)),
),
);
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.recv() => {}
}
});
}
// Handle just hot-patches for now.
// We don't do RSX hot-reload since usually the client handles that once the page is loaded.
//
// todo(jon): I *believe* SSR is resilient to RSX changes, but we should verify that...
Either::Right(DevserverMsg::HotReload(HotReloadMsg {
jump_table: Some(table),
for_build_id,
..
})) if for_build_id == our_build_id => {
// Apply the hot-reload patch to the dioxus devtools first
unsafe { dioxus_devtools::subsecond::apply_patch(table).unwrap() };
// Now recreate the router
// We panic here because we don't want their app to continue in a maybe-corrupted state
make_service = hot_serve_callback
.call(())
.await
.expect("Failed to create new router after hot-patch!")
.into_make_service();
// Make sure to wipe out the renderer state so we don't have stale elements
crate::document::reset_renderer();
_ = shutdown_tx.send(());
}
// Explicitly don't handle RSX hot-reloads on the server
// The client will handle that once the page is loaded. If we handled it here,
_ => {}
}
}
}
fn block_on<T>(app_future: impl Future<Output = T>) {
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.block_on(app_future);
} else {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(app_future);
}
}
fn apply_base_path<M: 'static>(
mut router: Router,
root: impl ComponentFunction<(), M> + Send + Sync,
cfg: ServeConfig,
base_path: Option<String>,
) -> 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 = Router::new().nest(&format!("/{base_path}/"), router).route(
&format!("/{base_path}"),
axum::routing::method_routing::get(
|state: State<FullstackState>, mut request: Request<Body>| async move {
// The root of the base path always looks like the root from dioxus fullstack
*request.uri_mut() = "/".parse().unwrap();
FullstackState::render_handler(state, request).await
},
)
.with_state(FullstackState::new(cfg, root)),
)
}
router
}
+217 -175
View File
@@ -6,18 +6,177 @@ use axum::{
body::Body, body::Body,
extract::State, extract::State,
http::{Request, StatusCode}, http::{Request, StatusCode},
response::IntoResponse, response::{IntoResponse, Response},
response::Response,
routing::*, routing::*,
}; };
use dioxus_core::{Element, VirtualDom}; use dioxus_core::{ComponentFunction, VirtualDom};
use http::header::*; use http::header::*;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use tokio_util::task::LocalPoolHandle;
use tower::util::MapResponse; use tower::util::MapResponse;
use tower::ServiceExt; use tower::ServiceExt;
use tower_http::services::fs::ServeFileSystemResponseBody; use tower_http::services::fs::ServeFileSystemResponseBody;
/// A extension trait with utilities for integrating Dioxus with your Axum router.
pub trait DioxusRouterExt {
/// 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;
///
/// #[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
/// // ...
/// .with_state(dioxus_server::FullstackState::headless());
/// let listener = tokio::net::TcpListener::bind(addr).await?;
/// axum::serve(listener, router).await?;
/// Ok(())
/// }
/// ```
fn serve_static_assets(self) -> Router<FullstackState>;
/// 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, 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(), app);
/// 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<M: 'static>(
self,
cfg: ServeConfig,
app: impl ComponentFunction<(), M> + Send + Sync,
) -> Router<()>;
/// Registers server functions with the default handler.
///
/// # Example
/// ```rust, no_run
/// # use dioxus::prelude::*;
/// # use dioxus_server::DioxusRouterExt;
/// #[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()
/// .with_state(dioxus_server::FullstackState::headless());
/// let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
/// axum::serve(listener, router).await.unwrap();
/// }
/// ```
#[allow(dead_code)]
fn register_server_functions(self) -> Router<FullstackState>;
/// 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::{DioxusRouterExt, ServeConfig};
/// #[tokio::main]
/// async fn main() {
/// let router = axum::Router::new()
/// .serve_api_application(ServeConfig::new(), app)
/// .into_make_service();
/// // ...
/// }
///
/// fn app() -> Element {
/// rsx! { "Hello World" }
/// }
/// ```
fn serve_api_application<M: 'static>(
self,
cfg: ServeConfig,
app: impl ComponentFunction<(), M> + Send + Sync,
) -> Router<()>
where
Self: Sized;
}
#[cfg(not(target_arch = "wasm32"))]
impl DioxusRouterExt for Router<FullstackState> {
fn register_server_functions(mut self) -> Router<FullstackState> {
use std::collections::HashSet;
let mut seen = HashSet::new();
for func in ServerFunction::collect() {
if seen.insert(format!("{} {}", func.method(), func.path())) {
tracing::info!(
"Registering server function: {} {}",
func.method(),
func.path()
);
self = self.route(func.path(), func.method_router())
}
}
self
}
fn serve_static_assets(self) -> Router<FullstackState> {
let Some(public_path) = public_path() else {
return self;
};
// Serve all files in public folder except index.html
serve_dir_cached(self, &public_path, &public_path)
}
fn serve_api_application<M: 'static>(
self,
cfg: ServeConfig,
app: impl ComponentFunction<(), M> + Send + Sync,
) -> Router<()> {
self.register_server_functions()
.fallback(get(FullstackState::render_handler))
.with_state(FullstackState::new(cfg, app))
}
fn serve_dioxus_application<M: 'static>(
self,
cfg: ServeConfig,
app: impl ComponentFunction<(), M> + Send + Sync,
) -> Router<()> {
self.register_server_functions()
.serve_static_assets()
.fallback(get(FullstackState::render_handler))
.with_state(FullstackState::new(cfg, app))
}
}
/// SSR renderer handler for Axum with added context injection. /// SSR renderer handler for Axum with added context injection.
/// ///
/// # Example /// # Example
@@ -27,7 +186,7 @@ use tower_http::services::fs::ServeFileSystemResponseBody;
/// ///
/// use axum::routing::get; /// use axum::routing::get;
/// use dioxus::prelude::*; /// use dioxus::prelude::*;
/// use dioxus_server::{RenderHandleState, render_handler, ServeConfig}; /// use dioxus_server::{FullstackState, render_handler, ServeConfig};
/// ///
/// fn app() -> Element { /// fn app() -> Element {
/// rsx! { /// rsx! {
@@ -44,206 +203,89 @@ use tower_http::services::fs::ServeFileSystemResponseBody;
/// // to inject the context into server functions running outside /// // to inject the context into server functions running outside
/// // of an SSR render context. /// // of an SSR render context.
/// .fallback(get(render_handler)) /// .fallback(get(render_handler))
/// .with_state(RenderHandleState::new(ServeConfig::new(), app)); /// .with_state(FullstackState::new(ServeConfig::new(), app));
/// ///
/// let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); /// let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
/// axum::serve(listener, router).await.unwrap(); /// axum::serve(listener, router).await.unwrap();
/// } /// }
/// ``` /// ```
pub async fn render_handler( pub async fn render_handler(
State(state): State<RenderHandleState>, State(state): State<FullstackState>,
request: Request<Body>, request: Request<Body>,
) -> impl IntoResponse { ) -> impl IntoResponse {
RenderHandleState::render_handler(State(state), request).await FullstackState::render_handler(State(state), request).await
} }
/// A extension trait with utilities for integrating Dioxus with your Axum router. /// State used by [`FullstackState::render_handler`] to render a dioxus component with axum
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(), 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 Some(public_path) = public_path() else {
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(), 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 func in ServerFunction::collect() {
tracing::info!(
"Registering server function: {} {}",
func.method(),
func.path()
);
self = func.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)] #[derive(Clone)]
pub struct RenderHandleState { pub struct FullstackState {
config: ServeConfig, config: ServeConfig,
build_virtual_dom: Arc<dyn Fn() -> VirtualDom + Send + Sync>, build_virtual_dom: Arc<dyn Fn() -> VirtualDom + Send + Sync>,
renderers: Arc<SsrRendererPool>, renderers: Arc<SsrRendererPool>,
pub(crate) rt: LocalPoolHandle,
} }
impl RenderHandleState { impl FullstackState {
/// Create a new [`RenderHandleState`] /// Create a headless [`FullstackState`] without a root component.
pub fn new(config: ServeConfig, root: fn() -> Element) -> Self { ///
/// This won't render pages, but can still be used to register server functions and serve static assets.
pub fn headless() -> Self {
let rt = LocalPoolHandle::new(
std::thread::available_parallelism()
.map(usize::from)
.unwrap_or(1),
);
Self { Self {
renderers: Arc::new(SsrRendererPool::new(4, config.incremental.clone())), renderers: Arc::new(SsrRendererPool::new(4, None)),
build_virtual_dom: Arc::new(move || VirtualDom::new(root)), build_virtual_dom: Arc::new(|| {
config, panic!("No root component provided for headless FullstackState")
}),
config: ServeConfig::new(),
rt,
} }
} }
/// Create a new [`RenderHandleState`] with a custom [`VirtualDom`] factory. This method can be /// Create a new [`FullstackState`]
pub fn new<M: 'static>(
config: ServeConfig,
root: impl ComponentFunction<(), M> + Send + Sync + 'static,
) -> Self {
let rt = LocalPoolHandle::new(
std::thread::available_parallelism()
.map(usize::from)
.unwrap_or(1),
);
Self {
renderers: Arc::new(SsrRendererPool::new(4, config.incremental.clone())),
build_virtual_dom: Arc::new(move || VirtualDom::new_with_props(root.clone(), ())),
config,
rt,
}
}
/// Create a new [`FullstackState`] with a custom [`VirtualDom`] factory. This method can be
/// used to pass context into the root component of your application. /// used to pass context into the root component of your application.
pub fn new_with_virtual_dom_factory( pub fn new_with_virtual_dom_factory(
config: ServeConfig, config: ServeConfig,
build_virtual_dom: impl Fn() -> VirtualDom + Send + Sync + 'static, build_virtual_dom: impl Fn() -> VirtualDom + Send + Sync + 'static,
) -> Self { ) -> Self {
let rt = LocalPoolHandle::new(
std::thread::available_parallelism()
.map(usize::from)
.unwrap_or(1),
);
Self { Self {
renderers: Arc::new(SsrRendererPool::new(4, config.incremental.clone())), renderers: Arc::new(SsrRendererPool::new(4, config.incremental.clone())),
config, config,
build_virtual_dom: Arc::new(build_virtual_dom), build_virtual_dom: Arc::new(build_virtual_dom),
rt,
} }
} }
/// Set the [`ServeConfig`] for this [`RenderHandleState`] /// Set the [`ServeConfig`] for this [`FullstackState`]
pub fn with_config(mut self, config: ServeConfig) -> Self { pub fn with_config(mut self, config: ServeConfig) -> Self {
self.config = config; self.config = config;
self self
@@ -258,7 +300,7 @@ impl RenderHandleState {
/// ///
/// use axum::routing::get; /// use axum::routing::get;
/// use dioxus::prelude::*; /// use dioxus::prelude::*;
/// use dioxus_server::{RenderHandleState, render_handler, ServeConfig}; /// use dioxus_server::{FullstackState, render_handler, ServeConfig};
/// ///
/// fn app() -> Element { /// fn app() -> Element {
/// rsx! { /// rsx! {
@@ -275,7 +317,7 @@ impl RenderHandleState {
/// // to inject the context into server functions running outside /// // to inject the context into server functions running outside
/// // of an SSR render context. /// // of an SSR render context.
/// .fallback(get(render_handler)) /// .fallback(get(render_handler))
/// .with_state(RenderHandleState::new(ServeConfig::new(), app)) /// .with_state(FullstackState::new(ServeConfig::new(), app))
/// .into_make_service(); /// .into_make_service();
/// ///
/// let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); /// let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
@@ -288,7 +330,7 @@ impl RenderHandleState {
let response = state let response = state
.renderers .renderers
.clone() .clone()
.render_to(parts, &state.config, { .render_to(parts, &state.config, &state.rt, {
let build_virtual_dom = state.build_virtual_dom.clone(); let build_virtual_dom = state.build_virtual_dom.clone();
let context_providers = state.config.context_providers.clone(); let context_providers = state.config.context_providers.clone();
move || { move || {
+98 -84
View File
@@ -1,35 +1,33 @@
use axum::body::Body; use crate::FullstackState;
use axum::routing::MethodRouter; use axum::{
use axum::Router; body::Body,
use dashmap::DashMap; extract::{Request, State},
use dioxus_fullstack_core::{DioxusServerState, FullstackContext}; response::Response,
use http::Method; routing::MethodRouter,
use std::{marker::PhantomData, sync::LazyLock}; };
use dioxus_fullstack_core::FullstackContext;
pub type AxumRequest = http::Request<Body>; use http::{Method, StatusCode};
pub type AxumResponse = http::Response<Body>; use std::{pin::Pin, prelude::rust_2024::Future};
/// A function endpoint that can be called from the client. /// A function endpoint that can be called from the client.
#[derive(Clone)] #[derive(Clone)]
pub struct ServerFunction<Caller = ()> { pub struct ServerFunction {
path: &'static str, path: &'static str,
method: Method, method: Method,
handler: fn() -> MethodRouter<DioxusServerState>, handler: fn() -> MethodRouter<FullstackState>,
_phantom: PhantomData<Caller>,
} }
impl ServerFunction { impl ServerFunction {
/// Create a new server function object. /// Create a new server function object from a MethodRouter
pub const fn new( pub const fn new(
method: Method, method: Method,
path: &'static str, path: &'static str,
handler: fn() -> MethodRouter<DioxusServerState>, handler: fn() -> MethodRouter<FullstackState>,
) -> Self { ) -> Self {
Self { Self {
path, path,
method, method,
handler, handler,
_phantom: PhantomData,
} }
} }
@@ -43,72 +41,103 @@ impl ServerFunction {
self.method.clone() self.method.clone()
} }
/// Collect all globally registered server functions
pub fn collect() -> Vec<&'static ServerFunction> { pub fn collect() -> Vec<&'static ServerFunction> {
inventory::iter::<ServerFunction>().collect() inventory::iter::<ServerFunction>().collect()
} }
pub fn handler(&self) -> fn() -> MethodRouter<DioxusServerState> { /// Create a `MethodRouter` for this server function that can be mounted on an `axum::Router`.
self.handler ///
/// This runs the handler inside the required `FullstackContext` scope and populates
/// `FullstackContext` so that the handler can use those features.
///
/// It also runs the server function inside a tokio `LocalPool` to allow !Send futures.
pub fn method_router(&self) -> MethodRouter<FullstackState> {
(self.handler)()
} }
pub fn register_server_fn_on_router<S>(&'static self, router: Router<S>) -> Router<S> /// Creates a new `MethodRouter` for the given method and !Send handler.
where ///
S: Send + Sync + Clone + 'static, /// This is used internally by the `ServerFunction` to create the method router that this
{ /// server function uses.
// // store Accepts and Referrer in case we need them for redirect (below) #[allow(clippy::type_complexity)]
// let referrer = req.headers().get(REFERER).cloned(); pub fn make_handler(
// let accepts_html = req method: Method,
// .headers() handler: fn(State<FullstackContext>, Request) -> Pin<Box<dyn Future<Output = Response>>>,
// .get(ACCEPT) ) -> MethodRouter<FullstackState> {
// .and_then(|v| v.to_str().ok()) axum::routing::method_routing::on(
// .map(|v| v.contains("text/html")) method
// .unwrap_or(false); .try_into()
.expect("MethodFilter only supports standard HTTP methods"),
move |state: State<FullstackState>, request: Request| async move {
// Allow !Send futures by running in the render handlers pinned local pool
let result = state.rt.spawn_pinned(move || async move {
use dioxus_fullstack_core::FullstackContext;
use http::header::{ACCEPT, LOCATION, REFERER};
use http::StatusCode;
// // it it accepts text/html (i.e., is a plain form post) and doesn't already have a // todo: we're copying the parts here, but it'd be ideal if we didn't.
// // Location set, then redirect to Referer // We can probably just pass the URI in so the matching logic can work and then
// if accepts_html { // in the server function, do all extraction via FullstackContext. This ensures
// if let Some(referrer) = referrer { // calls to `.remove()` work as expected.
// let has_location = res.headers().get(LOCATION).is_some(); let (parts, body) = request.into_parts();
// if !has_location { let server_context = FullstackContext::new(parts.clone());
// *res.status_mut() = StatusCode::FOUND; let request = axum::extract::Request::from_parts(parts, body);
// res.headers_mut().insert(LOCATION, referrer);
// }
// }
// }
async fn server_context_middleware( // store Accepts and Referrer in case we need them for redirect (below)
request: axum::extract::Request, let referrer = request.headers().get(REFERER).cloned();
next: axum::middleware::Next, let accepts_html = request
) -> axum::response::Response { .headers()
let (parts, body) = request.into_parts(); .get(ACCEPT)
let server_context = FullstackContext::new(parts.clone()); .and_then(|v| v.to_str().ok())
let request = axum::extract::Request::from_parts(parts, body); .map(|v| v.contains("text/html"))
.unwrap_or(false);
server_context server_context
.scope(async move { .clone()
// Run the next middleware / handler inside the server context .scope(async move {
let mut response = next.run(request).await; // Run the next middleware / handler inside the server context
let mut response = handler(State(server_context), request).await;
let server_context = FullstackContext::current().expect( let server_context = FullstackContext::current().expect(
"Server context should be available inside the server context scope", "Server context should be available inside the server context scope",
); );
// Get the extra response headers set during the handler and add them to the response // Get the extra response headers set during the handler and add them to the response
let headers = server_context.take_response_headers(); let headers = server_context.take_response_headers();
if let Some(headers) = headers { if let Some(headers) = headers {
response.headers_mut().extend(headers); response.headers_mut().extend(headers);
} }
response // it it accepts text/html (i.e., is a plain form post) and doesn't already have a
}) // Location set, then redirect to Referer
.await if accepts_html {
} if let Some(referrer) = referrer {
let has_location = response.headers().get(LOCATION).is_some();
if !has_location {
*response.status_mut() = StatusCode::FOUND;
response.headers_mut().insert(LOCATION, referrer);
}
}
}
router.route( response
self.path(), })
((self.handler)()) .await
.with_state(DioxusServerState {}) }).await;
.layer(axum::middleware::from_fn(server_context_middleware)),
match result {
Ok(response) => response,
Err(err) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::new(if cfg!(debug_assertions) {
format!("Server function panicked: {}", err)
} else {
"Internal Server Error".to_string()
}))
.unwrap(),
}
},
) )
} }
} }
@@ -120,18 +149,3 @@ impl inventory::Collect for ServerFunction {
&REGISTRY &REGISTRY
} }
} }
/// 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()
});
+4 -31
View File
@@ -21,12 +21,11 @@ use http::{request::Parts, HeaderMap, StatusCode};
use std::{ use std::{
collections::HashMap, collections::HashMap,
fmt::Write, fmt::Write,
future::Future,
iter::Peekable, iter::Peekable,
rc::Rc, rc::Rc,
sync::{Arc, RwLock}, sync::{Arc, RwLock},
}; };
use tokio::task::JoinHandle; use tokio_util::task::LocalPoolHandle;
use crate::StreamingMode; use crate::StreamingMode;
@@ -99,6 +98,7 @@ impl SsrRendererPool {
self: Arc<Self>, self: Arc<Self>,
parts: Parts, parts: Parts,
cfg: &ServeConfig, cfg: &ServeConfig,
rt: &LocalPoolHandle,
virtual_dom_factory: impl FnOnce() -> VirtualDom + Send + Sync + 'static, virtual_dom_factory: impl FnOnce() -> VirtualDom + Send + Sync + 'static,
) -> Result< ) -> Result<
( (
@@ -420,7 +420,8 @@ impl SsrRendererPool {
myself.renderers.write().unwrap().push(renderer); myself.renderers.write().unwrap().push(renderer);
}; };
let join_handle = Self::spawn_platform(create_render_future); // Spawn the render future onto the local pool
let join_handle = rt.spawn_pinned(create_render_future);
// Wait for the initial result which determines the status code // Wait for the initial result which determines the status code
let (status, headers) = initial_result_rx let (status, headers) = initial_result_rx
@@ -763,32 +764,4 @@ impl SsrRendererPool {
Ok(()) 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())
}
}
} }
+1 -1
View File
@@ -66,7 +66,7 @@ tower-http = { workspace = true, features = ["fs", "limit"], optional = true }
tower-layer = { version = "0.3.3", optional = true } tower-layer = { version = "0.3.3", optional = true }
# payloads # payloads
postcard = { features = ["alloc"], optional = true, workspace = true, default-features = true } postcard = { features = ["alloc", "use-std"], optional = true, workspace = true, default-features = true }
rmp-serde = { version = "1.3", optional = true } rmp-serde = { version = "1.3", optional = true }
async-stream = "0.3.6" async-stream = "0.3.6"
+7 -1
View File
@@ -129,10 +129,14 @@ impl ClientRequest {
} }
/// Creates a new reqwest request builder with the method, url, and headers set from this ClientRequest /// Creates a new reqwest request builder with the method, url, and headers set from this ClientRequest
///
/// Using this method attaches `X-Request-Client: dioxus` header to the request.
pub fn new_reqwest_request(&self) -> reqwest::RequestBuilder { pub fn new_reqwest_request(&self) -> reqwest::RequestBuilder {
let client = GLOBAL_REQUEST_CLIENT.get_or_init(Self::new_reqwest_client); let client = GLOBAL_REQUEST_CLIENT.get_or_init(Self::new_reqwest_client);
let mut req = client.request(self.method.clone(), self.url.clone()); let mut req = client
.request(self.method.clone(), self.url.clone())
.header("X-Request-Client", "dioxus");
for (key, value) in self.headers.iter() { for (key, value) in self.headers.iter() {
req = req.header(key, value); req = req.header(key, value);
@@ -141,6 +145,7 @@ impl ClientRequest {
req req
} }
/// Using this method attaches `X-Request-Client-Dioxus` header to the request.
#[cfg(feature = "web")] #[cfg(feature = "web")]
pub fn new_gloo_request(&self) -> gloo_net::http::RequestBuilder { pub fn new_gloo_request(&self) -> gloo_net::http::RequestBuilder {
let mut builder = gloo_net::http::RequestBuilder::new( let mut builder = gloo_net::http::RequestBuilder::new(
@@ -155,6 +160,7 @@ impl ClientRequest {
) )
.as_str(), .as_str(),
) )
.header("X-Request-Client", "dioxus")
.method(self.method.clone()); .method(self.method.clone());
for (key, value) in self.headers.iter() { for (key, value) in self.headers.iter() {
+31 -17
View File
@@ -7,8 +7,13 @@ use serde::{de::DeserializeOwned, Serialize};
pub trait Encoding { pub trait Encoding {
fn content_type() -> &'static str; fn content_type() -> &'static str;
fn stream_content_type() -> &'static str; fn stream_content_type() -> &'static str;
fn to_bytes(data: impl Serialize) -> Option<Bytes>; fn to_bytes(data: impl Serialize) -> Option<Bytes> {
fn from_bytes<O: DeserializeOwned>(bytes: Bytes) -> Option<O>; let mut buf = Vec::new();
Self::encode(data, &mut buf)?;
Some(buf.into())
}
fn encode(data: impl Serialize, buf: &mut Vec<u8>) -> Option<usize>;
fn decode<O: DeserializeOwned>(bytes: Bytes) -> Option<O>;
} }
pub struct JsonEncoding; pub struct JsonEncoding;
@@ -19,11 +24,14 @@ impl Encoding for JsonEncoding {
fn stream_content_type() -> &'static str { fn stream_content_type() -> &'static str {
"application/stream+json" "application/stream+json"
} }
fn to_bytes(data: impl Serialize) -> Option<Bytes> {
serde_json::to_vec(&data).ok().map(Into::into) fn encode(data: impl Serialize, mut buf: &mut Vec<u8>) -> Option<usize> {
let len = buf.len();
serde_json::to_writer(&mut buf, &data).ok()?;
Some(buf.len() - len)
} }
fn from_bytes<O: DeserializeOwned>(bytes: Bytes) -> Option<O> { fn decode<O: DeserializeOwned>(bytes: Bytes) -> Option<O> {
serde_json::from_slice(&bytes).ok() serde_json::from_slice(&bytes).ok()
} }
} }
@@ -36,14 +44,15 @@ impl Encoding for CborEncoding {
fn stream_content_type() -> &'static str { fn stream_content_type() -> &'static str {
"application/stream+cbor" "application/stream+cbor"
} }
fn to_bytes(data: impl Serialize) -> Option<Bytes> {
let mut buf = Vec::new(); fn decode<O: DeserializeOwned>(bytes: Bytes) -> Option<O> {
ciborium::into_writer(&data, &mut buf).ok()?; ciborium::de::from_reader(bytes.as_ref()).ok()
Some(buf.into())
} }
fn from_bytes<O: DeserializeOwned>(bytes: Bytes) -> Option<O> { fn encode(data: impl Serialize, mut buf: &mut Vec<u8>) -> Option<usize> {
ciborium::de::from_reader(bytes.as_ref()).ok() let len = buf.len();
ciborium::into_writer(&data, &mut buf).ok()?;
Some(buf.len() - len)
} }
} }
@@ -57,11 +66,14 @@ impl Encoding for PostcardEncoding {
fn stream_content_type() -> &'static str { fn stream_content_type() -> &'static str {
"application/stream+postcard" "application/stream+postcard"
} }
fn to_bytes(data: impl Serialize) -> Option<Bytes> {
postcard::to_allocvec(&data).ok().map(Into::into) fn encode(data: impl Serialize, mut buf: &mut Vec<u8>) -> Option<usize> {
let len = buf.len();
postcard::to_io(&data, &mut buf).ok()?;
Some(buf.len() - len)
} }
fn from_bytes<O: DeserializeOwned>(bytes: Bytes) -> Option<O> { fn decode<O: DeserializeOwned>(bytes: Bytes) -> Option<O> {
postcard::from_bytes(bytes.as_ref()).ok() postcard::from_bytes(bytes.as_ref()).ok()
} }
} }
@@ -76,11 +88,13 @@ impl Encoding for MsgPackEncoding {
fn stream_content_type() -> &'static str { fn stream_content_type() -> &'static str {
"application/stream+msgpack" "application/stream+msgpack"
} }
fn to_bytes(data: impl Serialize) -> Option<Bytes> { fn encode(data: impl Serialize, buf: &mut Vec<u8>) -> Option<usize> {
rmp_serde::to_vec(&data).ok().map(Into::into) let len = buf.len();
rmp_serde::encode::write(buf, &data).ok()?;
Some(buf.len() - len)
} }
fn from_bytes<O: DeserializeOwned>(bytes: Bytes) -> Option<O> { fn decode<O: DeserializeOwned>(bytes: Bytes) -> Option<O> {
rmp_serde::from_slice(&bytes).ok() rmp_serde::from_slice(&bytes).ok()
} }
} }
+30 -12
View File
@@ -88,6 +88,20 @@ impl<T: Send + Sync + 'static> Lazy<T> {
} }
Ok(()) Ok(())
} }
/// Get a reference to the value of the `Lazy` instance. This will block the current thread if the
/// value is not yet initialized.
pub fn get(&self) -> &T {
if self.constructor.is_none() {
return self.value.get().expect("Lazy value is not initialized. Make sure to call `initialize` before dereferencing.");
};
if self.value.get().is_none() {
self.initialize().expect("Failed to initialize lazy value");
}
self.value.get().unwrap()
}
} }
impl<T: Send + Sync + 'static> Default for Lazy<T> { impl<T: Send + Sync + 'static> Default for Lazy<T> {
@@ -100,15 +114,13 @@ impl<T: Send + Sync + 'static> std::ops::Deref for Lazy<T> {
type Target = T; type Target = T;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
if self.constructor.is_none() { self.get()
return self.value.get().expect("Lazy value is not initialized. Make sure to call `initialize` before dereferencing."); }
}; }
if self.value.get().is_none() { impl<T: std::fmt::Debug + Send + Sync + 'static> std::fmt::Debug for Lazy<T> {
self.initialize().expect("Failed to initialize lazy value"); fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
} f.debug_struct("Lazy").field("value", self.get()).finish()
self.value.get().unwrap()
} }
} }
@@ -129,10 +141,16 @@ where
{ {
let ptr: F = unsafe { std::mem::zeroed() }; let ptr: F = unsafe { std::mem::zeroed() };
let fut = ptr(); let fut = ptr();
let rt = tokio::runtime::Handle::current(); return std::thread::spawn(move || {
return std::thread::spawn(move || rt.block_on(fut).map_err(|e| e.into())) tokio::runtime::Builder::new_current_thread()
.join() .enable_all()
.unwrap(); .build()
.unwrap()
.block_on(fut)
.map_err(|e| e.into())
})
.join()
.unwrap();
} }
// todo: technically we can support constructors in wasm with the same tricks inventory uses with `__wasm_call_ctors` // todo: technically we can support constructors in wasm with the same tricks inventory uses with `__wasm_call_ctors`
+3
View File
@@ -107,6 +107,9 @@ pub mod payloads {
pub mod header; pub mod header;
pub use header::*; pub use header::*;
pub mod query;
pub use query::*;
#[cfg(feature = "ws")] #[cfg(feature = "ws")]
pub mod websocket; pub mod websocket;
#[cfg(feature = "ws")] #[cfg(feature = "ws")]
+61 -45
View File
@@ -42,7 +42,7 @@ use crate::{
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum_core::extract::{FromRequest, Request}; use axum_core::extract::{FromRequest, Request};
use bytes::Bytes; use bytes::Bytes;
use dioxus_fullstack_core::{DioxusServerState, RequestError}; use dioxus_fullstack_core::RequestError;
use http::StatusCode; use http::StatusCode;
use send_wrapper::SendWrapper; use send_wrapper::SendWrapper;
use serde::Serialize; use serde::Serialize;
@@ -172,7 +172,7 @@ pub mod req_to {
impl<T, O, R> EncodeRequest<T, O, R> for &&&&&&&&&ServerFnEncoder<T, O> impl<T, O, R> EncodeRequest<T, O, R> for &&&&&&&&&ServerFnEncoder<T, O>
where where
T: 'static, T: 'static,
O: FromRequest<DioxusServerState> + IntoRequest<R>, O: IntoRequest<R>,
{ {
type VerifyEncode = EncodeIsVerified; type VerifyEncode = EncodeIsVerified;
fn fetch_client( fn fetch_client(
@@ -214,6 +214,8 @@ pub mod req_to {
pub use decode_ok::*; pub use decode_ok::*;
mod decode_ok { mod decode_ok {
use crate::{CantDecode, DecodeIsVerified};
use super::*; use super::*;
/// Convert the reqwest response into the desired type, in place. /// Convert the reqwest response into the desired type, in place.
@@ -222,13 +224,16 @@ mod decode_ok {
/// This is because FromResponse types are more specialized and can handle things like websockets and files. /// This is because FromResponse types are more specialized and can handle things like websockets and files.
/// DeserializeOwned types are more general and can handle things like JSON responses. /// DeserializeOwned types are more general and can handle things like JSON responses.
pub trait RequestDecodeResult<T, R> { pub trait RequestDecodeResult<T, R> {
type VerifyDecode;
fn decode_client_response( fn decode_client_response(
&self, &self,
res: Result<R, RequestError>, res: Result<R, RequestError>,
) -> impl Future<Output = Result<Result<T, ServerFnError>, RequestError>> + Send; ) -> impl Future<Output = Result<Result<T, ServerFnError>, RequestError>> + Send;
fn verify_can_deserialize(&self) -> Self::VerifyDecode;
} }
impl<T: FromResponse<R>, E, R> RequestDecodeResult<T, R> for &&&ServerFnDecoder<Result<T, E>> { impl<T: FromResponse<R>, E, R> RequestDecodeResult<T, R> for &&&ServerFnDecoder<Result<T, E>> {
type VerifyDecode = DecodeIsVerified;
fn decode_client_response( fn decode_client_response(
&self, &self,
res: Result<R, RequestError>, res: Result<R, RequestError>,
@@ -240,11 +245,15 @@ mod decode_ok {
} }
}) })
} }
fn verify_can_deserialize(&self) -> Self::VerifyDecode {
DecodeIsVerified
}
} }
impl<T: DeserializeOwned, E> RequestDecodeResult<T, ClientResponse> impl<T: DeserializeOwned, E> RequestDecodeResult<T, ClientResponse>
for &&ServerFnDecoder<Result<T, E>> for &&ServerFnDecoder<Result<T, E>>
{ {
type VerifyDecode = DecodeIsVerified;
fn decode_client_response( fn decode_client_response(
&self, &self,
res: Result<ClientResponse, RequestError>, res: Result<ClientResponse, RequestError>,
@@ -304,6 +313,24 @@ mod decode_ok {
} }
}) })
} }
fn verify_can_deserialize(&self) -> Self::VerifyDecode {
DecodeIsVerified
}
}
impl<T, R, E> RequestDecodeResult<T, R> for &ServerFnDecoder<Result<T, E>> {
type VerifyDecode = CantDecode;
fn decode_client_response(
&self,
_res: Result<R, RequestError>,
) -> impl Future<Output = Result<Result<T, ServerFnError>, RequestError>> + Send {
async move { unimplemented!() }
}
fn verify_can_deserialize(&self) -> Self::VerifyDecode {
CantDecode
}
} }
pub trait RequestDecodeErr<T, E> { pub trait RequestDecodeErr<T, E> {
@@ -460,33 +487,34 @@ pub use req_from::*;
pub mod req_from { pub mod req_from {
use super::*; use super::*;
use axum::{extract::FromRequestParts, response::Response}; use axum::{extract::FromRequestParts, response::Response};
use dioxus_fullstack_core::FullstackContext;
pub trait ExtractRequest<In, Out, H, M = ()> { pub trait ExtractRequest<In, Out, H, M = ()> {
fn extract_axum( fn extract_axum(
&self, &self,
state: DioxusServerState, state: FullstackContext,
request: Request, request: Request,
map: fn(In) -> Out, map: fn(In) -> Out,
) -> impl Future<Output = Result<(H, Out), Response>> + 'static; ) -> impl Future<Output = Result<(Out, H), Response>> + 'static;
} }
/// When you're extracting entirely on the server, we need to reject client-consuning request bodies /// When you're extracting entirely on the server, we need to reject client-consuning request bodies
/// This sits above priority in the combined headers on server / body on client case. /// This sits above priority in the combined headers on server / body on client case.
impl<In, M, H> ExtractRequest<In, (), H, M> for &&&&&&&&&&&ServerFnEncoder<In, ()> impl<In, M, H> ExtractRequest<In, (), H, M> for &&&&&&&&&&&ServerFnEncoder<In, ()>
where where
H: FromRequest<DioxusServerState, M> + 'static, H: FromRequest<FullstackContext, M> + 'static,
{ {
fn extract_axum( fn extract_axum(
&self, &self,
state: DioxusServerState, state: FullstackContext,
request: Request, request: Request,
_map: fn(In) -> (), _map: fn(In) -> (),
) -> impl Future<Output = Result<(H, ()), Response>> + 'static { ) -> impl Future<Output = Result<((), H), Response>> + 'static {
async move { async move {
H::from_request(request, &state) H::from_request(request, &state)
.await .await
.map_err(|e| e.into_response()) .map_err(|e| e.into_response())
.map(|out| (out, ())) .map(|out| ((), out))
} }
} }
} }
@@ -496,14 +524,14 @@ pub mod req_from {
where where
In: DeserializeOwned + 'static, In: DeserializeOwned + 'static,
Out: 'static, Out: 'static,
H: FromRequestParts<DioxusServerState>, H: FromRequestParts<FullstackContext>,
{ {
fn extract_axum( fn extract_axum(
&self, &self,
_state: DioxusServerState, _state: FullstackContext,
request: Request, request: Request,
map: fn(In) -> Out, map: fn(In) -> Out,
) -> impl Future<Output = Result<(H, Out), Response>> + 'static { ) -> impl Future<Output = Result<(Out, H), Response>> + 'static {
async move { async move {
let (mut parts, body) = request.into_parts(); let (mut parts, body) = request.into_parts();
let headers = H::from_request_parts(&mut parts, &_state) let headers = H::from_request_parts(&mut parts, &_state)
@@ -525,7 +553,7 @@ pub mod req_from {
.map_err(|e| ServerFnError::from(e).into_response()) .map_err(|e| ServerFnError::from(e).into_response())
.unwrap(); .unwrap();
Ok((headers, out)) Ok((out, headers))
} }
} }
} }
@@ -533,15 +561,15 @@ pub mod req_from {
/// We skip the BodySerialize wrapper and just go for the output type directly. /// We skip the BodySerialize wrapper and just go for the output type directly.
impl<In, Out, M, H> ExtractRequest<In, Out, H, M> for &&&&&&&&&ServerFnEncoder<In, Out> impl<In, Out, M, H> ExtractRequest<In, Out, H, M> for &&&&&&&&&ServerFnEncoder<In, Out>
where where
Out: FromRequest<DioxusServerState, M> + 'static, Out: FromRequest<FullstackContext, M> + 'static,
H: FromRequestParts<DioxusServerState>, H: FromRequestParts<FullstackContext>,
{ {
fn extract_axum( fn extract_axum(
&self, &self,
state: DioxusServerState, state: FullstackContext,
request: Request, request: Request,
_map: fn(In) -> Out, _map: fn(In) -> Out,
) -> impl Future<Output = Result<(H, Out), Response>> + 'static { ) -> impl Future<Output = Result<(Out, H), Response>> + 'static {
async move { async move {
let (mut parts, body) = request.into_parts(); let (mut parts, body) = request.into_parts();
let headers = H::from_request_parts(&mut parts, &state) let headers = H::from_request_parts(&mut parts, &state)
@@ -554,7 +582,7 @@ pub mod req_from {
.await .await
.map_err(|e| e.into_response()); .map_err(|e| e.into_response());
res.map(|out| (headers, out)) res.map(|out| (out, headers))
} }
} }
} }
@@ -616,7 +644,7 @@ mod resp {
#[allow(clippy::result_large_err)] #[allow(clippy::result_large_err)]
pub trait MakeAxumError<E> { pub trait MakeAxumError<E> {
fn make_axum_error(self, result: Result<Response, E>) -> Result<Response, Response>; fn make_axum_error(self, result: Result<Response, E>) -> Response;
} }
/// Get the status code from the error type if possible. /// Get the status code from the error type if possible.
@@ -639,9 +667,9 @@ mod resp {
where where
E: AsStatusCode + From<ServerFnError> + Serialize + DeserializeOwned + Display, E: AsStatusCode + From<ServerFnError> + Serialize + DeserializeOwned + Display,
{ {
fn make_axum_error(self, result: Result<Response, E>) -> Result<Response, Response> { fn make_axum_error(self, result: Result<Response, E>) -> Response {
match result { match result {
Ok(res) => Ok(res), Ok(res) => res,
Err(err) => { Err(err) => {
let status_code = err.as_status_code(); let status_code = err.as_status_code();
let err = ErrorPayload { let err = ErrorPayload {
@@ -656,19 +684,16 @@ mod resp {
HeaderValue::from_static("application/json"), HeaderValue::from_static("application/json"),
); );
*resp.status_mut() = status_code; *resp.status_mut() = status_code;
Err(resp) resp
} }
} }
} }
} }
impl<T> MakeAxumError<CapturedError> for &&ServerFnDecoder<Result<T, CapturedError>> { impl<T> MakeAxumError<CapturedError> for &&ServerFnDecoder<Result<T, CapturedError>> {
fn make_axum_error( fn make_axum_error(self, result: Result<Response, CapturedError>) -> Response {
self,
result: Result<Response, CapturedError>,
) -> Result<Response, Response> {
match result { match result {
Ok(res) => Ok(res), Ok(res) => res,
// Optimize the case where we have sole ownership of the error // Optimize the case where we have sole ownership of the error
Err(errr) if errr._strong_count() == 1 => { Err(errr) if errr._strong_count() == 1 => {
@@ -721,19 +746,16 @@ mod resp {
HeaderValue::from_static("application/json"), HeaderValue::from_static("application/json"),
); );
*resp.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; *resp.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
Err(resp) resp
} }
} }
} }
} }
impl<T> MakeAxumError<anyhow::Error> for &&ServerFnDecoder<Result<T, anyhow::Error>> { impl<T> MakeAxumError<anyhow::Error> for &&ServerFnDecoder<Result<T, anyhow::Error>> {
fn make_axum_error( fn make_axum_error(self, result: Result<Response, anyhow::Error>) -> Response {
self,
result: Result<Response, anyhow::Error>,
) -> Result<Response, Response> {
match result { match result {
Ok(res) => Ok(res), Ok(res) => res,
Err(errr) => { Err(errr) => {
// The `WithHttpError` trait emits ServerFnErrors so we can downcast them here // The `WithHttpError` trait emits ServerFnErrors so we can downcast them here
// to create richer responses. // to create richer responses.
@@ -775,19 +797,16 @@ mod resp {
HeaderValue::from_static("application/json"), HeaderValue::from_static("application/json"),
); );
*resp.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; *resp.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
Err(resp) resp
} }
} }
} }
} }
impl<T> MakeAxumError<StatusCode> for &&ServerFnDecoder<Result<T, StatusCode>> { impl<T> MakeAxumError<StatusCode> for &&ServerFnDecoder<Result<T, StatusCode>> {
fn make_axum_error( fn make_axum_error(self, result: Result<Response, StatusCode>) -> Response {
self,
result: Result<Response, StatusCode>,
) -> Result<Response, Response> {
match result { match result {
Ok(resp) => Ok(resp), Ok(resp) => resp,
Err(status) => { Err(status) => {
let body = serde_json::to_string(&ErrorPayload::<()> { let body = serde_json::to_string(&ErrorPayload::<()> {
code: status.as_u16(), code: status.as_u16(),
@@ -801,19 +820,16 @@ mod resp {
HeaderValue::from_static("application/json"), HeaderValue::from_static("application/json"),
); );
*resp.status_mut() = status; *resp.status_mut() = status;
Err(resp) resp
} }
} }
} }
} }
impl<T> MakeAxumError<HttpError> for &ServerFnDecoder<Result<T, HttpError>> { impl<T> MakeAxumError<HttpError> for &ServerFnDecoder<Result<T, HttpError>> {
fn make_axum_error( fn make_axum_error(self, result: Result<Response, HttpError>) -> Response {
self,
result: Result<Response, HttpError>,
) -> Result<Response, Response> {
match result { match result {
Ok(resp) => Ok(resp), Ok(resp) => resp,
Err(http_err) => { Err(http_err) => {
let body = serde_json::to_string(&ErrorPayload::<()> { let body = serde_json::to_string(&ErrorPayload::<()> {
code: http_err.status.as_u16(), code: http_err.status.as_u16(),
@@ -829,7 +845,7 @@ mod resp {
HeaderValue::from_static("application/json"), HeaderValue::from_static("application/json"),
); );
*resp.status_mut() = http_err.status; *resp.status_mut() = http_err.status;
Err(resp) resp
} }
} }
} }
@@ -18,7 +18,7 @@ impl<T: From<String>> FromResponse for Html<T> {
impl<T> IntoRequest for Json<T> impl<T> IntoRequest for Json<T>
where where
T: Serialize + 'static, T: Serialize + 'static + DeserializeOwned,
{ {
fn into_request(self, request: ClientRequest) -> impl Future<Output = ClientResult> + 'static { fn into_request(self, request: ClientRequest) -> impl Future<Output = ClientResult> + 'static {
async move { request.send_json(&self.0).await } async move { request.send_json(&self.0).await }
+1 -1
View File
@@ -4,7 +4,7 @@ pub use axum::extract::Form;
impl<T> IntoRequest for Form<T> impl<T> IntoRequest for Form<T>
where where
T: Serialize + 'static, T: Serialize + 'static + DeserializeOwned,
{ {
fn into_request(self, req: ClientRequest) -> impl Future<Output = ClientResult> + 'static { fn into_request(self, req: ClientRequest) -> impl Future<Output = ClientResult> + 'static {
async move { req.send_form(&self.0).await } async move { req.send_form(&self.0).await }
+34
View File
@@ -0,0 +1,34 @@
use std::ops::Deref;
use crate::ServerFnError;
use axum::extract::FromRequestParts;
use http::request::Parts;
use serde::de::DeserializeOwned;
/// An extractor that deserializes query parameters into the given type `T`.
///
/// This uses `serde_qs` under the hood to support complex query parameter structures.
#[derive(Debug, Clone, Copy, Default)]
pub struct Query<T>(pub T);
impl<T, S> FromRequestParts<S> for Query<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = ServerFnError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let inner: T = serde_qs::from_str(parts.uri.query().unwrap_or_default())
.map_err(|e| ServerFnError::Deserialization(e.to_string()))?;
Ok(Self(inner))
}
}
impl<T> Deref for Query<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
+198 -22
View File
@@ -9,18 +9,83 @@ use axum_core::response::IntoResponse;
use bytes::Bytes; use bytes::Bytes;
use dioxus_fullstack_core::{HttpError, RequestError}; use dioxus_fullstack_core::{HttpError, RequestError};
use futures::{Stream, StreamExt}; use futures::{Stream, StreamExt};
#[cfg(feature = "server")]
use futures_channel::mpsc::UnboundedSender;
use headers::{ContentType, Header}; use headers::{ContentType, Header};
use send_wrapper::SendWrapper; use send_wrapper::SendWrapper;
use serde::{de::DeserializeOwned, Serialize}; use serde::{de::DeserializeOwned, Serialize};
use std::{future::Future, marker::PhantomData, pin::Pin}; use std::{future::Future, marker::PhantomData, pin::Pin};
/// A stream of text data.
///
/// # Chunking
///
/// Note that strings sent by the server might not arrive in the same chunking as they were sent.
///
/// This is because the underlying transport layer (HTTP/2 or HTTP/3) may choose to split or combine
/// chunks for efficiency.
///
/// If you need to preserve individual string boundaries, consider using `ChunkedTextStream` or another
/// encoding that preserves chunk boundaries.
pub type TextStream = Streaming<String>; pub type TextStream = Streaming<String>;
/// A stream of binary data.
///
/// # Chunking
///
/// Note that bytes sent by the server might not arrive in the same chunking as they were sent.
/// This is because the underlying transport layer (HTTP/2 or HTTP/3) may choose to split or combine
/// chunks for efficiency.
///
/// If you need to preserve individual byte boundaries, consider using `ChunkedByteStream` or another
/// encoding that preserves chunk boundaries.
pub type ByteStream = Streaming<Bytes>; pub type ByteStream = Streaming<Bytes>;
/// A stream of JSON-encoded data.
///
/// # Chunking
///
/// Normally, it's not possible to stream JSON over HTTP because browsers are free to re-chunk
/// data as they see fit. However, this implementation manually frames each JSON as if it were an unmasked
/// websocket message.
///
/// If you need to send a stream of JSON data without framing, consider using TextStream instead and
/// manually handling JSON buffering.
pub type JsonStream<T> = Streaming<T, JsonEncoding>; pub type JsonStream<T> = Streaming<T, JsonEncoding>;
/// A stream of Cbor-encoded data.
///
/// # Chunking
///
/// Normally, it's not possible to stream JSON over HTTP because browsers are free to re-chunk
/// data as they see fit. However, this implementation manually frames each item as if it were an unmasked
/// websocket message.
pub type CborStream<T> = Streaming<T, CborEncoding>; pub type CborStream<T> = Streaming<T, CborEncoding>;
/// A stream of manually chunked binary data.
///
/// This encoding preserves chunk boundaries by framing each chunk with its length, using Websocket
/// Framing.
pub type ChunkedByteStream = Streaming<Bytes, CborEncoding>;
/// A stream of manually chunked text data.
///
/// This encoding preserves chunk boundaries by framing each chunk with its length, using Websocket
/// Framing.
pub type ChunkedTextStream = Streaming<String, CborEncoding>;
/// A streaming payload. /// A streaming payload.
/// ///
/// ## Frames and Chunking
///
/// The streaming payload sends and receives data in discrete chunks or "frames". The size is converted
/// to hex and sent before each chunk, followed by a CRLF, the chunk data, and another CRLF.
///
/// This mimics actual HTTP chunked transfer encoding, but allows us to define our own framing
/// protocol on top of it.
///
/// Arbitrary bytes can be encoded between these frames, but the frames do come with some overhead.
///
/// ## Browser Support for Streaming Input /// ## Browser Support for Streaming Input
/// ///
/// Browser fetch requests do not currently support full request duplexing, which /// Browser fetch requests do not currently support full request duplexing, which
@@ -58,9 +123,9 @@ impl<T: 'static + Send, E> Streaming<T, E> {
pub fn new(value: impl Stream<Item = T> + Send + 'static) -> Self { pub fn new(value: impl Stream<Item = T> + Send + 'static) -> Self {
// Box and pin the incoming stream and store as a trait object // Box and pin the incoming stream and store as a trait object
Self { Self {
output_stream: Box::pin(futures::stream::empty()) as _,
input_stream: Box::pin(value.map(|item| Ok(item))) input_stream: Box::pin(value.map(|item| Ok(item)))
as Pin<Box<dyn Stream<Item = Result<T, StreamingError>> + Send>>, as Pin<Box<dyn Stream<Item = Result<T, StreamingError>> + Send>>,
output_stream: Box::pin(futures::stream::empty()) as _,
encoding: PhantomData, encoding: PhantomData,
} }
} }
@@ -69,9 +134,7 @@ impl<T: 'static + Send, E> Streaming<T, E> {
/// ///
/// The callback is provided an `UnboundedSender` that can be used to send items to the stream. /// The callback is provided an `UnboundedSender` that can be used to send items to the stream.
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub fn spawn<F>( pub fn spawn<F>(callback: impl FnOnce(UnboundedSender<T>) -> F + Send + 'static) -> Self
callback: impl FnOnce(futures_channel::mpsc::UnboundedSender<T>) -> F + Send + 'static,
) -> Self
where where
F: Future<Output = ()> + 'static, F: Future<Output = ()> + 'static,
T: Send, T: Send,
@@ -92,6 +155,17 @@ impl<T: 'static + Send, E> Streaming<T, E> {
pub fn into_inner(self) -> impl Stream<Item = Result<T, StreamingError>> + Send { pub fn into_inner(self) -> impl Stream<Item = Result<T, StreamingError>> + Send {
self.input_stream self.input_stream
} }
/// Creates a streaming payload from an existing stream of bytes.
///
/// This uses the internal framing mechanism to decode the stream into items of type `T`.
fn from_bytes(stream: impl Stream<Item = Result<T, StreamingError>> + Send + 'static) -> Self {
Self {
input_stream: Box::pin(stream),
output_stream: Box::pin(futures::stream::empty()) as _,
encoding: PhantomData,
}
}
} }
impl<S, U> From<S> for TextStream impl<S, U> From<S> for TextStream
@@ -100,11 +174,7 @@ where
U: Into<String>, U: Into<String>,
{ {
fn from(value: S) -> Self { fn from(value: S) -> Self {
Self { Self::new(value.map(|data| data.into()))
input_stream: Box::pin(value.map(|data| Ok(data.into()))),
output_stream: Box::pin(futures::stream::empty()) as _,
encoding: PhantomData,
}
} }
} }
@@ -129,11 +199,7 @@ where
E: Encoding, E: Encoding,
{ {
fn from(value: S) -> Self { fn from(value: S) -> Self {
Self { Self::from_bytes(value.map(|data| Ok(data.into())))
input_stream: Box::pin(value.map(|data| Ok(data.into()))),
output_stream: Box::pin(futures::stream::empty()) as _,
encoding: PhantomData,
}
} }
} }
@@ -158,7 +224,7 @@ impl IntoResponse for Streaming<Bytes> {
impl<T: DeserializeOwned + Serialize + 'static, E: Encoding> IntoResponse for Streaming<T, E> { impl<T: DeserializeOwned + Serialize + 'static, E: Encoding> IntoResponse for Streaming<T, E> {
fn into_response(self) -> axum_core::response::Response { fn into_response(self) -> axum_core::response::Response {
let res = self.input_stream.map(|r| match r { let res = self.input_stream.map(|r| match r {
Ok(res) => match E::to_bytes(&res) { Ok(res) => match encode_stream_frame::<T, E>(res) {
Some(bytes) => Ok(bytes), Some(bytes) => Ok(bytes),
None => Err(StreamingError::Failed), None => Err(StreamingError::Failed),
}, },
@@ -218,7 +284,7 @@ impl<T: DeserializeOwned + Serialize + 'static + Send, E: Encoding> FromResponse
SendWrapper::new(async move { SendWrapper::new(async move {
let client_stream = Box::pin(SendWrapper::new(res.bytes_stream().map( let client_stream = Box::pin(SendWrapper::new(res.bytes_stream().map(
|byte| match byte { |byte| match byte {
Ok(bytes) => match E::from_bytes(bytes) { Ok(bytes) => match decode_stream_frame::<T, E>(bytes) {
Some(res) => Ok(res), Some(res) => Ok(res),
None => Err(StreamingError::Decoding), None => Err(StreamingError::Decoding),
}, },
@@ -330,7 +396,7 @@ impl<T: DeserializeOwned + Serialize + 'static + Send, E: Encoding, S> FromReque
Ok(Self { Ok(Self {
input_stream: Box::pin(futures::stream::empty()), input_stream: Box::pin(futures::stream::empty()),
output_stream: Box::pin(stream.map(|byte| match byte { output_stream: Box::pin(stream.map(|byte| match byte {
Ok(bytes) => match E::from_bytes(bytes) { Ok(bytes) => match decode_stream_frame::<T, E>(bytes) {
Some(res) => Ok(res), Some(res) => Ok(res),
None => Err(StreamingError::Decoding), None => Err(StreamingError::Decoding),
}, },
@@ -380,11 +446,11 @@ impl<T: DeserializeOwned + Serialize + 'static + Send, E: Encoding> IntoRequest
async move { async move {
builder builder
.header("Content-Type", E::stream_content_type())? .header("Content-Type", E::stream_content_type())?
.send_body_stream( .send_body_stream(self.input_stream.map(|r| {
self.input_stream.map(|r| { r.and_then(|item| {
r.and_then(|item| E::to_bytes(&item).ok_or(StreamingError::Failed)) encode_stream_frame::<T, E>(item).ok_or(StreamingError::Failed)
}), })
) }))
.await .await
} }
} }
@@ -403,3 +469,113 @@ impl<T, E: Encoding> std::fmt::Debug for Streaming<T, E> {
.finish() .finish()
} }
} }
/// This function encodes a single frame of a streaming payload using the specified encoding.
///
/// The resulting `Bytes` object is encoded as a websocket frame, so you can send it over a streaming
/// HTTP response or even a websocket connection.
///
/// Note that the packet is not masked, as it is assumed to be sent over a trusted connection.
pub fn encode_stream_frame<T: Serialize, E: Encoding>(data: T) -> Option<Bytes> {
// We use full advantage of `BytesMut` here, writing a maximally full frame and then shrinking it
// down to size at the end.
//
// Also note we don't do any masking over this data since it's not going over an untrusted
// network like a websocket would.
//
// We allocate 10 extra bytes to account for framing overhead, which we'll shrink after
let mut bytes = vec![0u8; 10];
E::encode(data, &mut bytes)?;
let len = (bytes.len() - 10) as u64;
let opcode = 0x82; // FIN + binary opcode
// Write the header directly into the allocated space.
let offset = if len <= 125 {
bytes[8] = opcode;
bytes[9] = len as u8;
8
} else if len <= u16::MAX as u64 {
bytes[6] = opcode;
bytes[7] = 126;
let len_bytes = (len as u16).to_be_bytes();
bytes[8] = len_bytes[0];
bytes[9] = len_bytes[1];
6
} else {
bytes[0] = opcode;
bytes[1] = 127;
bytes[2..10].copy_from_slice(&len.to_be_bytes());
0
};
// Shrink down to the actual used size - is zero copy!
Some(Bytes::from(bytes).slice(offset..))
}
/// Decode a websocket-framed streaming payload produced by [`encode_stream_frame`].
///
/// This function returns `None` if the frame is invalid or cannot be decoded.
///
/// It cannot handle masked frames, as those are not produced by our encoding function.
pub fn decode_stream_frame<T, E>(frame: Bytes) -> Option<T>
where
E: Encoding,
T: DeserializeOwned,
{
let data = frame.as_ref();
if data.len() < 2 {
return None;
}
let first = data[0];
let second = data[1];
// Require FIN with binary opcode and no RSV bits
let fin = first & 0x80 != 0;
let opcode = first & 0x0F;
let rsv = first & 0x70;
if !fin || opcode != 0x02 || rsv != 0 {
return None;
}
// Mask bit must be zero for our framing
if second & 0x80 != 0 {
return None;
}
let mut offset = 2usize;
let mut payload_len = (second & 0x7F) as usize;
if payload_len == 126 {
if data.len() < offset + 2 {
return None;
}
payload_len = u16::from_be_bytes([data[offset], data[offset + 1]]) as usize;
offset += 2;
} else if payload_len == 127 {
if data.len() < offset + 8 {
return None;
}
let mut len_bytes = [0u8; 8];
len_bytes.copy_from_slice(&data[offset..offset + 8]);
let len_u64 = u64::from_be_bytes(len_bytes);
if len_u64 > usize::MAX as u64 {
return None;
}
payload_len = len_u64 as usize;
offset += 8;
}
if data.len() < offset + payload_len {
return None;
}
E::decode(frame.slice(offset..offset + payload_len))
}
+4 -5
View File
@@ -307,11 +307,11 @@ impl<I, O, E> Websocket<I, O, E> {
match msg { match msg {
Message::Text(text) => { Message::Text(text) => {
let e: O = let e: O =
E::from_bytes(text.into()).ok_or_else(WebsocketError::deserialization)?; E::decode(text.into()).ok_or_else(WebsocketError::deserialization)?;
return Ok(e); return Ok(e);
} }
Message::Binary(bytes) => { Message::Binary(bytes) => {
let e: O = E::from_bytes(bytes).ok_or_else(WebsocketError::deserialization)?; let e: O = E::decode(bytes).ok_or_else(WebsocketError::deserialization)?;
return Ok(e); return Ok(e);
} }
Message::Close { code, reason } => { Message::Close { code, reason } => {
@@ -707,13 +707,12 @@ impl<In: DeserializeOwned, Out: Serialize, E: Encoding> TypedWebsocket<In, Out,
match res { match res {
Ok(res) => match res { Ok(res) => match res {
AxumMessage::Text(utf8_bytes) => { AxumMessage::Text(utf8_bytes) => {
let e: In = E::from_bytes(utf8_bytes.into()) let e: In = E::decode(utf8_bytes.into())
.ok_or_else(WebsocketError::deserialization)?; .ok_or_else(WebsocketError::deserialization)?;
return Ok(e); return Ok(e);
} }
AxumMessage::Binary(bytes) => { AxumMessage::Binary(bytes) => {
let e: In = let e: In = E::decode(bytes).ok_or_else(WebsocketError::deserialization)?;
E::from_bytes(bytes).ok_or_else(WebsocketError::deserialization)?;
return Ok(e); return Ok(e);
} }
+37 -4
View File
@@ -34,7 +34,7 @@ pub trait IntoRequest<R = ClientResponse>: Sized {
impl<A, R> IntoRequest<R> for (A,) impl<A, R> IntoRequest<R> for (A,)
where where
A: IntoRequest<R> + 'static, A: IntoRequest<R> + 'static + Send,
{ {
fn into_request( fn into_request(
self, self,
@@ -157,9 +157,20 @@ impl<T, E> AssertIsResult for Result<T, E> {}
#[doc(hidden)] #[doc(hidden)]
pub fn assert_is_result<T: AssertIsResult>() {} pub fn assert_is_result<T: AssertIsResult>() {}
#[diagnostic::on_unimplemented( #[diagnostic::on_unimplemented(message = r#"❌ Invalid Arguments to ServerFn ❌
message = "The arguments to the server function must either be a single `impl FromRequest + IntoRequest` argument, or multiple `DeserializeOwned` arguments."
)] The arguments to the server function must be either:
- a single `impl FromRequest + IntoRequest` argument
- or multiple `DeserializeOwned` arguments.
Did you forget to implement `IntoRequest` or `Deserialize` for one of the arguments?
`IntoRequest` is a trait that allows payloads to be sent to the server function.
> See https://dioxuslabs.com/learn/0.7/essentials/fullstack/server_functions for more details.
"#)]
pub trait AssertCanEncode {} pub trait AssertCanEncode {}
pub struct CantEncode; pub struct CantEncode;
@@ -167,5 +178,27 @@ pub struct CantEncode;
pub struct EncodeIsVerified; pub struct EncodeIsVerified;
impl AssertCanEncode for EncodeIsVerified {} impl AssertCanEncode for EncodeIsVerified {}
#[diagnostic::on_unimplemented(message = r#"❌ Invalid return type from ServerFn ❌
The arguments to the server function must be either:
- a single `impl FromResponse` return type
- a single `impl Serialize + DeserializedOwned` return type
Did you forget to implement `FromResponse` or `DeserializeOwned` for one of the arguments?
`FromResponse` is a trait that allows payloads to be decoded from the server function response.
> See https://dioxuslabs.com/learn/0.7/essentials/fullstack/server_functions for more details.
"#)]
pub trait AssertCanDecode {}
pub struct CantDecode;
pub struct DecodeIsVerified;
impl AssertCanDecode for DecodeIsVerified {}
#[doc(hidden)] #[doc(hidden)]
pub fn assert_can_encode(_t: impl AssertCanEncode) {} pub fn assert_can_encode(_t: impl AssertCanEncode) {}
#[doc(hidden)]
pub fn assert_can_decode(_t: impl AssertCanDecode) {}