# 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 custom `SslContext`) - **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-For` is 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 ```java 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. ```java 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`. ```java @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: ```java 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: ```java AnnotationScanner.register(router, new UserController()); AnnotationScanner.register(router, new OrderController()); ``` --- ### Middleware Middleware runs before the matched handler on every request. ```java router.use((req, res) -> { res.header("X-Request-Id", UUID.randomUUID().toString()); }); ``` --- ## Request API ```java 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 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.attribute("k") // read it back ``` `json()` and `jsonAs()` throw `BadRequestException` (→ `400`) on malformed JSON. --- ## Response API ```java 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 ```java 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). ```java 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. ```java 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: ```java 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: ```java 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: ```java 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 | ```java 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. ```java 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: ```java 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(); ``` > `includeSubDomains` and `preload` are 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 ```java 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:` when absent | | `KeyResolver.cookie(name)` | Cookie value (e.g. a session id); falls back to `ip:` when absent | | `KeyResolver.principal()` | The authenticated principal id (`p:`); falls back to `ip:` 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's `X-Forwarded-For` and keyed on a raw bearer token) has been removed. It allowed trivial rate-limit bypass and unbounded key growth; use `header(...)`, `cookie(...)` or `principal()` 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. ```java 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 ```java 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 ```java 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. ```java 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 ```java // 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)