Guide

ZIO handlers

Write ordinary ZIO programs; the hand-written ZIO adapter runs them over the Kyo core.

The ZIO API is a first-class, hand-written adapter over the Kyo core. You write ordinary ZIO programs: for comprehensions, ZIO.service, typed errors, ZLayer and ZStream. ZHandler.define runs them on a ZIO Runtime and hands the result to the core, which owns the request lifecycle, the wire format and the response.

Your application depends on zio-h3-ext, which brings the Kyo core with it. You never write Kyo code in a ZIO application.

Status. This guide describes 0.2.0-alpha.1. The older 0.1.0-alpha.1 has a different ZIO API. The migration guide maps every old call to its replacement.

Prerequisites

  • Scala 3.9.0, Scala.js 1.22.0, and JDK 25 to run sbt. The ZIO modules share the Kyo core's toolchain.
  • ZIO 2.1.26. kyo-zio 1.0.0-RC7 pins this version; keep your own ZIO dependencies on it.
  • "dev.yuri4n" %%% "zio-h3-ext" % "<version>", plus h3@1.15.4 and @yuri4n/h34s-nuxt in your Nuxt app.

Import the API with import dev.yuri4n.exts.zioh3.*. It re-exports the shared values (Request, ResponseHead, SafeError, Secret, ...).

Write your first handler

A handler is a function from the request snapshot to a ZIO program that returns the body. This is the demo's database-free route (examples/nuxt/scala/zio/.../TestHandler.scala), which streams three letters:

import dev.yuri4n.exts.zioh3.{EventHandler, HandlerEnv, Request, Response, Route, Sent, ZHandler}
import zio.ZIO
import zio.stream.ZStream

import scala.scalajs.js.annotation.JSExportTopLevel

object TestHandler:
    val letters: Request => ZIO[HandlerEnv, Throwable, Sent] = _ => Response.sendStream(ZStream("a", "b", "c"))

@JSExportTopLevel("default", "test")
val handler: EventHandler = ZHandler.define(TestHandler.letters).at(Route.any("/api/test"))

The response body is abc. Response.sendStream writes the head, then the stream, and gives Sent, which is the handler's body. .at mounts the export at /api/test for every method: after h34s-scala links it, the Nuxt module registers the route, so you write no route file. See routes and typed $fetch.

Keep handler functions small and testable

The demo keeps each handler as a plain value, so a test can call it with a fake request (examples/nuxt/scala/zio/.../NotesHandler.scala):

object NotesHandler:
    val list: Request => ZIO[Notes, Throwable, List[NoteView]] =
      _ => ZIO.serviceWithZIO[Notes](_.list).map(_.map(NoteView.from))

    /** The schema validates and trims the input before any database work; the handler adds no
      * validation code.
      */
    val create: Request => ZIO[HandlerEnv & Notes, Throwable, NoteView] = request =>
      for
        input <- request.body.json[CreateNote]
        note  <- ZIO.serviceWithZIO[Notes](_.create(input.text))
        _     <- Response.status(Status.Created)
      yield NoteView.from(note)

@JSExportTopLevel("default", "notes-get")
val listNotes: EventHandler = AppHandler.define(NotesHandler.list).at(Route.get("/api/notes"))

@JSExportTopLevel("default", "notes-post")
val createNote: EventHandler = AppHandler.define(NotesHandler.create).at(Route.post("/api/notes"))

Nuxt types $fetch('/api/notes') as { data: { id: number; text: string }[] }: NoteView derives NativeSchema, which describes the JSON it writes.

Notes, Note (the domain note), NoteView (the JSON it sends), CreateNote (the JSON it reads) and AppHandler are demo code. Define your own.

Read the request

The Request snapshot is the same value the Kyo core uses; see Kyo handlers for every member and for the body status codes. The ZIO signatures:

