Reviewed-on: #17
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 |
Jackson JSON integration |
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-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-validation |
Request validation — jakarta constraints, compiled once per type |
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-jacksonflash-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-validationflash-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