Guide

Kyo handlers

Write handlers as Kyo functions from the request to the body, set the response head, fail safely, and wire services in your AppHandler.

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.4 as a runtime dependency of your Nuxt app, and the @yuri4n/h34s-nuxt module. 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:

MemberValue
method, pathUpper-case method; path and query as h3 reports them
header(name), headersCase-insensitive lookup; duplicate headers joined with ", "
headerValues(name)Every value of a header, in request order
query, queryAllFirst value per key; all values per key
routerParam(name), routerParamsDecoded router parameters
cookie(name), cookiesParsed cookies
bearerTokenThe token of exactly one Authorization: Bearer <token> header, else None
development, debugSet only by the h34s Nitro plugin; a request cannot set them
bodyThe 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.

SituationResult
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 acceptedHttpError 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 JSONHttpError 400
JSON does not match the converter's shapeInvalidInput 422 with a generic message
A schema-backed converter rejects the valueInvalidInput 422 with your schema's messages; see schemas
Any other exception from the converterA 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 (the SetCookie default) or Strict.
  • 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") or request.header("origin") on routes that change state.

Return a body

What your function returns decides the response body:

Return typeResponse
Any A with a NativeConverter200 (or the status you set) with {"data": <value>}
Unit204 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}!")
OperationEffect
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.headWhat 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
FailureStatusBody (default options)
Abort.fail(safeError)safeError.status (400 by default){"errors":[{"message":"..."}]}
KHandler.failWith(status, message) (Nothing < Abort[HttpError])statusthe same shape
InvalidInput from a body read422one entry per schema message, with path when known, never input values
Anything else: thrown exceptions, panics, a SafeError that is thrown500{"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):

OptionDefaultAlternatives
successSuccessEnvelope.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
errorsErrorEnvelope.List: always {"errors":[...]}ErrorEnvelope.SingleOrList: {"error":{...}} for one entry, {"errors":[...]} for several
defaultsResponseHead.emptyFor example ResponseHead.noStore: Cache-Control: no-store on success and failure
bodyLimitBodyLimit.default (1 MiB)Any BodyLimit(bytes)
requireJsonContentTypetrue: json[A] answers 415 for a body without a content typefalse: 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, post and as decode the JSON as it arrives. An h34s route with the default options answers {"data": ...}, so decode it with a model of the envelope, such as GreetingData. The typed route call (#46) will remove the envelope for you, because only the route knows its options.
  • Plain or streamed. raw returns as soon as the head arrives. It gives FetchResponse.Streamed when the response carries the x-h34s-stream marker (every Response.sendStream sets it) or a stream content type (text/event-stream, application/x-ndjson), and FetchResponse.Plain otherwise.
  • 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 with FetchError.BodyFailed(status, reason). Handle it with Abort.run[FetchError], as above. The reason is 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. raw gives any status as a response. text, as, get and post fail with FetchError.Status(status, body) unless the status is 2xx. There is no retry.
  • The base URL is added. h34s puts your app's app.baseURL in front of a path that starts with /, as Nuxt's $fetch does. 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 and authorization never reach another host. The called route gets only the _platform and waitUntil entries of your request's event.context, never the rest.
  • Errors are not public. A FetchError is not a SafeError. If your handler does not handle it, the client gets the opaque 500.
  • Repeated headers. header(name) joins repeated values with ", ". headerValues(name) gives each set-cookie value as its own entry.
  • Server-sent events. KHandler.$fetch.events[E](url) reads a text/event-stream route as a Stream[E, Async & Scope & Abort[FetchError]], each event's data decoded as JSON for E. 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 runs Abort.run[Throwable] (or recovers Throwable), a thrown exception becomes a typed failure there. h34s itself keeps thrown exceptions as defects. Recover narrower types, such as your own SafeError subtypes.
  • Abort.recover does not roll back Var changes. 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.
  • Var needs an isolate across forks. Forking operations such as Fiber.init and Async.race require an Isolate for a Var they carry, so code that forks while it needs Var[ResponseHead] does not compile without one. Call Response.* in the request fiber, before or after the parallel part.
  • Kyo Scope finalizer 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 (provisionally 0.3.0-alpha.1); its log line is h34s: a ZIO finalizer failed. Kyo differs here: a failing finalizer inside your own nested Scope.run is also logged (Scope finalizer failed) and keeps the response, because Kyo's Scope.run itself logs finalizer failures. In ZIO, a release that dies inside your own ZIO.scoped block fails the program and answers 500. This is an accepted difference: inner-scope semantics belong to each runtime.
  • Async.uninterruptible does not make the request wait. It runs the step on a fiber that nothing owns. In a handler, use KHandler.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