Guide

Getting started

Run the demo, make your first request to a Kyo handler, and see validation and errors on the wire.

h34s lets you write Nuxt server routes in Scala.js. Kyo is the core: one runtime owns the request lifecycle, cancellation, streaming and errors. A first-class ZIO adapter sits on top of it for ZIO applications.

In this tutorial you run the repository's demo, call a Kyo handler, trigger a validation error, and look at the code behind it. Then you are ready to add h34s to your own app.

Status. This site describes the published 0.2.0-alpha.1. The older 0.1.0-alpha.1 has a different API; the migration guide lists every change.

Prerequisites

ToolVersionWhy
JDK25Runs sbt. Kyo 1.0.0-RC7 macros need it. Not needed in production
sbt1.11.7Builds and links Scala.js
Node.js24Runs Nuxt and your handlers
pnpm10.34.4Installs the demo

Scala 3.9.0 and Scala.js 1.22.0 come from the build. You need no database for this tutorial.

Clone yuri4n/h34s and check out the v0.2.0-alpha.1 tag. From the repository root, with JDK 25 selected:

pnpm install --frozen-lockfile
pnpm run scala:fast
pnpm dev

scala:fast links the demo's Kyo and ZIO handlers into examples/nuxt/server/.scala-kyo and examples/nuxt/server/.scala. Wait until Nuxt prints its address.

Make your first request

In another terminal:

curl http://127.0.0.1:3000/api/kyo/greeting

Expected body:

{ "data": { "message": "Hello, Kyo!" } }

Every value body is wrapped in data. Now send a name:

curl -H 'content-type: application/json' --data '{"name":"Scala"}' \
  http://127.0.0.1:3000/api/kyo/greeting

Expected body: {"data":{"message":"Hello, Scala!"}}.

Trigger a validation error

Send a blank name:

curl -i -H 'content-type: application/json' --data '{"name":"  "}' \
  http://127.0.0.1:3000/api/kyo/greeting

The status is 422 and the body names the field:

{ "errors": [{ "message": "must not be blank", "path": "name" }] }

Send {not json and you get a 400 with Request body is not valid JSON. Send {"name":42} and you get a 422 with one generic entry. No input value ever appears in an error.

Evidence. On macOS x86_64, these responses came back from a production build of the demo, under nuxt dev, and from the linked modules behind a plain h3 server; see compatibility.

Read the code

The handler is a function from the request to the response body (examples/nuxt/scala/kyo/.../Handlers.scala):

@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

The model carries its validation in one schema, so the handler repeats no checks (Greetings.scala):

final case class GreetingRequest(name: String) derives NativeSchema

object GreetingRequest:
    given Schema[GreetingRequest] = Schema[GreetingRequest]
      .check(_.name)(_.trim.nonEmpty, "must not be blank")
      .checkMaxLength(_.name)(80)

AppHandler is the demo's own wiring point. It provides the Greetings service and sets Cache-Control: no-store on every response:

object AppHandler:
    private val options = HandlerOptions(defaults = ResponseHead.noStore)

    def define[B: ResponseBody](
        program: Request => B < (HandlerEffects & Env[Greetings]),
    )(using Frame): EventHandler =
      KHandler.define(options)(request => Env.run(Services.greetings)(program(request)))

AppHandler, Greetings and GreetingRequest are application code. h34s exports KHandler, Request, Response, HandlerOptions and the other values you see.

The handler declares its own route, .at(Route.post("/api/kyo/greeting")); the demo has no route files. After linking, the h34s Nuxt module mounts it, and Nuxt types $fetch('/api/kyo/greeting'). See routes and typed $fetch.

Try the ZIO route

The demo also has a ZIO handler that streams three strings:

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

The body is abc.

The ZIO greeting takes the same body as the Kyo one and needs no database:

curl -H 'content-type: application/json' --data '{"name":"Scala"}' \
  http://127.0.0.1:3000/api/zio/greeting

Expected body: {"data":{"message":"Hello, Scala!"}}. See ZIO handlers for the adapter.

Edit and repeat

Keep continuous linking running in a second terminal:

pnpm run scala:watch

Edit a Scala file, wait for linking and for Nitro to restart its worker, then send the request again. In development, an unexpected failure includes its cause and stack in the response; see debugging.

Run the tests

The Scala suites need two small npm fixtures:

for fixture in scala-modules/*/test-js; do npm ci --prefix "$fixture" --ignore-scripts; done
sbt 'h3Ext/test' 'kyoH3Ext/test' 'nativeConverterKyoSchemaExt/test' 'kyoNuxtHubExt/test' \
  'zioH3Ext/test' 'nativeConverterZIOSchemaExt/test' 'zioNuxtHubExt/test' 'nuxtsvr/test'
node --test scripts/*.test.mjs packages/nuxt/test/*.test.mjs

None of them needs PostgreSQL or a running Nuxt server.

Next steps