CallType
request.body.json[A]IO[HttpError | InvalidInput, A]
request.body.json[A](limit)the same, with an explicit BodyLimit
request.body.text()IO[HttpError, String]
request.param(name)IO[HttpError, String]; 400 when absent

Return a 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

A ZStream is not a body. To stream, end your handler with Response.sendStream(stream), where the stream is a ZStream[Any, E, String] with E <: Throwable, and return its Sent. A handler that returns a ZStream does not compile. ZHandler.sendStream is gone; see the migration guide.

Set status, headers and cookies

Response has the same operations as the Kyo API, as URIO[ZState[ResponseHead], Unit]: status, header, appendHeader, cookie, deleteCookie(name, path, domain, secure, onFailure), redirect, and head. h34s writes the head once, after your program and its finalizers finish. Response.sendStream(stream) is the one exception: it writes the head as it stands when it runs, then the stream, and gives Sent. Response.sendEvents(events) is sendStream for a ZStream[Any, E, ServerEvent]: it sets the event-stream headers and writes one frame per event; see server-sent events. The failure rule is the same: on failure, the status comes from the error, and only handler defaults, OnFailure.Keep entries and the error's headers survive.

Two ZIO details:

  • ZState is backed by a FiberRef. A forked fiber works on its own copy of the head; joining it copies the child's head back into the parent (ZIO's default FiberRef join). Set response entries in the request fiber when you can.
  • catchAll and other recoveries do not roll back head changes.

An invalid header name or value, cookie name, or redirect status makes the effect die, and h34s answers with a 500.

Fail safely

FailureStatusBody (default options)
ZIO.fail(safeError)safeError.status{"errors":[{"message":"..."}]}
ZHandler.failWith(status, msg, msgs*)statusone entry per message
InvalidInput from a body read422one entry per schema message, with path when known
Any other typed failure, a defect, interruption, or several failures500{"errors":[{"message":"Internal server error"}]}

ZHandler.failWith fails with ErrorResponse(errors, status), as in 0.1.0-alpha.1. ErrorResponse is not a SafeError (its errors field is a NonEmptyList[String]), so catchSome { case e: SafeError => ... } does not catch it; h34s approves both at the boundary. A SafeError or ErrorResponse stays approved even when sibling fibers were interrupted with it (for example in zipPar). Any other Throwable in the error channel is not approved: catching an exception and failing with it does not make it public. Map it to your own SafeError instead.

A ZIO finalizer that dies does not change the response, as in Kyo: h34s logs h34s: a ZIO finalizer failed with ZIO's Cause.prettyPrint on the server, and the client gets the value, the Unit response or the safe error that your program produced. Do work that must affect the response in the program, not in a finalizer. The rule applies to the request scope that h34s owns (finalizers your handler adds with ZIO.addFinalizer or ZIO.acquireRelease in its request Scope). A release that dies inside your own ZIO.scoped block, or inside a scoped layer that you provide inside the handler, fails the program under ZIO's rules and answers 500, as before. In development or debug mode, the diagnostics of a failed program show ZIO's full Cause.prettyPrint, with ZIO traces and Scala source lines.

Choose the wire format

ZHandler.define(options) takes the same HandlerOptions as the Kyo core:

ZHandler.define(HandlerOptions(defaults = ResponseHead.noStore)): request =>
  ...

See Kyo handlers for the envelopes, defaults, body limit and requireJsonContentType (see cross-site requests).

Compose services in your AppHandler

Write one wiring point that provides your layers. This is the demo's (examples/nuxt/scala/zio/.../AppHandler.scala):

