Skip to main content

Crate cratefield_runtime_native

Crate cratefield_runtime_native 

Source
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 same harness, as a binary.

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

§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.

KeyDefaultMeaning
LISTEN_ADDR127.0.0.1:8080Bind 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. A missing or short HARNESS_SECRET, a set-but-short ADMIN_TOKEN or an unknown ENV leaves the Signer port unset, warned once naming the problem; a production venture with signed-link writes then answers 503 not-production-ready on /v1/*.
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_SECS100, 60Sliding-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_HEADERS empty (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 header cratefield_core::client_ip reads 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/__ready

The 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.


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

Structs§

Connection
One member’s open socket. The caller writes Connection::next_outgoing messages to the wire and calls Connection::deliver with what it reads.
CronError
A CRONS entry that could not be parsed.
DirBlob
Stores blobs as files under base.
EnvConfig
Reads SCREAMING_SNAKE keys 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).
InProcessRealtime
A Realtime adapter that holds every room’s sockets in this process.
Native
The native runtime. Adapters are passed in as instances (the same pattern as the Cloudflare runtime’s .mailer(..)/.captcha(..)): a Postgres or SQLite Database from the matching adapter crate, a RedisRateLimiter and RedisKv over a shared Redis connection manager, Resend/Turnstile unchanged from the Workers path.
OutboundOptions
Outbound hardening knobs for ReqwestClient. The defaults are the hardened ones: a deployment loosens them field by field, deliberately.
RedisBundle
Both Redis ports over one shared connection, what a venture gets from REDIS_URL when it is set.
RedisKv
KeyValue over Redis (one shared ConnectionManager, cloned per call — a cheap channel copy, not a new connection).
RedisRateLimiter
RateLimiter over Redis: each allowed request ZADDs now into the key’s sorted set; a request is allowed while the set holds fewer than limit members within the trailing window.
ReqwestClient
A shared reqwest client. Built once per process (the connection pool is the Client); cheap to Arc, clone-free on the hot path.
SpawnDefer
Spawns deferred work on the tokio runtime. Request-scoped through the Scope like every defer — the runtime holds no state of its own.
TokioClock
The system clock on tokio. now() reads the wall clock through the time crate (the same path core’s SystemClock uses, never std::time directly); timeout_any abandons the future with tokio::time::timeout, which this runtime can always supply.

Enums§

RedisPortError
ServeError

Constants§

CLIENT_IP_HEADER
The header the runtime normalizes the resolved client IP into. Core’s client_ip reads it first on every target, so modules (which only ever call cratefield_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 of serve_scheduled on Workers. Every module gets an unbounded budget (ctx.scheduled is the same contract here it is on Workers, it just never runs out: the native runtime has no Workers invocation allowance to spend against). 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_URL opens a client and one reconnecting ConnectionManager shared (cloned) by the two adapters; RATE_LIMIT_MAX (default 100) and RATE_LIMIT_PERIOD_SECS (default 60) configure the limiter’s window. Ok(None) when REDIS_URL is unset — the ports stay absent and a module that requires one fails Harness::build by name.
serve
Serves the harness: builds the router once, wraps it in the client-IP middleware, starts the cron scheduler for the CRONS config, 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 CRONS from std::env), and serves until SIGTERM or SIGINT. serve is this plus LISTEN_ADDR resolution 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 stop and 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 CRONS entry fails the process at startup (wrangler’s deploy-time check), never as a silently dead schedule.
trusted_proxy_headers
The TRUSTED_PROXY_HEADERS list: the header names this deployment is willing to believe the client IP from (comma-separated, e.g. TRUSTED_PROXY_HEADERS=x-forwarded-for behind one nginx, or cf-connecting-ip behind Cloudflare). Default: empty — an unconfigured deployment must not trust any forwarding header, and the runtime resolves every address from the socket peer instead.
vet_ip_ok
The address rule (issue #136): refuse everything that is not publicly routable — loopback, the RFC 1918 ranges, CGNAT (100.64/10, behind which Oracle’s metadata endpoint lives), link-local (which carries 169.254.169.254), the IPv6 unique-local range (which carries AWS’s fd00:ec2::254), multicast, documentation and benchmarking blocks — and unwrap the IPv4 that IPv6 embeds (mapped, 6to4) so it is checked too, while refusing Teredo outright (its embedded address is the attacker’s to choose).