429 lines
13 KiB
Markdown
429 lines
13 KiB
Markdown
# 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
|
|
|
|
```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<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
|
|
|
|
```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.
|
|
|
|
---
|
|
|
|
## 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())
|
|
// 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.
|
|
|
|
```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 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
|
|
|
|
```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.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` |
|
|
| 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
|
|
|
|
```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)
|
|
.withWebSockets(wsRouter, wsConfig)
|
|
.start();
|
|
```
|
|
|
|
---
|
|
|
|
## Requirements
|
|
|
|
- Java 26+
|
|
- Netty 4.x
|
|
- Jackson (tools.jackson)
|