Zakaria El OrcheandClaude Opus 5 003fd6d1f0 feat(ext-vite): recognise navigations by Sec-Fetch-Mode, read every header in place
A path that is no file falls back to index.html when the request is a navigation:
Sec-Fetch-Mode: navigate, or an Accept naming text/html for older clients. That check,
Accept-Encoding and If-None-Match are all matched on the header bytes through the new
Request.headerView(name), so serving still allocates nothing. navigationOnly(false)
drops the check for an app that wants every GET miss to get the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:16:27 +00:00

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-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-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:

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 advertises h2 and http/1.1 through ALPN and uses the protocol selected by the client. Existing HTTP/1.1 clients continue to work.
  • On plaintext listeners, enable http2CleartextEnabled to 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 by TransportFactory). Java 21 required.
  • Zero-allocation routerFastPathRouterImpl uses fpr-core, a byte-level FSM that matches on METHOD + path bytes with no per-request allocation.
  • Keep-aliveRequestParser 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. Listeners bind either a plain ServerSocket or an SSLServerSocket; nothing downstream of accept() branches on which.
  • ConnectionProtocol seam — HTTP/1.1 and HTTP/2 are peers behind this interface, selected once per connection by ProtocolNegotiator; 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
S
Description
No description provided
Readme
3.3 MiB
Languages
Java 97.3%
JavaScript 2.2%
HTML 0.4%