CodingPhoenixx bcf5572aeb
CI - Test, Publish and Release / run-tests (push) Successful in 18s
CI - Test, Publish and Release / create-release (push) Successful in 20s
CI - Test, Publish and Release / check-and-publish (push) Successful in 18s
Introduce authentication framework with AuthConfig, AuthGate, and Authenticator classes, alongside comprehensive tests for rules, modes, and schemes.
2026-05-29 13:22:31 +02:00
2026-05-08 11:04:40 +02:00
2026-05-08 11:04:40 +02:00
2026-05-08 11:04:40 +02:00
2026-05-08 11:04:40 +02:00

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
  • 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 IPX-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

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.

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();

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'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.

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)
        .withWebSockets(wsRouter, wsConfig)
        .start();

Requirements

  • Java 26+
  • Netty 4.x
  • Jackson (tools.jackson)
S
Description
No description provided
Readme 514 KiB
Release 0.0.5 Latest
2026-05-29 11:23:26 +00:00
Languages
Java 100%