ZIO handlers
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>", plush3@1.15.4and@yuri4n/h34s-nuxtin 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:
| Call | Type |
|---|---|
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 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 |
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:
ZStateis backed by aFiberRef. A forked fiber works on its own copy of the head; joining it copies the child's head back into the parent (ZIO's defaultFiberRefjoin). Set response entries in the request fiber when you can.catchAlland 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
| Failure | Status | Body (default options) |
|---|---|---|
ZIO.fail(safeError) | safeError.status | {"errors":[{"message":"..."}]} |
ZHandler.failWith(status, msg, msgs*) | status | one entry per message |
InvalidInput from a body read | 422 | one entry per schema message, with path when known |
| Any other typed failure, a defect, interruption, or several failures | 500 | {"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,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.Streamedfor a response with thex-h34s-streammarker or a stream content type (text/event-stream,application/x-ndjson), andFetchResponse.Plainotherwise.Plain.as[A]decodes its text with theNativeConverter. - 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 withFetchError.BodyFailed(status, reason). Thereasonis h34s' own text; it never quotes the callee's error. - The body lives in your
Scope.rawneedsFetch & Scope. In a handler, that is the request scope; useZIO.scopedfor 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,getandpostread the whole body in their own scope, so they need onlyFetch. 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.
ZHandler.$fetch.events[E](url)reads atext/event-streamroute as aZStream[Any, FetchError, E], each event'sdatadecoded as JSON forE. See server-sent events.
Test such a handler with Fetch.fake; see answer $fetch calls.
Type aliases
| Alias | Meaning |
|---|---|
HandlerEnv | ZState[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.