dioxus_server/server.rs
1use crate::{
2 ServeConfig, ServerFunction,
3 ssr::{SSRError, SsrRendererPool},
4};
5use axum::{
6 body::Body,
7 extract::State,
8 http::{Request, StatusCode},
9 response::{IntoResponse, Response},
10 routing::*,
11};
12use dioxus_core::{ComponentFunction, VirtualDom};
13use http::header::*;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use tokio_util::task::LocalPoolHandle;
17use tower::ServiceExt;
18use tower::util::MapResponse;
19use tower_http::services::fs::ServeFileSystemResponseBody;
20
21/// A extension trait with utilities for integrating Dioxus with your Axum router.
22pub trait DioxusRouterExt {
23 /// Serves the static WASM for your Dioxus application (except the generated index.html).
24 ///
25 /// # Example
26 /// ```rust, no_run
27 /// # #![allow(non_snake_case)]
28 /// # use dioxus::prelude::*;
29 /// use dioxus_server::DioxusRouterExt;
30 ///
31 /// #[tokio::main]
32 /// async fn main() -> anyhow::Result<()> {
33 /// let addr = dioxus::cli_config::fullstack_address_or_localhost();
34 /// let router = axum::Router::new()
35 /// // Server side render the application, serve static assets, and register server functions
36 /// .serve_static_assets()
37 /// // Server render the application
38 /// // ...
39 /// .with_state(dioxus_server::FullstackState::headless());
40 /// let listener = tokio::net::TcpListener::bind(addr).await?;
41 /// axum::serve(listener, router).await?;
42 /// Ok(())
43 /// }
44 /// ```
45 fn serve_static_assets(self) -> Router<FullstackState>;
46
47 /// Serves the Dioxus application. This will serve a complete server side rendered application.
48 /// This will serve static assets, server render the application, register server functions, and integrate with hot reloading.
49 ///
50 /// # Example
51 /// ```rust, no_run
52 /// # #![allow(non_snake_case)]
53 /// # use dioxus::prelude::*;
54 /// use dioxus_server::{DioxusRouterExt, ServeConfig};
55 ///
56 /// #[tokio::main]
57 /// async fn main() {
58 /// let addr = dioxus::cli_config::fullstack_address_or_localhost();
59 /// let router = axum::Router::new()
60 /// // Server side render the application, serve static assets, and register server functions
61 /// .serve_dioxus_application(ServeConfig::new(), app);
62 /// let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
63 /// axum::serve(listener, router).await.unwrap();
64 /// }
65 ///
66 /// fn app() -> Element {
67 /// rsx! { "Hello World" }
68 /// }
69 /// ```
70 fn serve_dioxus_application<M: 'static>(
71 self,
72 cfg: ServeConfig,
73 app: impl ComponentFunction<(), M> + Send + Sync,
74 ) -> Router<()>;
75
76 /// Registers server functions with the default handler.
77 ///
78 /// # Example
79 /// ```rust, no_run
80 /// # use dioxus::prelude::*;
81 /// # use dioxus_server::DioxusRouterExt;
82 /// #[tokio::main]
83 /// async fn main() {
84 /// let addr = dioxus::cli_config::fullstack_address_or_localhost();
85 /// let router = axum::Router::new()
86 /// // Register server functions routes with the default handler
87 /// .register_server_functions()
88 /// .with_state(dioxus_server::FullstackState::headless());
89 /// let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
90 /// axum::serve(listener, router).await.unwrap();
91 /// }
92 /// ```
93 #[allow(dead_code)]
94 fn register_server_functions(self) -> Router<FullstackState>;
95
96 /// Serves a Dioxus application without static assets.
97 /// Sets up server function routes and rendering endpoints only.
98 ///
99 /// Useful for WebAssembly environments or when static assets
100 /// are served by another system.
101 ///
102 /// # Example
103 /// ```rust, no_run
104 /// # use dioxus::prelude::*;
105 /// # use dioxus_server::{DioxusRouterExt, ServeConfig};
106 /// #[tokio::main]
107 /// async fn main() {
108 /// let router = axum::Router::new()
109 /// .serve_api_application(ServeConfig::new(), app)
110 /// .into_make_service();
111 /// // ...
112 /// }
113 ///
114 /// fn app() -> Element {
115 /// rsx! { "Hello World" }
116 /// }
117 /// ```
118 fn serve_api_application<M: 'static>(
119 self,
120 cfg: ServeConfig,
121 app: impl ComponentFunction<(), M> + Send + Sync,
122 ) -> Router<()>
123 where
124 Self: Sized;
125}
126
127#[cfg(not(target_arch = "wasm32"))]
128impl DioxusRouterExt for Router<FullstackState> {
129 fn register_server_functions(mut self) -> Router<FullstackState> {
130 use std::collections::HashSet;
131
132 let mut seen = HashSet::new();
133
134 for func in ServerFunction::collect() {
135 if seen.insert(format!("{} {}", func.method(), func.path())) {
136 tracing::info!("Registering: {} {}", func.method(), func.path());
137
138 self = self.route(func.path(), func.method_router())
139 }
140 }
141
142 self
143 }
144
145 fn serve_static_assets(self) -> Router<FullstackState> {
146 let Some(public_path) = public_path() else {
147 return self;
148 };
149
150 // Serve all files in public folder except index.html
151 serve_dir_cached(self, &public_path, &public_path)
152 }
153
154 fn serve_api_application<M: 'static>(
155 self,
156 cfg: ServeConfig,
157 app: impl ComponentFunction<(), M> + Send + Sync,
158 ) -> Router<()> {
159 self.register_server_functions()
160 .fallback(get(FullstackState::render_handler))
161 .with_state(FullstackState::new(cfg, app))
162 }
163
164 fn serve_dioxus_application<M: 'static>(
165 self,
166 cfg: ServeConfig,
167 app: impl ComponentFunction<(), M> + Send + Sync,
168 ) -> Router<()> {
169 self.register_server_functions()
170 .serve_static_assets()
171 .fallback(get(FullstackState::render_handler))
172 .with_state(FullstackState::new(cfg, app))
173 }
174}
175
176/// SSR renderer handler for Axum with added context injection.
177///
178/// # Example
179/// ```rust,no_run
180/// #![allow(non_snake_case)]
181/// use std::sync::{Arc, Mutex};
182///
183/// use axum::routing::get;
184/// use dioxus::prelude::*;
185/// use dioxus_server::{FullstackState, render_handler, ServeConfig};
186///
187/// fn app() -> Element {
188/// rsx! {
189/// "hello!"
190/// }
191/// }
192///
193/// #[tokio::main]
194/// async fn main() {
195/// let addr = dioxus::cli_config::fullstack_address_or_localhost();
196/// let router = axum::Router::new()
197/// // Register server functions, etc.
198/// // Note you can use `register_server_functions_with_context`
199/// // to inject the context into server functions running outside
200/// // of an SSR render context.
201/// .fallback(get(render_handler))
202/// .with_state(FullstackState::new(ServeConfig::new(), app));
203///
204/// let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
205/// axum::serve(listener, router).await.unwrap();
206/// }
207/// ```
208pub async fn render_handler(
209 State(state): State<FullstackState>,
210 request: Request<Body>,
211) -> impl IntoResponse {
212 FullstackState::render_handler(State(state), request).await
213}
214
215/// State used by [`FullstackState::render_handler`] to render a dioxus component with axum
216#[derive(Clone)]
217pub struct FullstackState {
218 config: ServeConfig,
219 build_virtual_dom: Arc<dyn Fn() -> VirtualDom + Send + Sync>,
220 renderers: Arc<SsrRendererPool>,
221 pub(crate) rt: LocalPoolHandle,
222}
223
224impl FullstackState {
225 /// Create a headless [`FullstackState`] without a root component.
226 ///
227 /// This won't render pages, but can still be used to register server functions and serve static assets.
228 pub fn headless() -> Self {
229 let rt = LocalPoolHandle::new(
230 std::thread::available_parallelism()
231 .map(usize::from)
232 .unwrap_or(1),
233 );
234
235 Self {
236 renderers: Arc::new(SsrRendererPool::new(4, None)),
237 build_virtual_dom: Arc::new(|| {
238 panic!("No root component provided for headless FullstackState")
239 }),
240 config: ServeConfig::new(),
241 rt,
242 }
243 }
244
245 /// Create a new [`FullstackState`]
246 pub fn new<M: 'static>(
247 config: ServeConfig,
248 root: impl ComponentFunction<(), M> + Send + Sync + 'static,
249 ) -> Self {
250 let rt = LocalPoolHandle::new(
251 std::thread::available_parallelism()
252 .map(usize::from)
253 .unwrap_or(1),
254 );
255
256 Self {
257 renderers: Arc::new(SsrRendererPool::new(4, config.incremental.clone())),
258 build_virtual_dom: Arc::new(move || VirtualDom::new_with_props(root.clone(), ())),
259 config,
260 rt,
261 }
262 }
263
264 /// Create a new [`FullstackState`] with a custom [`VirtualDom`] factory. This method can be
265 /// used to pass context into the root component of your application.
266 pub fn new_with_virtual_dom_factory(
267 config: ServeConfig,
268 build_virtual_dom: impl Fn() -> VirtualDom + Send + Sync + 'static,
269 ) -> Self {
270 let rt = LocalPoolHandle::new(
271 std::thread::available_parallelism()
272 .map(usize::from)
273 .unwrap_or(1),
274 );
275
276 Self {
277 renderers: Arc::new(SsrRendererPool::new(4, config.incremental.clone())),
278 config,
279 build_virtual_dom: Arc::new(build_virtual_dom),
280 rt,
281 }
282 }
283
284 /// Set the [`ServeConfig`] for this [`FullstackState`]
285 pub fn with_config(mut self, config: ServeConfig) -> Self {
286 self.config = config;
287 self
288 }
289
290 /// SSR renderer handler for Axum with added context injection.
291 ///
292 /// # Example
293 /// ```rust,no_run
294 /// #![allow(non_snake_case)]
295 /// use std::sync::{Arc, Mutex};
296 ///
297 /// use axum::routing::get;
298 /// use dioxus::prelude::*;
299 /// use dioxus_server::{FullstackState, render_handler, ServeConfig};
300 ///
301 /// fn app() -> Element {
302 /// rsx! {
303 /// "hello!"
304 /// }
305 /// }
306 ///
307 /// #[tokio::main]
308 /// async fn main() {
309 /// let addr = dioxus::cli_config::fullstack_address_or_localhost();
310 /// let router = axum::Router::new()
311 /// // Register server functions, etc.
312 /// // Note you can use `register_server_functions_with_context`
313 /// // to inject the context into server functions running outside
314 /// // of an SSR render context.
315 /// .fallback(get(render_handler))
316 /// .with_state(FullstackState::new(ServeConfig::new(), app))
317 /// .into_make_service();
318 ///
319 /// let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
320 /// axum::serve(listener, router).await.unwrap();
321 /// }
322 /// ```
323 pub async fn render_handler(State(state): State<Self>, request: Request<Body>) -> Response {
324 let (parts, _) = request.into_parts();
325
326 let response = state
327 .renderers
328 .clone()
329 .render_to(parts, &state.config, &state.rt, {
330 let build_virtual_dom = state.build_virtual_dom.clone();
331 let context_providers = state.config.context_providers.clone();
332 move || {
333 let mut vdom = build_virtual_dom();
334 for state in context_providers.as_slice() {
335 vdom.insert_any_root_context(state());
336 }
337 vdom
338 }
339 })
340 .await;
341
342 match response {
343 Ok((status, headers, freshness, rx)) => {
344 let mut response = Response::builder()
345 .status(status.status)
346 .header(CONTENT_TYPE, "text/html; charset=utf-8")
347 .body(Body::from_stream(rx))
348 .unwrap();
349
350 // Write our freshness header
351 freshness.write(response.headers_mut());
352
353 // write the other headers set by the user
354 for (key, value) in headers.into_iter() {
355 if let Some(key) = key {
356 response.headers_mut().insert(key, value);
357 }
358 }
359
360 response
361 }
362
363 Err(SSRError::Incremental(e)) => {
364 tracing::error!("Failed to render page: {}", e);
365
366 Response::builder()
367 .status(StatusCode::INTERNAL_SERVER_ERROR)
368 .body(e.to_string())
369 .unwrap()
370 .into_response()
371 }
372
373 Err(SSRError::HttpError { status, message }) => Response::builder()
374 .status(status)
375 .body(Body::from(message.unwrap_or_else(|| {
376 status
377 .canonical_reason()
378 .unwrap_or("An unknown error occurred")
379 .to_string()
380 })))
381 .unwrap(),
382 }
383 }
384}
385
386/// Get the path to the public assets directory to serve static files from
387pub(crate) fn public_path() -> Option<PathBuf> {
388 if let Ok(path) = std::env::var("DIOXUS_PUBLIC_PATH") {
389 return Some(PathBuf::from(path));
390 }
391
392 // The CLI always bundles static assets into the exe/public directory
393 Some(
394 std::env::current_exe()
395 .ok()?
396 .parent()
397 .unwrap()
398 .join("public"),
399 )
400}
401
402fn serve_dir_cached<S>(mut router: Router<S>, public_path: &Path, directory: &Path) -> Router<S>
403where
404 S: Send + Sync + Clone + 'static,
405{
406 use tower_http::services::{ServeDir, ServeFile};
407
408 let dir = std::fs::read_dir(directory)
409 .unwrap_or_else(|e| panic!("Couldn't read public directory at {:?}: {}", &directory, e));
410
411 for entry in dir.flatten() {
412 let path = entry.path();
413 // Don't serve the index.html file. The SSR handler will generate it.
414 if path == public_path.join("index.html") {
415 continue;
416 }
417
418 let route = format!(
419 "/{}",
420 path.strip_prefix(public_path)
421 .unwrap()
422 .iter()
423 .map(|segment| segment.to_string_lossy())
424 .collect::<Vec<_>>()
425 .join("/")
426 );
427
428 if path.is_dir() {
429 // In debug builds, serve directories dynamically so new files (like
430 // wasm patch modules) are immediately visible without rebuilding
431 // the router. In release, keep the previous cached traversal
432 // behaviour.
433 #[cfg(debug_assertions)]
434 {
435 router = router.nest_service(&route, ServeDir::new(&path));
436 }
437
438 #[cfg(not(debug_assertions))]
439 {
440 router = serve_dir_cached(router, public_path, &path);
441 }
442 } else {
443 let serve_file = ServeFile::new(&path).precompressed_br();
444 // All cached assets are served at the root of the asset directory. If we know an asset
445 // is hashed for cache busting, we can cache the response on the client side forever. If
446 // the asset changes, the hash in the path will also change and the client will refetch it.
447 if file_name_looks_immutable(&route) {
448 router = router.nest_service(&route, cache_response_forever(serve_file))
449 } else {
450 router = router.nest_service(&route, serve_file)
451 }
452 }
453 }
454
455 router
456}
457
458type MappedAxumService<S> = MapResponse<
459 S,
460 fn(Response<ServeFileSystemResponseBody>) -> Response<ServeFileSystemResponseBody>,
461>;
462
463fn cache_response_forever<S>(service: S) -> MappedAxumService<S>
464where
465 S: ServiceExt<Request<Body>, Response = Response<ServeFileSystemResponseBody>>,
466{
467 service.map_response(|mut response: Response<ServeFileSystemResponseBody>| {
468 response.headers_mut().insert(
469 CACHE_CONTROL,
470 HeaderValue::from_static("public, max-age=31536000, immutable"),
471 );
472 response
473 })
474}
475
476fn file_name_looks_immutable(file_name: &str) -> bool {
477 // Check if the file name looks like a hash (e.g., "main-dxh12345678.js")
478 file_name.rsplit_once("-dxh").is_some_and(|(_, hash)| {
479 hash.chars()
480 .take_while(|c| *c != '.')
481 .all(|c| c.is_ascii_hexdigit())
482 })
483}
484
485#[test]
486fn test_file_name_looks_immutable() {
487 assert!(file_name_looks_immutable("main-dxh12345678.js"));
488 assert!(file_name_looks_immutable("style-dxhabcdef.css"));
489 assert!(!file_name_looks_immutable("index.html"));
490 assert!(!file_name_looks_immutable("script.js"));
491 assert!(!file_name_looks_immutable("main-dxh1234wyz.js"));
492 assert!(!file_name_looks_immutable("main-dxh12345678-invalid.js"));
493}