dioxus_cloudflare/handler.rs
1//! Request handler that bridges Cloudflare Workers to the Dioxus Axum router.
2//!
3//! Two entry points are available:
4//!
5//! - [`handle`] — simple one-shot dispatch (no middleware)
6//! - [`Handler`] — builder with before/after middleware hooks
7//!
8//! Both store the Worker `Env` and `Request` in thread-local context,
9//! convert `worker::Request` → `http::Request`, dispatch through the
10//! Dioxus Axum router, and stream the response back via `ReadableStream`.
11
12use std::convert::Infallible;
13use std::future::Future;
14use std::pin::Pin;
15use std::task::{Context, Poll};
16
17use axum::body::Body;
18use http_body::Body as HttpBody;
19use tower::ServiceExt;
20use worker::{Request, Response, ResponseBuilder};
21
22use crate::context::{set_context, take_cookies};
23
24#[cfg(feature = "ssr")]
25use crate::ssr::{self, IndexHtml};
26
27type BeforeHook = Box<dyn Fn(&Request) -> worker::Result<Option<Response>>>;
28type AfterHook = Box<dyn Fn(&mut Response) -> worker::Result<()>>;
29type WsHandler = Box<dyn Fn(Request) -> Pin<Box<dyn Future<Output = worker::Result<Response>>>>>;
30
31struct WebSocketRoute {
32 path_prefix: String,
33 handler: WsHandler,
34}
35
36/// Internal state for SSR rendering.
37#[cfg(feature = "ssr")]
38struct SsrState {
39 build_vdom: Box<dyn Fn() -> dioxus::dioxus_core::VirtualDom>,
40 index: IndexHtml,
41 streaming: bool,
42}
43
44/// Configurable request handler with before/after middleware hooks.
45///
46/// # Example
47///
48/// ```rust,ignore
49/// use dioxus_cloudflare::Handler;
50/// use worker::*;
51///
52/// #[event(fetch)]
53/// async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
54/// unsafe { __wasm_call_ctors(); }
55///
56/// Handler::new()
57/// .before(|req| {
58/// // Short-circuit OPTIONS requests for CORS preflight
59/// if req.method() == worker::Method::Options {
60/// let mut resp = Response::empty()?;
61/// resp.headers_mut().set("Access-Control-Allow-Origin", "*")?;
62/// resp.headers_mut().set("Access-Control-Allow-Methods", "GET,POST,OPTIONS")?;
63/// return Ok(Some(resp));
64/// }
65/// Ok(None) // continue to Axum dispatch
66/// })
67/// .after(|resp| {
68/// resp.headers_mut().set("Access-Control-Allow-Origin", "*")?;
69/// Ok(())
70/// })
71/// .handle(req, env)
72/// .await
73/// }
74/// ```
75pub struct Handler {
76 before: Vec<BeforeHook>,
77 after: Vec<AfterHook>,
78 websocket_routes: Vec<WebSocketRoute>,
79 session_config: Option<crate::session::SessionConfig>,
80 #[cfg(feature = "ssr")]
81 ssr: Option<SsrState>,
82}
83
84impl Handler {
85 /// Create a new handler with no middleware hooks.
86 #[must_use]
87 pub fn new() -> Self {
88 Self {
89 before: Vec::new(),
90 after: Vec::new(),
91 websocket_routes: Vec::new(),
92 session_config: None,
93 #[cfg(feature = "ssr")]
94 ssr: None,
95 }
96 }
97
98 /// Add a before-dispatch hook.
99 ///
100 /// Runs after context is set (so `cf::env()`, `cf::d1()`, etc. work),
101 /// before Axum dispatch. Return `Ok(None)` to continue,
102 /// `Ok(Some(resp))` to short-circuit with a custom response.
103 ///
104 /// Hooks run in the order they were added.
105 #[must_use]
106 pub fn before(
107 mut self,
108 hook: impl Fn(&Request) -> worker::Result<Option<Response>> + 'static,
109 ) -> Self {
110 self.before.push(Box::new(hook));
111 self
112 }
113
114 /// Add an after-dispatch hook.
115 ///
116 /// Runs after cookies are applied, before returning the response.
117 /// Use this to add headers, log, or modify the final response.
118 ///
119 /// After hooks also run on short-circuited responses from before hooks.
120 /// Hooks run in the order they were added.
121 #[must_use]
122 pub fn after(
123 mut self,
124 hook: impl Fn(&mut Response) -> worker::Result<()> + 'static,
125 ) -> Self {
126 self.after.push(Box::new(hook));
127 self
128 }
129
130 /// Configure session middleware.
131 ///
132 /// When enabled, `cf::session()` becomes available in server functions.
133 /// Session data is persisted to the configured backend (KV or D1) and
134 /// a session cookie is managed automatically.
135 ///
136 /// # Example
137 ///
138 /// ```rust,ignore
139 /// use dioxus_cloudflare::prelude::*;
140 ///
141 /// Handler::new()
142 /// .session(SessionConfig::kv("SESSIONS"))
143 /// .handle(req, env)
144 /// .await
145 /// ```
146 #[must_use]
147 pub fn session(mut self, config: crate::session::SessionConfig) -> Self {
148 self.session_config = Some(config);
149 self
150 }
151
152 /// Add a WebSocket upgrade route.
153 ///
154 /// When a request has the `Upgrade: websocket` header and its path starts
155 /// with `path`, the handler is called instead of dispatching through Axum.
156 /// The handler receives the owned `worker::Request` (to forward to a
157 /// Durable Object stub, for example).
158 ///
159 /// WebSocket routes are checked after context is set and before hooks run,
160 /// but before Axum dispatch.
161 ///
162 /// # Example
163 ///
164 /// ```rust,ignore
165 /// Handler::new()
166 /// .websocket("/ws", |req| async move {
167 /// let ns = cf::durable_object("WS_DO")?;
168 /// let id = ns.id_from_name("default").cf()?;
169 /// let stub = id.get_stub().cf()?;
170 /// Ok(stub.fetch_with_request(req).await.cf()?)
171 /// })
172 /// .handle(req, env)
173 /// .await
174 /// ```
175 #[must_use]
176 pub fn websocket<F, Fut>(mut self, path: &str, handler: F) -> Self
177 where
178 F: Fn(Request) -> Fut + 'static,
179 Fut: Future<Output = worker::Result<Response>> + 'static,
180 {
181 self.websocket_routes.push(WebSocketRoute {
182 path_prefix: path.to_string(),
183 handler: Box::new(move |req| Box::pin(handler(req))),
184 });
185 self
186 }
187
188 /// Enable SSR rendering for non-API requests.
189 ///
190 /// When the Axum router returns 404 and the request accepts `text/html`,
191 /// the handler renders the given component to HTML and returns it.
192 ///
193 /// Uses a default HTML shell (`<!DOCTYPE html>` with `<div id="main">`).
194 /// Call [`with_index_html`](Self::with_index_html) to provide your own
195 /// shell (e.g., one that loads client WASM for SPA takeover).
196 ///
197 /// # Example
198 ///
199 /// ```rust,ignore
200 /// Handler::new()
201 /// .with_ssr(App)
202 /// .handle(req, env)
203 /// .await
204 /// ```
205 #[cfg(feature = "ssr")]
206 #[must_use]
207 pub fn with_ssr(mut self, app: fn() -> dioxus::prelude::Element) -> Self {
208 self.ssr = Some(SsrState {
209 build_vdom: Box::new(move || dioxus::dioxus_core::VirtualDom::new(app)),
210 index: IndexHtml::default_shell(),
211 streaming: false,
212 });
213 self
214 }
215
216 /// Enable **streaming** SSR rendering for non-API requests.
217 ///
218 /// Like [`with_ssr`](Self::with_ssr), but sends the initial HTML with
219 /// suspense fallbacks immediately, then streams resolved content
220 /// out-of-order as each suspense boundary completes.
221 ///
222 /// If no suspense boundaries are pending after the initial render,
223 /// this automatically falls back to a single-shot response with no
224 /// overhead.
225 ///
226 /// # Example
227 ///
228 /// ```rust,ignore
229 /// Handler::new()
230 /// .with_streaming_ssr(App)
231 /// .with_index_html(include_str!("path/to/index.html"))?
232 /// .handle(req, env)
233 /// .await
234 /// ```
235 #[cfg(feature = "ssr")]
236 #[must_use]
237 pub fn with_streaming_ssr(mut self, app: fn() -> dioxus::prelude::Element) -> Self {
238 self.ssr = Some(SsrState {
239 build_vdom: Box::new(move || dioxus::dioxus_core::VirtualDom::new(app)),
240 index: IndexHtml::default_shell(),
241 streaming: true,
242 });
243 self
244 }
245
246 /// Provide a custom `index.html` shell for SSR responses.
247 ///
248 /// The HTML must contain an element with `id="main"` — rendered
249 /// component output is inserted at that point.
250 ///
251 /// Must be called **after** [`with_ssr`](Self::with_ssr).
252 ///
253 /// # Errors
254 ///
255 /// Returns `Err` if:
256 /// - SSR has not been configured (call `with_ssr` first)
257 /// - The HTML does not contain `id="main"`
258 ///
259 /// # Example
260 ///
261 /// ```rust,ignore
262 /// Handler::new()
263 /// .with_ssr(App)
264 /// .with_index_html(include_str!("../index.html"))?
265 /// .handle(req, env)
266 /// .await
267 /// ```
268 #[cfg(feature = "ssr")]
269 pub fn with_index_html(mut self, html: &str) -> Result<Self, String> {
270 let state = self
271 .ssr
272 .as_mut()
273 .ok_or_else(|| "with_index_html requires with_ssr to be called first".to_string())?;
274 state.index = IndexHtml::new(html)?;
275 Ok(self)
276 }
277
278 /// Dispatch the request through registered `#[server]` functions.
279 ///
280 /// This is the async entry point — call it from `#[event(fetch)]`.
281 #[allow(clippy::missing_errors_doc)]
282 pub async fn handle(&self, req: Request, env: worker::Env) -> worker::Result<Response> {
283 // Store env + request in thread-local for cf::env() / cf::req()
284 let req_clone = req
285 .clone()
286 .map_err(|e| worker::Error::RustError(format!("request clone failed: {e}")))?;
287 set_context(env, req_clone);
288
289 // Store session config if configured
290 if let Some(ref config) = self.session_config {
291 crate::session::set_session_config(config.clone());
292 }
293
294 // Run before hooks — short-circuit if one returns a response
295 for hook in &self.before {
296 if let Some(resp) = hook(&req)? {
297 return self.flush_and_finalize(resp).await;
298 }
299 }
300
301 // WebSocket upgrade routing — check before Axum dispatch.
302 // WebSocket 101 responses have immutable headers, so we return
303 // them directly without running finalize() (no cookies/after hooks).
304 if req
305 .headers()
306 .get("Upgrade")
307 .ok()
308 .flatten()
309 .is_some_and(|v| v.eq_ignore_ascii_case("websocket"))
310 {
311 for route in &self.websocket_routes {
312 if req.path().starts_with(&route.path_prefix) {
313 return (route.handler)(req).await;
314 }
315 }
316 }
317
318 // Convert worker::Request → http::Request
319 let http_req = worker_req_to_http(req).await?;
320
321 // Save URI before dispatch — needed for SSR fallback path
322 #[cfg(feature = "ssr")]
323 let uri = http_req.uri().clone();
324
325 // Save Accept header for SSR content-type check
326 #[cfg(feature = "ssr")]
327 let accepts_html = http_req
328 .headers()
329 .get(http::header::ACCEPT)
330 .and_then(|v| v.to_str().ok())
331 .is_some_and(|v| v.contains("text/html"));
332
333 // Dispatch through the Dioxus Axum router
334 let http_resp = dispatch(http_req).await?;
335
336 // SSR fallback: if Axum returned 404, SSR is configured, and the
337 // client accepts HTML, render the app component instead.
338 #[cfg(feature = "ssr")]
339 if http_resp.status() == http::StatusCode::NOT_FOUND {
340 if let Some(ref state) = self.ssr {
341 if accepts_html {
342 let worker_resp = if state.streaming {
343 ssr::render_ssr_streaming(&uri, &state.build_vdom, &state.index)
344 .await?
345 } else {
346 ssr::render_ssr(&uri, &state.build_vdom, &state.index).await?
347 };
348 return self.flush_and_finalize(worker_resp).await;
349 }
350 }
351 }
352
353 // Convert http::Response → worker::Response (streaming)
354 let worker_resp = http_to_worker_resp(http_resp)?;
355
356 self.flush_and_finalize(worker_resp).await
357 }
358
359 /// Flush session data to the backend, then apply cookies and after hooks.
360 async fn flush_and_finalize(&self, resp: Response) -> worker::Result<Response> {
361 if self.session_config.is_some() {
362 crate::session::flush_session().await?;
363 }
364 self.finalize(resp)
365 }
366
367 /// Apply queued cookies and run after hooks on the final response.
368 fn finalize(&self, mut resp: Response) -> worker::Result<Response> {
369 // Apply any cookies queued by cf::set_cookie() / cf::clear_cookie()
370 for cookie in take_cookies() {
371 resp.headers_mut()
372 .append("Set-Cookie", &cookie)
373 .map_err(|e| worker::Error::RustError(format!("cookie append failed: {e}")))?;
374 }
375
376 // Run after hooks
377 for hook in &self.after {
378 hook(&mut resp)?;
379 }
380
381 // Context is NOT cleared here — with streaming, the response body may
382 // still be generating after handle() returns. The next request's
383 // set_context() overwrites the old values, which is safe because Workers
384 // handle one request at a time per isolate.
385
386 Ok(resp)
387 }
388}
389
390impl Default for Handler {
391 fn default() -> Self {
392 Self::new()
393 }
394}
395
396/// Handle an incoming Cloudflare Worker request by dispatching it through
397/// the Dioxus server function router.
398///
399/// This is a convenience wrapper around [`Handler::new().handle()`](Handler::handle).
400/// Use [`Handler`] directly if you need before/after middleware hooks.
401///
402/// # Example
403///
404/// ```rust,ignore
405/// use worker::*;
406///
407/// extern "C" { fn __wasm_call_ctors(); }
408///
409/// #[event(fetch)]
410/// async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
411/// // Required: initialize inventory for server function registration.
412/// // SAFETY: Called once per Worker cold start. The `inventory` crate
413/// // requires this in WASM to register #[server] functions.
414/// unsafe { __wasm_call_ctors(); }
415///
416/// dioxus_cloudflare::handle(req, env).await
417/// }
418/// ```
419#[allow(clippy::missing_errors_doc)]
420pub async fn handle(req: Request, env: worker::Env) -> worker::Result<Response> {
421 Handler::new().handle(req, env).await
422}
423
424/// Convert a `worker::Request` into an `http::Request<Body>` that Axum
425/// can process.
426async fn worker_req_to_http(mut req: Request) -> worker::Result<http::Request<Body>> {
427 let method = match req.method() {
428 worker::Method::Get => http::Method::GET,
429 worker::Method::Post => http::Method::POST,
430 worker::Method::Put => http::Method::PUT,
431 worker::Method::Delete => http::Method::DELETE,
432 worker::Method::Options => http::Method::OPTIONS,
433 worker::Method::Head => http::Method::HEAD,
434 worker::Method::Patch => http::Method::PATCH,
435 _ => http::Method::GET,
436 };
437
438 let url = req.url()?;
439 let uri: http::Uri = url
440 .as_str()
441 .parse()
442 .map_err(|e| worker::Error::RustError(format!("invalid URI: {e}")))?;
443
444 let mut builder = http::Request::builder().method(method).uri(uri);
445
446 // Copy headers from worker request to http request
447 for (key, value) in req.headers() {
448 builder = builder.header(&key, &value);
449 }
450
451 // Read body as bytes and wrap in axum Body
452 let body_bytes = req.bytes().await?;
453
454 builder
455 .body(Body::from(body_bytes))
456 .map_err(|e| worker::Error::RustError(format!("request build failed: {e}")))
457}
458
459/// Build an Axum router containing all registered `#[server]` functions,
460/// then dispatch the request through it.
461///
462/// Returns the response with the body passed through (not collected into
463/// bytes), allowing streaming responses to flow through to the Worker.
464async fn dispatch(req: http::Request<Body>) -> worker::Result<http::Response<Body>> {
465 // Collect all #[server] functions registered by inventory and add
466 // them as routes to an Axum router.
467 //
468 // ServerFunction::collect() returns all functions that were registered
469 // when __wasm_call_ctors() ran. Each function knows its own path
470 // (e.g., "/api/ping") and HTTP method.
471 let mut router: axum::Router<dioxus_server::FullstackState> = axum::Router::new();
472 for func in dioxus_server::ServerFunction::collect() {
473 router = router.route(func.path(), func.method_router());
474 }
475
476 // Convert Router<FullstackState> → Router<()> using a headless state
477 // (no SSR renderer needed — we only serve API endpoints).
478 let router = router.with_state(dioxus_server::FullstackState::headless());
479
480 // Dispatch the request through the router. Router's Service impl has
481 // error type Infallible — it always produces a response (possibly 404).
482 Ok(router
483 .oneshot(req)
484 .await
485 .unwrap_or_else(|e: Infallible| match e {}))
486}
487
488/// Adapter that converts an `axum::body::Body` into a
489/// `Stream<Item = Result<Vec<u8>, worker::Error>>`.
490///
491/// This satisfies `TryStream<Ok = Vec<u8>, Error = worker::Error>` via the
492/// blanket impl, matching `ResponseBuilder::from_stream()`'s bounds.
493///
494/// `axum::body::Body` is `Unpin` (wraps `Pin<Box<...>>`), so no pin
495/// projection is needed.
496struct AxumBodyStream(Body);
497
498impl futures_core::Stream for AxumBodyStream {
499 type Item = Result<Vec<u8>, worker::Error>;
500
501 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
502 loop {
503 match Pin::new(&mut self.0).poll_frame(cx) {
504 Poll::Ready(Some(Ok(frame))) => {
505 if let Ok(data) = frame.into_data() {
506 return Poll::Ready(Some(Ok(data.to_vec())));
507 }
508 // Trailers frame — skip, poll for more data
509 }
510 Poll::Ready(Some(Err(e))) => {
511 return Poll::Ready(Some(Err(worker::Error::RustError(format!(
512 "body frame error: {e}"
513 )))));
514 }
515 Poll::Ready(None) => return Poll::Ready(None),
516 Poll::Pending => return Poll::Pending,
517 }
518 }
519 }
520}
521
522/// Convert an `http::Response<Body>` into a `worker::Response` backed by
523/// a `ReadableStream`, enabling streaming response bodies.
524fn http_to_worker_resp(resp: http::Response<Body>) -> worker::Result<Response> {
525 let (parts, body) = resp.into_parts();
526
527 let mut worker_resp = ResponseBuilder::new()
528 .with_status(parts.status.as_u16())
529 .from_stream(AxumBodyStream(body))?;
530
531 // Copy response headers
532 for (key, value) in &parts.headers {
533 if let Ok(v) = value.to_str() {
534 worker_resp
535 .headers_mut()
536 .set(key.as_str(), v)
537 .map_err(|e| worker::Error::RustError(format!("header set failed: {e}")))?;
538 }
539 }
540
541 Ok(worker_resp)
542}