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}