Files
Nextus-Web/README.md
T

13 KiB

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 21 virtual thread
  • 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-token, or custom key strategies
  • WebSockets — path-routed handlers with origin validation, 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.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

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.


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())
        // Rule for an entire path prefix
        .forPrefix("/api/", new SlidingWindowLimiter(1000, 1000), KeyResolver.userOrIp())
        .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.

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() Uses X-Forwarded-For if present, otherwise the remote IP
KeyResolver.userOrIp() Uses the Bearer token if present (u:<token>), otherwise the client IP (ip:<addr>)
Custom lambda (req, remoteAddr) -> myKey(req)

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 upgrade body cap
        .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.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
Memory exhaustion maxFramePayloadLength caps a single frame; maxAggregatedMessageSize caps the upgrade request body
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)