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.
Runtime request fidelity
Inbound conversion reads req.inner().method() rather than the closed Workers method enum. Valid runtime extension tokens retain their spelling. Headers.entries() values follow workerd's UTF-8 string convention and are copied through HeaderValue::from_bytes(value.as_bytes()). Invalid runtime HeaderValues produce bounded conversion errors. Conversion never comma-splits combined fields. Browser ByteString construction is not evidence of Worker wire-byte preservation.
Ingress captures req.inner().url() before additional parsing. It records a runtime URL and validated runtime origin. Fetch can already have normalized dot segments and combined repeated fields; those changes cannot be reversed by EdgeZero. Header fidelity remains conservative.
Browser converter tests cover custom methods, high bytes, metadata and service hook dispatch. Locked local workerd transport tests additionally distinguish pre-Worker rejections: its HTTP parser returns 501 for extension methods. Real Worker tests assert valid UTF-8 values C3 A9 and C4 80 survive conversion. Local wire FF becomes U+FFFD in Web Headers before Rust, so its runtime UTF-8 copy EF BF BD cannot prove original bytes. Common octet and multiplicity fidelity is Unknown: the observed transformations do not prove every particular field changed. Local absolute-form and duplicate Content-Length requests return 500 without a hook verdict. These transport/runtime limits remain separate from runtime-to-core conversion correctness. See the capability matrix.
Captured runtime-target preservation is Unknown. Fetch URL normalization is a known runtime capability, but the original request target is unavailable, so a transformation cannot be established for each individual incoming request.
Conversion compatibility
Unknown method tokens are retained rather than coerced to GET, so they no longer match a GET route. Invalid runtime header values fail conversion with a bounded EdgeError::bad_request. Direct converter callers receive that error; run_app, dispatch_app and CloudflareService surface it as worker::Error, which the standard fetch wrapper returns as HTTP 500. Method, URL, URI and header conversion errors use bounded messages without parse details. Primary routing still uses the existing SDK URL conversion, including Unicode percent encoding and backslash normalization; ingress target metadata is captured separately before that conversion.
Next Steps
- Learn about Fastly Compute as an alternative
- Explore the Axum adapter for local development