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::pin::Pin;
14use std::task::{Context, Poll};
15
16use axum::body::Body;
17use http_body::Body as HttpBody;
18use tower::ServiceExt;
19use worker::{Request, Response, ResponseBuilder};
20
21use crate::context::{set_context, take_cookies};
22
23#[cfg(feature = "ssr")]
24use crate::ssr::{self, IndexHtml};
25
26type BeforeHook = Box<dyn Fn(&Request) -> worker::Result<Option<Response>>>;
27type AfterHook = Box<dyn Fn(&mut Response) -> worker::Result<()>>;
28
29/// Internal state for SSR rendering.
30#[cfg(feature = "ssr")]
31struct SsrState {
32    build_vdom: Box<dyn Fn() -> dioxus::dioxus_core::VirtualDom>,
33    index: IndexHtml,
34}
35
36/// Configurable request handler with before/after middleware hooks.
37///
38/// # Example
39///
40/// ```rust,ignore
41/// use dioxus_cloudflare::Handler;
42/// use worker::*;
43///
44/// #[event(fetch)]
45/// async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
46///     unsafe { __wasm_call_ctors(); }
47///
48///     Handler::new()
49///         .before(|req| {
50///             // Short-circuit OPTIONS requests for CORS preflight
51///             if req.method() == worker::Method::Options {
52///                 let mut resp = Response::empty()?;
53///                 resp.headers_mut().set("Access-Control-Allow-Origin", "*")?;
54///                 resp.headers_mut().set("Access-Control-Allow-Methods", "GET,POST,OPTIONS")?;
55///                 return Ok(Some(resp));
56///             }
57///             Ok(None) // continue to Axum dispatch
58///         })
59///         .after(|resp| {
60///             resp.headers_mut().set("Access-Control-Allow-Origin", "*")?;
61///             Ok(())
62///         })
63///         .handle(req, env)
64///         .await
65/// }
66/// ```
67pub struct Handler {
68    before: Vec<BeforeHook>,
69    after: Vec<AfterHook>,
70    #[cfg(feature = "ssr")]
71    ssr: Option<SsrState>,
72}
73
74impl Handler {
75    /// Create a new handler with no middleware hooks.
76    #[must_use]
77    pub fn new() -> Self {
78        Self {
79            before: Vec::new(),
80            after: Vec::new(),
81            #[cfg(feature = "ssr")]
82            ssr: None,
83        }
84    }
85
86    /// Add a before-dispatch hook.
87    ///
88    /// Runs after context is set (so `cf::env()`, `cf::d1()`, etc. work),
89    /// before Axum dispatch. Return `Ok(None)` to continue,
90    /// `Ok(Some(resp))` to short-circuit with a custom response.
91    ///
92    /// Hooks run in the order they were added.
93    #[must_use]
94    pub fn before(
95        mut self,
96        hook: impl Fn(&Request) -> worker::Result<Option<Response>> + 'static,
97    ) -> Self {
98        self.before.push(Box::new(hook));
99        self
100    }
101
102    /// Add an after-dispatch hook.
103    ///
104    /// Runs after cookies are applied, before returning the response.
105    /// Use this to add headers, log, or modify the final response.
106    ///
107    /// After hooks also run on short-circuited responses from before hooks.
108    /// Hooks run in the order they were added.
109    #[must_use]
110    pub fn after(
111        mut self,
112        hook: impl Fn(&mut Response) -> worker::Result<()> + 'static,
113    ) -> Self {
114        self.after.push(Box::new(hook));
115        self
116    }
117
118    /// Enable SSR rendering for non-API requests.
119    ///
120    /// When the Axum router returns 404 and the request accepts `text/html`,
121    /// the handler renders the given component to HTML and returns it.
122    ///
123    /// Uses a default HTML shell (`<!DOCTYPE html>` with `<div id="main">`).
124    /// Call [`with_index_html`](Self::with_index_html) to provide your own
125    /// shell (e.g., one that loads client WASM for SPA takeover).
126    ///
127    /// # Example
128    ///
129    /// ```rust,ignore
130    /// Handler::new()
131    ///     .with_ssr(App)
132    ///     .handle(req, env)
133    ///     .await
134    /// ```
135    #[cfg(feature = "ssr")]
136    #[must_use]
137    pub fn with_ssr(mut self, app: fn() -> dioxus::prelude::Element) -> Self {
138        self.ssr = Some(SsrState {
139            build_vdom: Box::new(move || {
140                dioxus::dioxus_core::VirtualDom::new(app)
141            }),
142            index: IndexHtml::default_shell(),
143        });
144        self
145    }
146
147    /// Provide a custom `index.html` shell for SSR responses.
148    ///
149    /// The HTML must contain an element with `id="main"` — rendered
150    /// component output is inserted at that point.
151    ///
152    /// Must be called **after** [`with_ssr`](Self::with_ssr).
153    ///
154    /// # Errors
155    ///
156    /// Returns `Err` if:
157    /// - SSR has not been configured (call `with_ssr` first)
158    /// - The HTML does not contain `id="main"`
159    ///
160    /// # Example
161    ///
162    /// ```rust,ignore
163    /// Handler::new()
164    ///     .with_ssr(App)
165    ///     .with_index_html(include_str!("../index.html"))?
166    ///     .handle(req, env)
167    ///     .await
168    /// ```
169    #[cfg(feature = "ssr")]
170    pub fn with_index_html(mut self, html: &str) -> Result<Self, String> {
171        let state = self
172            .ssr
173            .as_mut()
174            .ok_or_else(|| "with_index_html requires with_ssr to be called first".to_string())?;
175        state.index = IndexHtml::new(html)?;
176        Ok(self)
177    }
178
179    /// Dispatch the request through registered `#[server]` functions.
180    ///
181    /// This is the async entry point — call it from `#[event(fetch)]`.
182    #[allow(clippy::missing_errors_doc)]
183    pub async fn handle(&self, req: Request, env: worker::Env) -> worker::Result<Response> {
184        // Store env + request in thread-local for cf::env() / cf::req()
185        let req_clone = req
186            .clone()
187            .map_err(|e| worker::Error::RustError(format!("request clone failed: {e}")))?;
188        set_context(env, req_clone);
189
190        // Run before hooks — short-circuit if one returns a response
191        for hook in &self.before {
192            if let Some(resp) = hook(&req)? {
193                return self.finalize(resp);
194            }
195        }
196
197        // Convert worker::Request → http::Request
198        let http_req = worker_req_to_http(req).await?;
199
200        // Save URI before dispatch — needed for SSR fallback path
201        #[cfg(feature = "ssr")]
202        let uri = http_req.uri().clone();
203
204        // Save Accept header for SSR content-type check
205        #[cfg(feature = "ssr")]
206        let accepts_html = http_req
207            .headers()
208            .get(http::header::ACCEPT)
209            .and_then(|v| v.to_str().ok())
210            .is_some_and(|v| v.contains("text/html"));
211
212        // Dispatch through the Dioxus Axum router
213        let http_resp = dispatch(http_req).await?;
214
215        // SSR fallback: if Axum returned 404, SSR is configured, and the
216        // client accepts HTML, render the app component instead.
217        #[cfg(feature = "ssr")]
218        if http_resp.status() == http::StatusCode::NOT_FOUND {
219            if let Some(ref state) = self.ssr {
220                if accepts_html {
221                    let worker_resp =
222                        ssr::render_ssr(&uri, &state.build_vdom, &state.index).await?;
223                    return self.finalize(worker_resp);
224                }
225            }
226        }
227
228        // Convert http::Response → worker::Response (streaming)
229        let worker_resp = http_to_worker_resp(http_resp)?;
230
231        self.finalize(worker_resp)
232    }
233
234    /// Apply queued cookies and run after hooks on the final response.
235    fn finalize(&self, mut resp: Response) -> worker::Result<Response> {
236        // Apply any cookies queued by cf::set_cookie() / cf::clear_cookie()
237        for cookie in take_cookies() {
238            resp.headers_mut()
239                .append("Set-Cookie", &cookie)
240                .map_err(|e| worker::Error::RustError(format!("cookie append failed: {e}")))?;
241        }
242
243        // Run after hooks
244        for hook in &self.after {
245            hook(&mut resp)?;
246        }
247
248        // Context is NOT cleared here — with streaming, the response body may
249        // still be generating after handle() returns. The next request's
250        // set_context() overwrites the old values, which is safe because Workers
251        // handle one request at a time per isolate.
252
253        Ok(resp)
254    }
255}
256
257impl Default for Handler {
258    fn default() -> Self {
259        Self::new()
260    }
261}
262
263/// Handle an incoming Cloudflare Worker request by dispatching it through
264/// the Dioxus server function router.
265///
266/// This is a convenience wrapper around [`Handler::new().handle()`](Handler::handle).
267/// Use [`Handler`] directly if you need before/after middleware hooks.
268///
269/// # Example
270///
271/// ```rust,ignore
272/// use worker::*;
273///
274/// extern "C" { fn __wasm_call_ctors(); }
275///
276/// #[event(fetch)]
277/// async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
278///     // Required: initialize inventory for server function registration.
279///     // SAFETY: Called once per Worker cold start. The `inventory` crate
280///     // requires this in WASM to register #[server] functions.
281///     unsafe { __wasm_call_ctors(); }
282///
283///     dioxus_cloudflare::handle(req, env).await
284/// }
285/// ```
286#[allow(clippy::missing_errors_doc)]
287pub async fn handle(req: Request, env: worker::Env) -> worker::Result<Response> {
288    Handler::new().handle(req, env).await
289}
290
291/// Convert a `worker::Request` into an `http::Request<Body>` that Axum
292/// can process.
293async fn worker_req_to_http(mut req: Request) -> worker::Result<http::Request<Body>> {
294    let method = match req.method() {
295        worker::Method::Get => http::Method::GET,
296        worker::Method::Post => http::Method::POST,
297        worker::Method::Put => http::Method::PUT,
298        worker::Method::Delete => http::Method::DELETE,
299        worker::Method::Options => http::Method::OPTIONS,
300        worker::Method::Head => http::Method::HEAD,
301        worker::Method::Patch => http::Method::PATCH,
302        _ => http::Method::GET,
303    };
304
305    let url = req.url()?;
306    let uri: http::Uri = url
307        .as_str()
308        .parse()
309        .map_err(|e| worker::Error::RustError(format!("invalid URI: {e}")))?;
310
311    let mut builder = http::Request::builder().method(method).uri(uri);
312
313    // Copy headers from worker request to http request
314    for (key, value) in req.headers() {
315        builder = builder.header(&key, &value);
316    }
317
318    // Read body as bytes and wrap in axum Body
319    let body_bytes = req.bytes().await?;
320
321    builder
322        .body(Body::from(body_bytes))
323        .map_err(|e| worker::Error::RustError(format!("request build failed: {e}")))
324}
325
326/// Build an Axum router containing all registered `#[server]` functions,
327/// then dispatch the request through it.
328///
329/// Returns the response with the body passed through (not collected into
330/// bytes), allowing streaming responses to flow through to the Worker.
331async fn dispatch(req: http::Request<Body>) -> worker::Result<http::Response<Body>> {
332    // Collect all #[server] functions registered by inventory and add
333    // them as routes to an Axum router.
334    //
335    // ServerFunction::collect() returns all functions that were registered
336    // when __wasm_call_ctors() ran. Each function knows its own path
337    // (e.g., "/api/ping") and HTTP method.
338    let mut router: axum::Router<dioxus_server::FullstackState> = axum::Router::new();
339    for func in dioxus_server::ServerFunction::collect() {
340        router = router.route(func.path(), func.method_router());
341    }
342
343    // Convert Router<FullstackState> → Router<()> using a headless state
344    // (no SSR renderer needed — we only serve API endpoints).
345    let router = router.with_state(dioxus_server::FullstackState::headless());
346
347    // Dispatch the request through the router. Router's Service impl has
348    // error type Infallible — it always produces a response (possibly 404).
349    Ok(router
350        .oneshot(req)
351        .await
352        .unwrap_or_else(|e: Infallible| match e {}))
353}
354
355/// Adapter that converts an `axum::body::Body` into a
356/// `Stream<Item = Result<Vec<u8>, worker::Error>>`.
357///
358/// This satisfies `TryStream<Ok = Vec<u8>, Error = worker::Error>` via the
359/// blanket impl, matching `ResponseBuilder::from_stream()`'s bounds.
360///
361/// `axum::body::Body` is `Unpin` (wraps `Pin<Box<...>>`), so no pin
362/// projection is needed.
363struct AxumBodyStream(Body);
364
365impl futures_core::Stream for AxumBodyStream {
366    type Item = Result<Vec<u8>, worker::Error>;
367
368    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
369        loop {
370            match Pin::new(&mut self.0).poll_frame(cx) {
371                Poll::Ready(Some(Ok(frame))) => {
372                    if let Ok(data) = frame.into_data() {
373                        return Poll::Ready(Some(Ok(data.to_vec())));
374                    }
375                    // Trailers frame — skip, poll for more data
376                }
377                Poll::Ready(Some(Err(e))) => {
378                    return Poll::Ready(Some(Err(worker::Error::RustError(format!(
379                        "body frame error: {e}"
380                    )))));
381                }
382                Poll::Ready(None) => return Poll::Ready(None),
383                Poll::Pending => return Poll::Pending,
384            }
385        }
386    }
387}
388
389/// Convert an `http::Response<Body>` into a `worker::Response` backed by
390/// a `ReadableStream`, enabling streaming response bodies.
391fn http_to_worker_resp(resp: http::Response<Body>) -> worker::Result<Response> {
392    let (parts, body) = resp.into_parts();
393
394    let mut worker_resp = ResponseBuilder::new()
395        .with_status(parts.status.as_u16())
396        .from_stream(AxumBodyStream(body))?;
397
398    // Copy response headers
399    for (key, value) in &parts.headers {
400        if let Ok(v) = value.to_str() {
401            worker_resp
402                .headers_mut()
403                .set(key.as_str(), v)
404                .map_err(|e| worker::Error::RustError(format!("header set failed: {e}")))?;
405        }
406    }
407
408    Ok(worker_resp)
409}