object AppHandler:
    /** Every response is `Cache-Control: no-store`; bodies use the default `{ "data": ... }` envelope. */
    private val options = HandlerOptions(defaults = ResponseHead.noStore)

    /** One Skunk pool for all requests: built when the first note needs a session, released when Nitro closes. */
    private val database = ZShared.layer(SessionPool.layer)

    /** The greeting service, built from the startup runtime config on first use. It needs no database. */
    private val greetings = ZShared.layer(Greetings.layer)

    /** `Notes` takes the pool only when a note operation runs, so a greeting never builds the pool. */
    private val notes = NotesLive.lazily(database.get.map(_.get[SessionPool]))

    def define[B: ZResponseBody](program: Request => ZIO[HandlerEnv & Notes & Greetings, Throwable, B]): EventHandler =
      ZHandler.define(options): request =>
          greetings.provide[HandlerEnv, Throwable, B](program(request).provideSomeLayer[HandlerEnv & Greetings](notes))

NotesLive.lazily runs database.get only when a note operation first needs a session. So POST /api/zio/greeting, which uses Greetings alone, never reads the database config and never builds the Skunk pool.

HandlerEnv is ZState[ResponseHead] & Scope & HandlerTransport & Fetch: what h34s supplies to every ZIO handler. The Scope is the request scope. It stays open while Response.sendStream sends a stream. HandlerTransport is the native response that Response.sendStream writes, and Fetch is the server-side $fetch of the request. h34s provides both, and ZHandler.test provides them in a test, so you never build them.

Use ordinary provide, provideSomeLayer and ZLayer composition. A ZLayer value is rebuilt each time you provide it. For a resource that all requests share, such as a pool, wrap the layer in ZShared; see lifetimes.

ZHandler.define runs on Runtime.default unless a given Runtime[Any] is in scope. You do not need to create one.

Call other routes with $fetch

A handler can call another route of your app, Scala or JavaScript, with ZHandler.$fetch. It follows the rules of the Kyo core, which it runs at the boundary: 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 NitroLocalFetchSpec:

val summary: Request => ZIO[HandlerEnv, FetchError, String] = _ =>
  for
    greeting <- ZHandler.$fetch.get[GreetingData]("/api/greeting")
    events   <- ZHandler.$fetch.raw("/api/events")
    first    <- events match
      case FetchResponse.Streamed(_, _, body) => body.take(2).runCollect.map(_.mkString).orElseSucceed("the stream broke")
      case plain: FetchResponse.Plain         => ZIO.succeed(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 for a response with the x-h34s-stream marker or a stream content type (text/event-stream, application/x-ndjson), and FetchResponse.Plain otherwise. Plain.as[A] decodes its text with the NativeConverter.
  • Read a streamed body once. It is a ZStream[Any, FetchError, String]. When the callee's stream fails after its head, or the connection closes, the stream fails with FetchError.BodyFailed(status, reason). The reason is h34s' own text; it never quotes the callee's error.
  • The body lives in your Scope. raw needs Fetch & Scope. In a handler, that is the request scope; use ZIO.scoped for a shorter one. When the scope closes, h34s cancels a body that you did not read to its end, and waits until the callee's stream and request finalizers have run. text, as, get and post read the whole body in their own scope, so they need only Fetch. 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. ZHandler.$fetch.events[E](url) reads a text/event-stream route as a ZStream[Any, FetchError, E], each event's data decoded as JSON for E. See server-sent events.

Test such a handler with Fetch.fake; see answer $fetch calls.

Type aliases

AliasMeaning
HandlerEnvZState[ResponseHead] & Scope & HandlerTransport & Fetch
ZHandler[R, E, A]ZIO[HandlerEnv & R, E, A]
Handler[E, A], TaskHandler[A], URHandler[R, A], UHandler[A], RHandler[R, A]The usual ZIO shapes over HandlerEnv
TaskStream[A], RStream[R, A], URStream[R, A]ZStream shapes

Run the demo's ZIO routes

/api/test needs no database. Run the demo as in Kyo handlers, then:

curl http://127.0.0.1:3000/api/test

The body is abc. The Notes routes need PostgreSQL; see databases and the development guide.

Next steps

Configuration, lifetimes, schemas, databases, streaming, testing, and debugging.