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
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
workercrate’shttpfeature stays off: with it enabled, D1 writes hang the isolate.serve()therefore takes the nativeworker::Request(the fetch macro’sFromRequestaccepts it). - Request bodies are gated before they are resident (issue #440): a
content-lengthover the route’s ceiling is refused unread, and a body with no usablecontent-lengthis read throughRequest::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’sstream()setsbody_usedbefore discovering there is no body, after whichbytes()only answersBodyUsed. Declared lengths within the ceiling are still buffered viaRequest::bytes(); every streaming bridge betweenworker::Bodyand axum hangs. - A route a module declares in
streaming_routes()is served opt-in in streaming mode (issue #585). The request half turnsRequest::stream()into aRequestStream(a!SendworkerByteStreambehindworker::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; acontent-lengthover the route’s own ceiling is still refused unread. The response half takes theResponseStreama handler left in the response extensions and bridges it withworker::Response::from_stream— neverto_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 devonly: refusing a body unread (issue #440) leaves the tail undrained, and the dev proxy’smiddleware-ensure-req-body-drainedlogsFailed to drain the unused request body. Error: Network connection lost.and breaks miniflare’sProxyWorkerloopback slot, so the NEXT local request fails with a spurious 500Network connection lost— it never reaches the isolate. Exactly one request is lost and the dev server recovers by itself; a client that overstatescontent-lengthand 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 (noProxyWorker, no drain middleware). Verified wrangler 4.135.0 / workerd 1.20260918.1; known upstream as workers-sdk issue #4562.- Installing a
tracingdispatcher (set_global_default/set_default) hangs the isolate, soinstall_tracing()is a no-op on wasm and runtime logs go throughworker::console_log!/console_error!directly (rt_log!). The JSON-lines subscriber with field redaction is used on native runs. - The
timecrate needs itswasm-bindgenfeature fornow_utc()on wasm (pinned in the workspaceCargo.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 — seetracing_setup),tracing!natively.
Structs§
- Actor
Driver - Forwards a Durable Object’s
fetchandalarmentry points to the module’sActorHandlers. - Cloudflare
- The Workers runtime. Binding names are static;
.mailer()/.captcha()take adapter instances (cratefield-adapter-resend,cratefield-adapter-turnstile). - Context
Defer - D1Database
- D1Rate
Limiter - EnvConfig
- Fetch
Client - The Workers implementation of the
HttpClientport: onefetchper send, through the isolate’s own stack. - KvStore
Port - Limit
- The per-key budget a
RateLimitPolicyanswers with:maxrequests perperiod. - Rate
Limit Port - Room
Driver - Forwards a Durable Object’s four entry points to a module’s
RoomHandler. - Schedule
Defer - Workers
Clock
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_counterstable the upsert below counts in. A venture wiring.d1_rate_limiter(..)ships this as a migration in itsmigrations/directory, next to the module SQL.
Functions§
- client_
ip - The caller’s IP from
cf-connecting-ip. Neverx-forwarded-foron 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)underCLOUDFLARE_SCHEDULED_LIMITS. Handler errors are logged and never fail the cron. - serve_
scheduled_ with_ limits serve_scheduledwith the invocation’s limits named explicitly (issue #537): the whole run may spendlimits.wallof wall time andlimits.subrequestssubrequest-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 asModuleContext::scheduled, which the module — andOutbox::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§
- Rate
Limit Policy - Maps a rate-limit key to its budget — the hook that makes limits
per-key.
Nonemeans 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; theip:/email:keys ofcratefield_core::rate_limit_keysslot in unchanged.