Skip to content

[http] Kyo-native support for HTTP clients and servers in all platforms#1479

Open
fwbrasil wants to merge 24 commits intomainfrom
kyo-http-pr
Open

[http] Kyo-native support for HTTP clients and servers in all platforms#1479
fwbrasil wants to merge 24 commits intomainfrom
kyo-http-pr

Conversation

@fwbrasil
Copy link
Collaborator

@fwbrasil fwbrasil commented Mar 3, 2026

IMPORTANT: Once this is merged, I'm planning to remove kyo-sttp and kyo-tapir. Please comment if anyone has concerns regarding the removal.

Problem

We currently integrate with sttp and tapir for http but they introduce too much overhead, especially due to high allocation rate. The APIs don't follow the common patterns in Kyo, which disrupts the experience, and don't offer type-level safety in some features.

Solution

Introduce kyo-http. The module has comprehensive support for HTTP/1.1 and and interpretable pure Route data structure (typically named Endpoint in other libraries) with type-level tracking of fields via Record, enabling even type-safe filter definitions. The design of the library is centered on routes and its type-level properties but several convenience APIs are added for common uses.

I was able to get both clients and servers working on all platforms: JVM, Native, and JS. The JVM backend is the most mature. The others are functional and covered by comprehensive test coverage but I'm planning to work further on these backends. I want to explore io_uring support for example.

Please see the README for more information on the module.

Performance

The allocation rate is remarkably lower than the other libraries compared in the benchmarks, which results in significantly higher throughput: JMH results. Example for HttpClientBenchmark:

image image

Reviewing

Given the size of the change, I've split it into multiple commits to aid in reviewing. The commits are structured according to the dependencies between implementations and designed to facilitate the understanding of the module. Each commit has a clarifying message as well.

Notes

  • The OpenAPI support has an initial macro implementation to statically consume specifications into typed routes. The support is very initial but I'll follow up on it.
  • I always found odd that Endpoint in other libraries imply a pure description while Route indicates a concrete handler so I decided to use Route for the equivalent of Endpoint.
  • The main missing features are websockets and http2 that we should explore later.

fwbrasil added 18 commits March 2, 2026 22:42
Read the README before looking at any code. It walks through the library from the user's perspective — client usage, server, then the route DSL that powers both. The code commits follow dependency order (bottom-up), which is the reverse of the README. As you review each commit, you can look back at the README to see how that piece surfaces in the user-facing API.
Leaf types with no internal dependencies — the vocabulary that everything else is built on. HttpMethod and HttpStatus are standard HTTP concepts as Scala types. HttpException defines the failure modes that appear in Abort[HttpException] signatures throughout. HttpCodec handles string conversions for path captures, query params, headers, and cookies. Json[A] wraps zio-schema and is what derives Json provides on case classes. HttpCookie is the typed cookie value with attributes (maxAge, httpOnly, sameSite, etc.).

Includes JS and Native SecureRandom shims required by HttpCodec's UUID support.
The wire-level data types. HttpUrl provides parsed URL access — scheme, host, port, path, and lazy query parameters. HttpHeaders uses a flat interleaved Chunk[String] ([name, value, name, value, ...]) rather than a Dict — avoids per-header allocation, supports zero-copy conversion from Netty's internal format, and handles case-insensitive lookup without key normalization. The tradeoff is O(n) lookup, but HTTP requests rarely have more than ~20 headers. HttpRequest and HttpResponse are data carriers: method + URL + headers + a Record[Fields] holding the fields a route declared.
Four standalone types that HttpRoute depends on. HttpPath is the path pattern DSL: literal segments and typed captures like "users" / Capture[Int]("id") producing HttpPath["id" ~ Int]. It depends only on HttpCodec. HttpFormCodec is a derived codec for URL-encoded form bodies. HttpSseEvent is the Server-Sent Event wrapper with typed data payload, optional event name, id, and retry interval. HttpServerConfig controls server binding: port, host, content limits, CORS, and OpenAPI.
HttpFilter has five type parameters — HttpFilter[ReqUse, ReqAdd, ResUse, ResAdd, E] — but the idea is straightforward: a filter can require fields on the request (ReqUse), add fields for downstream (ReqAdd), and transform the response (ResUse → ResAdd). andThen composes filters by intersecting their type parameters. Built-in server filters cover auth (basic, bearer), CORS, rate limiting, logging, security headers, and request IDs. Client filters attach auth headers to outgoing requests.
This is the commit to spend the most time on. HttpRoute[In, Out, E] is a complete endpoint contract: method, path, what to extract from the request, what the response looks like, and how domain errors map to status codes. The key mechanism is in RequestDef — each call (.query, .header, .cookie, .bodyJson) appends a Field and refines the In type via intersection. HttpHandler pairs a route with its implementation function. HttpRoute.handler is the main entry point for creating one.

