rust-web-server 18.5.0

Single-binary web server and reverse proxy: automatic HTTPS (Let's Encrypt), HTTP/1.1, HTTP/2 and HTTP/3, load balancing, auth and rate limiting, configured in one TOML file. Also a Rust library: routing, middleware, MCP server, ORM, background jobs. No hyper, no Tower; HTTP/2 and HTTP/3 framing via h2 and quinn.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
# 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).