The org's Gitea Actions runner is registered under the label "org" — "docker" doesn't match anything online, so the publish job never got picked up. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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
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.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:
@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-oidcflash-ext-mcpflash-ext-view-jteflash-ext-view-thymeleaf
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) |
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
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.
Architecture
ServerSocket.accept()
→ 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
→ HttpServer writes response # status line, headers, then fixed or chunked body
→ loop or close socket # based on Connection header
- Virtual threads — each accepted socket runs on a virtual thread (
Executors.newVirtualThreadPerTaskExecutor()). 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.
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
# Run the benchmark demo server
java -jar flash-bench/target/flash-bench-1.0-SNAPSHOT.jar