Adds Fields.Pin and Fields.Exact to kyo-data — compile-time mechanisms that prevent Scala from merging field names when chaining field definitions on routes.
HttpBackend is the platform seam — the traits that JVM/JS/Native must implement. The Client trait is connection-oriented: connectWith establishes a connection, sendWith sends a typed request through it. This design lets HttpClient manage connection pooling independently of the backend. The Server trait takes handlers and config, returns a Binding with port info and lifecycle.

Includes HttpPlatformBackend stubs for all platforms — wired to real implementations when each platform backend is introduced.
HttpClient's convenience methods (.getJson, .postJson, etc.) all create an HttpRoute internally and delegate to the typed send path — routes are the underlying abstraction even when users don't interact with them directly. The request lifecycle is layered as a chain of *With methods: sendWithConfig → retryWith → redirectsWith → timeoutWith → poolWith. ConnectionPool is a lock-free per-host Vyukov MPMC ring buffer with idle eviction. Includes LinkChecker demo — a client-only app that fetches a page, extracts links, and checks them concurrently.

HttpClientTest deferred to the Server commit since it requires HttpServer for its test harness.
Self-contained feature that works in two directions. Routes → spec: OpenApiGenerator walks route definitions and produces an OpenAPI 3.x JSON spec. Spec → routes: OpenApiMacro is a compile-time macro that reads an OpenAPI JSON spec and generates typed HttpRoute values. HttpOpenApi is the model type for the OpenAPI spec.
HttpServer binds handlers to a port and manages lifecycle. If OpenAPI is enabled, it generates a spec endpoint from all handlers via OpenApiGenerator, then calls backend.bind. The server is a thin orchestrator — the heavy lifting is in HttpBackend, HttpRouter, and RouteUtil.

HttpServerTest, HttpClientTest, and demos deferred until backend implementations are introduced.
This is where the type-safe route world meets raw HTTP bytes. Every platform backend delegates to these two files. RouteUtil is the codec bridge: it reads path captures, query params, headers, and cookies from a raw request and assembles the typed Record that handlers receive. It also serializes response fields back to wire format. HttpRouter compiles handlers into a trie for O(path-segments) dispatch, with support for literal segments, typed captures, and catch-all (Rest) routes.
The largest and most complex backend. The core challenge is bridging Netty's channel pipeline (async, callback-driven) with kyo's effect system (fiber-based). NettyServerHandler handles inbound requests — parses the Netty message, builds an HttpRequest, routes through HttpRouter, writes the response. NettyConnection handles the client side — request/response lifecycle including streaming bodies, backpressure, and connection reuse. FlatNettyHttpHeaders converts between Netty's header format and the flat Chunk[String] representation with zero-copy conversion. NettyTransport manages event loop groups with platform detection (epoll on Linux, kqueue on macOS, NIO fallback).

Wires JVM HttpPlatformBackend to NettyClientBackend and NettyServerBackend.
FetchClientBackend maps routes to Fetch API calls. NodeServerBackend wraps Node's http.createServer. NodeHttp is the JS facade for Node's HTTP module.

Wires JS HttpPlatformBackend to FetchClientBackend and NodeServerBackend.
C interop via Scala Native bindings. CurlEventLoop drives libcurl's multi interface for non-blocking concurrent requests, polling file descriptors and dispatching completions to kyo fibers. CurlTransferState tracks per-request state (headers, body chunks, completion promise). H2oServerBackend wraps the H2O HTTP server library, mapping H2O's request/response model to HttpRouter dispatch. The C wrapper files (curl_wrappers.c, h2o_wrappers.c) provide thin C helpers where Scala Native's interop needs assistance.

Wires Native HttpPlatformBackend to CurlClientBackend and H2oServerBackend.
Tests deferred from the Client and Server commits since they require backend implementations. HttpClientTest covers the full request lifecycle: retries, redirects, timeouts, pooling, and typed send/receive. HttpServerTest exercises server binding, handler dispatch, streaming, and error handling. Demos: HackerNews (simplest, proxying an external API), BookmarkStore (routes-first pattern with filters and cookies), NotePad (CRUD + SSE live change feed + cookie sessions).
Protocol compliance tests — no production code. Structured by RFC: URI syntax (3986), cookies (6265), additional status codes (6585), bearer auth (6750), multipart (7578), basic auth (7617), and HTTP semantics (9110). Most of these spin up a server and make real HTTP calls, exercising the full stack end-to-end.
More complete applications covering the full feature set. Streaming: CryptoTicker (NDJSON), GithubFeed (SSE re-streaming from external API). Binary/multipart: ImageProxy (binary round-trip, custom filter), FileLocker (multipart upload), StaticSite (file serving with ETag caching). Advanced patterns: PasteBin (Rest paths, ETag/If-None-Match), UrlShortener (rate limiting, redirects, cookies), ChatRoom, EventBus (form input + NDJSON), TaskBoard, UptimeMonitor, ApiGateway, WebhookRelay, McpServer, WikiSearch.
@ghostdogpr
Copy link
Contributor

IMPORTANT: Once this is merged, I'm planning to remove kyo-sttp and kyo-tapir. Please comment if anyone has concerns regarding the removal.

