Skip to main content

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}