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