I guess one side effect of this is you'll have to remove kyo-caliban as well since it relied on the tapir integration. A more performant one could be built on top of kyo-http but that'll require more work (similar to the integration Caliban has with zio-http).

@fwbrasil
Copy link
Collaborator Author

fwbrasil commented Mar 3, 2026

thanks for raising it! I hadn't considered Caliban. I'll explore moving it to kyo-http before the removal

* def modify[A : Precise](f: Def[Fields] => Def[A]): Record[A]
* }}}
*/
sealed trait Exact[F[_], R]:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
sealed trait Exact[F[_], R]:
sealed abstract class Exact[F[_], R]:

else s"$scheme://$host:$port"
end urlPrefix

// MacrotaskExecutor avoids microtask starvation and aligns with kyo-scheduler's JS usage
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use the Scheduler as an EC?

val streamBody = encodedStreamBody
resetEncoded()

if streamBody.isDefined then
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this be better as fold (to avoid the get?

Async.fromFuture(dom.fetch(url, init).toFuture).map { response =>
Async.fromFuture(response.arrayBuffer().toFuture).map { arrayBuffer =>
val int8 = new Int8Array(arrayBuffer)
val bytes = Span.fromUnsafe(int8.toArray)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love the immutability

RouteUtil.decodeBufferedResponse(route, status, headers, bytes, method.name, url) match
case Result.Success(r) => r
case Result.Failure(e) => Abort.fail(classifyError(e))
case Result.Panic(e) => Abort.panic(e)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

Suggested change
case Result.Panic(e) => Abort.panic(e)
case panic => Abort.error(panic)


/** Returns all values for headers matching `name` (case-insensitive). */
def getAll(name: String): Seq[String] =
val builder = Seq.newBuilder[String]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

Suggested change
val builder = Seq.newBuilder[String]
val builder = Chunk.newBuilder[String]

end isValidCookieValue

private[kyo] def serializeCookie[A](name: String, cookie: HttpCookie[A]): String =
val sb = new StringBuilder
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

Suggested change
val sb = new StringBuilder
val sb = new StringBuilder(name.size + 16)

Copy link
Collaborator Author

@fwbrasil fwbrasil Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've used 80 considering the cookie string format

val method = HttpMethod.unsafe(nettyReq.method().name())
val uri = nettyReq.uri()
val keepAlive = HttpUtil.isKeepAlive(nettyReq)
val headers = nettyReq.headers().asInstanceOf[FlatNettyHttpHeaders].toKyoHeaders
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to safely degrade here? or because we will always instantiate with the Header factory it shouldn't be possible?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point! We should safely degrade since it's an optimization. I'll update it

val methods =
allowed
++ (if allowed.contains(HttpMethod.GET) then Set(HttpMethod.HEAD) else Set.empty)
+ HttpMethod.OPTIONS
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI a mutable set will provide much faster building and iteration.

Class.forName("io.netty.channel.epoll.EpollServerSocketChannel").asInstanceOf[Class[? <: ServerSocketChannel]]
(factory, socketClass, serverClass)
else
throw new RuntimeException("Epoll not available")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since these won't be read?

Suggested change
throw new RuntimeException("Epoll not available")
throw new RuntimeException("Epoll not available") with NoStackTrace

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed it to HttpBindException instead, which already is NoStackTrace

@fwbrasil fwbrasil force-pushed the kyo-http-pr branch 3 times, most recently from e7a2318 to ad8b927 Compare March 8, 2026 21:29
@fwbrasil
Copy link
Collaborator Author

fwbrasil commented Mar 9, 2026

A few updates:

  1. @ghostdogpr I've started working on porting kyo-caliban to kyo-http. I had to use a hack to access internal Caliban APIs (see CalibanHttpUtils) and replicate some low-level code in the existing integrations with http libraries. I'd prefer to keep the glue code in Kyo, would exposing those APIs be an option? I've also added streaming support, increased test coverage, and avoided exposing ZIO in Runner. I'll post the migration after kyo-http is merged but feel free to provide early feedback: kyo-http-pr...kyo-caliban-migration
  2. @hearnadam I think I've addressed all feedback so far
  3. @LeeTibbert the native builds are passing consistently. It seems the bug making test execution hang is gone!
  4. I've fixed an issue with the content type being overriden when the user explicitly sets it. I identified the issue in the kyo-caliban migration.

@ghostdogpr
Copy link
Contributor

  1. @ghostdogpr I've started working on porting kyo-caliban to kyo-http. I had to use a hack to access internal Caliban APIs (see CalibanHttpUtils) and replicate some low-level code in the existing integrations with http libraries. I'd prefer to keep the glue code in Kyo, would exposing those APIs be an option? I've also added streaming support, increased test coverage, and avoided exposing ZIO in Runner. I'll post the migration after kyo-http is merged but feel free to provide early feedback: kyo-http-pr...kyo-caliban-migration

That looks awesome! Yes, I think we can definitely make caliban HttpUtils public (cc @kyri-petrou). Same with jsoniter I think.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants