Expand description
cratefield-runtime-native runs a Factory Zero Harness as a single
binary on tokio (ADR 0001, issue #19): axum on a TcpListener,
reqwest (rustls) behind the HttpClient port, Redis behind
RateLimiter and KeyValue, tokio::spawn behind Defer, the
system clock behind Clock, and an in-process cron scheduler fanning
out to every module’s scheduled(ctx, cron).
The move for a venture (architecture section 10): keep the modules and
adapters, switch .runtime(Cloudflare::new().db("DB")) for
.runtime(Native::new().db(Postgres::connect(url).await?)), ship the
Dockerfile. No module code changes.
§Native-only gate
tokio, reqwest, redis and the JSON log subscriber do not compile to
wasm, so this crate is impossible to build for a wasm target:
src/lib.rs fails compilation with a clear message on wasm32/
wasm64 (the same gate cratefield-adapter-postgres uses), and the
native dependencies are target-gated in Cargo.toml. The wasm graph
of examples/venture must stay free of this crate; CI asserts it
with cargo tree --target wasm32-unknown-unknown.
§Client IP (the one behavioral difference from Workers)
On Workers the edge sets cf-connecting-ip and forwarding headers are
client-forgeable. A native deployment sits behind its own proxy,
so this runtime owns IP resolution: trusted_proxy_headers lists
the header names the deployment is willing to believe (default
empty — an unconfigured deployment trusts no forwarding header),
client_ip resolves the address from those headers or the socket
peer, and the server middleware sanitizes every forwarding header
before the router sees the request. See client_ip for how the
resolved address is carried to the modules.
§Redis is not the Workers bindings
RedisRateLimiter and RedisKv implement the ports with
semantics as close to the Workers Rate Limiting binding and KV as
Redis allows; every difference is documented on the type, not papered
over. The headline ones: the limiter is a true sliding window that
can report retry_after (the binding is fixed-window and cannot),
and Redis is strongly consistent and single-node where KV is
eventually consistent and replicated per-colo.
use std::sync::Arc;
use cratefield_core::Harness;
use cratefield_runtime_native::{Native, serve};
let db = cratefield_adapter_postgres::Postgres::connect("postgres://user:pass@host:5432/venture").await?;
let harness = Arc::new(harness()); // your composition, as on Workers
let runtime = Native::new().db_arc(Arc::new(db));
serve(harness, runtime).await?;§cratefield-runtime-native
The native runtime for the Cratefield harness:
the same Harness served by axum on tokio as a single binary, for the
self-hosted move (ADR 0001, architecture section 10, issue #19). Cloudflare
Workers today, one binary on your own Postgres and Redis tomorrow — same
modules, same adapters, no module rewrites.
Native only. tokio, reqwest and redis do not compile to wasm, so this
crate fails compilation on any wasm target with a clear message and its
native dependencies are target-gated in Cargo.toml. A Worker depends on
cratefield-runtime-cloudflare, never on this crate; cargo tree --target wasm32-unknown-unknown of the venture must stay free of it.
§Usage
use std::sync::Arc;
use cratefield_core::Harness;
use cratefield_runtime_native::{Native, serve};
#[tokio::main]
async fn main() {
let db = cratefield_adapter_postgres::Postgres::connect(&std::env::var("DATABASE_URL").unwrap())
.await
.expect("database reachable");
let runtime = Native::new().db_arc(Arc::new(db));
let harness = Arc::new(harness()); // your composition, as on Workers
serve(harness, runtime).await.expect("serve");
}The builder mirrors the Cloudflare one: .db(..) takes any Database
(Postgres from cratefield-adapter-postgres, SqliteDatabase from
cratefield-adapter-sqlite), .rate_limiter(..)/.kv(..) take the Redis
adapters below, .mailer(..)/.captcha(..) take the adapter instances —
Resend::from_env(..) and Turnstile::from_env(..) unchanged from the
Workers path. serve(harness, runtime) binds, sanitizes client IPs,
starts the cron scheduler and serves until SIGTERM/SIGINT.
A complete example binary — the example venture’s modules on Postgres or
SQLite — is examples/venture-native,
with the multi-stage distroless Dockerfile and the repo-root
docker-compose.example.yml (app + Postgres + Redis).
§Configuration
All keys are read from std::env through the same Config trait the
Workers runtime uses, so module keys (EMAIL_SIGNUP_CONFIRM_TTL_DAYS, …)
behave identically.
| Key | Default | Meaning |
|---|---|---|
LISTEN_ADDR | 127.0.0.1:8080 | Bind address. Loopback by default; say 0.0.0.0:8080 when you mean it (the compose example does). |
HARNESS_SECRET, HARNESS_SECRET_PREVIOUS, ADMIN_TOKEN, ENV | — | Harness-level keys, parsed by HarnessConfig exactly as on Workers. HARNESS_SECRET missing/short leaves the Signer port unset (warned once). |
TRUSTED_PROXY_HEADERS | (empty) | Header names allowed to carry the client IP (see below). Default: trust no forwarding header. |
CRONS | (empty) | Comma-separated cron expressions (UTC), fanned out to every module’s scheduled(ctx, cron) — wrangler’s [triggers] crons in config form. Invalid entries fail startup. |
REDIS_URL | (unset) | Redis for RateLimiter + KeyValue; unset disables both ports. |
RATE_LIMIT_MAX, RATE_LIMIT_PERIOD_SECS | 100, 60 | Sliding-window parameters for RedisRateLimiter (per key). |
§Client IP: the one place native must differ
On Workers the edge sets cf-connecting-ip and forwarding headers are
client-forgeable. A native deployment sits behind its own proxy, so
this runtime owns IP resolution:
- With
TRUSTED_PROXY_HEADERSempty (the default) the client IP is the TCP peer address, and every forwarding header (cf-connecting-ip,x-forwarded-for,x-real-ip,forwarded) is stripped before the router sees the request. A spoofed header cannot pick its own rate-limit key. - With
TRUSTED_PROXY_HEADERS=x-forwarded-for(or whichever headers a proxy you control overwrites), the first configured header whose first comma-separated entry parses as an IP wins, else the peer address. - The resolved address is written into
cf-connecting-ip— the headercratefield_core::client_ipreads first — after stripping, so modules (which only ever call core) see exactly the runtime’s verdict.
§Redis is not the Workers bindings
RedisRateLimiter and RedisKv implement the ports as closely as Redis
allows; the differences are documented on each type, not papered over.
Headlines: the limiter is a true sliding window (sorted-set + Lua) and
reports retry_after; the Workers Rate Limiting binding is fixed-window
and reports none. Redis is strongly consistent and single-node; Workers
KV is eventually consistent (up to ~60 s propagation) and replicated
per-colo. See the rustdoc of each adapter for the full list.
§Observability
install_tracing() (called by serve) installs a tracing-subscriber
JSON formatter writing one line per event to stdout, with the same
field-redaction rules as the Workers runtime (rules live in
cratefield-core; secret-ish field names become [redacted], email-ish
values become truncated SHA-256 hashes). RUST_LOG sets the filter
(default info). On wasm the Cloudflare runtime’s install_tracing is a
no-op; here the real subscriber runs.
§Docker
export HARNESS_SECRET=$(openssl rand -hex 32) # never commit a real secret
export POSTGRES_PASSWORD=$(openssl rand -hex 16)
docker compose -f docker-compose.example.yml up --build
curl -fsS http://127.0.0.1:8080/__health
curl -fsS http://127.0.0.1:8080/__readyThe compose file uses /__ready (a SELECT 1 through the Database
port) as the app container’s health check, executed by the binary itself
(venture-native --check-ready) because distroless ships no curl.
Structs§
- Cron
Error - A
CRONSentry that could not be parsed. - DirBlob
- Stores blobs as files under
base. - EnvConfig
- Reads
SCREAMING_SNAKEkeys from the process environment. Secrets and plain variables are the same thing natively — there is no separate secret store, so the deployment (Docker, systemd) owns secret injection (see the crate README’s compose example). - Native
- The native runtime. Adapters are passed in as instances (the same
pattern as the Cloudflare runtime’s
.mailer(..)/.captcha(..)): a Postgres or SQLiteDatabasefrom the matching adapter crate, aRedisRateLimiterandRedisKvover a shared Redis connection manager,Resend/Turnstileunchanged from the Workers path. - Redis
Bundle - Both Redis ports over one shared connection, what a venture gets from
REDIS_URLwhen it is set. - RedisKv
KeyValueover Redis (one sharedConnectionManager, cloned per call — a cheap channel copy, not a new connection).- Redis
Rate Limiter RateLimiterover Redis: each allowed request ZADDsnowinto the key’s sorted set; a request is allowed while the set holds fewer thanlimitmembers within the trailingwindow.- Reqwest
Client - A shared reqwest client. Built once per process (the connection pool
is the
Client); the port is cheap toArcand clone-free on the hot path. - Spawn
Defer - Spawns deferred work on the tokio runtime. Request-scoped through the
Scopelike every defer — the runtime holds no state of its own. - Tokio
Clock - The system clock on tokio.
now()reads the wall clock through thetimecrate (the same path core’sSystemClockuses, neverstd::timedirectly);timeout_anyabandons the future withtokio::time::timeout, which this runtime can always supply.
Enums§
Constants§
- CLIENT_
IP_ HEADER - The header the runtime normalizes the resolved client IP into. Core’s
client_ipreads it first on every target, so modules (which only ever callcratefield_core::client_ip) see the same header contract on Workers and native. The server middleware sets it after stripping every inbound forwarding header, so its value is always the one this runtime resolved — never what a client sent.
Functions§
- client_
ip - The client IP for one request, in trust order:
- fan_out
- Fans one trigger out to every module’s
scheduled(ctx, cron)with a per-tick context. Handler errors are logged and never fail the tick (or the process) — the semantics ofserve_scheduledon Workers. Public so a venture that owns its scheduler (or its tests) can drive the same fan-out itself. - install_
tracing - Installs the stdout JSON subscriber exactly once per process. Process-wide infrastructure installed before the first response, not request state (ADR 0007). Fails silently only when the process already installed another global subscriber (e.g. a test harness), which then keeps receiving the events.
- redis_
from_ env - Wires both Redis ports from config:
REDIS_URLopens a client and one reconnectingConnectionManagershared (cloned) by the two adapters;RATE_LIMIT_MAX(default100) andRATE_LIMIT_PERIOD_SECS(default60) configure the limiter’s window.Ok(None)whenREDIS_URLis unset — the ports stay absent and a module that requires one failsHarness::buildby name. - serve
- Serves the harness: builds the router once, wraps it in the client-IP
middleware, starts the cron scheduler for the
CRONSconfig, and serves until SIGTERM or SIGINT. - serve_
on - Serves the harness on an already-bound listener: builds the router
once, wraps it in the client-IP middleware (trust list and
CRONSfromstd::env), and serves until SIGTERM or SIGINT.serveis this plusLISTEN_ADDRresolution and binding; tests and ventures with their own socket management (fd passing, port 0) call this directly. - shutdown_
signal - Resolves when the process should stop: SIGINT (Ctrl-C) or, on Unix,
SIGTERM (what
docker stopand systemd send). Public so a venture that assembles its own serving loop gets the same semantics. - spawn_
cron_ scheduler - Validates every expression, then spawns one task per expression.
Validation happens before any task starts so an invalid
CRONSentry fails the process at startup (wrangler’s deploy-time check), never as a silently dead schedule. - trusted_
proxy_ headers - The
TRUSTED_PROXY_HEADERSlist: the header names this deployment is willing to believe the client IP from (comma-separated, e.g.TRUSTED_PROXY_HEADERS=x-forwarded-forbehind one nginx, orcf-connecting-ipbehind Cloudflare). Default: empty — an unconfigured deployment must not trust any forwarding header, and the runtime resolves every address from the socket peer instead.