Rpress
An async HTTP/1.1 and HTTP/2 framework in Rust, built on top of tokio. Designed to be lightweight, secure, and production-ready.
Features
- Trie-based routing (static, dynamic, multi-method)
- Middleware (global and per route group)
- Native TLS via rustls (HTTPS with PEM certificates)
- HTTP/2 via h2 (automatic ALPN negotiation over TLS)
- Request body streaming via
mpsc::channel - Automatic gzip/brotli compression
- Native CORS with builder pattern and fail-fast validation (RFC compliance)
- Granular body size limits (global and per route group)
- Pluggable rate limiting via
RateLimitertrait (in-memory or distributed backends like Redis) - Static file serving
- Cookies (parsing and Set-Cookie builder)
- Graceful shutdown
- Configurable timeouts (read and idle)
- Concurrent connection limits
- Automatic security headers (
X-Content-Type-Options: nosniff) with configurable CSP, X-Frame-Options, and more - Automatic request ID (
X-Request-ID)
Quick Start
use ;
async
Routing
Routes use the format :method/path. Dynamic segments are prefixed with :.
Static routes
let mut routes = new;
routes.add;
Dynamic route parameters
routes.add;
Multi-method on the same path
routes.add;
routes.add;
routes.add;
Supported HTTP methods
GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
Middleware
Global middleware
Applied to all routes:
app.use_middleware;
Route group middleware
let mut routes = new;
routes.use_middleware;
routes.add;
Request extensions (middleware → handler data)
Middleware often needs to pass extracted data (e.g. JWT claims) to downstream handlers. Use set_extension / get_extension on RequestPayload:
let mut public = new;
public.add;
let mut protected = new;
protected.use_middleware;
protected.add;
app.add_route_group;
app.add_route_group;
Extensions are plain HashMap<String, String> key-value pairs — lightweight and zero-cost when unused. Later middleware in the chain can overwrite values set by earlier middleware.
Observability (Distributed Tracing)
Rpress automatically creates structured tracing spans for every request. This makes the framework compatible with distributed tracing backends like Jaeger, Datadog, Grafana Tempo, and Zipkin out of the box.
Automatic spans
Every incoming request is wrapped in an http.request span with these fields:
| Field | Description |
|---|---|
http.method |
HTTP method (GET, POST, etc.) |
http.route |
Request URI path |
http.request_id |
Unique UUID v4 (same as X-Request-ID header) |
http.status_code |
Response status code (recorded after handler completes) |
http.latency_ms |
Total processing time in milliseconds |
Each connection also gets a parent span:
| Span | Fields | Description |
|---|---|---|
http.connection |
peer.addr |
Per-connection span (HTTP/1.1 and TLS) |
h2.stream |
— | Per-stream span for HTTP/2 multiplexed streams |
The hierarchy looks like this:
http.connection (peer.addr=192.168.1.10)
└── http.request (method=GET, route=/users/1, request_id=abc-123, status_code=200, latency_ms=3)
└── app.request (your middleware span)
└── tracing::info!("...") ← inherits full context
Any tracing::info!, tracing::warn!, or tracing::error! emitted inside a middleware or handler automatically inherits the parent span context — no manual propagation needed.
Adding custom fields in middleware
The framework span already exists when your middleware runs. Create a child span to add application-specific fields:
app.use_middleware;
Exporting to Jaeger / Datadog / Tempo
Rpress uses the standard tracing crate. To export spans to a distributed tracing backend, configure tracing-subscriber with an OpenTelemetry layer in your main():
// Cargo.toml:
// tracing-subscriber = { version = "0.3", features = ["env-filter"] }
// opentelemetry = "0.27"
// opentelemetry-otlp = "0.27"
// tracing-opentelemetry = "0.28"
use TracerProvider;
use WithExportConfig;
use ;
With this setup, every http.request span (and its children) is automatically exported as a trace to your backend. The http.request_id field matches the X-Request-ID response header, making it easy to correlate logs with traces.
Request
Accessing request data
routes.add;
Body Streaming
For large uploads, Rpress can stream the body in chunks via a channel instead of accumulating everything in memory. The threshold is configurable:
app.set_stream_threshold; // stream bodies > 64KB
collect_body() — Simple usage (recommended)
Collects the entire body into a Vec<u8>. Works for both small bodies (already buffered) and streamed ones:
routes.add;
body_stream() — Chunk-by-chunk processing
For processing data on demand without accumulating everything in memory:
routes.add;
Response
Available builders
// Plain text
text
// HTML
html
// JSON
json.unwrap
// Bytes with custom content-type
bytes
// Empty (204 No Content)
empty
// Redirect
redirect
Chaining modifiers
text
.with_status
.with_content_type
.with_header
Cookies
use CookieBuilder;
let cookie = new
.path
.max_age
.same_site
.http_only
.secure
.domain;
text
.set_cookie
Multiple Set-Cookie headers are supported — each .set_cookie() call adds a separate header.
CORS
Native configuration via builder pattern:
let cors = new
.set_origins
.set_methods
.set_headers
.set_expose_headers
.set_max_age
.set_credentials;
let mut app = new;
Without CORS:
let mut app = new;
Automatic headers: Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, Vary: Origin. Preflight OPTIONS requests are handled automatically.
CORS validation (fail-fast)
Rpress enforces RFC-compliant CORS at startup. Using wildcard origin "*" with set_credentials(true) will panic immediately, preventing the application from starting with an insecure configuration that browsers would silently reject:
// This will panic at startup:
let cors = new
.set_origins
.set_credentials;
let app = new; // panics!
// Use explicit origins instead:
let cors = new
.set_origins
.set_credentials;
let app = new; // ok
Compression
Gzip and Brotli with automatic negotiation via Accept-Encoding:
app.enable_compression;
Behavior:
- Brotli is preferred when
Accept-Encoding: bris present - Gzip is used when
Accept-Encoding: gzipis present - Bodies smaller than 256 bytes are not compressed
- Already compressed types (image/, video/, audio/*, zip, gzip) are skipped
- SVG is compressed normally
Content-EncodingandVary: Accept-Encodingare added automatically- Compression runs inside
tokio::task::spawn_blocking— CPU-bound work (Brotli/Gzip encoding) never blocks the async event loop, even under high concurrency
Rate Limiting
Limit requests per IP with a sliding window counter:
app.set_rate_limit; // 100 requests per 60 seconds
When the limit is exceeded, returns 429 Too Many Requests.
By default, set_rate_limit uses an in-memory backend (InMemoryRateLimiter) suitable for single-instance deployments. Expired entries are automatically cleaned up when the store exceeds 10,000 records.
Distributed rate limiting
For multi-instance environments (e.g. Kubernetes), inject a custom backend that implements the RateLimiter trait:
use RateLimiter;
use Pin;
let mut app = new;
app.set_rate_limiter;
app.set_rate_limit;
The set_rate_limiter call must come before set_rate_limit, or after it to replace the default in-memory limiter. The framework does not ship a Redis implementation -- it only provides the trait and the in-memory default.
Body Size Limits
By default, Rpress rejects request bodies larger than 10 MB with 413 Payload Too Large.
Global limit
app.set_max_body_size; // 5 MB for all routes
Per route group limit
Individual route groups can override the global limit. This allows a file upload group to accept large bodies while keeping the rest of the API tightly restricted:
let mut api_routes = new;
api_routes.set_max_body_size; // 8 KB for API routes
api_routes.add;
let mut upload_routes = new;
upload_routes.set_max_body_size; // 50 MB for uploads
upload_routes.add;
app.set_max_body_size; // 1 MB global default
app.add_route_group;
app.add_route_group;
When a route group has its own limit, that limit takes precedence over the global one -- even if the group limit is larger. The global limit acts as the baseline for routes without a specific override.
Static Files
app.serve_static;
app.serve_static;
- Content-Type is detected by file extension
- Path traversal is prevented with
canonicalize()— both the base directory and the requested path are resolved and compared before any read is performed - File reads use
tokio::fs::readand path resolution usestokio::fs::canonicalize— no blocking syscalls on the event loop - Supports: HTML, CSS, JS, JSON, images (PNG, JPG, GIF, SVG, WebP, ICO), fonts (WOFF, WOFF2, TTF), PDF, XML, videos (MP4, WebM)
TLS (HTTPS)
Rpress supports native TLS via rustls. Use listen_tls instead of listen to serve over HTTPS:
use ;
async
RpressTlsConfig
| Method | Description |
|---|---|
from_pem(cert_path, key_path) |
Loads a PEM certificate chain and private key from files |
from_config(rustls::ServerConfig) |
Uses an existing rustls::ServerConfig for full control |
Both methods automatically configure ALPN to support HTTP/2 (h2) and HTTP/1.1.
Plaintext and TLS side by side
The listen() method continues to work for plaintext HTTP. You can use either one depending on your environment:
// Development — plaintext
app.listen.await?;
// Production — TLS
let tls = from_pem?;
app.listen_tls.await?;
HTTP/2
HTTP/2 is supported automatically over TLS connections. When a client negotiates the h2 protocol via ALPN during the TLS handshake, Rpress routes the connection through its HTTP/2 handler.
- All routes, middleware, CORS, and response features work identically over HTTP/2
- No code changes required — the same
RpressRoutesand handlers serve both protocols - HTTP/2 multiplexing is fully supported (concurrent streams on a single connection)
- Plaintext connections (
listen()) always use HTTP/1.1
// This handler serves both HTTP/1.1 and HTTP/2 clients transparently
routes.add;
Full Configuration
use Duration;
use ;
let mut app = new;
// Read buffer capacity (default: 40KB)
app.set_buffer_capacity;
// Read timeout per request (default: 30s)
app.set_read_timeout;
// Idle timeout between keep-alive requests (default: 60s)
app.set_idle_timeout;
// Maximum concurrent connections (default: 1024)
app.set_max_connections;
// Global max body size (default: 10MB)
app.set_max_body_size;
// Rate limiting (in-memory by default)
app.set_rate_limit;
// Or inject a custom backend:
// app.set_rate_limiter(my_redis_limiter);
// Body streaming threshold (default: 64KB)
app.set_stream_threshold;
// Gzip/brotli compression (default: disabled)
app.enable_compression;
// Static files
app.serve_static;
// Routes and middleware
app.use_middleware;
app.add_route_group;
// Start the server (choose one)
app.listen.await?; // HTTP
// or
let tls = from_pem?;
app.listen_tls.await?; // HTTPS + HTTP/2
// With a ready callback (like Express's app.listen(port, callback))
app.listen_with.await?;
Controllers with the handler! macro
Organize handlers in structs with Arc:
use handler;
;
State Management
Shared state — database pools, config, caches, service clients — is passed into route groups as function parameters and stored inside controllers wrapped in Arc.
The pattern
main()
└── Arc::new(MyPool::new()) — created once
├── .clone() → get_user_routes(db)
│ └── UserController { db }
│ └── self.db.query(…).await
└── .clone() → get_order_routes(db)
└── OrderController { db }
Example — database pool
// db.rs — your database pool (e.g. sqlx::PgPool or a mock)
// routes/user.rs
use Arc;
use ;
use crateDbPool;
// The pool is injected here — route groups are plain functions.
// main.rs — create the pool once, share it via Arc::clone
use Arc;
use Rpress;
use crateDbPool;
async
Multiple state types
Pass additional state the same way — just add more parameters:
// main.rs
let db = new;
let cache = new;
let cfg = new;
app.add_route_group;
Any type that is Send + Sync + 'static can be wrapped in Arc and shared this way, including tokio::sync::RwLock and tokio::sync::Mutex for mutable shared state.
Custom Errors
Implement RpressErrorExt to return errors with custom status codes:
use ;
routes.add;
Handlers can return:
ResponsePayload(implicit 200)Result<ResponsePayload, RpressError>Result<ResponsePayload, E>whereE: RpressErrorExt- Any
E: RpressErrorExtdirectly (error without Result) ()(202 Accepted with no body)
Security Headers
Always Applied
These headers are sent automatically on every response:
| Header | Value |
|---|---|
X-Content-Type-Options |
nosniff |
X-Request-ID |
Unique UUID v4 per request |
Server |
Rpress/1.0 |
Connection |
keep-alive |
Configurable Security Headers
Use RpressSecurityHeaders to opt-in to additional security headers such as
Content-Security-Policy, X-Frame-Options, X-XSS-Protection, and any custom
header. These are injected into every response unless the handler already set
the same header via with_header().
use ;
let mut app = new;
app.set_security_headers;
If a handler needs a different policy for a specific route, it can override by setting the header directly:
html
.with_header
The handler-set value takes priority and the global default is skipped for that header.
Graceful Shutdown
The server responds to SIGINT (Ctrl+C):
- Stops accepting new connections
- Waits for active connections to finish
- Shuts down cleanly
Security Limits
| Resource | Limit |
|---|---|
| Request line | 8 KB |
| Headers (size) | 8 KB |
| Headers (count) | 100 |
| Body (Content-Length) | Configurable per route group (default 10 MB) |
| Individual chunk | 1 MB |
| Connection buffer | Configurable (default 40 KB) |
Socket.IO (Real-time Communication)
Rpress includes a built-in Socket.IO server compatible with socket.io-client v4+
(Engine.IO v4, Socket.IO protocol v5). It supports HTTP long-polling and WebSocket
transports, namespaces, rooms, event-based messaging, acknowledgements, and broadcasting.
Basic Setup
use ;
use Arc;
async
Namespaces
let io = new;
// Default namespace "/"
io.on_connection;
// Custom namespace "/admin"
io.of.on_connection;
Rooms
socket.on.await;
Acknowledgements
socket.on.await;
On the client side (JavaScript):
socket.;
Client Connection (JavaScript)
import from "socket.io-client";
const socket = ;
socket.;
socket.;
socket.;
Client Connection (Rust — rpress-client)
For server-to-server communication, use the rpress-client crate to connect from Rust:
[]
= "0.1"
= { = "1", = ["full"] }
= "1"
use SocketIoClient;
async
Authentication
Protect Socket.IO connections by registering an authentication handler. The handler
receives the auth payload from the client's CONNECT packet and must return
Ok(claims) to allow the connection or Err(message) to reject it with a
CONNECT_ERROR. The returned claims are accessible on the socket via socket.auth().
Server (Rust):
use ;
use Arc;
let io = new;
// Register auth handler for the default namespace
io.use_auth;
io.on_connection;
// Per-namespace auth is also supported:
io.of.use_auth;
Client (JavaScript):
import from "socket.io-client";
const socket = ;
socket.;
socket.;
Client (Rust — rpress-client):
use SocketIoClient;
// Connect with authentication
let client = connect_with_auth.await?;
// Connect to a specific namespace with auth
let admin = connect_to_with_auth.await?;
Without an auth handler configured, connections are accepted without validation (backward compatible).
Scaling with Redis
By default, Rpress uses an in-memory adapter for room management and broadcasting. This works perfectly for a single server instance, but when running multiple replicas behind a load balancer (e.g. Kubernetes), broadcasts on one Pod won't reach sockets connected to another Pod.
Enable the redis feature to use Redis Pub/Sub for cross-instance broadcasting:
[]
= { = "0.5", = ["redis"] }
Server setup:
use ;
async
You can also use a custom adapter with set_adapter:
use ;
let adapter = new.await?;
let mut io = new;
io.set_adapter;
Deploying with Multiple Replicas (Kubernetes / Load Balancers)
Engine.IO starts connections via HTTP long-polling before upgrading to WebSocket.
In a multi-replica deployment, successive polling requests from the same client may
be routed to different Pods by the load balancer, causing "Session ID unknown" errors.
There are two solutions:
Option A: WebSocket-only mode (recommended)
Force all clients to connect directly via WebSocket, bypassing long-polling entirely. This eliminates the sticky session requirement because WebSocket is a single persistent connection that stays on the same Pod.
Server:
use ;
let config = EioConfig ;
let io = with_config;
Client (JavaScript):
const socket = ;
Client (Rust — rpress-client):
// rpress-client connects via WebSocket by default — no changes needed
let client = connect.await?;
When websocket_only is enabled, the server rejects any long-polling request with
a clear error message instructing the client to use WebSocket transport.
Option B: Sticky sessions
If you need long-polling support (e.g. for clients behind restrictive proxies that block WebSocket), configure your load balancer with session affinity so that all requests from the same client reach the same Pod.
Nginx example:
upstream rpress_backend {
ip_hash; # sticky sessions by client IP
server pod1:3000;
server pod2:3000;
}
server {
location /socket.io/ {
proxy_pass http://rpress_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
Kubernetes Ingress (nginx-ingress) example:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
nginx.ingress.kubernetes.io/affinity: "cookie"
nginx.ingress.kubernetes.io/session-cookie-name: "RPRESS_AFFINITY"
nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
rules:
- host: api.example.com
http:
paths:
- path: /socket.io/
pathType: Prefix
backend:
service:
name: rpress-service
port:
number: 3000
The Redis adapter handles only cross-instance broadcast synchronization. Room membership, socket state, and Engine.IO sessions remain local to each instance, which is why sticky sessions (or WebSocket-only mode) are required.
Benchmarks
Load tested on a single machine with oha (HTTP) and Artillery (Socket.IO). All tests run against a release build of the benchmark server included in bench/.
HTTP Performance
| Scenario | Requests | Concurrency | Req/sec | p50 | p99 | Success |
|---|---|---|---|---|---|---|
| Warmup | 1,000 | 50 | 85,422 | 0.37ms | 1.79ms | 100% |
| Max Throughput | 50,000 | 500 | 144,126 | 2.91ms | 10.91ms | 100% |
| JSON Serialization | 20,000 | 200 | 30,528 | 6.13ms | 15.03ms | 100% |
| POST Echo (1KB body) | 20,000 | 200 | 28,886 | 6.51ms | 16.48ms | 100% |
| Large Body + Compression | 10,000 | 100 | 1,840 | 53.30ms | 108.01ms | 100% |
| Static File (32KB CSS) | 10,000 | 100 | 10,295 | 9.01ms | 22.52ms | 100% |
| Extreme Concurrency | 10,000 | 1,000 | 94,558 | 6.19ms | 35.60ms | 100% |
| Sustained Load (60s) | 1,854,463 | 200 | 30,906 | 6.10ms | 15.69ms | 100% |
Socket.IO Performance
| Metric | Value |
|---|---|
| Virtual Users | 520 created, 520 completed, 0 failed |
| Scenarios | Ping-Pong (60%), Room Join (30%), Broadcast Storm (10%) |
| Total Emits | 3,567 |
| Peak Emit Rate | 135/sec |
| Session Length (median) | 2.0s |
| Total Test Time | 1 min 10s |
Stress Tests
| Test | Result | Detail |
|---|---|---|
| Connection Limit (5,000 vs 4,096 max) | PASS | 4,883 successful, excess gracefully rejected |
| Slowloris (20 slow connections) | PASS | Server remained responsive |
| Oversized Body (15MB vs 10MB limit) | PASS | Returned 413 Payload Too Large |
| Post-Stress Health Check | PASS | 10/10 checks passed |
All scenarios are configurable via environment variables. See bench/README.md for details on running your own benchmarks.
License
MIT