flash-ext-jackson and flash-ext-validation become three modules: - flash-ext-jackson-core: the Codec (one mapper, body/write/writeView), JacksonHandler, the outbound marshalling, and the constraint engine that used to be flash-ext-validation - flash-ext-jackson-json: Json, JsonExtension, JsonHandler - flash-ext-jackson-xml: Xml, XmlExtension, XmlHandler Every Jackson format is the same databind model behind a different factory, so the annotations, the constraints and the published schema are the same for all of them: only the mapper and the content type differ, and that is all a format module says. A route picks its format by the handler it extends — there is no negotiation and nothing to configure. A typed body is now always verified against its own type's jakarta constraints, whatever the format: malformed is a 400, a broken constraint is a 422, and neither reaches the handler. The validator was already allocation-free and stays so; validating is no longer something an application remembers to do. bodyFrom is gone. body has the streaming semantics, because the request's stream is reused per connection while bytes() allocates the whole body: one name, the path that does not allocate. JacksonExtension is JsonExtension, and autoJson() is auto(). The root POM now manages every module of this build, so anything composing Flash imports it once and never names a version again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
23 KiB
Flash
A high-performance HTTP/1.1 and HTTP/2 server library for Java 21, built around virtual threads, a zero-allocation FSM router, bounded protocol state, and one shared request/response API.
Modules
| Module | Description |
|---|---|
flash |
Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model |
flash-testing |
JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses |
flash-extensions/flash-ext-jackson-core |
What every Jackson format shares: the codec, the body handler, the constraints a body is checked against |
flash-extensions/flash-ext-jackson-json |
JSON bodies and responses |
flash-extensions/flash-ext-jackson-xml |
XML bodies and responses |
flash-extensions/flash-ext-openapi |
OpenAPI 3.0 spec + Swagger UI |
flash-extensions/flash-ext-security-core |
Security: authentication chain, annotations, sessions, OpenAPI |
flash-extensions/flash-ext-security-oidc |
OpenID Connect: bearer tokens, code flow + PKCE |
flash-extensions/flash-ext-security-apikey |
API keys |
flash-extensions/flash-ext-security-form |
Password sign-in |
flash-extensions/flash-ext-security-oauth-server |
OAuth 2.1 authorization server for the application's own users and resources |
flash-extensions/flash-ext-security-test |
Test identities, fake OpenID Provider |
flash-extensions/flash-ext-mcp |
MCP (Model Context Protocol) server — Streamable HTTP, secured by flash-ext-security-core |
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-extensions/flash-ext-vite |
Vite frontend: dev server in DEV, the built SPA from the jar otherwise |
flash-extensions/flash-ext-vite-maven-plugin |
Builds the Vite frontend into the jar during mvn package |
flash-extensions/flash-ext-scheduler |
Interval and cron background jobs on virtual threads |
flash-extensions/flash-ext-cache-core |
Caching contract — Cache, CacheManager, CacheSpec |
flash-extensions/flash-ext-cache-caffeine |
In-process cache backed by Caffeine |
Requirements
- Java 21+
- Maven 3.8+
Quick start
FlashApp.create(8080)
.get("/ping", (req, res) -> "pong")
.start();
With full configuration:
FlashApp.create(
FlashConfiguration.builder()
.port(8080)
.host("0.0.0.0")
.maxHeaderBufferSize(65536)
.build()
)
.get("/ping", (req, res) -> "pong")
.start();
Route registration
Lambda routes
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.param("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:
@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:
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):
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:
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):
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”.
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-coreflash-ext-jackson-jsonflash-ext-jackson-xmlflash-ext-openapiflash-ext-security-coreflash-ext-security-oidcflash-ext-security-apikeyflash-ext-security-formflash-ext-security-testflash-ext-mcpflash-ext-view-jteflash-ext-view-thymeleafflash-ext-schedulerflash-ext-cache-caffeineflash-testing
Error handlers
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 |
listeners |
[] |
Multiple bind targets (port + host + optional TLS) on one app — see 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. |
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. |
maxConnections |
auto (~heap/10MB) | Maximum concurrent connections across all listeners before new ones are closed immediately at accept time, before any per-connection state (TLS handshake included) is created. Auto-scales from Runtime.maxMemory(); set explicitly for a known deployment size, or 0 to disable. |
http2Enabled |
false |
Whether TLS listeners advertise HTTP/2 through ALPN. |
http2CleartextEnabled |
false |
Whether plaintext listeners accept HTTP/2 prior knowledge (h2c). Independent from TLS HTTP/2. |
h2HuffmanDynamicValues |
false |
HPACK-Huffman encode runtime response values. Constants remain pre-encoded; the measured default avoids an extra encode pass. |
h2MaxResetStreamsPerInterval |
200 |
Rapid Reset budget per rolling interval. |
h2MaxStreamsCreatedPerInterval |
400 |
New-stream budget per rolling interval. |
h2AbuseRateIntervalMs |
10000 |
Rolling interval for the two operator-tunable rate limits above. |
h2MaxStreamsPerConnection |
100000 |
Total stream budget; 0 disables it. |
h2MaxBytesPerConnection |
0 |
Optional total wire-byte budget; 0 disables it. |
h2MaxConnectionLifetimeMs |
0 |
Optional connection lifetime; 0 disables it. |
h2StreamIdleTimeoutMs |
60000 |
Inactive open-stream deadline. |
sendDate |
true |
Add an RFC 9110 Date field to responses; disable when an upstream proxy supplies it. |
Protocols
Routes, middleware, Request, Response, bodies, trailers, streaming and WebSockets use the same
API on HTTP/1.1 and HTTP/2. Protocol selection happens once per connection:
- On TLS listeners, enable
http2Enabled; Flash advertisesh2andhttp/1.1through ALPN and uses the protocol selected by the client. Existing HTTP/1.1 clients continue to work. - On plaintext listeners, enable
http2CleartextEnabledto accept the HTTP/2 prior-knowledge preface on the same port as HTTP/1.1. Clients that do not send that exact preface are parsed as HTTP/1.1. - With both switches left at their default
false, Flash behaves as an HTTP/1.1 server.
After enabling the appropriate switch, application routes need no protocol-specific code. TLS still requires the normal certificate configuration shown below.
Flash deliberately does not implement HTTP/2 server push, RFC 7540 dependency-tree priority
scheduling, or the obsolete HTTP/1.1 Upgrade: h2c transition. Server push has no application API,
RFC 9113 deprecated the old priority scheme, and cleartext HTTP/2 uses prior knowledge instead.
See the HTTP/2 compliance record for exact coverage.
WebSockets over HTTP/2
The same ws(path, handler) route serves WebSockets over HTTP/1.1 and HTTP/2. When HTTP/2 is
enabled, Flash advertises RFC 8441 extended CONNECT support and carries WebSocket frames inside
flow-controlled DATA frames. No alternate handler, route, or session API is required:
app.ws("/live", handler);
HTTP/1.1 clients use the ordinary 101 Switching Protocols upgrade. HTTP/2 clients use an
extended CONNECT and receive status 200; Flash applies the same RFC 6455 framing, masking,
fragmentation, close, and callback behavior on both transports. Client support for negotiating
WebSockets over HTTP/2 varies, so clients without RFC 8441 support continue to use HTTP/1.1.
TLS
HTTPS and WSS are a transport-layer concern only. Once a listener is bound, the accepted socket is plain or TLS; the selected HTTP connection implementation then performs either the HTTP/1.1 upgrade or the HTTP/2 extended CONNECT. WSS does not require a separate route or handler API.
Quick start
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:
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:
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
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.
Object lifetime
Request and Response are pooled per connection, not allocated per request: one instance is
created per connection and repositioned (reset()) over each new request/response in turn — the
same idiom Java NIO buffers use, applied to the whole request/response model
(flash/docs/core/MESSAGE-MODEL.md has the full design record). This is what makes a warm h1
request/response cycle 0 B/op.
Do not retain a Request or Response past the handler that received it. A reference kept in
a field, a captured closure, a CompletableFuture continuation, or a background thread and read
after the handler returns will observe whatever the next request on that connection
repositioned the same instance to — not the request you thought you had:
// WRONG — captures `req`, reads it after the handler has returned
app.get("/slow", (req, res) -> {
CompletableFuture.runAsync(() -> log(req.header("X-Trace-Id"))); // may log the NEXT request's header
return "ok";
});
Copy out whatever you need before returning or handing work off asynchronously — every accessor
that returns a String (header, param, query, path, …) gives you an independent heap copy
that's safe to keep as long as you like:
app.get("/slow", (req, res) -> {
String traceId = req.header("X-Trace-Id"); // copy now, safe to retain
CompletableFuture.runAsync(() -> log(traceId));
return "ok";
});
Run with -Dflash.env=dev and a use-after-return access throws IllegalStateException immediately
at the offending call site instead of silently reading the wrong request's data — turn this on in
tests and local development. It's a no-op in production beyond a single boolean field read.
req.body()/RequestBody follows the same rule — materialise (.bytes()) or fully consume
(.stream()) it inside the handler; don't stash the RequestBody itself for later.
Reusable response headers
Use PreEncodedHeader for a constant header sent by many responses. It stores the name and value
once and remains valid on both HTTP versions:
private static final PreEncodedHeader NO_STORE =
new PreEncodedHeader("cache-control", "no-store");
app.get("/health", (req, res) -> res.header(NO_STORE).body("ok"));
Response.header(byte[]) accepts a complete CRLF-terminated HTTP/1 field line and is therefore
HTTP/1-only; HPACK needs the name and value as separate fields. Prefer PreEncodedHeader for shared
application and middleware code.
Trailers and push streaming
Request trailers become available after the body reaches EOF:
byte[] payload = req.body().bytes();
String status = req.trailers().first("grpc-status");
For a producer-driven response, Response.streaming provides a blocking ResponseStream. Its
bounded buffer and HTTP/2 flow-control windows apply backpressure directly to the producer's
virtual thread:
return res.streaming(stream -> {
try {
stream.write(payload, 0, payload.length);
stream.trailer("result", "complete");
} catch (IOException failure) {
throw new UncheckedIOException(failure);
}
});
The API renders as chunked data and trailers on HTTP/1.1, and DATA plus trailing HEADERS on
HTTP/2. Flash core supplies these transport primitives; a higher-level gRPC codec belongs in a
future flash-ext-grpc extension.
Testing
flash-testing boots a real app on an OS-assigned port for the duration of a test, and hands you
a client pointed at it. Add it with test scope:
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<version>${flash.version}</version>
<scope>test</scope>
</dependency>
class UserRoutesTest {
@RegisterExtension
static FlashTest app = FlashTest.of(new BlogApp())
.mock(UserService.class, new InMemoryUserService());
@Test
void listsUsers() {
app.get("/api/users")
.expectStatus(200)
.expectHeader("content-type", "application/json")
.expectBodyContains("alice");
}
}
FlashTest.of takes a FlashApplication — your app's routes, extensions and services expressed
independently of which port they run on:
public final class BlogApp implements FlashApplication {
@Override public void configure(FlashApp app) {
app.install(new JacksonExtension());
app.mount("/api", scope -> scope.scan("dev.blog.api"));
}
}
FlashApp.create(8080).apply(new BlogApp()).startAndBlock(); // production
It is a functional interface, so a lambda works too:
FlashTest.of(app -> app.get("/ping", (req, res) -> "pong")).
Requests
The HTTP verb sends the request; expect* assertions chain and report the real response body on
failure. get and delete skip the builder when there is nothing to add.
app.get("/api/users").expectStatus(200);
app.request()
.header("Authorization", "Bearer " + token)
.json("{\"name\":\"bob\"}")
.post("/api/users")
.expectStatus(201);
try (FlashWebSocket socket = app.ws("/live")) {
socket.sendText("hello");
assertEquals("echo:hello", socket.awaitText(Duration.ofSeconds(2)));
}
Replacing services
mock installs replacements after everything your app and its extensions declare, so a fake always
wins. Any object will do — flash-testing depends on no mocking library, so a hand-written fake and
a Mockito mock are equally welcome.
More than one server
FlashTest is an ordinary object in a field, so a test class can hold as many as it needs and wire
one from another in plain Java. Startup is lazy — reading baseUri() boots that server on the spot
— so declaration order does the wiring:
@RegisterExtension static FlashTest auth = FlashTest.of(new FakeOidcApp());
@RegisterExtension static FlashTest api = FlashTest.of(new BlogApp(auth.baseUri()));
Scope
A static field boots once for the test class; a non-static field boots a fresh app for every test.
That is stock JUnit field semantics — the isolation switch is the keyword, not an option.
Configuration
Full reference: flash-testing/docs, including the
limits the harness deliberately does not cross.
profile customises the FlashConfiguration — timeouts, HTTP/2 switches, buffer sizes. Host, port
and the shutdown drain window are stamped afterwards, so a profile cannot break the harness;
listener(...) and tls(...) are rejected because the harness owns the loopback listener it gives
you a client for.
FlashTest.of(new BlogApp()).profile(cfg -> cfg.http2CleartextEnabled(true));
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() # request parser, router, handler, h1 response writer
└─ Http2Connection.run() # frame demux, HPACK, stream dispatch, flow control
→ RequestHandler.handle() # the same protocol-neutral request/response API
- Virtual threads — each accepted socket runs on a virtual thread (
Executors.newVirtualThreadPerTaskExecutor(), owned byTransportFactory). Java 21 required. - Zero-allocation router —
FastPathRouterImplusesfpr-core, a byte-level FSM that matches onMETHOD + pathbytes with no per-request allocation. - Keep-alive —
RequestParserreuses 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. Listeners bind either a plain
ServerSocketor anSSLServerSocket; nothing downstream ofaccept()branches on which. ConnectionProtocolseam — HTTP/1.1 and HTTP/2 are peers behind this interface, selected once per connection byProtocolNegotiator; routing and application models are shared.
Build & test
# 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