Kyo handlers
Kyo is the h34s core. KHandler.define turns a Kyo function into an h3 handler: it takes the request snapshot, runs your program once per request, owns its scope and cancellation, and writes the response once. This guide covers everything a handler does. Your Kyo application never depends on ZIO.
Status. This guide describes 0.2.0-alpha.1. The older 0.1.0-alpha.1 has a different Kyo API (see the migration guide). See releases for installation.
Prerequisites
- Scala 3.9.0 and Scala.js 1.22.0. Kyo 1.0.0-RC7 needs both, and its macros need JDK 25 to run sbt. Java is a build tool only; your handlers run in Node.
- Node 24,
h3@1.15.4as a runtime dependency of your Nuxt app, and the@yuri4n/h34s-nuxtmodule. See npm tooling. - The library dependency
"dev.yuri4n" %%% "kyo-h3-ext" % "<version>". Registry access is in Releases.
Import the API with import dev.yuri4n.exts.kyoh3.* and Kyo with import kyo.*. The kyoh3 package re-exports the shared values (Request, ResponseHead, SafeError, Secret, ...).
Write your first handler
A handler is a function from the request snapshot to the response body:
import dev.yuri4n.exts.kyoh3.*
import kyo.*
import org.getshaka.nativeconverter.NativeConverter
import scala.scalajs.js.annotation.JSExportTopLevel
final case class Greeting(message: String) derives NativeConverter
@JSExportTopLevel("default", "hello-get")
val hello: EventHandler = KHandler
.define: request =>
Greeting(s"Hello from ${request.path}")
.at(Route.get("/api/hello"))
The response is 200 with {"data":{"message":"Hello from <path>"}}. The function runs once per request, never when the module loads.
Export each handler as the default export of its own Scala.js module, as above, and give it a route with .at. After h34s-scala links your modules, the Nuxt module mounts the route, and Nuxt types $fetch('/api/hello') from the generated declaration. You write no route file. See routes and typed $fetch.
Read the request
Request is an immutable snapshot, taken once when the handler starts:
| Member | Value |
|---|---|
method, path | Upper-case method; path and query as h3 reports them |
header(name), headers | Case-insensitive lookup; duplicate headers joined with ", " |
headerValues(name) | Every value of a header, in request order |
query, queryAll | First value per key; all values per key |
routerParam(name), routerParams | Decoded router parameters |
cookie(name), cookies | Parsed cookies |
bearerToken | The token of exactly one Authorization: Bearer <token> header, else None |
development, debug | Set only by the h34s Nitro plugin; a request cannot set them |
body | The one-shot request body |
request.param(name) reads a required router parameter. It fails with a 400 HttpError when the parameter is absent.
Read the body with request.body.json[A] or request.body.text(). The first read fixes the outcome; a second read returns the same text.
By default, json[A] needs a JSON content type when the request has a body. $fetch with an object body and curl --json send application/json. text() never checks the content type.
| Situation | Result |
|---|---|
Content-Type is not JSON (application/json, application/*+json), or is absent while the request has a body; with HandlerOptions(requireJsonContentType = false), an absent one is accepted | HttpError 415 |
Body larger than the limit (checked from Content-Length, then while reading) | HttpError 413; h34s never buffers more than the limit. A body without a content type is read up to the limit before the 415, so one above the limit answers 413 |
| Text is not valid JSON | HttpError 400 |
| JSON does not match the converter's shape | InvalidInput 422 with a generic message |
| A schema-backed converter rejects the value | InvalidInput 422 with your schema's messages; see schemas |
| Any other exception from the converter | A defect: opaque 500 |
The default limit is BodyLimit.default (1 MiB). Change it for one handler with HandlerOptions(bodyLimit = ...), or for one read with request.body.json[A](BodyLimit(bytes)). An absent body is "" for text(). For json[A], an absent or empty body with a JSON content type decodes like JSON null: a model answers 422, and json[Option[A]] gives None. Without a content type, a request without a body reads the same way; a request with a body answers 415 first, unless you turned requireJsonContentType off.
Cross-site requests
A browser sends no Content-Type for a cross-site fetch with a byte body or for navigator.sendBeacon, and such requests need no CORS preflight. That is why json[A] answers 415 for a body without a content type by default: such a request fails the JSON read before your code sees its body. A cross-site request that sends application/json needs a CORS preflight first. For routes that change state:
- Keep session cookies
SameSite=Lax(theSetCookiedefault) orStrict. - Keep the default
HandlerOptions(requireJsonContentType = true). Turn it off only for a handler whose clients cannot send a content type, and only when that handler does not change state for a cookie-authenticated user. - Check
request.header("sec-fetch-site")orrequest.header("origin")on routes that change state.
Return a body
What your function returns decides the response body:
| Return type | Response |
|---|---|
Any A with a NativeConverter | 200 (or the status you set) with {"data": <value>} |
Unit | 204 with no body, unless you set another status |
Sent, from Response.sendStream(stream) | The stream's strings, written as UTF-8 text as they come; see streaming |
NativeConverter converts directly between Scala and JavaScript values. No JSON text is made on the way; h3 writes the HTTP body.
A kyo.Stream is not a body. To stream, end your handler with Response.sendStream(stream) and return its Sent; a handler that returns a stream does not compile.
Set status, headers and cookies
Status, headers and cookies are local state of your program, a Var[ResponseHead]. The Response operations change that state; h34s writes the head once, after your program and its finalizers finish. A streaming handler is the one exception: Response.sendStream writes the head as it stands when you call it. GreetingRequest here is a request model like the demo's:
val created: EventHandler = KHandler.define: request =>
for
input <- request.body.json[GreetingRequest]
_ <- Response.status(Status.Created)
_ <- Response.header("x-request-id", "abc", OnFailure.Keep)
_ <- Response.cookie(SetCookie("session", "abc", maxAge = Some(3600)))
yield Greeting(s"Hello, ${input.name}!")
| Operation | Effect |
|---|---|
Response.status(status) | Last write wins. Use Status.Created or Status.unsafe(code) for literals |
Response.header(name, value) | Replaces earlier values of that header |
Response.appendHeader(name, value) | Adds another value |
Response.cookie(SetCookie(...)) | Set-Cookie. Defaults: Path=/, HttpOnly, Secure, SameSite=Lax |
Response.deleteCookie(name, path, domain, secure, onFailure) | Expires a cookie. path (default /) and domain must match the cookie; Secure is sent by default, which browsers need to delete a __Host- or __Secure- cookie |
Response.redirect(location, status) | A 3xx status (default Status.Found) and Location |
Response.head | What your program has set so far, without the handler defaults |
Response.sendStream(stream) | Writes the head now, then the stream, and gives Sent; make it the last step. See streaming |
Response.sendEvents(events) | sendStream for server-sent events: sets the event-stream headers, then writes one frame per ServerEvent. See server-sent events |
An invalid header name or value, cookie name, or a non-3xx redirect status is a programming error. The check runs when the operation runs, not when you build the value; the handler then fails, and h34s answers with a 500.
Fail safely
Fail with a SafeError through Kyo's typed channel, Abort.fail. Only these errors reach the client:
final case class NoteNotFound(id: String) extends SafeError:
def message: String = "Note not found"
override def status: Int = 404
| Failure | Status | Body (default options) |
|---|---|---|
Abort.fail(safeError) | safeError.status (400 by default) | {"errors":[{"message":"..."}]} |
KHandler.failWith(status, message) (Nothing < Abort[HttpError]) | status | the same shape |
InvalidInput from a body read | 422 | one entry per schema message, with path when known, never input values |
Anything else: thrown exceptions, panics, a SafeError that is thrown | 500 | {"errors":[{"message":"Internal server error"}]} |
HttpError(status, message) is a ready-made SafeError. Helpers: HttpError.badRequest, unauthorized, notFound, conflict, unprocessable. Add a header with .header(name, value), for example WWW-Authenticate on a 401. A SafeError whose status is outside 400-599 is treated as a defect.
The failure rule protects the response. On failure, the status always comes from the error, so a 2xx never leaks. The handler defaults, the entries you marked OnFailure.Keep, and the error's own headers survive. Everything else your program set is discarded.
Never put secrets, stack traces or input values in a SafeError message. Clients see it in production.
Choose the wire format
Pass HandlerOptions to KHandler.define(options)(program):
| Option | Default | Alternatives |
|---|---|---|
success | SuccessEnvelope.Data: {"data": value} | SuccessEnvelope.Raw: the converted value without the envelope. A string is sent as text/plain; charset=utf-8 and any other value as JSON (application/json), unless you set a content type; X-Content-Type-Options: nosniff comes with the default. A null or undefined value becomes 204 |
errors | ErrorEnvelope.List: always {"errors":[...]} | ErrorEnvelope.SingleOrList: {"error":{...}} for one entry, {"errors":[...]} for several |
defaults | ResponseHead.empty | For example ResponseHead.noStore: Cache-Control: no-store on success and failure |
bodyLimit | BodyLimit.default (1 MiB) | Any BodyLimit(bytes) |
requireJsonContentType | true: json[A] answers 415 for a body without a content type | false: accept such a body as JSON; see cross-site requests |
Sent streams and Unit bodies are never wrapped.
JSON has no NaN or infinity, and JSON.stringify would send them as null. So h34s answers a value body that contains one with the opaque 500, in both envelopes, and a number in the generated type is always a finite number.
Compose services in your AppHandler
Write one wiring point that provides your services to every handler. The demo calls it AppHandler; you choose the name. Define your service first:
trait Greetings:
def greet(name: String): Greeting < Sync
Then provide it around each handler function. This is the demo's AppHandler (examples/nuxt/scala/kyo/.../AppHandler.scala):
object AppHandler:
/** Every response is `Cache-Control: no-store`; bodies use the default `{ "data": ... }` envelope. */
private val options = HandlerOptions(defaults = ResponseHead.noStore)
def define[B: ResponseBody](
program: Request => B < (HandlerEffects & Env[Greetings]),
)(using Frame): EventHandler =
KHandler.define(options)(request => Services.greetings.get.map(greetings => Env.run(greetings)(program(request))))
Services.greetings is a Shared[Greetings] built from the startup runtime config; see lifetimes.
Endpoints then ask for the service:
@JSExportTopLevel("default", "kyo-greeting-post")
val personalizedGreeting: EventHandler = AppHandler
.define: request =>
for
input <- request.body.json[GreetingRequest]
greeting <- Env.use[Greetings](_.greet(input.name))
yield greeting
.at(Route.post("/api/kyo/greeting"))
HandlerEffects is Async & Scope & Abort[Throwable] & Var[ResponseHead] & Env[Fetch & HandlerTransport]: the effects the core handles. Env[Fetch & HandlerTransport] holds the request's server-side $fetch and the transport that Response.sendStream writes. h34s provides it for each request, and KHandler.test provides it in a test, so you never build it. The two services share one Env entry because, with two entries, Kyo 1.0.0-RC7 cannot reduce what is left after your own Env.run(service), and the wiring above would stop compiling. Resolve every other effect, such as Env[Greetings], before you pass the program to KHandler.define.
Env.run provides a value. It does not acquire or release anything. For a database pool or another resource that lives as long as the application, use Shared; see lifetimes. Read configuration with RuntimeConfig; see configuration.
Call other routes with $fetch
A handler can call another route of your app, Scala or JavaScript, with KHandler.$fetch. Nitro answers a path that starts with / in-process, without a socket, and your route rules, middleware and Nitro plugins run as they do for a browser request.
In this example, /api/greeting is an h34s route that returns Greeting("hi"), and /api/events sends NDJSON lines with Response.sendStream. GreetingData is the example's model of the {"data": ...} envelope: final case class GreetingData(data: Greeting) derives NativeConverter. The code is the guide's $fetch test of NitroLocalFetchSuite:
val summary: Request => String < HandlerEffects = _ =>
for
greeting <- KHandler.$fetch.get[GreetingData]("/api/greeting")
events <- KHandler.$fetch.raw("/api/events")
first <- events match
case FetchResponse.Streamed(_, _, body) =>
Abort.run[FetchError](body.take(2).run).map(_.foldError(_.mkString, _ => "the stream broke"))
case plain: FetchResponse.Plain => Kyo.lift(plain.text)
yield s"${greeting.data.message}: $first"
The handler answers "hi: 1\n2\n". Keep these rules in mind:
- Model the envelope.
get,postandasdecode the JSON as it arrives. An h34s route with the default options answers{"data": ...}, so decode it with a model of the envelope, such asGreetingData. The typed route call (#46) will remove the envelope for you, because only the route knows its options. - Plain or streamed.
rawreturns as soon as the head arrives. It givesFetchResponse.Streamedwhen the response carries thex-h34s-streammarker (everyResponse.sendStreamsets it) or a stream content type (text/event-stream,application/x-ndjson), andFetchResponse.Plainotherwise. - Read a streamed body once. Its type is
Stream[String, Async & Scope & Abort[FetchError]]. When the callee's stream fails after its head, or the connection closes, the read fails withFetchError.BodyFailed(status, reason). Handle it withAbort.run[FetchError], as above. Thereasonis h34s' own text; it never quotes the callee's error. - Your scope cancels the rest. A body that you do not read to its end is cancelled when your handler's scope closes. The cancel waits until the callee's stream and request finalizers have run. See in-process calls.
- Statuses do not throw.
rawgives any status as a response.text,as,getandpostfail withFetchError.Status(status, body)unless the status is 2xx. There is no retry. - The base URL is added. h34s puts your app's
app.baseURLin front of a path that starts with/, as Nuxt's$fetchdoes. A path that already starts with the base stays as it is. - Credentials stay in-process. A path that starts with
/gets the incoming request's headers and cookies, as in Nuxt. An absolute URL gets only the headers that you pass, so the user's cookies andauthorizationnever reach another host. The called route gets only the_platformandwaitUntilentries of your request'sevent.context, never the rest. - Errors are not public. A
FetchErroris not aSafeError. If your handler does not handle it, the client gets the opaque 500. - Repeated headers.
header(name)joins repeated values with", ".headerValues(name)gives eachset-cookievalue as its own entry. - Server-sent events.
KHandler.$fetch.events[E](url)reads atext/event-streamroute as aStream[E, Async & Scope & Abort[FetchError]], each event'sdatadecoded as JSON forE. See server-sent events.
Test such a handler with Fetch.fake; see answer $fetch calls.
Kyo details to keep in mind
These come from Kyo 1.0.0-RC7 semantics. They matter once your handlers grow:
Abort.run[Throwable]also catches thrown exceptions. If your own code runsAbort.run[Throwable](or recoversThrowable), a thrown exception becomes a typed failure there. h34s itself keeps thrown exceptions as defects. Recover narrower types, such as your ownSafeErrorsubtypes.Abort.recoverdoes not roll backVarchanges. A status or header you set before a recovered failure stays set. Set response entries after the risky step, or set them again in the recovery branch.Varneeds an isolate across forks. Forking operations such asFiber.initandAsync.racerequire anIsolatefor aVarthey carry, so code that forks while it needsVar[ResponseHead]does not compile without one. CallResponse.*in the request fiber, before or after the parallel part.- Kyo
Scopefinalizer errors are logged and do not change the response. Do work that must affect the response in the main program, not in a finalizer. The ZIO adapter behaves the same since the next release (provisionally0.3.0-alpha.1); its log line ish34s: a ZIO finalizer failed. Kyo differs here: a failing finalizer inside your own nestedScope.runis also logged (Scope finalizer failed) and keeps the response, because Kyo'sScope.runitself logs finalizer failures. In ZIO, a release that dies inside your ownZIO.scopedblock fails the program and answers 500. This is an accepted difference: inner-scope semantics belong to each runtime. Async.uninterruptibledoes not make the request wait. It runs the step on a fiber that nothing owns. In a handler, useKHandler.uninterruptible(step); see lifetimes.
Choose locale data in your build
Kyo brings a full currency database through scala-java-locales. It adds tens of MB to the linked JavaScript. The demo excludes it in build.sbt, because its English-only greeting does not need currency data:
excludeDependencies += ExclusionRule("io.github.cquiroz", "locales-full-currencies-db_sjs1_3")
This is application policy. Keep the database if you format currencies or other locales.
Run the demo's Kyo routes
The demo in examples/nuxt/ has a Kyo greeting with no database. The getting-started tutorial walks through it step by step. From the repository root, with JDK 25 selected for sbt:
pnpm install --frozen-lockfile
pnpm run scala:fast
pnpm dev
Then, in another terminal:
curl http://127.0.0.1:3000/api/kyo/greeting
curl -H 'content-type: application/json' --data '{"name":"Scala"}' http://127.0.0.1:3000/api/kyo/greeting
curl -H 'content-type: application/json' --data '{"name":" "}' http://127.0.0.1:3000/api/kyo/greeting
Expected bodies: {"data":{"message":"Hello, Kyo!"}}, {"data":{"message":"Hello, Scala!"}}, and a 422 with {"errors":[{"message":"must not be blank","path":"name"}]}. The salutation comes from runtimeConfig.greeting.salutation; start the server with NUXT_GREETING_SALUTATION=Hi to change it.
Evidence. On macOS x86_64 with Node 24.19.0, these responses came back from a production build of the demo (nuxt build, then the Node server), and under nuxt dev (Nuxt 4.3.1, after pnpm run scala:fast): GET 200 with {data} and no-store, a blank name 422 with path name. See compatibility.
Next steps
- Configuration and secrets
- Application and request lifetimes
- Schemas for validated models
- Databases with kyo-sql
- Streaming and cancellation
- Testing handlers without h3
- Debugging and source maps