Breaks HttpServer (563 lines, eleven responsibilities) into named, single-purpose components and introduces the ConnectionProtocol seam HTTP/2 plugs into starting Phase 8, per flash/docs/http2/IMPLEMENTATION-PLAN.md Phase 2. New packages: - dev.relism.flash.transport: TransportFactory (composition root, EX-34), ListenerBinder, BoundListener, TransportTuning, AcceptLoop, ConnectionRunner (per-connection setup/teardown), ConnectionProtocol (the h1/h2 seam), ConnectionContext, ConnectionScratch + ScratchPool (EX-06), ServerLifecycle (implements ServerHandle; start/stop/graceful shutdown, EX-32). - dev.relism.flash.http1: Http1Connection (the keep-alive request loop, implements ConnectionProtocol), Http1ResponseWriter, Http1KeepAlive (the shared Connection-header token-list scanner, EX-13). - dev.relism.flash.websocket additions: WebSocketUpgrade (detection + handshake), WebSocketLoop (session loop), WebSocketProtocolException. Existing-code defects fixed (EX-nn): - EX-01: WebSocketSession's two blocking-write sites use ReentrantLock instead of synchronized (out) -- a virtual thread blocking inside synchronized pins its carrier platform thread on Java 21. - EX-06: HttpServer's three ThreadLocals (SHA1, LONG_BUF, STREAM_RELAY_BUFFER) replaced by ConnectionScratch, pooled via ScratchPool instead of one-per-virtual-thread (i.e. one-per-connection) growth. The router's ThreadLocals are deliberately deferred to Phase 4 per this EX item's own phasing -- see DEC-15 for the plan-wording fix. - EX-11: WebSocketSession.readFrame's extended-length and mask-key bytes are now read in a single bounded readFully instead of one at a time. - EX-12: full RFC 6455 frame validation -- continuation-frame reassembly, mandatory masking-direction enforcement, opcode validation, control-frame constraints (not fragmented, <=125 bytes), and WebSocketProtocolException carrying the correct close code (1002 protocol error, 1009 message too big). - EX-13: Connection header token-list scanning shared between the keep-alive decision and the WebSocket upgrade check. - EX-14: HEAD responses report Content-Length but write no body. - EX-15: Content-Type omitted when empty; Content-Length and the body omitted entirely for 204/304/1xx responses. - EX-16: Date header (dev.relism.flash.http.DateHeader), refreshed once per second by a shared daemon thread; FlashConfiguration.sendDate. - EX-32: two-stage graceful shutdown -- stop accepting, force Connection: close on the response an in-flight handler is still producing (re-checked after the handler runs, not just before dispatch, so a shutdown beginning mid-handler is still honoured), drain up to shutdownDrainTimeoutMs, then force-close. - EX-34: ServerHandle.create delegates to TransportFactory instead of constructing HttpServer directly. Two plan corrections recorded: DEC-15 (Phase 2's "no ThreadLocal anywhere" DoD line contradicted EX-06's own multi-phase assignment -- corrected to match the registry) and DEC-16 (no separate WebSocketFrameCodec class this phase; the EX-11/EX-12 fixes stay inside WebSocketSession, which is one cohesive state machine under R6's own carve-out -- revisit at Phase 15 if RFC 8441 needs the decoupling for real). HttpServer.java deleted. 311/311 tests green (flash module), run three times for stability of the wall-clock-based timeout/shutdown tests. Whole-repo build green. h1 benchmark regression check remains unverified in the plan's DoD (no JMH harness until Phase 3, same caveat as Phase 1). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
301 lines
12 KiB
Markdown
301 lines
12 KiB
Markdown
# Flash
|
||
|
||
A high-performance HTTP/1.1 server library for Java 21, built around virtual threads and a zero-allocation FSM router.
|
||
|
||
## Modules
|
||
|
||
| Module | Description |
|
||
|---|---|
|
||
| `flash` | Core server library — router, request parser, HTTP I/O transport |
|
||
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration |
|
||
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
|
||
| `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow |
|
||
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, optional OAuth2 via flash-ext-oidc |
|
||
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
|
||
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
|
||
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
|
||
| `flash-bench` | Demo harness (OIDC + OpenAPI + Jackson) |
|
||
|
||
## Requirements
|
||
|
||
- Java 21+
|
||
- Maven 3.8+
|
||
|
||
## Quick start
|
||
|
||
```java
|
||
FlashApp.create(8080)
|
||
.get("/ping", (req, res) -> "pong")
|
||
.start();
|
||
```
|
||
|
||
With full configuration:
|
||
|
||
```java
|
||
FlashApp.create(
|
||
FlashConfiguration.builder()
|
||
.port(8080)
|
||
.host("0.0.0.0")
|
||
.maxHeaderBufferSize(65536)
|
||
.build()
|
||
)
|
||
.get("/ping", (req, res) -> "pong")
|
||
.start();
|
||
```
|
||
|
||
## Route registration
|
||
|
||
### Lambda routes
|
||
|
||
```java
|
||
FlashApp app = FlashApp.create(8080);
|
||
|
||
app.get("/hello", (req, res) -> "world");
|
||
|
||
app.post("/echo", (req, res) -> {
|
||
byte[] body = req.body().bytes();
|
||
return res.status(200).body(body);
|
||
});
|
||
|
||
app.get("/users/{id}", (req, res) -> {
|
||
String id = req.pathParam("id");
|
||
return "user:" + id;
|
||
});
|
||
```
|
||
|
||
### Class-based handlers
|
||
|
||
Extend `RequestHandler`, annotate it, then scan its package. Dependencies are cached in
|
||
`onInit()` after Flash has resolved its complete boot-time service graph:
|
||
|
||
```java
|
||
@GET("/api/users")
|
||
public class ListUsers extends RequestHandler {
|
||
private UserService users;
|
||
|
||
@Override protected void onInit() { users = require(UserService.class); }
|
||
@Override public Object handle(Request req, Response res) { return users.list(); }
|
||
}
|
||
|
||
app.scan("dev.example.api");
|
||
```
|
||
|
||
### Middleware
|
||
|
||
Apply middleware at registration. Flash composes the final chain at boot:
|
||
|
||
```java
|
||
Middleware authCheck = next -> (req, res) -> {
|
||
if (req.header("Authorization") == null)
|
||
return res.status(401).body("Unauthorized");
|
||
return next.handle(req, res);
|
||
};
|
||
|
||
app.get("/secure", (req, res) -> "secret data", authCheck);
|
||
```
|
||
|
||
Multiple middlewares are composed outermost-first (left-to-right in the call):
|
||
|
||
```java
|
||
app.get("/admin", handler, logging, auth, rateLimit);
|
||
// execution order: logging → auth → rateLimit → handler
|
||
```
|
||
|
||
### Classpath scan
|
||
|
||
Scans a package for classes that extend `RequestHandler` and carry `@Route`. Each is
|
||
instantiated via its public no-arg constructor:
|
||
|
||
```java
|
||
app.scan("dev.example.handlers");
|
||
```
|
||
|
||
### Namespace mounting
|
||
|
||
Mount a scoped sub-router under a prefix. All routes registered inside the scope get the
|
||
prefix prepended automatically. The scope inherits the parent's extension context (annotation
|
||
processors, services):
|
||
|
||
```java
|
||
app.mount("/api", scope -> {
|
||
scope.get("/health", (req, res) -> "ok"); // → GET /api/health
|
||
scope.scan("dev.example.api");
|
||
});
|
||
```
|
||
|
||
## Extensions
|
||
|
||
Extensions have one declarative `configure` method. They declare services, processors and route
|
||
callbacks; Flash resolves the complete graph, materialises routes, compiles both routers, then
|
||
opens listeners. Extension install order never makes a service “not ready”.
|
||
|
||
```java
|
||
FlashApp.create(8080)
|
||
.install(new JacksonExtension())
|
||
.install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
|
||
.install(new OidcExtension(oidcConfig))
|
||
.scan("dev.example.handlers")
|
||
.start();
|
||
```
|
||
|
||
See extension-specific READMEs for full details:
|
||
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
|
||
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
|
||
- [`flash-ext-oidc`](flash-extensions/flash-ext-oidc/README.md)
|
||
- [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md)
|
||
- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md)
|
||
- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
|
||
|
||
## Error handlers
|
||
|
||
```java
|
||
app.onNotFound((req, res) -> res.status(404).body("Not found: " + req.path()));
|
||
|
||
app.onException((ex, req, res) -> {
|
||
if (ex instanceof IllegalArgumentException)
|
||
return res.status(400).body(ex.getMessage());
|
||
return res.status(500).body("Internal error");
|
||
});
|
||
```
|
||
|
||
## FlashConfiguration
|
||
|
||
| Field | Default | Description |
|
||
|---|---|---|
|
||
| `port` | — | TCP port to bind |
|
||
| `host` | `"0.0.0.0"` | Bind address |
|
||
| `tls` | `null` | TLS for the default listener — see [TLS](#tls) |
|
||
| `listeners` | `[]` | Multiple bind targets (port + host + optional TLS) on one app — see [TLS](#tls) |
|
||
| `maxHeaderBufferSize` | `65536` | Max size of the header buffer (bytes) |
|
||
| `wsFrameBufferSize` | `65536` | Per-connection WebSocket read buffer (bytes) |
|
||
| `headerReadTimeoutMs` | `10000` | Once a request's first byte arrives, how long the full header block may take. Bounds slowloris-style attacks — see [`HTTP1-HARDENING.md`](flash/docs/http2/HTTP1-HARDENING.md). |
|
||
| `idleKeepAliveTimeoutMs` | `60000` | How long a keep-alive connection may sit idle waiting for its next request. |
|
||
| `bodyReadTimeoutMs` | `30000` | How long reading a request body (handler or automatic drain) may take. |
|
||
| `shutdownDrainTimeoutMs` | `15000` | How long graceful shutdown waits for in-flight requests before force-closing. |
|
||
| `http2Enabled` | `false` | Whether this server will ever negotiate HTTP/2. Off by default until the HTTP/2 connection state machine lands (see `flash/docs/http2/IMPLEMENTATION-PLAN.md`). |
|
||
|
||
## TLS
|
||
|
||
HTTPS and WSS are a transport-layer concern only: once a listener is bound, the accepted
|
||
`Socket` is either plain or an `SSLSocket` indistinguishably from `HttpServer`'s point of view
|
||
onward — the request parser, router, and WebSocket upgrade never branch on it. WSS is therefore
|
||
not a separate feature; it's a WebSocket upgrade running over whatever transport it was handed.
|
||
|
||
### Quick start
|
||
|
||
```java
|
||
FlashApp.create(FlashConfiguration.builder()
|
||
.port(443)
|
||
.tls(TlsConfig.keystore(Path.of("cert.p12"), "changeit"))
|
||
.build())
|
||
.get("/ping", (req, res) -> "pong") // HTTPS
|
||
.ws("/live", handler) // WSS, same route API
|
||
.start();
|
||
```
|
||
|
||
### Multiple listeners
|
||
|
||
One app can bind any number of ports, each independently plain or TLS:
|
||
|
||
```java
|
||
FlashApp.create(FlashConfiguration.builder()
|
||
.listener(new FlashConfiguration.Listener(80)) // plain
|
||
.listener(new FlashConfiguration.Listener(443, TlsConfig.keystore(cert, pass))) // TLS
|
||
.build());
|
||
```
|
||
|
||
A non-empty `listeners` list takes precedence over the top-level `port`/`host`/`tls` fields.
|
||
Each listener gets its own accept threads; the router, WS router, and virtual-thread executor
|
||
are shared by all of them — one app, N ports.
|
||
|
||
### `TlsConfig`
|
||
|
||
| Factory | Use |
|
||
|---|---|
|
||
| `TlsConfig.keystore(Path, String)` | Builds the `SSLContext` from a PKCS12/JKS keystore (type guessed from the extension). Pins `TLSv1.2`/`TLSv1.3` as enabled protocols; cipher suites are left at the JDK's own curated default. |
|
||
| `TlsConfig.ofContext(SSLContext)` | Escape hatch — the given `SSLContext` is used exactly as built. Flash never calls `setSSLParameters` on this path beyond what you explicitly request via `clientAuth`/`applicationProtocols`, so anything else you configured (custom `KeyManager`, ALPN, cipher suites) is authoritative. |
|
||
|
||
Chainable on either factory:
|
||
|
||
```java
|
||
TlsConfig.keystore(cert, pass)
|
||
.clientAuth(ClientAuth.REQUIRE) // mTLS: NONE (default) | OPTIONAL | REQUIRE
|
||
.applicationProtocols("acme-tls/1", "http/1.1") // ALPN, in preference order
|
||
```
|
||
|
||
**SNI** falls out of `keystore()` for free: a keystore holding more than one certificate entry
|
||
is matched against the requested hostname by each certificate's SAN (falling back to CN) — no
|
||
per-hostname config. The first entry in the keystore is the default when SNI is absent or
|
||
matches nothing (same convention as nginx/HAProxy's `default_server`).
|
||
|
||
**ALPN and custom certificate selection** (e.g. TLS-ALPN-01 / RFC 8737 for on-demand ACME
|
||
issuance): ALPN is resolved while consuming `ClientHello`/producing `ServerHello`, which always
|
||
precedes `Certificate` production. A custom `X509ExtendedKeyManager` passed via `ofContext`
|
||
can therefore read `engine.getHandshakeApplicationProtocol()` (or
|
||
`((SSLSocket) socket).getHandshakeApplicationProtocol()`) inside
|
||
`chooseEngineServerAlias`/`chooseServerAlias` — the negotiated protocol is already resolved by
|
||
then, so the certificate decision can key off it.
|
||
|
||
**mTLS with a private CA**: `clientAuth(...)` only requests/requires a client certificate;
|
||
`keystore()` deliberately doesn't expose a way to configure which CAs are trusted for that
|
||
certificate (it uses the JDK default trust store). For a private CA, build the `SSLContext`
|
||
yourself with a `TrustManagerFactory` and use `ofContext(...)`.
|
||
|
||
### Reading TLS info from a request
|
||
|
||
```java
|
||
app.get("/whoami", (req, res) -> {
|
||
if (!req.isSecure()) return "plain";
|
||
SSLSession session = req.sslSession(); // null iff !isSecure()
|
||
X509Certificate peer = (X509Certificate) session.getPeerCertificates()[0]; // mTLS only
|
||
return session.getCipherSuite() + " / " + session.getProtocol();
|
||
});
|
||
```
|
||
|
||
`Request.isSecure()` / `Request.sslSession()` cost nothing extra per request: the `SSLSocket`
|
||
reference is threaded through once per connection (same mechanism as `remoteAddress()`), and
|
||
`sslSession()` only calls `SSLSocket#getSession()` — a cached-field read once the handshake
|
||
that got the request this far has already completed, never a forced handshake.
|
||
|
||
`WebSocketSession` mirrors this exactly (`isSecure()`, `sslSession()`) by delegating to the
|
||
upgrading `Request` — no separate TLS state is tracked for WS.
|
||
|
||
## Architecture
|
||
|
||
```
|
||
TransportFactory.create() # binds every listener, wires the connection runner
|
||
→ AcceptLoop # one per listener × accept thread; hands sockets off
|
||
→ ConnectionRunner.accept() # per-connection setup: TLS handshake, protocol negotiation
|
||
→ ProtocolNegotiator # ALPN / h2c-preface — decides the protocol once
|
||
→ Http1Connection.run() # the ConnectionProtocol seam; HTTP/2 plugs in here later
|
||
→ RequestParser.parse() # zero-alloc header parsing, buffer reuse across keep-alive
|
||
→ GlobalRouter.route() # two-tier: mounted sub-routers (longest prefix) then FastPathRouterImpl
|
||
→ RequestHandler.handle() # user handler; return value sets body
|
||
→ Request.drain() # consume unread body for keep-alive
|
||
→ Http1ResponseWriter.write() # status line, headers, then fixed or chunked body
|
||
→ loop or close socket # based on Connection header, or ServerLifecycle draining
|
||
```
|
||
|
||
- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`, owned by `TransportFactory`). Java 21 required.
|
||
- **Zero-allocation router** — `FastPathRouterImpl` uses `fpr-core`, a byte-level FSM that matches on `METHOD + path` bytes with no per-request allocation.
|
||
- **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection.
|
||
- **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported.
|
||
- **TLS is transport-only** — see [TLS](#tls). Listeners bind either a plain `ServerSocket` or an `SSLServerSocket`; nothing downstream of `accept()` branches on which.
|
||
- **`ConnectionProtocol` seam** — h1 and h2 (in progress, see `flash/docs/http2/`) are peers behind this interface, decided once per connection by `ProtocolNegotiator`, never by an `if` inside shared code. See `flash/docs/http2/TRANSPORT.md` for the full component breakdown.
|
||
|
||
## Build & test
|
||
|
||
```bash
|
||
# Build all modules (skip tests)
|
||
mvn clean package -DskipTests
|
||
|
||
# Run all tests
|
||
mvn test
|
||
|
||
# Run a single test class
|
||
mvn test -pl flash -Dtest=RequestParserTest
|
||
|
||
# Run the benchmark demo server
|
||
java -jar flash-bench/target/flash-bench-1.0-SNAPSHOT.jar
|
||
```
|