NexusWeb
A lightweight, high-performance HTTP server library built on top of Netty. NexusWeb provides a fluent API for routing, middleware, CORS handling, and rate limiting — all running on Java virtual threads for maximum concurrency.
Features
- Netty-based — uses epoll/kqueue/NIO automatically based on the platform
- Virtual thread dispatch — each request is handled on a Java virtual thread, with per-connection read backpressure and HTTP keep-alive
- TLS / HTTPS — enable encryption with a single
withTls(...)call (PEM files or a customSslContext) - Pluggable authentication — insert an auth layer that protects selected paths; API key, cookie, HTTP Basic, bearer or any custom scheme (not tied to bearer tokens)
- Trie-based router — supports static paths, path parameters (
{id}), and wildcards (*) - Annotation-based controllers — define routes declaratively with
@Controller,@GET,@POST, etc. - Middleware chain — attach cross-cutting logic to all routes
- CORS support — configurable origins, methods, headers, credentials, and preflight caching
- Security headers — opt-in
nosniff,X-Frame-Options,Referrer-Policy, CSP and HTTPS-only HSTS, applied to every response - Rate limiting — four algorithm implementations with per-IP, per-header, per-cookie, per-principal or custom key strategies, with automatic eviction of idle state
- Spoofing-safe client IP —
X-Forwarded-Foris honoured only behind configured trusted proxies - WebSockets — path-routed handlers with origin validation, optional authentication, ordered per-connection delivery, backpressure, idle timeout, frame size limits and permessage-deflate
- JSON I/O — built-in Jackson integration for request parsing and response serialization
Quick Start
Router router = new Router();
router.get("/hello", (req, res) -> {
res.status(200).json("{\"message\":\"Hello, World!\"}");
});
HttpServer.builder(8080, router).start();
Routing
Routes are registered with GET, POST, PUT, DELETE, or the generic register method.
Router router = new Router();
// Static path
router.get("/users", (req, res) -> { ... });
// Path parameter
router.get("/users/{id}", (req, res) -> {
String id = req.pathParam("id");
res.json("{\"id\":\"" + id + "\"}");
});
// Wildcard
router.get("/files/*", (req, res) -> { ... });
// POST with JSON body
router.post("/users", (req, res) -> {
MyDto dto = req.jsonAs(MyDto.class);
res.status(201).json(dto);
});
Router resolution
| Outcome | HTTP Status |
|---|---|
| Path and method match | Handler is called |
| Path exists, method not registered | 405 Method Not Allowed + Allow header |
| Path not found | 404 Not Found |
Annotation-based Controllers
Instead of registering lambdas on the Router directly, you can declare routes as methods on a class and register the whole object at once via AnnotationScanner.
Annotations
| Annotation | Target | Description |
|---|---|---|
@Controller("prefix") |
class | Optional path prefix applied to all methods in the class |
@GET("/path") |
method | Maps a GET route |
@POST("/path") |
method | Maps a POST route |
@PUT("/path") |
method | Maps a PUT route |
@DELETE("/path") |
method | Maps a DELETE route |
@PATCH("/path") |
method | Maps a PATCH route |
@Route(method="…", path="…") |
method | Generic — any standard HTTP method by name |
@CUSTOM(method="…", value="…") |
method | Custom/non-standard HTTP methods |
Every annotated method must have the exact signature (Request req, Response res) and return void.
@Controller("/users")
public class UserController {
@GET("")
public void list(Request req, Response res) {
res.json("[{\"id\":1}]");
}
@GET("/{id}")
public void get(Request req, Response res) {
res.json("{\"id\":\"" + req.pathParam("id") + "\"}");
}
@POST("")
public void create(Request req, Response res) {
UserDto dto = req.jsonAs(UserDto.class);
// ... persist ...
res.status(201).json(dto);
}
@PUT("/{id}")
public void update(Request req, Response res) {
// ...
}
@DELETE("/{id}")
public void delete(Request req, Response res) {
res.status(204);
}
@PATCH("/{id}")
public void patch(Request req, Response res) {
// ...
}
}
Register the controller with:
Router router = new Router();
AnnotationScanner.register(router, new UserController());
HttpServer.builder(8080, router).start();
AnnotationScanner.register prints each registered route to stdout:
Registered: GET /users -> UserController.list
Registered: GET /users/{id} -> UserController.get
Registered: POST /users -> UserController.create
Multiple controllers can be registered on the same router:
AnnotationScanner.register(router, new UserController());
AnnotationScanner.register(router, new OrderController());
Middleware
Middleware runs before the matched handler on every request.
router.use((req, res) -> {
res.header("X-Request-Id", UUID.randomUUID().toString());
});
Request API
req.pathParam("id") // path parameter, e.g. from /users/{id}
req.queryParam("search") // first value of ?search=
req.queryParams("tag") // all values of ?tag= as List<String>
req.header("Authorization") // raw header value
req.cookie("sid") // value of a named cookie
req.body() // raw body as UTF-8 String
req.json() // body parsed as Jackson JsonNode
req.jsonAs(MyDto.class) // body deserialized into a POJO
req.method() // HttpMethod
req.path() // decoded path without query string
req.clientIp() // resolved client IP (honours trusted proxies)
req.principal() // authenticated principal, or null (see Authentication)
req.isAuthenticated() // whether a principal is attached
req.attribute("k", value) // attach per-request state
req.<T>attribute("k") // read it back
json() and jsonAs() throw BadRequestException (→ 400) on malformed JSON.
Response API
res.status(201)
.header("X-Custom", "value")
.json("{\"ok\":true}"); // sets Content-Type: application/json
res.text("plain text"); // sets Content-Type: text/plain
res.json(someObject); // serializes POJO via Jackson
CORS
CorsConfig config = CorsConfig.builder()
.allowedOrigins("https://app.example.com")
.allowedMethods(HttpMethod.GET, HttpMethod.POST)
.allowedHeaders("Content-Type", "Authorization")
.allowCredentials(true)
.maxAgeSeconds(3600)
.build();
CorsHandler cors = new CorsHandler(config);
HttpServer.builder(8080, router)
.withCorsHandler(cors)
.start();
Use CorsConfig.permissive() for a development preset that allows any origin with common methods and headers (incompatible with allowCredentials).
Preflight requests (OPTIONS + Access-Control-Request-Method) are handled automatically and short-circuit before the router.
TLS / HTTPS
Enable encryption by attaching a TlsConfig. The TLS handler becomes the first element of every connection's pipeline, so both HTTP and WebSocket traffic are served over TLS (HTTPS / WSS).
import dev.coph.nextusweb.server.tls.TlsConfig;
HttpServer.builder(443, router)
.withTls(TlsConfig.fromPem(
new File("fullchain.pem"), // PEM certificate chain
new File("privkey.pem"))) // PKCS#8 private key
.start();
| Factory | Use |
|---|---|
TlsConfig.fromPem(cert, key) |
PEM certificate chain + unencrypted PKCS#8 key |
TlsConfig.fromPem(cert, key, password) |
…with a password-protected key |
TlsConfig.fromPem(certStream, keyStream, password) |
Load PEM material from the classpath or another stream |
TlsConfig.fromSslContext(ctx) |
Full control — supply a Netty SslContext (custom ciphers, mutual TLS, …) |
Any initialisation failure (missing/invalid material) is reported as an IllegalStateException.
Authentication
The auth layer authenticates selected paths before they reach handlers and attaches a Principal to the request (visible to rate limiting, middleware and handlers). It is deliberately not tied to bearer tokens — choose any credential scheme.
import dev.coph.nextusweb.server.auth.*;
// 1. An authenticator turns a credential into a Principal (or null if invalid).
Authenticator auth = Authenticator.apiKey("X-API-Key", key ->
key.equals(System.getenv("API_KEY")) ? Principal.of("service", Set.of("admin")) : null);
// 2. Decide which paths it protects.
AuthConfig authConfig = AuthConfig.builder(auth)
.protectPrefix("/api/") // required: 401 if missing/invalid
.optional("/feed") // attach principal if present, never reject
.challenge("ApiKey realm=\"api\"")
.build();
HttpServer.builder(8080, router)
.withAuth(new AuthGate(authConfig))
.start();
In a handler:
router.get("/api/me", (req, res) -> {
Principal p = req.principal(); // never null on a protected path
if (!p.hasRole("admin")) { res.status(403); return; }
res.json(Map.of("id", p.id()));
});
Authenticators
| Factory | Credential |
|---|---|
Authenticator.apiKey(header, validator) |
An API key in a request header (e.g. X-API-Key) |
Authenticator.cookie(name, validator) |
A session (or other) cookie |
Authenticator.basic(validator) |
HTTP Basic username / password |
Authenticator.bearer(validator) |
A bearer token (provided for completeness; never required) |
Authenticator.anyOf(a, b, …) |
Tries each in order, first match wins |
| Custom | Implement Authenticator — e.g. mutual-TLS cert, HMAC-signed request |
validator returns the resolved Principal, or null for missing/invalid credentials (→ 401 on a REQUIRED path). A thrown exception is treated as an internal error (→ generic 500); details are logged, never sent to the client. Rate limiting runs before authentication, so an unauthenticated flood is shed before reaching a (potentially expensive) authenticator.
When a validator compares a presented secret (API key, token, password) against an expected value, use Authenticator.constantTimeEquals(presented, expected) instead of String.equals to avoid leaking how many characters matched through a timing side channel:
Authenticator auth = Authenticator.apiKey("X-API-Key",
key -> Authenticator.constantTimeEquals(key, EXPECTED_KEY) ? Principal.of("svc") : null);
WebSocket upgrades on protected paths are authenticated the same way; the resolved principal is available via session.principal().
Trusted proxies & client IP
req.clientIp() and KeyResolver.clientIp() return a spoofing-safe client address. By default (TrustedProxies.none()) the transport peer address is used and X-Forwarded-For is ignored — a directly connected client cannot forge its IP. When running behind a reverse proxy, declare it trusted so the forwarded header is honoured:
import dev.coph.nextusweb.server.net.TrustedProxies;
HttpServer.builder(8080, router)
.withTrustedProxies(TrustedProxies.of("10.0.0.0/8", "127.0.0.1", "::1"))
.start();
The resolver walks X-Forwarded-For from right to left and returns the first hop that is not a trusted proxy, so forged left-most entries are ignored. Use TrustedProxies.all() only when the server can never be reached except through a trusted proxy.
Hardening & limits
| Concern | How it's handled |
|---|---|
| Connection reuse | HTTP keep-alive is honoured; connections close only on Connection: close or error |
| Slow-client / Slowloris | A per-connection read timeout (httpReadTimeout, default 30s) closes stalled/idle connections |
| Request memory | Auto-read is disabled while a request is in flight (one buffered body per connection); maxHttpContentLength (default 1 MiB) caps the body, returning 413 |
| Error disclosure | Handler exceptions return a generic 500; the detail is logged server-side, never sent to the client |
HttpServer.builder(8080, router)
.maxHttpContentLength(2 * 1024 * 1024) // 2 MiB body cap
.httpReadTimeout(Duration.ofSeconds(20)) // null/zero disables
.start();
Security headers
withSecurityHeaders(...) adds standard browser-hardening response headers to every response (handler responses, errors, CORS preflights and rejections alike). It is opt-in; without the call no security headers are sent.
import dev.coph.nextusweb.server.security.SecurityHeaders;
HttpServer.builder(443, router)
.withTls(TlsConfig.fromPem(cert, key))
.withSecurityHeaders(SecurityHeaders.defaults())
.start();
SecurityHeaders.defaults() emits a conservative baseline:
| Header | Value | Notes |
|---|---|---|
X-Content-Type-Options |
nosniff |
Blocks MIME sniffing |
X-Frame-Options |
DENY |
Click-jacking defence |
Referrer-Policy |
no-referrer |
No referrer leakage |
Strict-Transport-Security |
max-age=31536000 |
Only sent over HTTPS (when withTls(...) is set); pins HTTPS for a year |
Two safety rules keep it from breaking anything: HSTS is emitted only on TLS connections (a browser ignores it on plain HTTP), and a header a handler has already set is never overwritten — so per-route choices win.
For full control use the builder. Passing null/blank to a setter omits that header:
SecurityHeaders headers = SecurityHeaders.builder()
.frameOptions("SAMEORIGIN") // or null to omit
.referrerPolicy("strict-origin-when-cross-origin")
.contentSecurityPolicy("default-src 'self'") // off by default (app-specific)
.hsts(Duration.ofDays(365), true, false) // maxAge, includeSubDomains, preload
.header("Permissions-Policy", "geolocation=()") // any extra header
.build();
HttpServer.builder(443, router)
.withTls(TlsConfig.fromPem(cert, key))
.withSecurityHeaders(headers)
.start();
includeSubDomainsandpreloadare hard to roll back — enable them only once every subdomain is reliably served over HTTPS.
Rate Limiting
Algorithms
| Class | Description |
|---|---|
TokenBucketLimiter |
Smooth bursts up to a configurable capacity, refills at a steady rate |
FixedWindowLimiter |
Hard limit per fixed time window |
SlidingWindowLimiter |
Weighted sliding window — reduces boundary spikes vs. fixed window |
LeakyBucketLimiter |
Constant outflow rate; excess requests are rejected immediately |
Configuration
RateLimitConfig config = RateLimitConfig.builder()
// Global rule for all routes — keyed by client IP
.global(new TokenBucketLimiter(100, 200), KeyResolver.clientIp())
// Stricter rule for a specific path
.forPath("/login", new FixedWindowLimiter(5, 60_000), KeyResolver.clientIp())
// Per-API-key rule for an entire path prefix (no bearer token required)
.forPrefix("/api/", new SlidingWindowLimiter(1000, 1000), KeyResolver.header("X-API-Key"))
.build();
RateLimitGate gate = new RateLimitGate(config);
HttpServer.builder(8080, router)
.withRateLimitGate(gate)
.start();
When the limit is exceeded the server responds with 429 Too Many Requests and a Retry-After header.
Per-key limiter state is evicted automatically by a background task (every 5 minutes, entries idle for >10 minutes), so a high-cardinality key (many distinct IPs/API keys) cannot grow the limiter maps without bound. Call gate.shutdown() when stopping the server.
Response headers
Every response automatically includes:
| Header | Description |
|---|---|
X-RateLimit-Limit |
Configured limit for the matched rule |
X-RateLimit-Remaining |
Remaining requests in the current window |
Retry-After |
Seconds until the client may retry (only on 429) |
Key resolvers
| Factory | Behaviour |
|---|---|
KeyResolver.clientIp() |
The resolved client IP (honours trusted proxies — X-Forwarded-For is not trusted from a direct client) |
KeyResolver.header(name) |
Header value (e.g. an API key in X-API-Key); falls back to ip:<addr> when absent |
KeyResolver.cookie(name) |
Cookie value (e.g. a session id); falls back to ip:<addr> when absent |
KeyResolver.principal() |
The authenticated principal id (p:<id>); falls back to ip:<addr> when anonymous (requires the auth layer to run for the path) |
| Custom lambda | (req, clientIp) -> myKey(req) — req is the framework Request, clientIp the resolved IP |
The old
KeyResolver.userOrIp()(which trusted any client'sX-Forwarded-Forand keyed on a raw bearer token) has been removed. It allowed trivial rate-limit bypass and unbounded key growth; useheader(...),cookie(...)orprincipal()instead.
WebSockets
WebSocket routes are registered on a WebSocketRouter and attached to the server alongside the HTTP Router. Upgrade requests (GET + Upgrade: websocket) are intercepted before the HTTP router runs.
Handler
Implement WebSocketHandler. All callbacks are optional.
public class ChatSocket implements WebSocketHandler {
private final WebSocketGroup room = new WebSocketGroup("chat");
@Override
public void onOpen(WebSocketSession session) {
room.add(session);
session.send("{\"type\":\"welcome\",\"id\":\"" + session.id() + "\"}");
}
@Override
public void onMessage(WebSocketSession session, String message) {
room.broadcastExcept(session, message);
}
@Override
public void onClose(WebSocketSession session, int code, String reason) {
room.remove(session);
}
}
Registration
WebSocketRouter wsRouter = new WebSocketRouter()
.on("/ws/chat", new ChatSocket())
.on("/ws/rooms/{room}", new RoomSocket());
WebSocketConfig wsConfig = WebSocketConfig.builder()
.allowedOrigins("https://app.example.com")
.maxFramePayloadLength(64 * 1024) // 64 KiB per frame
.maxAggregatedMessageSize(1024 * 1024) // 1 MiB cap on a reassembled (fragmented) message
.maxQueuedMessages(1024) // per-connection backlog before backpressure
.idleTimeout(Duration.ofSeconds(60)) // close idle peers
.subprotocols("chat.v1")
.compression(true) // permessage-deflate
.build();
HttpServer.builder(8080, router)
.withWebSockets(wsRouter, wsConfig)
.start();
Use WebSocketConfig.defaults() (or .anyOrigin() on the builder) only for local development — production deployments should always allow-list origins explicitly.
Session API
session.id(); // stable UUID for this connection
session.path(); // matched path
session.pathParam("room"); // path parameter, e.g. from /ws/rooms/{room}
session.remoteAddress(); // client IP
session.principal(); // authenticated principal, or null
session.attribute("userId", id); // attach state to the session
session.attribute("userId"); // read it back
session.send("text"); // text frame
session.sendJson(dto); // serialized via Jackson
session.sendBinary(bytes); // binary frame
session.ping(); // ping frame
session.close(); // normal close (1000)
session.close(1011, "internal"); // close with code + reason
Broadcasting
WebSocketGroup is a thin fluent wrapper around Netty's ChannelGroup — joining a session is cheap and removal happens automatically when the channel closes.
WebSocketGroup group = new WebSocketGroup("lobby")
.add(sessionA)
.add(sessionB);
group.broadcast("hello everyone");
group.broadcastJson(eventDto);
group.broadcastExcept(sessionA, "everyone but A");
Security & limits
| Concern | How it's handled |
|---|---|
| Cross-origin upgrades | Origin header validated against WebSocketConfig.allowedOrigins(...); mismatched origins are rejected with 403 |
| Authentication | When an AuthGate is configured, protected upgrade paths are authenticated and the principal is exposed via session.principal() |
| Memory exhaustion | maxFramePayloadLength caps a single frame; maxQueuedMessages (default 1024) bounds the per-connection callback backlog and pauses reads (backpressure) when exceeded |
| Message ordering | Callbacks for a single connection run strictly in arrival order on a per-connection serial drainer (still on virtual threads, so handlers may block) |
| Idle / zombie connections | idleTimeout triggers a server-side close when no read and no write happen within the window |
| User code isolation | All callbacks dispatch onto Java virtual threads, never the Netty event loop |
| Subprotocol negotiation | Server advertises the configured subprotocols(...) list; clients that ask for an unsupported subprotocol fail the handshake |
Full Example
// Controller class
@Controller("/users")
public class UserController {
@GET("")
public void list(Request req, Response res) {
res.json("[{\"id\":1}]");
}
@GET("/{id}")
public void get(Request req, Response res) {
res.json("{\"id\":\"" + req.pathParam("id") + "\"}");
}
@POST("")
public void create(Request req, Response res) {
UserDto dto = req.jsonAs(UserDto.class);
res.status(201).json(dto);
}
}
// Server setup
Router router = new Router();
// Middleware — add request ID to every response
router.use((req, res) -> res.header("X-Request-Id", UUID.randomUUID().toString()));
AnnotationScanner.register(router, new UserController());
CorsHandler cors = new CorsHandler(CorsConfig.permissive());
RateLimitConfig rlConfig = RateLimitConfig.builder()
.global(new TokenBucketLimiter(200, 400), KeyResolver.clientIp())
.build();
RateLimitGate gate = new RateLimitGate(rlConfig);
// WebSockets
WebSocketRouter wsRouter = new WebSocketRouter()
.on("/ws/chat", new ChatSocket());
WebSocketConfig wsConfig = WebSocketConfig.builder()
.allowedOrigins("https://app.example.com")
.idleTimeout(Duration.ofSeconds(60))
.build();
HttpServer.builder(8080, router)
.withCorsHandler(cors)
.withRateLimitGate(gate)
.withSecurityHeaders(SecurityHeaders.defaults())
.withWebSockets(wsRouter, wsConfig)
.start();
Requirements
- Java 26+
- Netty 4.x
- Jackson (tools.jackson)