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