# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Build (http3 is the default — includes HTTP/3, HTTP/2, and TLS)
cargo build
# Build HTTP/2 + TLS only (no QUIC/HTTP/3)
cargo build --no-default-features --features http2
# Build HTTP/1.1-only (no TLS, lightest binary)
cargo build --no-default-features --features http1
# Run (falls back to plain HTTP/1.1 if no cert is configured)
cargo run
# Run with HTTPS + HTTP/2 + HTTP/3 active
cargo run -- --tls-cert-file=cert.pem --tls-key-file=key.pem
# Run all tests
cargo test
# Run a single test (replace the test path with the one you want)
cargo test --lib client_hint::tests::accept_client_hints_header -- --exact
# End-to-end checks against real implementations (CI runs both on every push):
# ACME against Pebble (Let's Encrypt's test CA): issuance, HTTPS h1/h2, proxy, redirects, renewal
ci/e2e/acme_pebble.sh target/debug/rws <pebble-binary> <pebble-source-dir>
# HTTP/2 conformance with h2spec — must be 0 failed, 0 skipped
ci/e2e/h2spec.sh target/debug/rws <h2spec-binary>
# gRPC against tonic (ci/e2e/grpc, its own crate + Cargo.lock; needs protoc): every call type, cleartext and mTLS upstreams; h2c with grpcurl
cargo build --release --manifest-path ci/e2e/grpc/Cargo.toml --bins
ci/e2e/grpc.sh target/debug/rws ci/e2e/grpc/target/release/server ci/e2e/grpc/target/release/client [grpcurl]
# PROXY protocol v1/v2 behind HAProxy, plain and TLS listeners
ci/e2e/proxy_protocol.sh target/debug/rws "$(command -v haproxy)"
# MCP conformance (official suite via npx, CI workflow mcp-conformance.yml); known failures in ci/mcp-conformance-baseline.yml
ci/e2e/mcp_conformance.sh target/debug/examples/mcp_conformance ci/mcp-conformance-baseline.yml
# Fuzz every untrusted-input parser (nightly + cargo-fuzz) — see fuzz/README.md
fuzz/run.sh 60 4
```
Build `rws` with `--features "acme auth"` first for the ACME script. Pebble: `gh release download v2.10.1 --repo letsencrypt/pebble` (binary) plus `gh api repos/letsencrypt/pebble/tarball/v2.10.1` (its `test/` config and certs). h2spec: `gh release download v2.6.0 --repo summerwind/h2spec`. These caught bugs the unit tests didn't (ACME Host header/User-Agent, proxy 502s on non-canonical reason phrases, duplicate headers breaking HTTP/2) — run them after touching `acme`, `proxy`, `server`, `h2_server`, or response parsing.
**Parsers of untrusted input** (requests, upstream responses, bodies, JSON, MCP, SAML XML, config files) must not recurse per line, part, or nesting level without a bound: a stack overflow aborts the whole process and can't be caught. Use loops; where recursion is natural (XML nesting), cap the depth (`sso::saml::xml::MAX_DEPTH`). `src/stack_depth_tests.rs` runs each such parser on a 256 KB stack — add a case there for a new parser, and a fuzz target in `fuzz/`. Never `unwrap()` a parse of a header or body value. The sync `ThreadPool` wraps each job in `catch_unwind`, so a handler panic costs one connection, not a worker; tokio tasks already isolate panics. Request targets are normalized in `Request::parse` (origin-form kept, absolute-form reduced to its path, `*` kept, anything else rejected) and code that needs a URL from `request_uri` uses `URL::parse_request_target`, never `"http://localhost" + uri`.
**Container image** (`Dockerfile.release`): distroless static, nonroot, no shell or coreutils — startup code must not shell out (the banner used to run `whoami` and `unwrap()` it, crashing every 17.111–17.112 image) and must not assume a writable working directory (`/srv` is root-owned and usually mounted read-only; ACME state goes to the `/data` volume via `RWS_CONFIG_ACME_*_PATH` env defaults). The release workflow runs the image and checks `/healthz`, the served page and `--version` before pushing to GHCR and (with `DOCKERHUB_USERNAME`/`DOCKERHUB_TOKEN`) Docker Hub `bohdaq/rws`; its overview comes from `.github/DOCKERHUB.md`, synced by `dockerhub-overview.yml` on every change to that file.
Tests that write files use `crate::test_env::TempPath::new(name)` — it deletes the file/dir on drop, even when an assertion fails — never a bare path under `temp_dir()`.
MSRV is 1.88 (the highest `rust-version` among current dependencies across all features — tokio needs 1.85, `time`/`icu` via sqlx/tera need 1.88). CI checks it with the `msrv` job in `.github/workflows/ci.yml`; raise `rust-version` there and in `Cargo.toml` together.
## Required for every change
Every code change — new feature, bug fix, refactor — must include both:
### 1. Tests
- Add tests in a `tests.rs` sibling file (e.g. `src/csrf/tests.rs`) or inline `#[cfg(test)]` block.
- Use `TestClient::new(app)` for integration-style tests; pure unit tests for helpers and pure functions.
- Cover the happy path and at least one failure/edge case per public function or method.
- Run `cargo test` before committing. All tests must pass.
#### Shared-state lock — mandatory for any test that touches `RWS_CONFIG_*` env vars
`cargo test` runs all tests in parallel within one process. `RWS_CONFIG_*` environment variables are process-wide mutable state. Any test that **writes** them — directly or transitively — races with every other test that reads them, causing intermittent failures that are hard to reproduce.
**Rule:** hold `let _g = crate::test_env::lock();` as the very first line of every test that does any of the following (directly or via a called function):
| Trigger | Why it writes env vars |
|---|---|
| `std::env::set_var("RWS_CONFIG_*", …)` | direct write |
| `std::env::remove_var("RWS_CONFIG_*")` | direct remove |
| `override_environment_variables_from_config(…)` | reads a `.toml` file and writes all keys it finds, including `thread_count` |
| `bootstrap()` | calls `override_environment_variables_from_config(None)` which reads the project-root `rws.config.toml` |
| `config_reload::reload()` | calls `override_environment_variables_from_config(None)` |
| `CommandLineArgument::_parse(…)` / `set_environment_variable(…)` | calls `env::set_var` for each parsed arg |
| `metrics::SERVER_READY.store(…)` | mutates a global `AtomicBool` read by `/readyz` tests |
The lock is a `static OnceLock<Mutex<()>>` defined in `src/lib.rs` under `#[cfg(test)]`. It must be held for the **entire** test body — assign it to a named binding (`_g`, not `_`) so it isn't dropped immediately:
```rust
#[test]
fn my_test() {
let _g = crate::test_env::lock(); // held until end of function
override_environment_variables_from_config(Some("/src/test/rws.config.toml"));
// ... rest of test
}
```
**The transitive trap.** The lock is required even when a test doesn't call `set_var` directly. `bootstrap()` looks innocent but it calls `override_environment_variables_from_config(None)`, which finds the project-root `rws.config.toml` (which sets `thread_count = 200`) and writes it into the process environment. A test that calls `bootstrap()` without the lock will silently corrupt `RWS_CONFIG_THREAD_COUNT` for any concurrently running test that just set it to something else.
When adding a new test, ask: *does any function I call eventually call `env::set_var`?* If yes, take the lock.
#### Preferred alternative: `App::with_config` for CORS/CSP tests
Tests that only verify CORS, CSP, or other header behavior (not filesystem-dependent paths) should prefer the lock-free pattern:
```rust
#[test]
fn my_cors_test() {
// No env writes, no lock needed — runs safely in parallel.
use crate::server_config::ServerConfig;
let config = ServerConfig {
cors_allow_all: false,
cors_allow_origins: "https://example.com".to_string(),
..ServerConfig::default()
};
let app = App::with_config(config);
let client = crate::test_client::TestClient::new(app);
// ... assertions
}
```
`App::with_config(config)` pins the app to a fixed `ServerConfig` — no env reads happen during request processing, so no lock is needed even under parallelism. The `test_env::lock()` pattern is still required for tests that call `bootstrap()`, `override_environment_variables_from_config()`, or any function that writes `RWS_CONFIG_*` vars.
### 2. Docs — one home per fact
- **Usage** goes on the matching page under `docs/src/content/docs/` (`building-apps/`, `features/`, `proxy/`, `database/`, `mcp/`, `deployment/`, `configuration/`, `reference/`). Extend the page that covers the feature (a new `##` section, updated example); a new topic gets a new page plus a sidebar entry in `docs/astro.config.mjs`. Examples live only there — no copies in README, DEVELOPER.md or elsewhere.
- **API**: rustdoc on the item (`///`) and module (`//!`); a new public type also gets a row in `docs/src/content/docs/reference/api.md`.
- **Settings**: a new `RWS_CONFIG_*` goes on `configuration/env-vars.md` (and `config-file.md` if it has a file key) — `ci/check_docs.py` fails otherwise.
- **`CHANGELOG.md`**: an entry under "Unreleased" (breaking changes also on `reference/migrating-*.md`).
- **`llms.txt` / `llms-full.txt`** are generated: run `python3 ci/gen_llms.py` after changing docs (CI runs it with `--check`). Never edit them by hand.
- README.md is the pitch and links; touch it only for headline features.
Skipping either is not acceptable. Docs and tests ship with the code, not after.
## Architecture
The default build (`http3` feature) uses a tokio async runtime and serves HTTP/3 over QUIC, HTTP/2, and HTTP/1.1 over TLS. The `http1`-only build is a fully synchronous, thread-pool-based server with no async runtime. HTTP/1.1 parsing, JSON, CORS, MIME types, range requests, WebSocket, SSE, and routing are implemented from scratch (no hyper/Tower/Axum); HTTP/2 framing uses the `h2` crate, HTTP/3 uses `h3`/`quinn`, and TLS uses `rustls`.
### Request lifecycle
**Async builds (`http2`/`http3`, the default):** `main.rs` → `Server::setup()` binds the listener → `Server::run_tls()`. With a certificate it accepts TLS (10 s handshake deadline, `http1_async::TLS_HANDSHAKE_TIMEOUT`) and routes by ALPN to `h2_server` or `server::http1_async::serve`; without one it calls `Server::run_plain()`, an async accept loop (backs off on accept errors instead of exiting) that also uses `http1_async::serve`. `serve` is the one HTTP/1.1 handler for plain TCP and TLS: keep-alive loop, every read bounded by `http1_async::READ_TIMEOUT` (30 s) via one `Sleep` per connection that `read_more` pushes back before each read (extending a tokio timer is a lock-free CAS; `tokio::time::timeout` per read took the timer driver's shared lock), the synchronous `Application` run via `http1_async::execute_request` (HTTP/2 and HTTP/3 use the same helper): inline on the async task when `Application::runs_inline(request, connection)` is true (panics caught with `catch_unwind`), otherwise `execute_blocking` on tokio's blocking pool. Before that, if the body is non-empty and `Application::streams_body` says so (#31; `WithMiddleware` only if every layer's `accepts_streamed_body`, `AppWithState::stream_body` routes, `McpServer` forwards for non-MCP paths), `serve` runs the handler on the blocking pool at once with `request.body` = the receiving end of `server::body_pump::body_channel` (4 chunks) and `pump_body` feeds it (length or `ChunkedBody::take_decoded`), racing the handler; an unfinished body means `Connection: close`. HTTP/2 (`h2_server::Conn::start_streamed`) does the same from HEADERS: DATA goes to a `flow_controlled_body_channel` whose reads send `(stream, n)` back over `credit_tx`, and only then is the stream window credited; `waiting_on_client` counts such handlers as waiting on the client, and teardown/RST_STREAM send them an error. `runs_inline` defaults to `false`; `App` says yes for GET/HEAD, `WithMiddleware` only if every layer's `Middleware::runs_inline` and the inner app agree (the built-in in-memory layers opt in), `AppWithState` only when no user route matches (`Router::matches`), `ConfigDrivenApp` only when no route matches and there's no global `[middleware]`, `McpServer` never for `/mcp`. Never return `true` for anything that can wait (DB, upstream, Redis) — it would stall the worker. The blocking-pool handoff (a shared lock, two cross-thread wake-ups) was the biggest per-request cost for small static files. `stream_file` GETs with a `Content-Length` over plain TCP go out via `sendfile(2)` (`server::sendfile::send_file(socket, head, file, len)`, Linux/macOS; `SendfileSocket` exposes the `TcpStream` only for plain TCP) with the head from `Server::stream_file_head` in the same segment — Linux sends the head with `MSG_MORE` (not `MSG_MORE` for an empty body, or it waits 200 ms; `TCP_CORK` on/off cost two `setsockopt`s per response), macOS `sendfile`'s `sf_hdtr` header vector — and the file handle taken from `open_file_cache::open` rather than reopened by path. Buffered responses with a body ≤ `http1_async::COALESCE_LIMIT` (64 KiB) are written head + body in one `write_all` — except that on a stream with vectored writes (plain TCP) a body ≥ `VECTORED_MIN` (16 KiB) goes beside the head in one `writev` (`write_all_vectored`) instead of being copied behind it; a `stream_file` GET with a `Content-Length` where `sendfile` doesn't apply (TLS) is read on the connection's task (`write_file_inline`: `pread` 64 KiB at a time from the open-file cache's handle into one buffer, as nginx does without aio — the blocking-pool hand-off per chunk cost 13% of a 1 MiB TLS download and, under mimalloc, cross-thread frees doubled its peak memory); everything else (`stream_pipe`, chunked files, HEAD) is written by the sync chunked writers on the blocking pool through a bounded channel (`ChannelWriter`; written chunks come back over a second channel for reuse — a fresh 64 KiB `Vec` per chunk kept faulting in new pages). HTTP/2 and HTTP/3 stream those bodies via `server::body_stream::spawn_producer` (64 KB chunks; proxied chunked framing decoded by `ChunkedDecoder`; HTTP/2 sends within the peer's flow-control window). With `RWS_CONFIG_RUNTIME=per-core` (`server::per_core`, Unix) `run_tls`/`run_plain` drop the setup listener and start one `current_thread` runtime per worker (`TOKIO_WORKER_THREADS`, else one per CPU), each accepting on its own `SO_REUSEPORT` socket, while the main runtime keeps the signals (a `watch` stops the loops and carries the rebuilt TLS acceptor after SIGHUP), HTTP/3, redirects and ACME; tokio I/O belongs to the runtime that made it, so async upstream pool keys get the runtime's index (`per_core::pool_key`) — anything else that keeps tokio sockets in a process-wide structure must do the same. Before the TLS handshake or any HTTP parsing, `serve_plain_connection`/`serve_tls_connection` (and the sync `dispatch_connection`, on the worker) read a PROXY protocol header when `proxy_protocol = true` (`server::proxy_protocol`: pure `parse` → `Need`/`Header`/`Invalid`, read exactly — v1 a byte at a time past 12 — so the ClientHello/request after it is untouched; `enabled()` is a `OnceLock`, so its end-to-end test is the separate binary `tests/proxy_protocol.rs`; `ci/e2e/proxy_protocol.sh` behind HAProxy), and the plain listener `peek`s for the HTTP/2 preface (`starts_with_h2_preface`) to serve h2c with prior knowledge through `h2_server::handle_connection`. Connections are tasks, so idle clients can't exhaust anything but file descriptors — `ci/e2e/slow_clients.sh` holds 1000 idle sockets and requires service to continue.
**`http1`-only build:** `Server::setup()` also creates the `ThreadPool` → `Server::run()` accepts connections → dispatches each to the pool (30 s socket read timeout) → `Server::process()` implements an HTTP/1.1 keep-alive loop: reads bytes, calls `Request::parse()`, calls `app.execute()`, applies compression (`compression::apply_compression()` — the coding negotiated from `Accept-Encoding` q-values among `zstd`/`br`/`gzip` (`negotiate`, `DYNAMIC_PREFERENCE`; br/zstd are the default `brotli`/`zstd` features) at `Level::Dynamic` (brotli 4, zstd 3); text bodies ≥ `MIN_COMPRESS_SIZE` 512 B only; never a response that already has `Content-Encoding` or a 206; the original is kept when the result isn't smaller; a compressed body's `ETag` gets `-<coding>`), records metrics, sets `Connection` header, then writes the response. For large files `response.stream_file` triggers `Server::write_chunked_file()` instead.
Request framing for both HTTP/1.1 loops (`Server::process` and the async `http1_async::serve`) lives in `src/server/framing.rs`: each connection keeps a `pending` byte buffer across requests; `scan_head` finds the end of the head (it may span many reads, capped at `MAX_HEAD_SIZE` = 64 KiB → `Server::header_fields_too_large_response()` 431); the head alone is parsed by `Request::parse` (`cursor_read` slices each line out of the input; its pre-rewrite body is kept in `request/reference_parser.rs` for `request_parse_matches_the_reference_parser` — keep that test passing); `body_framing` picks `Content-Length` / chunked / none (CL+TE, conflicting CLs, non-chunked TE → 400); the body is drained from `pending` by length, or decoded incrementally by `ChunkedBody::feed`; whatever remains is the next pipelined request. A body cut short by the client ends the session without dispatching. A head must also complete within `server::REQUEST_HEAD_TIMEOUT` (30 s) of its first byte (`Server::request_timeout_response()` 408): the async loop passes that deadline to `read_more`, which arms the idle timer for the earlier of it and `READ_TIMEOUT`; the sync loop checks elapsed time after each read. Waiting for the next keep-alive request is governed by the idle timeout only.
`Server::process()` and `http1_async::serve()` both support `Expect: 100-continue` (RFC 9110 §10.1.1): after parsing headers and passing the `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` check, a request with `Expect: 100-continue` gets `Server::continue_response()` written immediately, before the body-continuation read loop runs — otherwise the read loop would block waiting for a body the client is itself waiting to be told to send. An `Expect` value other than `100-continue` gets `Server::expectation_failed_response()` (417) without reading the body. HTTP/2 (`h2_server`) and HTTP/3 (`h3_handler`) read bodies as separate async `DATA` frames rather than one blocking read, so they don't have this deadlock risk and don't implement it.
`Server::run_redirect()` optionally listens on a second port and issues `301` redirects to HTTPS.
WebSocket routes (`WebSocket::upgrade`, `AppWithState::websocket`, `Router::websocket`; `src/websocket/connection.rs`): the handler can't return a socket, so `upgrade` stores it in a process-wide table under a random token sent as the internal header `X-Rws-WebSocket-Upgrade` on the `101` (no new `Response` field — struct literals would break). After the app returns, `http1_async::serve` and the sync `Server::process_upgradable` call `websocket::take_upgrade` (removes the header; only a `101` with a known token hands over), write `upgrade_head` (no `Content-Length`), and give the connection — `pending` bytes first — to the handler: async builds via `hand_over_async` (`tokio::io::split` halves bridged to blocking `Read`/`Write` with `Handle::block_on`, on `spawn_blocking`), the sync loop via its `split` callback (`try_clone`, read timeout cleared, new thread). Public `Server::process` passes no `split`, so an upgrade there gets `500`. Unsent handlers expire after `UPGRADE_TTL`. `WebSocketConnection::recv` reads `read_raw_frame` (RSV, mask, opcode) and enforces RFC 6455 (close codes 1002/1007/1009). `ci/e2e/realtime.sh` + `realtime_client.mjs` (Node 24 WebSocket/fetch) against `examples/realtime.rs`.
### Routing / controller dispatch
`App` (in `src/app/mod.rs`) implements the `Application` trait (`src/application/mod.rs`). `Application::execute` returns `Result<Response, String>`. `App::execute` walks a hardcoded list of `if Controller::is_matching(...)` checks and calls the first matching controller's `process()`. Controllers are checked in declaration order. Static files come after every built-in: `App::execute` first tries `StaticResourceController::serve_regular_file` (it only succeeds for a regular file, so `is_matching` would have matched) and otherwise asks `is_matching` and calls `process_general` (the same as `process` without that fast path) — so a static GET builds its path and looks up the open-file cache once, not twice.
The `Controller` trait (`src/controller/mod.rs`) has two methods:
- `is_matching(request, connection) -> bool`
- `process(request, response, connection) -> Response`
Three ways to add routes:
1. **Controller pattern** — create a module under `src/app/controller/`, implement `Controller`, register it in `App::execute`.
2. **Router** — build a `Router` inside `Application::execute` and call `router.handle(request, connection)`. Prefer this for new code when you need named path parameters or don't want boilerplate controllers.
3. **State-aware app** — `App::with_state(S)` returns `AppWithState<S>` which has `.get()`, `.post()`, `.put()`, `.patch()`, `.delete()` builder methods accepting `fn(request, path_params, connection, state: &S) -> Response`. `App::with_async_state(S)` gives the same API with `async fn` handlers (requires `http2` feature). Wrap with `.wrap(layer)` to add middleware.
### Application variants
- `App` — zero-config, wraps all built-in controllers. Entry point: `App::new()`.
- `AppWithState<S>` (`src/state/mod.rs`) — state-aware dynamic router; `state: Arc<S>` shared across handlers. Entry point: `App::with_state(S)`.
- `AsyncAppWithState<S>` (`src/async_state/mod.rs`) — same as `AppWithState` but handlers are `async fn`; requires `http2` feature. Entry point: `App::with_async_state(S)`.
- `WithMiddleware<A>` (`src/middleware/mod.rs`) — wraps any `Application` with a middleware stack. Entry point: `app.wrap(layer)`.
- `McpServer` (`src/mcp/mod.rs`) — implements `Application`; serves the MCP Streamable HTTP protocol at `POST /mcp`. Entry point: `app.mcp(name, version)` or `McpServer::new(name, version)`.
### Middleware
`src/middleware/mod.rs` defines the `Middleware` trait:
```rust
trait Middleware: Send + Sync {
fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application) -> Result<Response, String>;
}
```
`WithMiddleware<A>::wrap(layer)` pushes layers onto a `Vec<Box<dyn Middleware>>`; layers are applied in push order (first-pushed is outermost). Any `Application` can be wrapped via `.wrap(layer)`.
Built-in middleware: `RateLimitLayer`, `MetricsLayer`, `CacheLayer`, `OtelLayer`, `RewriteLayer`, `ReverseProxy`, `H2ReverseProxy`, `GrpcProxy`, `GrpcWebLayer`, `CanaryLayer`, `RetryLayer`, `BasicAuthLayer`, `JwtLayer`, `ForwardAuthLayer`, `CsrfLayer`, `IpFilter`, `BlocklistLayer`, `MaintenanceLayer`, `LogLayer`, `RequestIdLayer`. (`TimeoutLayer` wraps an `Application` rather than implementing `Middleware`.)
### Configuration
Configuration is layered (lowest → highest priority):
1. Defaults hardcoded in `src/entry_point/mod.rs` (`Config::*_DEFAULT_VALUE`)
2. System environment variables
3. `rws.config.toml` in the working directory
4. Command-line args (`rws.command_line`)
All config is read at startup into process environment variables (`RWS_CONFIG_*`); per-request code reads the typed `ServerConfig::current()` snapshot (`src/server_config/`, rebuilt after `server_config::invalidate()`), not `env::var(...)` — see the static-file hot path below.
Key config constants (all in `src/entry_point/mod.rs`):
- `RWS_CONFIG_IP`, `RWS_CONFIG_PORT`, `RWS_CONFIG_THREAD_COUNT`
- `RWS_CONFIG_TLS_CERT_FILE`, `RWS_CONFIG_TLS_KEY_FILE`
- `RWS_CONFIG_TLS_CLIENT_CA_FILE` — mTLS CA cert; empty disables client cert verification
- `RWS_CONFIG_HTTP_REDIRECT_PORT` — if set, `Server::run_redirect()` sends `301` to HTTPS on this port
- `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS`, `RWS_CONFIG_RATE_LIMIT_WINDOW_SECS`
- `RWS_CONFIG_LOG_FORMAT` — `"json"` (the binary's default), `"combined"`, or `"off"`/`"none"` (no per-request access-log line)
- `RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES` — per-read chunk size (default 10000); bodies larger than this span multiple reads automatically
- `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` — max accepted request body size (default `0`, unlimited); `413 Payload Too Large` before buffering when exceeded, checked in `Server::process`, `http1_async::serve`, `h2_server`, and `h3_handler`
`rws.config.toml` → env mapping (`entry_point::config_file::read_config_file`): a key's setting is `RWS_CONFIG_` + table + key, upper-cased (`[rate_limit] max_requests` → `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS`). If that matches a `CommandLineArgument` it goes through the CLI parser; otherwise it must be listed in `config_file::SETTINGS_WITHOUT_FLAG` (a test scans `src/` for every `"RWS_CONFIG_*"` literal and fails if one isn't reachable — add new settings there). `[[virtual_host]]` blocks become `RWS_CONFIG_VIRTUAL_HOST_<n>_{DOMAIN,CERT_FILE,KEY_FILE}`. Unknown keys print a startup warning; proxy tables (`route`, `upstream`, `tcp_proxy`, `udp_proxy`, `ws_proxy`) are skipped. Values are real TOML-ish strings (quotes stripped, inner spaces kept; arrays → comma lists). `RWS_CONFIG_FILE` selects the file for settings and proxy routes alike. Long-only CLI arguments use an empty `short_form` (never matched by `-=`). The `rws` binary enforces the global rate limit only when `rate_limit::is_configured()`. Proxy routes (`[[route]]`/`[[upstream]]`) go through the separate `proxy_config::parser`, where action keys may sit directly under `[route.action]` or in `[route.action.<type>]` (`merge_action_keys` folds the former into the latter; nested wins).
ACME (`acme` feature): `main.rs::start_acme()` → `AcmeConfig::from_env()` (empty env values = unset; cert/key default to the TLS paths, else `cert.pem`/`key.pem`) → `install_as_tls_certificate()` (fills empty `RWS_CONFIG_TLS_*` so `run_tls` serves it) → `provision_until_ready()` (backoff retry) → `run_renewal_loop()`. Renewal (`AcmeManager::check`, `renewal_time`) is due at two-thirds of the certificate's lifetime (`crypto::read_cert_info` reads notBefore/notAfter and the RFC 9773 certID = base64url(AKI keyIdentifier).base64url(serial)), or earlier when the directory's `renewalInfo` window opens sooner; renewal orders send `replaces` (retried without it if refused) and `profile` (`RWS_CONFIG_ACME_PROFILE`, checked against `meta.profiles`). `RWS_CONFIG_ACME_RENEW_BEFORE_DAYS`, if set, replaces the two-thirds point. `ci/e2e/acme_pebble.sh` orders Pebble's `shortlived` profile and renews through Pebble's `/set-renewal-info/` management endpoint. HTTP-01 key authorizations live in a process-wide store (`acme::challenges()`); a temporary listener serves them at startup, and once `Server::run_redirect` has bound the challenge port (`note_redirect_listener`) it serves them instead, so renewal never re-binds port 80. Challenge and redirect listeners use `server::bind_all_families` (IPv4 + IPv6). The ACME HTTP client sends `User-Agent` and a `Host` header with non-default ports, and trusts `RWS_CONFIG_ACME_CA_FILE` roots for private CAs. Verified end to end against Pebble. Challenges (`AcmeConfig::challenge`, `[acme] challenge`, since 18.4.0) are answered by `AcmeManager::present` → `Presented::withdraw`: HTTP-01 as above; TLS-ALPN-01 (`acme::tls_alpn`, RFC 8737) puts an rcgen certificate with the critical acmeIdentifier extension in a process-wide store that `tls::SniCertResolver` serves only to ClientHellos offering `acme-tls/1` (added to the acceptor's ALPN list when `tls_alpn::enabled()`; `serve_tls_connection` drops such connections after the handshake) — at startup a temporary acceptor on a `try_clone` of the HTTPS socket that `main` hands over (`set_startup_listener`, because `Server::setup` binds before `start_acme`), after `run_tls` notes the listener (`note_tls_listener`) the live resolver; DNS-01 (`acme::dns01`) publishes `_acme-challenge.<authz name>` = base64url(SHA-256(key authorization)) via `rfc2136` (UPDATE over TCP: delete the TXT RRset, add the value, TTL 60; TSIG per RFC 8945 over the message + variables, MAC pinned by an independently built vector in `dns01/tests.rs`; reply RCODE + TSIG error explained, reply MAC not verified) or `exec` (`<program> present|cleanup <fqdn.> <value>`, no shell), waits for it at `dns_server` (or the system resolver), and deletes it after validation; the zone is configured or the SOA owner the server returns. `cloudflare` and `route53` go through `acme::dns_api` (`AsyncClient`; zone by `dns_zone_id` or the record name's parents in turn; Route 53 requests signed by `sigv4_headers` — pinned to AWS's `get-vanilla` vector — over the host as the client sends it, i.e. without the port; `dns_api_url` replaces the endpoint, which `ci/e2e/acme_dns_api.sh` points at `ci/e2e/dns_api_mock.py`, a stand-in that writes into BIND and verifies SigV4 itself). DNS-PERSIST-01 (`ChallengeType::DnsPersist01`): no token, `issuer-domain-names` from the challenge; `AcmeManager::check_persist_record` looks up `_validation-persist.<name>` (`dns01::persist_record_matches`: issuer, `accounturi` = the account URL, `policy=wildcard` when `*.<name>` is in `domains`, `persistUntil`) and errors with the exact record to publish when it's missing — rws never writes it (`DnsProvider::Manual`). DNS wire format lives in `crate::dns` (shared with `service_discovery::dns_srv`; fuzz target `dns_message`). Startup output hides values of settings named `*SECRET*`/`*PASSWORD*`/`*TOKEN*` (`entry_point::shown`); `dns_tsig_secret` has no CLI flag. `ci/e2e/acme_dns01.sh` runs Pebble with `-dnsserver` against a BIND copy (outside AppArmor's profile). With `[acme] redis` (`acme::shared`, `RespConn`, keys under `redis_prefix`) replicas share state: `check()` first adopts a newer certificate from Redis (`adopt_shared`, then the usual hot-swap), orders go through `provision_coordinated` (`SET NX PX` lock for 5 min; a loser polls Redis for the winner's certificate; the winner stores cert+key, then unlocks via a compare-and-delete EVAL), and HTTP-01/TLS-ALPN-01 answers are also put in Redis (10-min expiry) so `challenge_http_response_shared` and `tls_alpn::certificate` answer challenges another replica presented. Redis errors are logged and never fatal. `ci/e2e/acme_shared.sh`: two replicas, one order per certificate. On-demand TLS (`acme::on_demand`, `[acme] on_demand` + `on_demand_ask`): when `on_demand::enabled()`, `serve_tls_connection` reads the ClientHello with `LazyConfigAcceptor`, awaits `on_demand::ensure(sni)` (skipped for `acme-tls/1`; a spawned `obtain` task — not cancelled with the handshake — asks the endpoint, takes a per-name tokio mutex and a 4-permit semaphore, then `AcmeManager::check` with a per-name `AcmeConfig` under `<state dir>/on-demand/<name>/`), then finishes with `acceptor.config()`; `SniCertResolver` consults `on_demand::certificate` after the vhosts. Refusals are held off 1 min, failures 10 min; `renewal_loop` re-installs renewed ones into the store (no SIGHUP). `ci/e2e/acme_on_demand.sh`: Pebble + BIND (`*.example.test`) + a Python ask endpoint.
Static-file hot path: `StaticResourceController::serve_regular_file` answers a GET/HEAD of a regular file without `Range` through `static_resource::open_file_cache` (open handles per path, 8 `RwLock` shards, 128 files max, revalidated by one path `stat` — device+inode — every `RWS_CONFIG_OPEN_FILE_CACHE_SECS`, default 1, `0` = off, Unix only; a hit is one `fstat` for current size/mtime (skipped within the window when `RWS_CONFIG_OPEN_FILE_CACHE_STRICT=false`, nginx-style; `OpenFile::state` calls `libc::fstat` on Linux — `File::metadata` uses the heavier `statx`, 1.1 vs 0.7 µs) and a `pread`; names without a known MIME type open with `O_NOFOLLOW` so symlinks go to the general path; `is_matching` skips its `stat` for a cached file) (`OpenFile::contents(state)` also caches the bytes of cache entries ≤ `MAX_CACHED_FILE` (64 KiB) within `CONTENTS_BUDGET` (8 MiB, atomic counter released on drop), served while the per-request `fstat`'s `(len, modified)` match, and only for files modified ≥ 2 s ago (coarse-mtime filesystems); small files otherwise read from that handle; static responses (fast and general path) get `Cache-Control` from `ServerConfig::static_cache_control` (default `no-cache`; other responses keep `no-store, …`), a standard `Last-Modified`, and `is_not_modified` (If-None-Match, else If-Modified-Since for GET/HEAD) → 304 — `CacheLayer` refuses `no-cache`, `Range` and `Vary`-outside-its-key responses, so this doesn't make it cache static files; for a client that accepts a coding (`negotiate` with `STATIC_PREFERENCE`: br, zstd, gzip), a compressible file ≤ `MAX_GZIPPED_FILE` (1 MiB — above `STREAM_THRESHOLD` too, instead of an uncompressed sendfile) is sent from `OpenFile::encoded(state, encoding, path)` — a precompressed `<path>.br`/`.zst`/`.gz` sibling (regular file, not a symlink, mtime ≥ the file's) or `compression::encode` at `Level::Static`, cached per `(len, modified)` in one slot per coding within the same budget, headers from `compression::mark_encoded`, ETag `"<mtime>-<len>-<coding>"`, so `apply_compression` then sees `Content-Encoding` and does nothing; compressing per request cost ~80× serving the file; over `STREAM_THRESHOLD` (256 KiB) → `stream_file` before reading). It declines directories, OPTIONS, ranges and symlinks whose name has no known MIME type (the general path takes the type from the link target) — `process_static_resources` stats and opens several times. Use `request.header(Header::_X)` (case-insensitive `&str` lookup, no allocation), not `get_header(Header::_X.to_string())`. `Header::get_header_list_with_config` clones a cached `FixedHeaders` (built per CSP value, `client_hints` and `security_headers` setting — client hints, `X-Frame-Options`/`Referrer-Policy`/`Permissions-Policy` and the default CSP are opt-in since 18.0.0: `RWS_CONFIG_CLIENT_HINTS`, `RWS_CONFIG_SECURITY_HEADERS`; an explicit `RWS_CONFIG_CSP` is always sent) plus a standard `Date` (`http::date::now()`, formatted once per second per thread) and, with `RWS_CONFIG_NANOS_HEADERS` (opt-in since 18.0.0, as is `Last-Modified-Unix-Epoch-Nanos` on static files), `Date-Unix-Epoch-Nanos` (per request — never cache it) around the per-request CORS and `Date` headers; its values are interned (`Header::interned`: each distinct string leaked once into a process-wide set — bounded by the distinct CSP values ever configured), so that clone allocates nothing. `Log::log_access` formats straight into a per-thread buffer (`log::access_sink`: written to stdout by a background thread every 100 ms, past 32 KiB, on thread exit, and via `atexit`; `Log::flush_access_log()` forces it; with `RWS_CONFIG_ACCESS_LOG` the flushes go to that file instead — `access_sink::LogFile`, size rotation `RWS_CONFIG_ACCESS_LOG_MAX_SIZE`/`_KEEP`, reopened on `SIGUSR1` via a flag-only handler installed by `Log::reopen_access_log_on_sigusr1()` in `main.rs` only when a file is configured; `ci/e2e/access_log.sh`) — `println!` per request (stdout lock + one write syscall) was ~4 µs of a ~26 µs request; `Log::json`/`Log::combined` keep returning `String` for callers. `request_log::RequestLog` is sharded per thread (global sequence numbers, each shard holds up to `capacity`, readers merge and keep the newest `capacity`) — one mutex there was the top lock wait. `http1_async::serve` builds `ConnectionInfo` once per connection and `execute_request` borrows it. Per-request config comes from `ServerConfig::current()` (an `Arc` snapshot behind a generation counter), never `from_env()`: `std::env::var` takes the process-wide environment lock, and ten of them per request showed up as lock contention. Anything that writes `RWS_CONFIG_*` must call `server_config::invalidate()` (config file loader, CLI parser, `set_default_values`, `bootstrap`, `config_reload::reload`, `secrets` already do; tests that `set_var` directly must too). Use `static_resource::static_fs_path`/`working_directory()` (the working dir, cached once) instead of `FileExt::get_static_filepath`, and `static_resource::file_modified_utc` (a `stat`) instead of `FileExt::file_modified_utc` (an `open`); `getcwd` and repeated opens dominated the profile. `is_matching` implementations must be silent and check cheap things first (method before path) — an `eprintln!` there serialized every request on the stderr lock, and each built-in's `get_uri_path()` (a full URL parse) was asked for every static request; take the path with `URL::request_target_path` (borrowed, up to `?`/`#`) and turn it into a filesystem path with `static_resource::os_specific_path` (no copy on Unix). Connection writers use `Response::generate_response_parts` (head and body separately; single-range bodies are moved, not copied) with `TCP_NODELAY` set on accepted sockets; `http1_async` uses `Response::generate_response_coalesced(.., COALESCE_LIMIT)`, which sizes one buffer for head + small body (appending the body to an exactly-sized head reallocated every response) and writes `Content-Type`/`Content-Range`/`Content-Length` straight into the head. Numbers written per request (status, sizes, ETag, nanosecond timestamps) go through `ext::decimal::Decimal` (stack digits, u64 fast path), not `to_string()`/`format!`; `Response::set_status` reuses the reason-phrase buffer and `Response::empty()` is the allocation-free placeholder for `mem::replace`. `bench/run.sh` (and the manual `Benchmark` workflow) compares against Caddy and nginx (no compression, so all do equal work). Runner hardware varies threefold between runs, so judge a change only against its baseline in the same run: workflow input `base_ref` (`RWS_BASE=<binary>` locally) benches that build as `rws-base` beside `rws`; `profile` also samples nginx's workers. Release binaries use the `mimalloc` feature (`#[global_allocator]` in `main.rs` only — the library never sets one): mimalloc v3 at its defaults, 4–6% less CPU per request than glibc in same-run A/Bs, 2.5–4× the peak memory; `MIMALLOC_PURGE_DELAY=0` saves memory but re-faults pages (up to +50% CPU on 1 MiB TLS downloads). Per-core runtimes (`RWS_CONFIG_RUNTIME=per-core`; the `rws` binary's default since 18.0.0 via `per_core::default_to_per_core()` in `main.rs`, library apps stay `shared`) cost 5–17% more CPU per request than the shared runtime on slow/medium runners but win on the fastest ones (proxy −4% CPU, +12% req/s; small files +16% req/s — run 40242045036): tokio's idle-worker park/unpark is what limits throughput on fast CPUs. Compare runtimes in one run with workflow input `base_runtime` (`RWS_BASE_RUNTIME`).
Byte ranges: `Range::parse_range_in_content_range` returns an **inclusive** `end` (RFC 9110 §14.1.2: open-ended/suffix ranges end at the last byte, a last-pos past EOF is clamped, oversized suffixes select the whole file). `response::content_range_header_value(status, cr)` is the single source of the `Content-Range` header — emitted only on `206` — used by `generate_response`, `generate`, `h2_server`, `h3_handler`, and `TestClient`. Note `Range::get_content_range(body, ..)` (full bodies) still stores `end = len`; it never reaches a header because full bodies are `200`.
`Response::_parse_http_version_status_code_reason_phrase_string` (used by `Response::parse`, which parses every proxied upstream response) accepts any 3-digit status ≥ 100 and any or no reason phrase (RFC 9112 §4) — it used to require the canonical phrase for a known code, so a backend's "404 File not found" became a 502. `Response::parse_raw_response_via_cursor` slices each line out of the input (no per-line `Vec`/`String`; `without_cr_lf` and `split_name_value` borrow instead of `str::replace`/`split_once(": ")`); its pre-rewrite body is kept verbatim in `response/reference_parser.rs` (test-only) and `response_parse_matches_the_reference_parser` compares the two on a corpus plus deterministic mutations — keep that test passing when changing the parser. HTTP/2 advertises `SETTINGS_MAX_CONCURRENT_STREAMS = h2_server::MAX_CONCURRENT_STREAMS` (250) and `SETTINGS_MAX_HEADER_LIST_SIZE = h2_server::MAX_HEADER_LIST_SIZE` (64 KiB; larger lists get `431`); HTTP/3 uses the same value for `max_field_section_size`. `URL::parse` uses rws's own panic-free RFC 3986 parser (`url::parse_url`), not url-build-parse's (which unwraps on input like a non-numeric port); url-build-parse still provides `UrlComponents` and `build_url`.
Config-driven proxy routes (HTTP and HTTPS upstreams) with no route middleware and no global `[middleware]` skip the blocking pool entirely: `CompiledRoute.async_proxy` (set in `builder::compile_routes` when `DynamicProxy::supports_async()` — any `http2` build — and `route.middleware.is_empty()`) → `ConfigDrivenApp::execute_async` → `DynamicProxy::execute_async` (shares `prepare()` — backend choice, least-connections guard, prefix rewriting — with the sync `execute`) → `proxy::async_upstream::forward` (generic `forward_on<S: UpstreamConn>`: a `Pool<tokio::net::TcpStream>` for plain backends, `Pool<AsyncTlsStream>` (tokio-rustls) for TLS — the WebPKI `default_tls_pool()` or `UpstreamTls::async_tls()`'s per-CA-file pool; same `build_request`/stale-retry/`should_stream_response` rules; a streamed plain response converts the socket with `into_std()`, a streamed TLS response is copied by a task into a bounded channel read by `ChannelBodyReader`, both handed to the blocking writer). `Application::execute_async` (`http2`, default `None`) is awaited by `http1_async::execute_request` before `runs_inline`/`execute_blocking`, panics caught by `CatchUnwind`; `WithMiddleware` offers it only when every layer's `Middleware::is_passthrough` is true (the binary's `GlobalRateLimit` is when no rate limit is configured); `McpServer` forwards it for non-MCP paths. HEAD/1xx/204/304 responses end at the head in both forwarders (`proxy::response_has_no_body`). Upstream TLS is decided per route from the declared `[[upstream]]`, else from an `https://` upstream address. Otherwise upstream HTTP/1.1 goes through `proxy::forward_upstream(plain_pool, …, tls, …)` (ReverseProxy, `DynamicProxy`, canary; ingress calls `forward_pooled` directly): plain backends use a `ConnPool` (`= Pool<TcpStream>`; `proxy::shared_pool()`, 64 idle per `host:port`; ReverseProxy has its own), `https://` backends a process-wide `Pool<TlsStream>` (`rustls::StreamOwned`) — or, with `[[upstream]] tls_ca_file` / `ReverseProxy::tls_ca_file`, an `UpstreamTls` (CA roots replacing WebPKI, a per-CA-file pool; built per `compile_routes` so reload re-reads it; unloadable → `UpstreamTls::reject_all`, i.e. 502; health checks use it too) via `forward_upstream_trusting` — both driven by the generic `forward_via<S: UpstreamIo>` / `exchange` (same streaming, chunked decoding and stale-connection handling). A pooled connection that fails before any response byte is retried once on a fresh connection only for idempotent methods. `connect_tcp` tries every resolved address (`localhost` → `::1` then `127.0.0.1`). The TLS client config is built once (`tls_client_config()`). Every outbound rustls client is built with `crate::tls_client_builder()` (explicit aws-lc-rs provider): `ClientConfig::builder()` panics when no default provider is installed and two are compiled in, and `quinn` is configured with `rustls-aws-lc-rs` (not its default `rustls-ring`) so the default build has only one.
Proxied buffered responses go through `proxy::normalize_upstream_response`: upstream `Content-Type` moves onto the body's `ContentRange`, upstream `Content-Length`/`Transfer-Encoding`/hop-by-hop headers are dropped (rws regenerates them — a duplicate `content-length` is an HTTP/2 protocol error), and the status line is re-emitted as HTTP/1.1. Streaming (`stream_pipe`) responses are not normalized.
Hot reload: `SIGHUP` (or `POST /admin/config/reload`) calls `config_reload::reload()` which re-reads CORS rules, rate limits, log format, and request allocation size, then runs hooks registered with `config_reload::on_reload`. On TLS builds, SIGHUP also rebuilds the `TlsAcceptor` from updated certs for all virtual hosts. `proxy_config::build_from_file()` registers a hook that calls `ConfigDrivenApp::reload_from_file()`: `ConfigDrivenApp` is an `Arc<RwLock<Arc<Generation>>>` shared by all clones (each `Generation` = compiled routes + fallback + global-middleware entry + `ProxyConfig` + source text + a `health_stop` flag); `reload_from_str` validates (`check_unknown_keys` + `validate`), keeps the old generation on errors, otherwise compiles routes (`builder::compile_routes`, new health checkers) and swaps, setting the old `health_stop`. Reloads are serialized (`reload_lock`) because one SIGHUP reaches every accept loop; an unchanged source is a no-op. L4/WS listeners (`builder::spawn_listeners`) are startup-only. With `watch = true` (`RWS_CONFIG_WATCH`) `config_watch::start()` (called from `main.rs`) polls the size+mtime of the config file and every TLS cert/key once a second and, when a change is the same on two polls (`Watcher::poll`), calls `config_reload::request_reload()` (SIGHUP to itself). `ci/e2e/config_reload.sh` exercises `--check` exit codes, SIGHUP reload and the watch reload against the binary.
### Key types
- `Request` (`src/request/mod.rs`) — method, request_uri, http_version, headers, body (`request::Body`, 18.0: bytes or a stream; derefs to `&[u8]`, build with `.into()`/`Default::default()`)
- `Response` (`src/response/mod.rs`) — status code, headers, body as `Vec<ContentRange>`, and `stream_file: Option<String>` for chunked file streaming
- `Header` (`src/header/mod.rs`) — name/value pair, both `Cow<'static, str>` (18.0.0): build built-in headers from the `Header::_X` constants and `&'static str` values (borrowed, no allocation) — never `Header::_X.to_string()` — and other values from a `String` (`Header::new(name, value)` or `.into()`); read via deref (`&*h.value`; `str::as_str` isn't stable on the MSRV, use `.as_ref()`). Both parsers take header names through `Header::name_from` (common names in their canonical or lower-case spelling are borrowed, anything else owned — same text)
- `ConnectionInfo` (`src/server/mod.rs`) — client/server IP+port, request allocation size, and `sni_hostname: Option<String>` (SNI hostname from TLS handshake, `None` for plain HTTP), passed into every controller
### HTTP version constants
`src/http/mod.rs` defines `VERSION` (HTTP/0.9 through HTTP/3.0) and `HTTP::version_list()` which is used by `Request::parse_method_and_request_uri_and_http_version_string` to validate the version token on every incoming request.
### Dynamic router
`src/router/mod.rs` provides `Router` — a fluent, path-based router with named parameters and wildcards. Register handlers with `.get()`, `.post()`, `.put()`, `.patch()`, `.delete()`, then call `router.handle(request, connection)` from inside `Application::execute`. Pattern syntax: `:name` for a named segment, `*name` for a trailing wildcard. `PathParams::get(name)` retrieves extracted values.
Call `.with_host("example.com")` before registering routes to restrict a `Router` to requests whose SNI hostname (TLS) or `Host` header (plain HTTP) matches that value — the foundation for virtual-host routing.
### Typed extractors
`src/extract/mod.rs` defines the `FromRequest` trait and built-in extractors:
- `Body` — raw bytes (never fails)
- `BodyText` — UTF-8 body (400 on invalid UTF-8)
- `Query` — parsed query string as `HashMap<String, String>`
- `RequestHeaders` — all request headers with case-insensitive `get(name)`
- `RequestId` — the ID `RequestIdLayer` put on the request (empty string without it; never fails)
### Typed errors
`src/error/mod.rs` defines:
- `IntoResponse` trait — implement on your error enum to map it to a `Response`; `Response` itself is the identity implementation
- `AppError` enum — covers `BadRequest`, `Unauthorized`, `Forbidden`, `NotFound`, `Conflict`, `UnprocessableEntity`, `TooManyRequests`, `PayloadTooLarge`, `Internal`; all implement `IntoResponse`
### Rate limiting
`src/rate_limit/mod.rs` provides `RateLimiter` — a thread-safe sliding-window limiter keyed by a string (typically the client IP). Call `limiter.check(key)` → `true` if within budget, `false` to return 429. `rate_limit::global()` returns a process-wide singleton configured via `RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS` (default 1000) and `RWS_CONFIG_RATE_LIMIT_WINDOW_SECS` (default 60).
### Metrics
`src/metrics/mod.rs` — global counters incremented directly in `Server::process()` and `dispatch_connection()`:
- `record_request()` — total requests
- `record_error()` — total errors
- `connection_open()` / `connection_close()` — current connection gauge
- `SERVER_READY: AtomicBool` — cleared on shutdown; checked by `/readyz`
`MetricsLayer` middleware records per-route `rws_route_requests_total{method,path,status}` counters and `rws_route_duration_seconds{method,path}` histograms. The built-in `GET /metrics` endpoint exposes all metrics in Prometheus text format.
### MCP server
`src/mcp/mod.rs` — `McpServer` implements `Application` and serves MCP Streamable HTTP (`POST /mcp`, JSON-RPC 2.0; `GET /mcp` = SSE push stream), protocol versions 2024-11-05 to 2025-11-25 (`SUPPORTED_PROTOCOL_VERSIONS`, `negotiate_version`; the session stores the result; `MCP-Protocol-Version` checked, batches only up to 2025-03-26), and 2026-07-28 statelessly (`src/mcp/modern.rs`): in `execute`, after origin/content-type/auth and before any session lookup, a POST that `modern::is_modern` (header not a legacy version, or no header and `_meta` names a version) goes to `handle_modern` — exact-depth parsing with `json_rpc::object_members` (the other extractors match keys at any depth), `validate_modern` (order matters to the suite: `_meta` fields -32602, version header -32020, unsupported -32022, `Mcp-Method`/`Mcp-Name` -32020), `answer` decorates (`with_fields`: `resultType`, `_meta.serverInfo`, cache hints unless a retry) and maps `ModernError.status`; `dispatch_modern` reuses `do_*`/`call_tool`/`get_prompt` (they return unformatted results so `input_required` is visible); MRTR: `McpContent` kind `input_required` carries `InputRequired::to_json`, `input_required_result` checks client capabilities (-32021) and signs `requestState` (`sign_state`/`verify_state`, `core::sha256::hmac_sha256`, payload `exp\nmethod\nname\nprincipal\nstate`); `ModernRequest` reaches handlers via the private `McpContext::modern`. `subscriptions/listen` streams are `Listener`s fed by `deliver_to_listeners` from `notify`/`notify_resource_updated`. Tasks extension (`src/mcp/tasks.rs`): `ctx.start_task(work)` — with `McpContext::tasks` set (client declared `io.modelcontextprotocol/tasks`) it registers a `Task` in `McpServer::task_store` before returning `McpContent` kind `task` (so a `tasks/get` right after resolves) and runs `work` on a thread under `catch_unwind`; without it runs `work` inline. Task state is a `Mutex<State>` + `Condvar`: `TaskContext::input` sets `input_required` and waits; `task_update` removes answered keys and flips back to `working`; `Task::set` ignores changes once terminal (so a cancel sticks) and pushes `notifications/tasks` to listeners with matching `taskIds`. Tasks are keyed by id and the caller's `sub`. `check_param_headers` (`x-mcp-header`, `properties`-only paths, numeric compare) runs before `tools/call` dispatch. Conformance: `examples/mcp_conformance.rs` has the 2026 `test_*` fixtures; CI runs suites 0.1.16 and 0.2.0-alpha.12 `--requirements` per revision (`REQUIREMENTS` in `ci/e2e/mcp_conformance.sh`). Builders: `.tool`, `.tool_with_context` (handler also gets `McpContext`), `.typed_tool` (`serde`; `In`/`Out` types → `inputSchema`/`outputSchema` via `schema::JsonSchema`, usually `#[derive(JsonSchema)]` from rws-macros, which follows `#[serde]` renames and rejects attributes it can't describe — `examples/mcp_inventory.rs` uses it), `.tool_annotated`, `.async_tool` (`http2`), `.resource`, `.prompt`, `.completion`, `.page_size`, `.logging_enabled`, `.require_bearer(token)`, `.require_bearer_tokens([(token, identity)])` (identity → `auth_claims` `{"sub":…}`), `.require_oauth(provider, audience)` (`sso`), `.tool_filter(|ctx, tool| bool)` (hides tools from `tools/list`, `Unknown tool` on call), `.wrap(app)` (non-MCP requests fall through). `mcp::gateway::McpGateway` builds an `McpServer` whose tools proxy upstream MCP servers (`prefix__tool`, `allow` rules on the `sub` claim, deny by default; upstream `Connection` = sync `http_client`, session + protocol-version headers, JSON or SSE replies via `parse_reply` (fuzz target `mcp_gateway_reply`), re-init once on 404; results passed through with the crate-private `McpContent::raw_result`); tools are listed only at `build()`. The binary registers 20 rws tools in `main.rs` (config, metrics, routes, cache, rate limits, CORS, static files, reload, maintenance, IP blocklist, feature flags, recent requests/errors).
Things that aren't obvious from the code layout:
- **Shared live state.** `tools`/`resources`/`prompts`/`completions` are `Arc<RwLock<Vec<_>>>`, so every clone of an `McpServer` sees `register_*`/`remove_*` (which take `&self` and push `notifications/*/list_changed`). Handlers are cloned out from under a short read lock before running — never call a handler while holding it.
- **Sessions.** `initialize` negotiates the protocol version down to the lower of client and server, mints `Mcp-Session-Id`, and stores `clientInfo` and client capabilities (`supports_sampling`, `supports_roots`, cached `roots`). Sessions carry `last_seen`: idle past `session_ttl` (1 h) they're evicted when next touched or when `make_room_for_a_session` runs at `initialize` (which also enforces `max_sessions`, LRU); `DELETE /mcp` ends one; `forget_sessions` drops an evicted session's subscriptions and SSE clients. A request with an unknown `Mcp-Session-Id` (not `initialize`) gets `404` (`session_not_found`). `max_sse_clients` caps `GET /mcp` streams (`503`; at the cap dead senders are pruned with a comment frame first).
- **SSE.** `GET /mcp` returns a `Response` whose `stream_pipe` is `crate::sse::SseReader` (the same reader as the public `Sse::stream()`) (bounded `sync_channel(32)`, 15 s keep-alive comment that doubles as disconnect detection); HTTP/1.1, HTTP/2 and HTTP/3 all drive it. `.notify()` broadcasts with `try_send` and drops full or dead clients; `resources/subscribe` and sampling/roots requests are targeted by session id (`send_sse_to_sessions`). `TestClient` doesn't drive `stream_pipe`, so SSE tests read the reader in-process.
- **Server → client requests.** `ctx.sample()` / `ctx.list_roots()` send a JSON-RPC request over SSE and **block the calling thread** until the client POSTs the reply (a method-less body whose `id` is in `pending_replies`) or the timeout passes; they fail fast without a session or the client capability, and wait (within the timeout) for the session's `GET /mcp` stream when the client hasn't opened it yet.
- **Cancellation and progress** are cooperative: `notifications/cancelled` flips a per-call `AtomicBool` read by `ctx.is_cancelled()`; `ctx.report_progress()` uses the request's `_meta.progressToken`. Both are special-cased before the generic "ignore notifications" branch, in single and batch dispatch.
- **Batches.** A top-level JSON array goes to `handle_batch`; notifications produce no entry, an all-notification batch is `202`, `[]` is one Invalid Request error. Single and batch paths share `dispatch()`/`format_result()`.
- **Auth.** OAuth (JWKS via `sso::jwks::JwksCache`) wins over a static bearer token if both are set; the check runs before method dispatch, so `GET`/`OPTIONS /mcp` need the token too. With OAuth configured, `/.well-known/oauth-protected-resource` (RFC 9728; `resource` = the audience when it is a URL, else `Host` + MCP path) is served and `401`s carry `WWW-Authenticate: Bearer resource_metadata=…`. Verified claims land in `McpContext.auth_claims`.
- No base64 or JSON crate: cursors, image content and JSON-RPC are hand-rolled (`json_rpc::*`, local `base64_encode`/`decode`).
### RSA (`rsa_key`, `auth-asymmetric` / `sso`)
`src/rsa_key/` — `RsaPublicKey` (from `from_components(n, e)`, `from_pkcs1_der`, `from_public_key_der` (SPKI), `from_public_key_pem` (`BEGIN PUBLIC KEY` or `BEGIN RSA PUBLIC KEY`); 2048–8192 bits, odd `e` ≥ 3) and `verify_pkcs1v15_sha256(message, signature)`: Montgomery modexp over u32 limbs (CIOS), then the whole `00 01 FF…FF 00 || DigestInfo(SHA-256) || hash` block compared byte for byte. Replaces the `rsa` crate (RUSTSEC-2023-0071 has no fixed release; rws only verifies, so no private key and nothing secret to leak through timing). Used by `auth::verify_jwt_rs256`/`JwtLayer::rs256`, `sso::jwks` (a JWKS key it can't use is skipped, not fatal), `sso::saml`. Tests mint keys and signatures with `rsa_key::test_support::TestRsaKey` (aws-lc-rs, a dev dependency) plus OpenSSL fixtures in `rsa_key/tests.rs`. `rsa` stays in Cargo.lock only via `sqlx-mysql` (`model-mysql`), hence the narrowed ignore in `.cargo/audit.toml`.
### Test client
`src/test_client/mod.rs` provides `TestClient<A>` — dispatches requests directly through an `Application` without opening a TCP socket. Use it in unit and integration tests:
```rust
let client = TestClient::new(App::new());
let res = client.get("/healthz").send();
assert_eq!(200, res.status());
```
### Graceful shutdown
HTTP/1.1 (`http1` feature, `ctrlc` dep): a `SIGINT`/`SIGTERM` handler sets an `AtomicBool`; the accept loop exits and the `ThreadPool` drains in-flight requests before the process exits.
HTTP/2 + HTTP/3 (async features): `tokio::signal` handles `SIGINT` and `SIGTERM`; each `run_tls`/`run_quic` loop `select!`s on the signal and breaks cleanly. `SERVER_READY` is cleared before breaking. `server::request_shutdown()` (a process-wide `watch`, irreversible — tested in its own binary, `tests/request_shutdown.rs`) resolves the same `sigterm()` arm; `rws --windows-service=<dir>` (`main.rs::windows_service_mode`, `windows-service` crate) calls it on the SCM's Stop/Shutdown, after `main` builds the runtime itself and runs `serve()` (the async mains' body) inside the service. CI: `packaging.yml` job `windows-service`.
### No async (`http1` feature only)
When compiled with `--no-default-features --features http1`, the server uses `std::net::TcpListener` and a hand-rolled `ThreadPool` (`src/thread_pool/mod.rs`). Each connection is handled synchronously on a worker thread. No tokio, no async.
### HTTP/2 feature (`--features http2`)
When built with the `http2` feature, the binary uses a `tokio` runtime and serves TLS via `rustls` (aws-lc-rs crypto backend). ALPN negotiation selects HTTP/2 (rws's own `h2_server`) or HTTP/1.1 per connection automatically. New modules:
- `src/tls/mod.rs` — `SniCertResolver` implements `rustls::server::ResolvesServerCert`; `create_tls_acceptor_from_vhosts(vhosts, default_cert, default_key)` builds a multi-domain `TlsAcceptor` that picks the right cert per SNI hostname at handshake time. `create_tls_acceptor(cert, key)` is a backward-compat wrapper for single-cert deployments. When `RWS_CONFIG_TLS_CLIENT_CA_FILE` is set, `load_client_verifier()` builds a `WebPkiClientVerifier` that enforces mTLS on both HTTPS and QUIC listeners.
- `src/virtual_host/mod.rs` — `VirtualHostConfig { domain, cert_file, key_file }` carries per-domain cert configuration.
- `src/h2_server/` — rws's own HTTP/2 server (RFC 9113), replacing the `h2` crate on the server side in 18.0.0 (`h2` remains the client of `H2ReverseProxy`/`GrpcProxy`): with `h2`, framing and HPACK cost more per 1 KiB request than all of nginx's request handling; same-run A/B: −20 to −37% CPU per HTTP/2 request. `handle_connection` runs one task per connection: reads frames into a buffer and handles each (`Conn::frame`), keeps stream states and both flow-control windows, and writes everything a loop iteration produced (SETTINGS ACKs, several streams' HEADERS and DATA) in one `write_all`. A complete bodiless request the app answers inline (`runs_inline`) executes in the connection task; others run in a spawned task (`http1_async::execute_request`) whose response returns over a channel (drained every iteration — the `select!` is unbiased so a client that keeps sending can't starve them); `stream_file`/`stream_pipe` bodies come from `body_stream::spawn_producer`, polled only while the stream has nothing queued, and are sent within the windows. `hpack.rs`: decoder (static + dynamic table, Huffman via a trie in `huffman.rs`, size updates only at block start and ≤ 4096, integers bounded in value and shift — the fuzzer found the unbounded shift) and encoder (`:status` from the static table, repeated headers inserted once then sent as one-byte indexes, `changes_every_response` values as non-indexed literals, raw octets — no Huffman). Limits: frames > 16 KiB → `FRAME_SIZE_ERROR`; header blocks > `MAX_HEADER_BLOCK` (CONTINUATION flood) and more than `MAX_RESETS_PER_WINDOW` stream resets per `RESET_WINDOW` — the client's (rapid reset) and the ones its rule-breaking makes the server send (`Conn::reset` with anything but `NO_ERROR`, MadeYouReset) → `GOAWAY(ENHANCE_YOUR_CALM)`; a connection with nothing in flight and no streamed body (`waiting_on_client`) that reads nothing for `http1_async::READ_TIMEOUT` → `GOAWAY(NO_ERROR)`; decoded lists > `MAX_HEADER_LIST_SIZE` → `431`; `MAX_CONCURRENT_STREAMS` → `REFUSED_STREAM`. Request validation per §8.3 (pseudo-headers first/once/known, lower-case names, no connection-specific headers, `te: trailers` only, `content-length` must match the DATA). `h2_server::fuzz` feeds `fuzz/fuzz_targets/h2_hpack.rs` and `h2_connection.rs`; `ci/e2e/h2spec.sh` must stay 146/146.
- `src/server/mod.rs::Server::run_tls()` — async accept loop; extracts SNI hostname after the TLS handshake (`tls_stream.get_ref().1.server_name()`), routes by ALPN to `h2_server::handle_connection` or `http1_async::serve`, populates `ConnectionInfo::sni_hostname`.
- `src/server/mod.rs::Server::run_redirect()` — optional second listener; sends `301 Moved Permanently` to HTTPS for every request; activated by `RWS_CONFIG_HTTP_REDIRECT_PORT`.
- HTTP/1.1 TLS responses include `Alt-Svc: h3=":PORT"` (or `h2` when http3 feature is absent) to advertise protocol availability.
- Forbidden HTTP/2 headers (`connection`, `keep-alive`, `transfer-encoding`, `upgrade`, `proxy-connection`, `te`) are stripped from responses before sending.
- SIGHUP: reloads hot config via `config_reload::reload()` AND rebuilds `TlsAcceptor` with fresh certs for all virtual hosts.
### HTTP/3 feature (`--features http3`, default)
When built with the `http3` feature (which implies `http2`), a second listener starts over UDP using QUIC (`quinn` crate). HTTP/3 uses `h3` + `h3-quinn`. New additions:
- `src/tls/mod.rs::create_quinn_server_config_from_vhosts()` — builds a multi-domain `quinn::ServerConfig` via the same `SniCertResolver`; `create_quinn_server_config()` is the single-cert wrapper.
- `src/h3_handler/mod.rs` — accepts QUIC connections, extracts SNI from `conn.handshake_data()?.downcast::<HandshakeData>()`, resolves H3 streams via `RequestResolver::resolve_request()`, calls `app.execute()`, sends H3 responses.
- `src/server/mod.rs::Server::run_quic()` — binds a UDP endpoint on the same port as TCP; skipped silently if no cert is configured.
- `main()` runs `Server::run_tls`, `Server::run_quic`, and `Server::run_redirect` concurrently via `tokio::join!`.
### Proxy modules
- `src/proxy/mod.rs` — `ReverseProxy` (HTTP/1.1 reverse proxy middleware); `H2ReverseProxy` (HTTP/2 upstream proxy, `http2` feature) and `GrpcProxy` (wraps it, filters on `Content-Type: application/grpc*`), both on `proxy::h2_upstream` (18.3.0, #13), as is the config file's `grpc` action (`proxy_config::H2Route` via `proxy::forward_h2`): one pooled, multiplexed `h2::client::SendRequest` per `(backend, TLS config)` (pool entries carry an id so a closing connection removes only itself), driven by a dedicated 2-thread runtime `rws-h2-upstream` (not the caller's: handlers run under shared, per-core or no runtime, and tokio I/O belongs to the runtime that made it); `forward` sends the head, spawns the request body sender (a `Body::Stream` is read on that runtime's blocking pool; a read error resets the upstream stream), and returns the response head over a std channel — the body streams through `BodyReader` (`stream_pipe`, errors surface as read errors → the h2/h3 writers reset the stream) with upstream trailers put in `Response::trailers` before the body ends; non-gRPC responses ≤ 1 MiB with a `content-length` are buffered. Streamed request bodies reach it because `H2ReverseProxy`/`GrpcProxy` answer `Middleware::answers_streamed_body` and `H2Route::streams_body` (via `ConfigDrivenApp`/`RouteDispatch`/`ArcApp::streams_body`) is true. `Response::trailers` (`response::Trailers`, a shared slot) is sent by `h2_server` as a final HEADERS frame with END_STREAM (`pump`/`send_trailers`) and by `h3_handler` (`send_trailers`); `h2_server` keeps a valid `te: trailers` on requests. `proxy::grpc_web::GrpcWebLayer` (in front of `GrpcProxy`; built into `H2Route` via `H2Forward`) rewrites `application/grpc-web[-text]` to `application/grpc` + `te: trailers` (base64 bodies decoded by `Base64Decoder`, a stream too), and the response back: content type renamed (upstream's suffix kept), `Response::trailers` taken and appended as the `0x80` frame after the body (`TrailerFrame` reader, or appended to a buffered body), base64 by `Base64Encoder` (3-byte groups as they come) for `-text`; uses `core::base64`. e2e: `ci/e2e/grpc_web.sh` (Playwright Chromium, fetch over h1 and h2). TLS: `UpstreamTls::new(ca_file, client_cert)` (also `h2_config()` with ALPN `h2`); tests in `src/proxy/h2_upstream/tests.rs` (gRPC echo backend over the `h2` crate, mTLS PKI from rcgen). `Backend::parse()` strips `h2://` and `http://` prefixes.
- `src/async_bridge/mod.rs` (`http2` feature) — `block_on_isolated(f)`: runs an async closure to completion regardless of whether the calling thread is already inside a tokio runtime. Used by both `H2ReverseProxy` and `AsyncAppWithState::execute` to bridge their sync trait methods (`Middleware`/`Application`) into async code without `tokio::task::block_in_place`'s `multi_thread`-only requirement.
- `src/tcp_proxy/mod.rs` — `TcpProxy` standalone L4 TCP proxy; `bind(addr)` blocks and accepts connections; `relay(client)` spawns two threads doing `std::io::copy` for bidirectional byte relay; round-robin backend selection via `AtomicUsize`.
- `src/udp_proxy/mod.rs` — `UdpProxy` standalone UDP datagram proxy; per-datagram thread model; ephemeral socket per datagram connects to the backend; `set_read_timeout()` controls reply wait; round-robin backend selection.
- `src/ws_proxy/mod.rs` — `WsProxy` standalone WebSocket proxy; reads the HTTP upgrade request from the client, connects to the backend, exchanges upgrade handshake, then relays raw bytes bidirectionally in a two-thread loop.
### Dependency injection
`src/di/mod.rs` — `Container` is a type-keyed service store backed by `HashMap<TypeId, Box<dyn Any + Send + Sync>>`. Services are stored as `Arc<T>` or `Arc<dyn Trait>` (both are `Sized`, `'static`, `Any + Send + Sync` and can be stored in the map). Registration: `register::<T>(value)` wraps in `Arc<T>`; `provide::<T: ?Sized>(Arc<T>)` stores trait objects directly. Both keyed by `TypeId::of::<T>()`. Named services use a second map keyed by `(TypeId, String)`. Resolution: `get::<T>()` (works for both concrete and `dyn Trait`) and `get_named::<T>(name)`. `into_arc()` seals the container for sharing. No external dependencies.
### Config-driven proxy server
`src/proxy_config/` — turns `rws.config.toml` into a live proxy stack at startup; activated when the config file contains `[[route]]` or `[[upstream]]` sections.
- `mod.rs` — all config types (`ProxyConfig`, `UpstreamConfig`, `RouteConfig`, `ActionConfig`, `MiddlewareConfig`, `AuthConfig`, etc.); `ProxyConfig::is_proxy_mode()` scans the file; `ProxyConfig::load()` and `ProxyConfig::from_str()` parse it. `ConfigDrivenApp` (an `Arc<RwLock<Arc<Generation>>>`, see Hot reload above) implements `Application + Clone` (first-match router). `RouteMatcher` evaluates host, path prefix/exact, method, content-type prefix. `DynamicProxy` picks a live backend from `Arc<RwLock<Vec<String>>>` with an atomic round-robin counter. `RedirectAdapter` and `RespondAdapter` handle redirect/fixed-response actions. `PerRouteRateLimit` and `BearerAuthMiddleware` implement `Middleware`. `route.middleware.auth` accepts `AuthConfig::Bearer { token_env }`, `AuthConfig::Jwt { secret_env }` (wraps `auth::JwtLayer`, requires the `auth` feature), or `AuthConfig::Basic { htpasswd_file }` (wraps `auth::BasicAuthLayer::from_htpasswd_file`, requires the `auth` feature) — auth fails closed: when the secret/token env var is unset or empty, the htpasswd file is unreadable, or the `auth` feature isn't compiled in, `builder.rs::apply_middleware()` wraps the route in `AuthMisconfigured` (every request → `503`, error logged at startup) instead of skipping auth. `BearerAuthMiddleware` compares tokens with `constant_time_eq`.
- `validate.rs` — `ProxyConfig::validate(&mut ConfigReport)` (semantic checks: undeclared upstreams, unknown action types, unset auth secrets, bad backends/listen addresses, IPv4/IPv6 filter entries) and `check_unknown_keys(toml, report)` (a per-section schema of the keys the parser reads); used by `rws --check` (`src/config_check/`) and printed at startup by `build_from_file()`. `ConfigDrivenApp` wraps its `RouteDispatch` (routes + fallback `App`) in the top-level `[middleware]` table via `builder::apply_middleware`, rebuilt by `with_config`.
- `parser.rs` — hand-rolled TOML parser producing `SectionMap` (`HashMap<String, Vec<(String, String)>>`). Tracks `[[array]]` tables with counters (`upstream[0]`, `route[0].match`), expands an inline table `key = { a = 1 }` into the sub-section `<section>.key` (same as `[<section>.key]`) and arrays of strings.
- `health.rs` — `start_health_checker()` spawns a daemon thread per upstream; sends `GET {path} HTTP/1.1` with connect+read timeouts, tracks consecutive pass/fail, writes updated live-backend list via `RwLock`.
- `builder.rs` — `build_from_file()` / `build(config)`: creates upstream live-backend pools, starts health-checker threads, compiles routes (applies middleware stack via `apply_middleware()`), spawns L4/WS proxy threads. `ArcApp` adapter wraps `Arc<dyn Application>` so `WithMiddleware::new()` can take ownership.
- `main()` (all three feature variants) checks `ProxyConfig::is_proxy_mode()` before calling `build_app()`; if true, calls `build_from_file()` and passes the resulting `ConfigDrivenApp` to `Server::run` / `run_tls` / `run_quic`.
### Rewrite middleware
- `src/rewrite/mod.rs` — `RewriteLayer` implements `Middleware`; clones `Request` and applies `RequestRule` variants (header set/remove, URI set/strip-prefix/add-prefix) before dispatch, then applies `ResponseRule` variants (header set/remove, status override, body byte find-and-replace) on the way back. Private `replace_bytes()` does linear non-overlapping scan.
### Authentication middleware (`auth` feature)
`src/auth/mod.rs` — gated behind the `auth` Cargo feature (adds `hmac` + `sha2`, RustCrypto). All base64/JWT logic is hand-rolled — no third-party JWT or base64 crate.
- `BasicAuthLayer<F>` — validates `Authorization: Basic` against a `Fn(&str, &str) -> bool` closure; `BasicAuthLayer::from_htpasswd_file(path)` loads an htpasswd-style file once at construction (supports plain-text and rws's own `{SHA256}` scheme — not Apache's real `{SHA}`/`$apr1$`/bcrypt hashes). Issues `401` with a `WWW-Authenticate` challenge when the header is missing/malformed, `401` without a challenge when validation fails.
- `JwtLayer` — verifies `Authorization: Bearer <jwt>` signed HS256; rejects expired (`exp`) tokens. `build_jwt(claims_json, secret)` and `verify_jwt(token, secret) -> Option<Claims>` are public helpers for issuing/inspecting tokens outside the middleware (e.g. a login handler). `extract_bearer_token(request)` pulls the raw token string.
- `src/auth/forward.rs` — `ForwardAuthLayer` (Traefik/nginx-style forward-auth): forwards every request header as a `GET` to an external auth URL via `http_client::Client`. 2xx → request proceeds, with any `.copy_header(name)`-listed response header replacing the same-named request header (e.g. an auth service resolving a cookie to `X-User-Id`). Any other status → the auth service's response (status/headers/body) is passed through to the client verbatim, preserving `WWW-Authenticate`/`Location`/custom bodies. Unreachable auth service → `502 Bad Gateway` (fails closed). `.timeout_ms()` defaults to 5000ms.
### Request timeouts
`src/timeout/mod.rs` — per-route timeouts layered on top of the single global 30s connection read timeout. Rust cannot forcibly preempt a running synchronous handler, so the sync-side helpers (`with_timeout`, `with_timeout_state` for `Router`/`AppWithState` handlers, and `TimeoutLayer` for wrapping a whole `Application`) run the wrapped work on a background thread and bound how long they *wait*: past the deadline the caller gets `504 Gateway Timeout` immediately, but the spawned thread keeps running to completion with its result discarded. Only `with_timeout_async` (requires `http2`, for `AsyncAppWithState`) achieves genuine cancellation — dropping the `Future` via `tokio::time::timeout` actually stops it at its next `.await`.
### Request ID / correlation ID middleware
`src/request_id/mod.rs` — `RequestIdLayer` ensures every request/response pair carries a stable ID in a header (default `X-Request-Id`, override via `.header(name)`). If the incoming request already has the header (set by an upstream gateway), that value is echoed back unchanged so one ID follows a request across hops; otherwise `generate_request_id()` mints a UUID-v4-*shaped* (not spec-compliant, not cryptographically random) ID from a monotonic counter + timestamp splitmix64 finalizer. Works without OpenTelemetry configured, unlike `otel`'s span-based tracing.
### OpenAPI generation (`openapi` feature)
`src/openapi/mod.rs` — generates a minimal OpenAPI 3.0.3 JSON document directly from `Router::route_entries()` (paths, methods, path parameters only — no request/response body schemas, since Rust has no runtime type reflection). `build_spec(&OpenApiConfig, &[RouteInfo])` merges routes sharing a path into one `paths` entry and converts `:name`/`*name` segments to `{name}`. `swagger_ui_html(spec_url)` returns a self-contained Swagger UI page loading `swagger-ui-dist` from a CDN. Wired in via `AppWithState`/`AsyncAppWithState::openapi(OpenApiConfig::new(title, version))`, which registers `GET /openapi.json` and `GET /docs`.
### Model layer
`src/model/` — async JPA/Hibernate-style ORM backed by `sqlx`. Enabled by feature flags `model-sqlite`, `model-postgres`, `model-mysql` (each implies `http2`); they are combinable — each compiled-in backend adds a variant to `Backend` (`backend.rs`, `RWS_DB_BACKEND`) and to the `DbPool`/`DbTransaction` enums.
- `mod.rs` — `Value` enum (backend-independent SQL value: `Null`, `Bool`, `Int`, `Float`, `Text`, `Bytes`); `ModelRow` (named column bag with typed `get::<T>(col)`); `Model` trait (generated by `#[derive(Model)]`); `FromColumn` / `ToColumn` impls for `i16`, `i32`, `i64`, `u32`, `u64`, `f32`, `f64`, `bool`, `String`, `Option<T>`, `&str`.
- `connection.rs` — `DbConfig` (host, port, user, password, database, pool_size, backend; `from_env()` reads `RWS_DB_*` vars).
- `pool.rs` — `DbPool` (wraps a `sqlx::Pool` per backend, cheap to clone; `new(config)`, `from_env()`, `memory()`). Async methods: `execute`, `query_rows`, `query::<T>`, `query_raw`, `begin` → `DbTransaction` (`execute`, `query_rows`, `query`, `commit`, `rollback`), `transaction(closure)`, `migrate`, `migration_status`, `rollback_last`, `rollback(n)`.
- `repository.rs` — `Repository<T, ID>` trait; `ModelRepository<'a, T, ID>` impl for `Repository<T, i64>`. `save` chooses INSERT (pk==0 or auto-increment) vs UPDATE. SQLite uses `last_insert_rowid()`; PostgreSQL uses `RETURNING id`; MySQL uses `last_insert_id()`.
- `query.rs` — `QueryBuilder<'a, T>` (`new(&pool)`) with free-function SQL builders to avoid borrow conflicts. `where_eq` injects `__placeholder__` tokens replaced with `?` (SQLite/MySQL) or `$N` (PostgreSQL). `filter`, `order_by`, `limit`, `offset`; async `delete`, `update`, `fetch_all`, `fetch_one`, `count`, `paginate`, `paginate_after`.
- `migration.rs` — reads `*.sql` from a directory in lexicographic order; creates `_schema_migrations(version TEXT PRIMARY KEY, applied_at TEXT)` if absent; wraps each unapplied file in a `BEGIN`/`COMMIT` transaction; an optional `NNNN_name.down.sql` companion enables `rollback_last`/`rollback`.
- `relation.rs` — `HasMany<T>`, `HasOne<O>`, `BelongsTo<O>` — explicit-load helpers with an async `.load(&pool)` method. No lazy loading, no hidden N+1 queries.
- `tests.rs` — async integration tests using SQLite `:memory:` (`cargo test --no-default-features --features http2,macros,model-sqlite -- model`).
`rws-macros/src/lib.rs` — `#[proc_macro_derive(Model, attributes(table, column, primary_key))]` generates `impl Model for Struct` plus `Struct::repository(&pool)` and `Struct::query(&pool)` helpers. Attributes: `#[table(name = "…")]` (struct-level); `#[primary_key]` / `#[primary_key(auto_increment)]`; `#[column(name = "…")]`; `#[ignore]` (uses `Default::default()` in `from_row`, excluded from `to_values`).
### HTTP client
`src/http_client/mod.rs` — synchronous outbound HTTP/1.1 client. Always compiled in; no feature flag needed for plain HTTP. HTTPS requires `any(feature = "http-client", feature = "http2")`.
- `Client` — builder-pattern client; `Client::new()` sets `timeout_ms: 30_000` and `max_redirects: 10`. Convenience methods `.get()`, `.post()`, `.put()`, `.patch()`, `.delete()`, `.head()`, `.request(method, url)` each return a `RequestBuilder`.
- `RequestBuilder` — `.header(k,v)`, `.body(bytes)`, `.body_text(s)` (sets `Content-Type: text/plain`), `.body_json(s)` (sets `Content-Type: application/json`), `.form(&[(&str,&str)])` (percent-encodes and joins pairs, sets `Content-Type: application/x-www-form-urlencoded` — the body shape OAuth 2.0 token endpoints require; used by `sso::client::OidcClient::exchange_code`), `.timeout_ms(ms)`, `.send() -> Result<Response, HttpClientError>`. Follows redirects automatically, downgrading to GET on 301/302/303, preserving method on 307/308.
- `Response` — `.status()`, `.is_success()` (200–299), `.is_redirect()` (301/302/303/307/308), `.header(name)` (case-insensitive lookup), `.bytes()`, `.text()`, `.json::<T>()` (requires `serde` feature).
- `HttpClientError(String)` — implements `std::error::Error` and `Display`.
- `ParsedUrl` (private) — splits `scheme://host[:port]/path[?query]` without external URL crate.
- `tls_connect()` (private, `#[cfg(any(feature = "http-client", feature = "http2"))]`) — wraps a `TcpStream` in `rustls::StreamOwned<ClientConnection, TcpStream>` using `webpki_roots` CA store.
- `AsyncClient` / `AsyncRequestBuilder` (gated on `#[cfg(feature = "http2")]`) — same API including `.form()`, with `async fn send()`, backed by `tokio::net::TcpStream` and `tokio_rustls::TlsConnector`. Timeouts via `tokio::time::timeout`.
- Body reading strategy: chunked (`Transfer-Encoding: chunked`) → decode via `decode_chunked()`; content-length → read exactly N bytes; otherwise read until EOF.
### SSO (OAuth 2.0 / OIDC client, `sso` feature; Authorization Server, `sso-server` feature; SAML 2.0 SP, `sso-saml` feature)
`src/sso/` — OAuth 2.0 / OIDC client support (`rws` as the relying party, e.g. "Login with Google"). Implies `http-client` for TLS calls to the IdP. All JSON parsing and JWT verification is hand-rolled, matching the crate's no-third-party-HTTP-parsing philosophy elsewhere — the only new deps are `p256` (ES256 verification), `sha2` (hashing), and `rand_core` (PKCE verifier / RSA keygen randomness).
- `jwks::JwksCache` — thread-safe cache of public keys from a JWKS endpoint (`Mutex<Vec<JwkEntry>>`, private). `.fetch()` downloads and replaces the cache via `crate::http_client::Client`; `.verify_jwt(token, &VerifyOptions) -> Result<OidcClaims, SsoError>` lazy-loads keys on first call (empty cache), verifies the RS256/ES256 signature via `try_verify` (matches `kid` from the JWT header against cached keys, falling back to trying every cached key if the JWT has no `kid`), then validates `exp`/`iat`/`iss`/`aud`. **Key rotation is reactive, not scheduled** — there's no background refresh timer (the original design sketch's `.refresh_interval_secs()` doesn't exist); instead, a failed `try_verify` triggers exactly one `fetch()` retry before giving up, so a token signed with a key rotated in after the last successful verification still verifies correctly on the very next call. ES256 signatures are the raw 64-byte `r||s` form (not DER) per the JWS spec, reconstructed via `p256::EncodedPoint::from_affine_coordinates`; RS256 uses `crate::rsa_key::RsaPublicKey::verify_pkcs1v15_sha256` (in-crate, no `rsa` crate).
- `jwks::OidcClaims` — the standard OIDC claims (`sub`, `iss`, `aud: Vec<String>`, `exp`, `iat`, `nonce`, `email`, `email_verified`, `name`, `given_name`, `family_name`, `picture`, `locale`). No `groups`/`extra` fields — this module models only spec-standard claims, not IdP-specific extensions.
- `jwks::VerifyOptions` — `audience`, `issuer`, `leeway_secs` (clock-skew tolerance applied to both `exp` and `iat` checks).
- `discovery::OidcProvider` — endpoint URLs (`authorization_endpoint`, `token_endpoint`, `jwks_uri`, `userinfo_endpoint`, `end_session_endpoint`). `::discover(issuer)` fetches `{issuer}/.well-known/openid-configuration`; hardcoded presets `::google()`, `::microsoft(tenant_id)`, `::github()` (OAuth-only, empty `jwks_uri` — no id_token), `::okta(domain)`, `::auth0(domain)`, `::keycloak(base_url, realm)`.
- `config::OidcConfig` — bundles `provider`, `client_id`, `client_secret`, `redirect_uri`, `scopes`, `post_login_redirect`. Presets mirror `OidcProvider`'s; `::from_env()` reads `RWS_OIDC_*` vars (see `docs/src/content/docs/features/sso.md`).
- `client::OidcClient` — `.authorization_url(pkce, state, nonce)` builds the redirect URL (adds PKCE `code_challenge` only when `provider.jwks_uri` is non-empty, since GitHub's token endpoint rejects PKCE params); `.exchange_code(code, pkce_verifier)` POSTs to `token_endpoint` via `Client::form()` and returns `TokenResponse`; `.fetch_user_info(access_token)` covers GitHub-style providers with no `id_token`.
- `pkce::PkceVerifier` / `PkceChallenge` — `code_verifier` (random 43-char base64url) and `code_challenge = BASE64URL(SHA256(verifier))` (S256 only, per RFC 7636).
- `oidc_auth::OidcAuth` — `Middleware` impl: no session → redirect to `/auth/login`; intercepts `/auth/login` (redirect to IdP), `/auth/callback` (validate `state`, exchange code, verify `id_token` via `JwksCache`, verify `nonce`, store claims in session), `/auth/logout`. `.exclude(path)` bypasses auth for a path prefix. Claims are injected into the `X-Rws-Oidc-Claims` request header (JSON) for downstream handlers; `OidcAuth::claims(request)`/`::sub()`/`::email()` are the read-side helpers.
`server::AuthServer` / `client_store::ClientStore` (`sso-server` feature, implies `sso` + `auth`) — the reverse role: `rws` as its own OAuth 2.0 Authorization Server rather than a client of one. A `Middleware` intercepting `POST /oauth/token` (`client_credentials`, `authorization_code`+PKCE, `refresh_token` grants), `GET /oauth/authorize`, `GET /.well-known/openid-configuration`, `GET /.well-known/jwks.json`. Two deliberate deviations from the original design sketch, both documented at length in `src/sso/server.rs`'s module docs: (1) tokens are signed HS256 via the existing `auth::build_jwt`/`auth::verify_jwt` (not an RSA/EC key loaded from PEM — this crate has no private-key PEM/DER parser), so `/.well-known/jwks.json` always returns `{"keys":[]}` (no public key exists for a symmetric algorithm; resource servers must share `signing_secret` directly); (2) `/oauth/authorize` has no built-in login page — it reads a configurable session key (default `"user_id"`, override via `.subject_session_key()`) from an existing app session via `crate::session::SessionStore` to identify the resource owner, minting a code immediately if present or redirecting to a configurable `login_url` (default `/login`) with `?return_to=...` if absent; building that login page is the embedding application's responsibility. Authorization codes and refresh tokens are opaque random strings held in-memory (`Mutex<HashMap<String, _>>`), not JWTs themselves — codes are single-use (removed from the map on exchange) and short-lived (60s). `client_store::ClientStore` (`.new().add(OAuthClient{...}).get(client_id)`) has no `::from_env()`/`::from_db()` — a list of clients with per-client secrets has no natural flat-env-var encoding, unlike `OidcConfig`'s one-client-per-process shape.
`saml::SamlSp` (`sso-saml` feature, implies `sso`) — SAML 2.0 Service Provider, the SAML counterpart to `OidcAuth`. A `Middleware` intercepting `GET /saml/metadata`, `GET /saml/login` (renders an auto-submitting HTML form POSTing an unsigned `AuthnRequest` — HTTP-POST binding, not HTTP-Redirect, since this crate has no DEFLATE dependency and requests are never signed since this crate has no private-key PEM parser, the same reason `AuthServer` uses HS256), `POST /saml/acs`, `GET /saml/logout` (local session destruction only, no IdP `LogoutRequest`, mirroring `OidcAuth`'s own logout). Four submodules: `saml/xml.rs` — a small hand-rolled XML parser (not `quick-xml`, deliberately: this crate hand-rolls every text format it parses; the parser rejects any `<!DOCTYPE>` outright, eliminating XXE by never implementing entity resolution) tracking each `XmlNode`'s exact byte range in the source document; `saml/der.rs` — a minimal DER/ASN.1 TLV walker that locates an X.509 certificate's `subjectPublicKeyInfo` field (skipping `tbsCertificate`'s preceding fields by length) and hands the slice to `RsaPublicKey::from_public_key_der` — not a general X.509 parser; `saml/metadata.rs` — `SamlIdpMetadata::from_file`/`::from_url`/`::from_str`, preferring an `HTTP-POST`-binding `SingleSignOnService` entry; `saml/assertion.rs` — signature verification and claim extraction.
**Signature verification is byte-exact against the transmitted document, not full XML C14N** — `parse_and_verify` computes the SHA-256 digest over the `Assertion`'s own raw byte range with the `Signature` element's raw byte range excised (the enveloped-signature transform, applied to literal bytes rather than a canonicalized form), and verifies `SignatureValue` (RSA-SHA256 only) over `SignedInfo`'s raw byte range. This is a deliberate scope decision (documented in `assertion.rs`'s module docs), not an oversight: it fails closed, not open — an IdP that reformats XML between signing and transmission would have legitimate logins rejected, never would a forged assertion be accepted. **XSW (XML Signature Wrapping) mitigation is structural**: `parse_and_verify` requires exactly one `Assertion` in the response (rejecting zero or more than one), requires `Signature` to be a direct child of that same `Assertion`, requires `Reference/@URI` to point at that assertion's own `ID`, and reads every claim from that same parsed node — never a second search pass an attacker could point elsewhere. `EncryptedAssertion` is rejected outright (no XML decryption implemented).
`saml::AttributeMap` (`.new().map(saml_attribute_name, field)`) translates raw SAML attribute names into `saml::SamlClaims { name_id: String, attributes: HashMap<String, String> }` — **not** `jwks::OidcClaims`, since `OidcClaims` was deliberately built without a `groups` field (see above) and this feature's own design sketch maps exactly a `groups` attribute; reusing `OidcClaims` would silently drop it. `SamlSp::claims(request)`/`::name_id()`/`::attr(request, field)` read the `X-Rws-Saml-Claims` header the same way `OidcAuth`'s equivalents read `X-Rws-Oidc-Claims`.
`sso-saml` adds no new Cargo dependency beyond what `sso` already pulls in — the spec's own suggestion of a `quick-xml` dependency was not taken (see above).