Optional third-party integrations for the Freeway framework.
Freeway core ships with FreewayHttpEngine — a raw-socket engine built from scratch
on virtual threads (one per connection). It supports:
- HTTP/1.1 with keep-alive and per-connection context reuse
- HTTP/2 — both h2c (cleartext upgrade via
PRI * HTTP/2.0preface) and h2 (TLS with ALPN negotiation) - WebSocket — full RFC 6455 implementation (text, binary, ping/pong, close handshake, fragmentation)
- HTTPS — TLS wrapping with ALPN for protocol selection
Internally it uses a bulk-read parser with reusable 4 KB buffers, assembles the entire HTTP response into a single buffer for one-shot socket writes, and reuses parser and context objects across keep-alive requests on the same connection. Zero third-party dependencies.
For the vast majority of applications, this is all you need.
| Module | When to use | External Dependency |
|---|---|---|
freeway-http-jetty |
Jetty 12 deployments, Servlet-style processing, or existing Jetty operational tooling | Jetty 12.1.13 |
freeway-http-undertow |
Undertow-specific handler/listener config, or existing Undertow operational tooling | Undertow 2.4.3.Final |
freeway-mq-kafka |
Distributed event streaming across services | Kafka Clients 4.3.1 |
freeway-cloud-consul |
Service discovery/registration backed by Consul (non-Kubernetes deployments) | none — JDK HttpClient only |
freeway-db-hikari |
Connection pooling tuned for high-concurrency OLTP | HikariCP 7.1.0 |
freeway-benchmark |
JMH comparison suite and the bench CLI (not published) |
JMH 1.37, robaho 1.0.29 |
freeway-http-adapter-testkit |
Shared fixtures and contracts every engine must pass (test scope only) | JUnit 6.1.3 |
Adapter modules are leaf nodes: no cross-dependencies between them. freeway-benchmark is the
one exception — comparing engines requires both of them — and is excluded from deployment.
The receiving plane deserializes a record only into the payload type a
KafkaEvents.subscribe(prefix, type, handler) declared — the subscription
table is the inbound allowlist. A record whose topic matches no subscription is
acknowledged and skipped without being read at all, so an undeclared class can
never be loaded off the wire. The former freeway.kafka.allowed-event-types
key is gone: declaring a subscription is the only way to receive, and there is
no "silent empty allowlist" state to misconfigure.
⚠️ This replaces the old class-name allowlist. Consumers that bridged typed events now declare the type per subscription instead:events.subscribe("orders", OrderCreated.class, handler);
Rejected (undecodable) messages follow the poison-message policy: they are
retried, then either logged and moved on (default
freeway.kafka.poison-policy=skip) or fail the subscriber without committing
(fail). A throwing handler is never poison — it is isolated and counted,
because the record itself decoded fine.
With fail, the failing offset is not committed, so already-processed events
from the same batch may be redelivered after a restart (at-least-once
semantics). Consumers that need exactly-once deduplicate by their own business
key — the send partition key is the natural one. Transports without a
natural business key (signal events, handlers not written idempotent) can
instead arm the bounded redelivery window — freeway.kafka.dedup-capacity
(default 0, off) remembers the last N dispatch ids and drops second copies
before dispatch. Poison still throws first, so the DLQ path is unaffected;
unmatched records never occupy the window.
KafkaEvents.send(topic, payload) writes to the named topic itself — the
routing topic and the wire topic are one, and the old bridge-topic override
(the record always going to freeway.kafka.topics with the local topic riding
a ce-fwtopic header) is gone. freeway.kafka.topics is now purely the
subscriber's poll set: a topic nobody polls receives nothing, and
subscribe warns at registration when its prefix can match no polled topic.
send never throws at the caller: synchronous producer failures are logged
and counted, and a null payload is skipped (in Kafka a null value is a
compaction tombstone, not an event). The plane is started and closed by the
freeway.kafka.lifecycle hook (matching core's freeway.<module>.<thing>
naming); send works before the hook too (the producer is live from
construction — only the poll loop waits for lifecycle).
Additional Kafka client options (TLS, SASL, etc.) can be passed through with
freeway.kafka.properties as semicolon-separated key=value pairs, e.g.
-Dfreeway.kafka.properties=security.protocol=SASL_SSL;sasl.mechanism=PLAIN.
These are applied last and override adapter defaults.
| Property | Default | Meaning |
|---|---|---|
freeway.kafka.max-retries |
1 |
Number of delivery/processing retries before a message is treated as poison. |
freeway.kafka.retry-backoff-ms |
1000 |
Base backoff between retries; each attempt doubles it (exponential). |
freeway.kafka.dlq-topic |
(unset) | When set, poison messages are published to this dead-letter topic instead of being skipped. The original topic/offset and a reason are preserved in X-DLQ-Original-Topic / X-DLQ-Original-Offset / X-DLQ-Reason headers. |
freeway.kafka.concurrency |
1 |
Number of poll/processing workers; when > 1 messages are fanned out by key so ordering per key is preserved. |
freeway.kafka.dedup-capacity |
0 (off) |
Bound on remembered dispatch ids for redelivery suppression (rebalance/restart replays carry the same CE id); second copies are dropped before dispatch. Poison still throws first, so the DLQ path is unaffected. |
Records sent by KafkaEvents carry the optional send partition key as the
Kafka record key (per-aggregate ordering on the broker, per-key parallel
consumption), and the same key rides the ce-subject header. Sends without a
key have a null record key — no cross-broker ordering promise. Keys set by
other producers are honored.
Without a DLQ topic, poison messages follow freeway.kafka.poison-policy as
described above. With a DLQ topic, they are moved to the DLQ first and the
policy then decides whether processing continues (skip) or stops (fail).
If the DLQ write itself fails, the subscriber stops without committing so the
message is redelivered until the DLQ accepts it — a poison message is never
silently dropped.
The Jetty and Undertow adapters read the shared freeway.http.ssl.* keys of the
built-in engine (same names, same defaults), so a TLS configuration written for
FreewayHttpEngine works unchanged on either adapter. All keys below resolve
through the full config cascade (CLI, JVM properties, env, files) — -D flags
in the example are just one tier. A malformed max-frame-size fails startup
naming the key instead of silently falling back.
| Property | Default | Applies to | Meaning |
|---|---|---|---|
freeway.http.ssl.enabled |
false |
Jetty, Undertow | Serve HTTPS instead of plain HTTP. |
freeway.http.ssl.key-store |
— | Jetty, Undertow | Path to the key store (PKCS12 or JKS). |
freeway.http.ssl.key-store-password |
`` | Jetty, Undertow | Key store password. |
freeway.http.ssl.key-store-type |
inferred (.jks → JKS, else PKCS12) |
Jetty, Undertow | Key store type. |
freeway.http.ssl.trust-store |
— | Jetty, Undertow | Optional trust store for validating peer certificates. |
freeway.http.ssl.trust-store-password |
`` | Jetty, Undertow | Trust store password. |
freeway.http.ssl.trust-store-type |
PKCS12 |
Jetty, Undertow | Trust store type. |
freeway.http.ssl.client-auth |
false |
Jetty, Undertow | Require client certificates (mTLS). |
freeway.http.ssl.protocols |
(JVM defaults) | Jetty, Undertow | Comma-separated TLS protocol versions (e.g. TLSv1.3,TLSv1.2). |
freeway.http.ssl.ciphers |
(JVM defaults) | Jetty, Undertow | Comma-separated TLS cipher suite names. |
freeway.http.ssl.http2 |
true |
Jetty, Undertow | HTTP/2 over TLS via ALPN (same key and default as the built-in engine). |
freeway.http.ssl.key-password |
(same as store) | Jetty | Key manager password (Jetty extension for JKS keystores). |
freeway.http.ssl.key-alias |
(first entry) | Jetty | Alias of the server certificate (Jetty extension). |
freeway.http.http2 |
false |
Jetty | Jetty-only: h2c (cleartext HTTP/2). Ignored when TLS is enabled — use freeway.http.ssl.http2 there. |
freeway.http.websocket.max-frame-size |
65536 |
Jetty, Undertow | Maximum WebSocket text/binary message size in bytes; 0 disables the limit. |
freeway.http.undertow.dispatch-io |
true |
Undertow | Dispatch handler execution from I/O threads to the worker pool. Keep enabled when handlers can block (body reads, DB calls); set false only for fully non-blocking handlers. |
freeway.http.jetty.dispatch-io |
true |
Jetty | Dispatch handler execution from the connection's producer thread to the Jetty thread pool. Keep enabled when handlers can block (body reads, DB calls); set false only for fully non-blocking handlers — the handler then runs inline on the producer, skipping the per-request hand-off (a keepalive ping measured ~1.6x on the raw Jetty shape). A blocking handler in this mode stalls the shared producer thread. |
Example (Jetty, TLS + HTTP/2):
-Dfreeway.http.ssl.enabled=true \
-Dfreeway.http.ssl.key-store=/etc/freeway/keystore.p12 \
-Dfreeway.http.ssl.key-store-password=changeit \
-Dfreeway.http.ssl.http2=trueBoth adapters receive the same HttpServerConfig as the built-in engine and
honor the shared freeway.http.server.* keys where the underlying server
supports them:
| Key | Adapters | Notes |
|---|---|---|
freeway.http.server.read-timeout |
Jetty, Undertow | Jetty maps it to the connector idle timeout; Undertow to its idle and request-parse timeouts (default 30s, 0 disables). |
freeway.http.server.backlog |
Jetty, Undertow | Accept backlog (0 = platform default). |
freeway.http.server.max-connections |
Jetty | Rejected at accept time via Jetty's connection limit. Undertow has no equivalent; the knob is ignored there. |
freeway.http.server.write-timeout |
— | Neither adapter exposes a per-write timeout; the knob is ignored. |
freeway.http.server.receive-buffer-size / send-buffer-size |
Jetty, Undertow | Desired socket buffer sizes (0 = OS default). |
freeway.http.compression.enabled / min-size |
Jetty, Undertow | gzip response compression with the built-in engine's semantics (status, min-size, Accept-Encoding and Content-Type gates, Vary: Accept-Encoding). |
freeway-http-undertow and freeway-http-jetty are independent adapters: an
application depends on exactly one of them, so the other's HttpEngine
binding is not on the classpath at all. No conflict occurs, even with SPI
auto-discovery enabled.
Both adapters register their engine as HttpEngine.primary(). This only
matters when both artifacts end up on the same classpath (for example
freeway-benchmark, which bundles them for comparative runs). In that case,
either disable auto-discovery (FreewayApp.create(...).autoDiscovery(false)) and
install the desired module explicitly, or resolve the engine by id.
UndertowWebSocketSession and JettyWebSocketSession send frames asynchronously
so application code can safely call sendText / sendBinary / ping from the
server's receive (I/O) threads without deadlocking. As a trade-off, send
failures are logged by the adapter instead of being thrown to the caller, and
close() initiates the graceful close handshake and returns immediately. If you
need to react to send failures, watch the adapter's error logs or the underlying
server's WebSocket error callbacks.
HttpContext.sse() streams are owned by the application: close the returned
emitter when finished so the underlying connection can be released.
WebSocket sends have no backpressure: a slow or disconnected client accumulates outbound frames until the server's buffers are exhausted, so applications that broadcast to many clients should bound their own fan-out (e.g. drop or throttle sessions that fall behind). Send failures are logged by the adapters.
On the Undertow adapter, WebSocket messages over
freeway.http.websocket.max-frame-size are rejected with a 1009 close frame
after Undertow buffers them; the cap prevents the message from reaching
application code and closes the connection. Jetty rejects oversized messages
during assembly. Note: on Undertow an oversized message surfaces to the
application as onError only (no onClose callback); on Jetty the listener
also receives onClose(1009).
The Undertow adapter's idle/parse deadlines come from the shared
freeway.http.server.read-timeout (default 30 seconds, matching the built-in
engine; 0 disables) — connections with no traffic are dropped. Long-lived
SSE streams should therefore emit periodic heartbeat comments, and WebSocket
peers should stay within the idle window or expect the connection to be
closed.
Pick the modules you need — versions track the Freeway core framework:
<dependency>
<groupId>com.jujin8.freeway</groupId>
<artifactId>freeway-http-undertow</artifactId>
<version>1.5.3</version>
</dependency>Requires JDK 25+. Build Freeway core first, then extensions:
# 1. Install core modules into local Maven repository
cd ../freeway-framework && mvn -DskipTests install
# 2. Build all extensions
cd ../freeway-ext && mvn test
# 3. Single module
mvn -pl freeway-http-undertow -am test
# 4. Format check (bound to verify, not part of mvn test)
mvn spotless:checkCI runs mvn -B -ntp verify -Dgpg.skip=true.