Cloudflare Workers
Deploy EdgeZero applications to Cloudflare Workers using WebAssembly.
Prerequisites
- Wrangler CLI
- worker-build:
cargo install worker-build - Rust
wasm32-unknown-unknowntarget:rustup target add wasm32-unknown-unknown
Project Setup
When scaffolding with edgezero new my-app, the Cloudflare adapter includes:
crates/my-app-adapter-cloudflare/
├── Cargo.toml
├── wrangler.toml
└── src/
├── lib.rs
└── main.rswrangler.toml
The Wrangler manifest configures your Worker:
name = "my-app"
main = "build/worker/shim.mjs"
compatibility_date = "2023-05-01"
[build]
command = "worker-build --release"Entrypoint
The Cloudflare Wasm entrypoint in src/lib.rs wires the adapter:
use my_app_core::App;
use worker::*;
#[event(fetch)]
pub async fn main(req: Request, env: Env, ctx: Context) -> Result<Response> {
edgezero_adapter_cloudflare::run_app::<App>(req, env, ctx).await
}run_app reads the portable store metadata baked into App by the app! macro and the EDGEZERO__* env vars exposed on the worker Env (Workers cannot enumerate Env, so the canonical key set is derived from the baked store ids and queried individually). Per-id KV / Config / Secret registries are built and injected into request extensions automatically. No edgezero.toml is loaded by the runtime — see the migration guide.
For fully manual wiring, CloudflareService::new(&app) builds a dispatcher one store at a time: .with_config(binding) (a KV binding name), .with_config_handle(handle), .with_kv(binding), .with_secrets(), the matching .require_kv() / .require_secrets() flags, and finally .dispatch(req, env, ctx).await, which needs the worker Env and Context to open bindings:
use edgezero_adapter_cloudflare::request::CloudflareService;
use edgezero_core::app::Hooks as _;
use my_app_core::App;
use worker::*;
#[event(fetch)]
pub async fn main(req: Request, env: Env, ctx: Context) -> Result<Response> {
let app = App::build_app();
CloudflareService::new(&app)
.with_config("app_config")
.with_kv("sessions")
.dispatch(req, env, ctx)
.await
}This path takes bindings verbatim and does not resolve EDGEZERO__STORES__* selectors, so prefer run_app unless you are mocking a backend. run_app delegates to the public dispatch_app::<A> registry-aware path. Use application retention with that function when the caller owns a prebuilt app and wants fresh per-request store bindings.
Building
Build for Cloudflare's Wasm target:
# Using the CLI
edgezero build --adapter cloudflareLocal Development
Run locally with Wrangler:
# Using the CLI
edgezero serve --adapter cloudflareThis starts a local server at http://127.0.0.1:8787.
Deployment
Deploy to Cloudflare Workers:
# Using the CLI
edgezero deploy --adapter cloudflare
# Or directly
wrangler deploy --cwd crates/my-app-adapter-cloudflareFetch API
Cloudflare Workers use the global fetch API for outbound requests:
use edgezero_adapter_cloudflare::proxy::CloudflareProxyClient;
use edgezero_core::proxy::ProxyService;
let client = CloudflareProxyClient;
let response = ProxyService::new(client).forward(request).await?;Unlike Fastly, there's no backend configuration needed - Workers can fetch any URL directly.
Logging
EdgeZero does not install a Cloudflare logger by default. Use your preferred logger (for example console_log or your own log implementation), and view output in Wrangler or the Cloudflare dashboard.
Logging status
Cloudflare logging is opt-in; install a logger (such as console_log) in your entrypoint if you need structured output.
Context Access
Access Cloudflare-specific APIs via the request context extensions:
use edgezero_core::context::RequestContext;
use edgezero_adapter_cloudflare::context::CloudflareRequestContext;
async fn handler(ctx: RequestContext) -> Result<Response, EdgeError> {
if let Some(cf_ctx) = CloudflareRequestContext::get(ctx.request()) {
// Access Cloudflare-specific data
let env = cf_ctx.env();
let ctx = cf_ctx.ctx();
// ...
}
// ...
}Environment Variables
Define non-secret variables in wrangler.toml; the adapter reads the EDGEZERO__* selectors from the same [vars] table:
[vars]
API_URL = "https://api.example.com"Secrets go through the Worker secret store instead; see Secret Store.
Config Store
Cloudflare does not expose a Fastly-style mutable config-store product, so each declared [stores.config] id maps to a KV namespace binding. Reads are asynchronous (worker::kv::KvStore::get(key).text().await).
# edgezero.toml
[stores.config]
ids = ["app_config"]
# default = "app_config" # required when ids.len() > 1# wrangler.toml
[[kv_namespaces]]
binding = "app_config"
id = "abc123…"The binding name comes from EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME (defaulting to the logical id app_config when unset). Populate the namespace via wrangler kv key put. Missing bindings log a one-time warning and the id is dropped from the registry. See the migration guide if you are coming from the pre-rewrite [vars]-backed JSON-string form.
KV and config share the same [[kv_namespaces]] binding space on Cloudflare, so the same logical id must not appear under both [stores.kv] and [stores.config]; both would resolve to a single underlying namespace at runtime. edgezero config validate rejects the collision.
Secret Store
Worker Secrets is a single flat bag with no namespace concept, so exactly one [stores.secrets] id is permitted; edgezero config validate --strict rejects more than one. Handlers read values through the Secrets extractor or ctx.secret_store(id), and a secret with no matching binding resolves to None rather than erroring.
# edgezero.toml
[stores.secrets]
ids = ["default"]Populate secrets with the Wrangler CLI; there is no binding flag, since the secret name is the binding:
wrangler secret put API_KEYKV Storage
Each declared [stores.kv] id maps to a KV namespace binding, exactly like a config id:
# edgezero.toml
[stores.kv]
ids = ["sessions"]# wrangler.toml
[[kv_namespaces]]
binding = "sessions"
id = "abc123…"The binding name comes from EDGEZERO__STORES__KV__SESSIONS__NAME, defaulting to the logical id. edgezero provision --adapter cloudflare creates the namespace and appends the binding for you. Handlers reach the store through the portable Kv extractor or ctx.kv_store(id); a hand-picked binding that is not declared under [stores.kv] is never opened. See KV Storage for the API.
Durable Objects
For stateful edge computing, configure Durable Objects:
# wrangler.toml
[durable_objects]
bindings = [
{ name = "COUNTER", class_name = "Counter" }
]Streaming
Cloudflare Workers support streaming via ReadableStream. The adapter automatically converts Body::Stream to Cloudflare's streaming format.
See the Streaming guide for examples and patterns.
Testing
Run contract tests for the Cloudflare adapter:
WASM_BINDGEN_VERSION=$(
awk '
$1 == "name" && $3 == "\"wasm-bindgen\"" { in_pkg=1; next }
in_pkg && $1 == "version" {
gsub(/"/, "", $3)
print $3
exit
}
' Cargo.lock
)
cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked --force
export CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner
cargo test -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown --test contractThese tests use wasm-bindgen-test-runner and execute the adapter's real wasm32 request path. The CLI version must exactly match the workspace's wasm-bindgen version from Cargo.lock.
Manifest Configuration
Configure the Cloudflare adapter in edgezero.toml. See Configuration for the full manifest reference.
Comparison with Fastly
| Feature | Cloudflare Workers | Fastly Compute |
|---|---|---|
| Target | wasm32-unknown-unknown | wasm32-wasip1 |
| Outbound requests | Global fetch | Dynamic backends (derived from URI) |
| Storage | KV, Durable Objects, R2 | KV Store, Object Store |
| Logging | console.log | Log endpoints |
| CLI | Wrangler | Fastly CLI |
Retaining an application
For explicit retention, keep a cache owned by one concrete application and select its Hooks type with dispatch_app::<MyApp> on every fetch:
use edgezero_core::app::{App, Hooks};
use std::sync::OnceLock;
static APP: OnceLock<App> = OnceLock::new();
#[worker::event(fetch)]
async fn fetch(req: worker::Request, env: worker::Env, ctx: worker::Context)
-> worker::Result<worker::Response>
{
edgezero_adapter_cloudflare::dispatch_app::<MyApp>(
APP.get_or_init(MyApp::build_app), req, env, ctx,
).await
}Repeated response headers such as multiple Set-Cookie values are preserved; the first application value replaces any body-generated default. This correction also applies to existing run_app users.
dispatch_app::<MyApp> reads MyApp::stores() and does not initialize logging or construct an app. Pass an app built from the same Hooks type. The caller owns initialization before construction. It resolves configuration and bindings for each invocation; never retain Env, Context, request bodies, or registries in this cache. The initializer is synchronous and must not recursively access the cache or block waiting for async initialization. Apps needing fallible or async initialization own that state machine and publish only a complete snapshot.
Fetch invocations may overlap. Verify isolation while two requests are actually in flight in the same instance. A serialized test or two separate instances does not establish this property. Restore the existing run_app entry point to remove explicit retention. The generated default remains unchanged.
Next Steps
- Learn about Fastly Compute as an alternative
- Explore the Axum adapter for local development