[http] Kyo-native support for HTTP clients and servers in all platforms#1479
[http] Kyo-native support for HTTP clients and servers in all platforms#1479
Conversation
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.
I guess one side effect of this is you'll have to remove |
|
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]: |
There was a problem hiding this comment.
| 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 |
There was a problem hiding this comment.
Can we use the Scheduler as an EC?
| val streamBody = encodedStreamBody | ||
| resetEncoded() | ||
|
|
||
| if streamBody.isDefined then |
There was a problem hiding this comment.
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) |
| 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) |
There was a problem hiding this comment.
?
| 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] |
There was a problem hiding this comment.
?
| 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 |
There was a problem hiding this comment.
?
| val sb = new StringBuilder | |
| val sb = new StringBuilder(name.size + 16) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Do we need to safely degrade here? or because we will always instantiate with the Header factory it shouldn't be possible?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
Since these won't be read?
| throw new RuntimeException("Epoll not available") | |
| throw new RuntimeException("Epoll not available") with NoStackTrace |
There was a problem hiding this comment.
I changed it to HttpBindException instead, which already is NoStackTrace
e7a2318 to
ad8b927
Compare
|
A few updates:
|
That looks awesome! Yes, I think we can definitely make caliban |
IMPORTANT: Once this is merged, I'm planning to remove
kyo-sttpandkyo-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 pureRoutedata structure (typically namedEndpointin other libraries) with type-level tracking of fields viaRecord, 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_uringsupport 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: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
Endpointin other libraries imply a pure description whileRouteindicates a concrete handler so I decided to useRoutefor the equivalent ofEndpoint.