Skip to main content

Crate cratefield_runtime_cloudflare

Crate cratefield_runtime_cloudflare 

Source
Expand description

cratefield-runtime-cloudflare runs a Factory Zero Harness on Cloudflare Workers (ADR 0001, 0002). It maps bindings to ports: D1 -> Database, KV -> KeyValue, the Rate Limiting binding -> RateLimiter, HARNESS_SECRET -> Signer, Context::wait_until -> Defer, worker::Fetch -> HttpClient.

A venture’s Worker is three lines:

ⓘ
#[event(fetch)]
pub async fn fetch(req: HttpRequest, env: Env, ctx: Context)
    -> Result<http::Response<axum::body::Body>> {
    let (harness, runtime) = INSTANCE.get_or_init(build);
    serve(harness, runtime, req, env, ctx).await
}

Every port adapter here holds JS handles that workers-rs already marks Send + Sync (unsafe impl inside the worker crate, sound because a Workers isolate is single-threaded — ADR 0002). This crate itself contains no unsafe.

cratefield-runtime-cloudflare — One stateless Worker.

cratefield-runtime-cloudflare on crates.io cratefield-runtime-cloudflare documentation MIT

§cratefield-runtime-cloudflare

Cloudflare Workers runtime for the Cratefield harness: maps Workers bindings to the harness ports and serves a Harness on #[event(fetch)] / #[event(scheduled)].

§Usage

A venture’s Worker is three lines:

ⓘ
use cratefield_core::Harness;
use cratefield_runtime_cloudflare::{serve, serve_scheduled, Cloudflare};
use std::sync::OnceLock;
use worker::{event, Context, Env, Request, Response};

static INSTANCE: OnceLock<(Harness, Cloudflare)> = OnceLock::new();

#[event(fetch)]
pub async fn fetch(req: Request, env: Env, ctx: Context) -> worker::Result<Response> {
    let (harness, runtime) = INSTANCE.get_or_init(build);
    serve(harness, runtime, req, env, ctx).await
}

#[event(scheduled)]
pub async fn scheduled(event: worker::ScheduledEvent, env: Env, ctx: worker::ScheduleContext) {
    let (harness, runtime) = INSTANCE.get_or_init(build);
    serve_scheduled(harness, runtime, event, env, ctx).await;
}

The runtime builder: Cloudflare::new().db("DB").kv("KV").rate_limiter("RATE_LIMITER") plus .mailer(...)/.captcha(...) with adapter instances. For per-key limits (per plan, per address — the binding above is one limit per namespace), wire .d1_rate_limiter("DB", policy) instead: a D1-backed fixed window per key whose policy maps each key to its cap. Ship the table it counts in by adding the RATE_LIMIT_COUNTERS_SQL statement to the venture’s migrations/ directory.

§wrangler.toml

name = "my-venture-api"
main = "build/worker/shim.mjs"
compatibility_date = "2026-09-01"

[build]
command = "cargo install -q worker-build && worker-build --release"

[observability]
enabled = true

[[d1_databases]]
binding = "DB"
database_name = "my-venture-db"
database_id = "<uuid>"
migrations_dir = "migrations"

# Optional bindings:
# [kv_namespaces] binding = "KV", id = "..."
# [unsafe.bindings] name = "RATE_LIMITER", type = "ratelimit"

Secrets (wrangler secret put, or .dev.vars locally — copy .dev.vars.example): HARNESS_SECRET (required for the Signer port, ≥ 32 bytes), HARNESS_SECRET_PREVIOUS (rotation), ADMIN_TOKEN, ENV.

§wasm notes (workerd/miniflare, wrangler 4.x)

These are empirical facts recorded while building this crate; see PROGRESS.md issue #5 for the full debugging history:

  • The worker crate’s http feature stays off: with it enabled, D1 writes hang the isolate. serve() therefore takes the native worker::Request (the fetch macro’s FromRequest accepts it).
  • Request bodies are gated before they are resident (issue #440): a content-length over the route’s ceiling is refused unread, and a body with no usable content-length is read through Request::stream() and cut off once the ceiling is passed. A request with no body at all (most GETs) is answered as empty without any read: 0.8.5’s stream() sets body_used before discovering there is no body, after which bytes() only answers BodyUsed. Declared lengths within the ceiling are still buffered via Request::bytes(); every streaming bridge between worker::Body and axum hangs.
  • A route a module declares in streaming_routes() is served opt-in in streaming mode (issue #585). The request half turns Request::stream() into a RequestStream (a !Send worker ByteStream behind worker::send::SendWrapper, sound because an isolate is single-threaded — ADR 0002) and hands the router an empty body plus the handle, so nothing is buffered; a content-length over the route’s own ceiling is still refused unread. The response half takes the ResponseStream a handler left in the response extensions and bridges it with worker::Response::from_stream — never to_bytes, which is capped at 1 MiB and for a stream would be the hang above. Every non-streaming response is still buffered under that 1 MiB ceiling.
  • wrangler dev only: refusing a body unread (issue #440) leaves the tail undrained, and the dev proxy’s middleware-ensure-req-body-drained logs Failed to drain the unused request body. Error: Network connection lost. and breaks miniflare’s ProxyWorker loopback slot, so the NEXT local request fails with a spurious 500 Network connection lost — it never reaches the isolate. Exactly one request is lost and the dev server recovers by itself; a client that overstates content-length and then sends fewer bytes may see the 413 withheld locally for the same reason. Dev-proxy artefact only: production workerd cancels unread request bodies natively (no ProxyWorker, no drain middleware). Verified wrangler 4.135.0 / workerd 1.20260918.1; known upstream as workers-sdk issue #4562.
  • Installing a tracing dispatcher (set_global_default/set_default) hangs the isolate, so install_tracing() is a no-op on wasm and runtime logs go through worker::console_log!/console_error! directly (rt_log!). The JSON-lines subscriber with field redaction is used on native runs.
  • The time crate needs its wasm-bindgen feature for now_utc() on wasm (pinned in the workspace Cargo.toml); without it every clock read panics (time not implemented on this platform).

MIT. Built in the open for Cratefield, a Factory Zero venture.

Macros§

rt_log
Runtime logging: worker::console_log! on Workers (no tracing dispatcher can be installed there — see tracing_setup), tracing! natively.

Structs§

ActorDriver
Forwards a Durable Object’s fetch and alarm entry points to the module’s ActorHandlers.
Cloudflare
The Workers runtime. Binding names are static; .mailer()/.captcha() take adapter instances (cratefield-adapter-resend, cratefield-adapter-turnstile).
ContextDefer
D1Database
D1RateLimiter
EnvConfig
FetchClient
The Workers implementation of the HttpClient port: one fetch per send, through the isolate’s own stack.
KvStorePort
Limit
The per-key budget a RateLimitPolicy answers with: max requests per period.
RateLimitPort
RoomDriver
Forwards a Durable Object’s four entry points to a module’s RoomHandler.
ScheduleDefer
WorkersClock

Constants§

CLOUDFLARE_SCHEDULED_LIMITS
The conservative limits a scheduled invocation runs under when the venture does not choose its own: 25 seconds of wall time and 40 subrequest-shaped steps. Chosen to fit the Workers Free plan’s 50 subrequests per invocation with headroom, not taken from the plan: it is a default, not a platform fact, and a venture on a paid plan passes its own limits to serve_scheduled_with_limits.
RATE_LIMIT_COUNTERS_SQL
The forward-only DDL for the rate_limit_counters table the upsert below counts in. A venture wiring .d1_rate_limiter(..) ships this as a migration in its migrations/ directory, next to the module SQL.

Functions§

client_ip
The caller’s IP from cf-connecting-ip. Never x-forwarded-for on Workers (architecture section 11): that header is client-controlled.
install_tracing
Installs the console JSON subscriber exactly once per process. This is process-wide infrastructure installed before the first response, not request state (ADR 0007). No-op on wasm32 (see module docs).
serve
Serves one fetch event: resolves ports from the bindings, builds the router, refuses an oversize body before it is resident, hands the request over, and converts the response (issue #440).
serve_scheduled
Fans a scheduled event out to every module’s scheduled(ctx, cron) under CLOUDFLARE_SCHEDULED_LIMITS. Handler errors are logged and never fail the cron.
serve_scheduled_with_limits
serve_scheduled with the invocation’s limits named explicitly (issue #537): the whole run may spend limits.wall of wall time and limits.subrequests subrequest-shaped steps, split across the modules in order with each module’s unspent share rolling forward to the ones after it. A module’s share arrives as ModuleContext::scheduled, which the module — and Outbox::drain_within — checks cooperatively before each unit of work; the runtime never cancels a module that ignores it. A module that runs its share out is named in Workers Logs, and a handler error is still only logged, never a failed cron.

Type Aliases§

RateLimitPolicy
Maps a rate-limit key to its budget — the hook that makes limits per-key. None means unlimited: the key is allowed without a D1 round trip and no row is written for it. Apps encode the plan in the key itself (plan:pro:ip:203.0.113.7, plan:pro:email:n@x.co) and match on the prefix; the ip:/email: keys of cratefield_core::rate_limit_keys slot in unchanged.