dioxus-cloudflare
The missing bridge between Dioxus server functions and Cloudflare Workers.
What It Does
Write a #[server] function once. It runs on Cloudflare Workers. The client calls it like a normal async function. No manual routing, no manual serialization, no duplicated endpoints.
// shared crate — server function
use *;
use *;
pub async
// client component — just call it
let user = get_user.await;
Architecture
Client WASM Cloudflare Worker
┌──────────┐ fetch() ┌─────────────────────┐
│ #[server] │ ───────────▶ │ handle(req, env) │
│ generates │ │ ↓ set_context() │
│ POST to │ │ ↓ worker→http req │
│ /api/... │ │ ↓ Axum dispatch │
│ │ ◀─ stream ─ │ ↓ http→worker resp │
└──────────┘ └─────────────────────┘
Request Flow
- Client calls
get_user(id)— Dioxus serializes args, sends POST to/api/get_user - Worker
#[event(fetch)]receives the request dioxus_cloudflare::handle(req, env)is called:- Stores
Envin thread-local (cf::env()becomes available) - Stores raw
Requestin thread-local (cf::req()becomes available) - Converts
worker::Request→http::Request - Dispatches through the Dioxus Axum router (
axum_corefeature) - Converts
http::Response→worker::Response(streaming viaReadableStream)
- Stores
- Worker returns the response
Why Thread-Local Works
Cloudflare Workers run one request per isolate at a time (single-threaded WASM). There is no concurrent access to thread-locals within a single Worker invocation.
The Crate Provides
| Export | What It Does |
|---|---|
cf::d1(name) |
D1 database — env + binding + error conversion in one call |
cf::kv(name) |
Workers KV namespace |
cf::r2(name) |
R2 bucket |
cf::durable_object(name) |
Durable Object namespace |
cf::queue(name) |
Queue producer (requires queue feature) |
cf::secret(name) |
Encrypted secret (wrangler secret put) |
cf::var(name) |
Plaintext environment variable ([vars] in wrangler.toml) |
cf::env() |
Full Worker Env — for bindings without a shorthand |
cf::req() |
Raw worker::Request — headers, IP |
cf::cookie(name) |
Read a named cookie from the request |
cf::cookies() |
Read all cookies from the request |
cf::set_cookie() |
Set an HttpOnly/Secure auth cookie (secure defaults) |
cf::set_cookie_with() |
Set a cookie with custom options (builder pattern) |
cf::clear_cookie() |
Clear a cookie (logout) |
handle(req, env) |
Main entry point — wire this into #[event(fetch)] |
Handler |
Builder with before/after middleware hooks + .websocket() routing |
cf::websocket_upgrade() |
Create a WebSocketPair + 101 response in one call (for Durable Objects) |
cf::websocket_pair() |
Create a raw WebSocketPair for custom handling |
CfError |
Newtype for worker::Error → ServerFnError conversion |
CfResultExt |
.cf() method on Result<T, worker::Error> and Result<T, KvError> |
Prerequisites
This crate requires a patched version of dioxus-server that adds wasm32 target support. Add the following to your workspace Cargo.toml:
[]
= { = "https://github.com/JaffeSystems/dioxus-server-cf.git" }
This is necessary because upstream dioxus-server 0.7.3 does not compile for wasm32-unknown-unknown. The patch applies minimal cfg-gating to make it compatible with Cloudflare Workers.
Usage
Worker Entry Point
use *;
use *;
// Import server functions so they register with inventory
use *;
extern "C"
async
Middleware Hooks
Use [Handler] for before/after middleware without touching bridge internals.
CORS headers on all responses:
use *;
use Handler;
async
Auth check (short-circuit unauthorized requests):
new
.before
.handle
.await
Before hooks run after context is set (cf::env(), cf::d1(), etc. work). Return Ok(None) to continue, Ok(Some(resp)) to short-circuit. After hooks run on all responses (including short-circuited ones) and can modify headers.
Secrets and Environment Variables
Access encrypted secrets and plaintext variables from inside server functions.
Secrets are set via wrangler secret put or the Cloudflare dashboard — encrypted at rest, never in wrangler.toml:
pub async
Variables are set in the [vars] section of wrangler.toml — plaintext, visible in source:
pub async
# wrangler.toml
[]
= "production"
SSR (Server-Side Rendering)
Render Dioxus components to HTML at the edge. Requires the ssr feature.
When the Axum router returns 404 and the request accepts text/html, the handler renders your app component and returns the HTML. Non-HTML requests (JS, CSS, WASM, JSON) pass through normally.
Minimal SSR (default HTML shell, no client JS):
new
.with_ssr
.handle
.await
SSR with custom index.html (SPA takeover after first paint):
new
.with_ssr
.with_index_html?
.handle
.await
The custom index.html must contain an element with id="main" — rendered component output is inserted at that point.
Suspense is supported: wait_for_suspense() resolves server futures during SSR, so components that call #[server] functions via use_server_future will have their data ready in the initial HTML.
SSR with Hydration
SSR always renders with hydration markers (data-node-hydration attributes) and injects serialized hydration data. When the client WASM is built with hydrate(true), it reuses the server-rendered DOM instead of re-rendering — providing instant first paint with no flash.
Worker (server):
new
.with_ssr
.with_index_html?
.handle
.await
Client WASM (must render the same component):
The client must enable the fullstack feature on dioxus (which activates dioxus-web/hydrate):
# Cargo.toml
[]
= { = "=0.7.3", = ["web", "fullstack"] }
Important: Do not use ? on use_server_future in hydrated components. The ? operator suspends the component if the resource isn't immediately ready, which creates a VirtualDom/DOM tree mismatch and crashes the hydration walker. Instead, match on the Result:
Build order:
dx build --release— builds client WASM +index.htmlcargo build --release --target wasm32-unknown-unknown -p your-worker— worker includesindex.htmlviainclude_str!
Streaming SSR
Send the initial HTML immediately with suspense fallbacks as placeholders, then stream resolved content out-of-order via ReadableStream as each suspense boundary completes. Fast data renders instantly; slow data streams in later. Requires the ssr feature.
new
.with_streaming_ssr
.with_index_html?
.handle
.await
If no suspense boundaries are pending after the initial render, streaming SSR automatically falls back to a single-shot response with no overhead — you can always use with_streaming_ssr without penalty.
The client-side JavaScript (window.dx_hydrate) swaps suspense placeholders with resolved content as chunks arrive. This is the same mechanism used by upstream Dioxus streaming SSR.
WebSocket Support
Real-time WebSocket connections via Durable Objects. The worker upgrades the request and forwards it to a DO, which creates the WebSocketPair and handles messages.
Worker entry point — route WebSocket upgrades to a Durable Object:
new
.websocket
.handle
.await
Durable Object — accept the socket and handle messages:
use *;
use *;
wrangler.toml — bind the DO and route WebSocket paths:
[]
= [
{ name = "WS_DO", class_name = "EchoDo" }
]
[[]]
= "v1"
= ["EchoDo"]
[]
= ["/api/*", "/ws/*"]
Server Function (Shared Crate)
use *;
use *;
pub async
Client Component
use *;
use create_order;
Crate Structure
src/
├── lib.rs # Public API: cf module, handle(), re-exports
├── bindings.rs # Typed binding shorthands: d1(), kv(), r2(), durable_object(), queue()
├── context.rs # Thread-local Env + Request storage
├── handler.rs # handle() — Worker↔Axum bridge, Handler builder, SSR fallback
├── cookie.rs # Cookie read/write helpers + CookieBuilder
├── error.rs # CfError newtype + CfResultExt trait
├── prelude.rs # Convenience re-exports
├── ssr.rs # SSR rendering: single-shot + streaming, hydration data extraction
├── streaming.rs # Out-of-order streaming data structures: MountPath, Mount, PendingSuspenseBoundary
└── websocket.rs # WebSocket helpers: websocket_upgrade(), websocket_pair()
Implementation Status
Working end-to-end
-
Thread-local context (
cf::env(),cf::req()) -
Typed binding shorthands (
cf::d1(),cf::kv(),cf::r2(),cf::durable_object(),cf::queue()) -
Durable Objects —
cf::durable_object()shorthand for namespace access -
Queues —
cf::queue()shorthand for producer access (requiresqueuefeature) -
Request headers — read via
cf::req()inside server functions -
Cookie reading (
cf::cookie(),cf::cookies()) -
Cookie writing (
cf::set_cookie(),cf::clear_cookie()) — queue-based, applied to response automatically -
Configurable cookie builder (
cf::set_cookie_with()) — customSameSite,Domain,Path,Max-Age, etc. -
Error bridge (
CfError,.cf()) — covers bothworker::ErrorandKvError -
Streaming responses —
TextStream,ByteStream, and other streaming payloads viaReadableStream -
Request/response conversion (
worker::Request↔http::Request) -
dispatch()— Axum router withServerFunction::collect()+.oneshot() -
__wasm_call_ctors+inventoryinitialization -
D1 — read/write queries via
cf::d1("DB") -
Workers KV — put/get/delete via
cf::kv("KV") -
R2 — put/get/delete via
cf::r2("BUCKET") -
Static assets via wrangler
[assets]+run_worker_first = ["/api/*"] -
Middleware hooks —
Handlerbuilder with before/after hooks for auth, CORS, custom headers -
SSR inside Workers — render Dioxus components to HTML at the edge (requires
ssrfeature) -
Hydration — client reuses server-rendered DOM instead of re-rendering (SSR always emits hydration markers)
-
Streaming SSR — send initial HTML with suspense fallbacks immediately, stream resolved content out-of-order via
ReadableStream -
WebSocket support —
Handler::websocket()routes upgrade requests to Durable Objects;cf::websocket_upgrade()creates theWebSocketPair+ 101 response
Streaming
Response bodies are streamed via ReadableStream — handler.rs wraps axum::body::Body as a TryStream and pipes it through ResponseBuilder::from_stream(). Server functions returning TextStream, ByteStream, JsonStream, etc. stream chunks directly to the client without buffering the full response into memory.
Roadmap
Near-term
- Template project /
cargo generate— scaffolding for new dioxus-cloudflare projects with wrangler config, shared/web/worker crates, and build scripts - Remove
dioxus-serverpatch requirement — upstream the wasm32cfg-gating to Dioxus core so users don't need[patch.crates-io]
Ideas / Exploration
- Session middleware — built-in session management backed by KV or D1 with cookie-based session IDs
- Service bindings — call other Workers from server functions via
cf::env().service() - AI bindings —
cf::env().ai()for Workers AI inference from server functions - Wrangler plugin — automate the build pipeline (cargo build → wasm-bindgen → shim generation) without manual wrangler.toml
[build]commands
Optional Features
| Feature | Enables |
|---|---|
queue |
cf::queue() shorthand (activates worker/queue) |
ssr |
Server-side rendering via Handler::with_ssr() / with_streaming_ssr() (adds dioxus-ssr, dioxus-history, futures-channel, wasm-bindgen-futures) |
Dependencies
= { = "=0.7.3", = ["fullstack"] }
= { = "0.7", = ["http"] }
= { = "0.8", = false }
= "1"
License
Copyright (C) 2026-2027 Jaffe Systems
This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0).
If you use this software in a network service (SaaS, web application, API, etc.), you must make the complete source code of your application available to its users under the AGPL-3.0. This includes any modifications and derivative works.
Commercial License: If you need to use this software in a proprietary or closed-source application without the AGPL-3.0 obligations, a commercial license is available. See COMMERCIAL-LICENSE.md for details.
| Use Case | License | Source Disclosure Required? |
|---|---|---|
| Open-source project | AGPL-3.0 (free) | Yes |
| Internal tools (not served to users) | AGPL-3.0 (free) | No |
| Proprietary SaaS / closed-source | Commercial (paid) | No |