# 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](/guide/adoption). > **Status.** This site describes the [published `0.2.0-alpha.1`](https://github.com/yuri4n/h34s/releases/tag/v0.2.0-alpha.1). The older `0.1.0-alpha.1` has a different API; the [migration guide](/reference/migration) lists every change. ## Prerequisites
Tool Version Why
JDK 25 Runs sbt. Kyo 1.0.0-RC7 macros need it. Not needed in production
sbt 1.11.7 Builds and links Scala.js
Node.js 24 Runs Nuxt and your handlers
pnpm 10.34.4 Installs the demo
Scala 3.9.0 and Scala.js 1.22.0 come from the build. You need no database for this tutorial. ## Link and start the demo Clone `yuri4n/h34s` and check out the `v0.2.0-alpha.1` tag. From the repository root, with JDK 25 selected: ```sh 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: ```sh curl http://127.0.0.1:3000/api/kyo/greeting ``` Expected body: ```json { "data": { "message": "Hello, Kyo!" } } ``` Every value body is wrapped in `data`. Now send a name: ```sh 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: ```sh 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: ```json { "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](/reference/compatibility). ## Read the code The handler is a function from the request to the response body (`examples/nuxt/scala/kyo/.../Handlers.scala`): ```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`): ```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: ```scala 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](/guide/routes). ## Try the ZIO route The demo also has a ZIO handler that streams three strings: ```sh 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: ```sh 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](/guide/zio) for the adapter. ## Edit and repeat Keep continuous linking running in a second terminal: ```sh 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](/guide/debugging). ## Run the tests The Scala suites need two small npm fixtures: ```sh 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 - [Adopt h34s in your project](/guide/adoption): new app, existing Nuxt app, or existing Scala.js project. - [Kyo handlers](/guide/kyo): requests, responses, errors and services. - [Configuration](/guide/configuration) and [lifetimes](/guide/lifetimes) for real applications. --- # Adoption paths > Add h34s to a new Nuxt app, an existing Nuxt app, or an existing Scala.js project. h34s is a toolbox. You add a Scala.js server module to a Nuxt application, write handlers in Kyo (or ZIO), and mount them as Nuxt routes. Your models, services, configuration and resources stay in your application. Pick the path that matches what you have: 1. [Start a new project](#_1-start-a-new-project) 2. [Add Scala handlers to an existing Nuxt app](#_2-add-scala-handlers-to-an-existing-nuxt-app) 3. [Add Nuxt to an existing Scala.js project](#_3-add-nuxt-to-an-existing-scalajs-project) Every path ends with the same steps: [write a handler, mount it, run it](#write-mount-and-run-a-handler). Prefer a coding assistant? Give it the prompt in [Set up h34s with a coding assistant](/guide/agent-setup); it follows the steps on this page. For path 3, the prompt uses the `0.2.0-alpha.1` layout, with the Nuxt application at the root of your sbt build. ## Get the libraries **The API in these guides is 0.2.0-alpha.1.** The older `0.1.0-alpha.1` has a different API; see the [migration guide](/reference/migration). Install `0.2.0-alpha.1` from GitHub Packages with a GitHub token, as [Releases](/reference/releases) describes. Its [release workflow](https://github.com/yuri4n/h34s/actions/runs/39145732257) published the packages and verified them through the registry. To build against h34s source changes that are not released, use one of these: - Publish an h34s checkout to your local Ivy repository: run `sbt releasePublishLocal` in it (JDK 25), then depend on the version in its `version.sbt`. sbt resolves the local Ivy repository by default, before the registry, so remove that local copy before you depend on a registry release with the same version. This path has not been exercised by hand; the repository's own consumer check publishes to a temporary Maven directory instead. - Reference the h34s source projects from your sbt build. Pick the modules for your runtime. Kyo applications never need ZIO; ZIO applications get Kyo through the ZIO adapter:
You want Kyo ZIO
Handlers kyo-h3-ext zio-h3-ext
Schemas (optional) native-converter-kyo-schema-ext native-converter-zio-schema-ext + zio-schema-derivation 1.7.5
Nitro storage (optional) kyo-nuxthub-ext zio-nuxthub-ext
On the npm side, every application needs `@yuri4n/h34s-nuxt` (a dev dependency) and `h3@1.15.4` (a runtime dependency). See [npm tooling](/guide/npm-tooling). ## Toolchain
Tool Version Why
JDK 25 Runs sbt; Kyo 1.0.0-RC7 macros need it. Not needed in production
sbt 1.11.7 Builds and links Scala.js
Scala / Scala.js 3.9.0 / 1.22.0 Every h34s module uses this pair
Node / pnpm 24 / 10.34.4 Runs Nuxt and your handlers
See [compatibility](/reference/compatibility) for what was tested. ## 1. Start a new project The project tour arranges the demo's greeting files in layout A: the setup files from this guide, and the demo's greeting handler, models and `AppHandler` as the Scala code. It is not a generated or compiled project. The Scala files also use the schema bridge (`native-converter-kyo-schema-ext`, or `native-converter-zio-schema-ext` with `zio-schema-derivation`), and the ZIO `AppHandler` also wires the demo's Notes service. Pick a file to read it, and switch between Kyo and ZIO: 1. Create a Nuxt 4 application with Nuxt's own tooling. h34s has no project generator. 2. Add an sbt build at the Nuxt root: `build.sbt`, `project/` and `.jvmopts` next to `nuxt.config.ts`, and your Scala sources in `scala-server/`. See [Configure the Scala.js module](#configure-the-scalajs-module). 3. Add the npm package and the Nuxt module, as in [npm tooling](/guide/npm-tooling). 4. [Write, mount and run a handler](#write-mount-and-run-a-handler). To see a working setup first, follow the [getting-started tutorial](/guide/getting-started), or run the demo in `examples/nuxt/` with the [development guide](https://github.com/yuri4n/h34s/blob/main/docs/development.md). Its Kyo greeting needs no database. The demo is a reference, not a template: you do not need to clone this repository to use h34s. ## 2. Add Scala handlers to an existing Nuxt app Keep your TypeScript handlers. Scala handlers live next to them and declare their own routes. 1. Add an sbt build at the Nuxt root, with your Scala sources in `scala-server/`, as in [Configure the Scala.js module](#configure-the-scalajs-module). Point its linker output at a generated directory such as `server/.scala`. If you keep a separate sbt build next to the app instead, set [`sbtRoot`](/guide/npm-tooling#point-the-cli-at-your-sbt-build) in `h34s.config.mjs`. 2. Add `@yuri4n/h34s-nuxt` and `h3@1.15.4`, and register the module in `nuxt.config.ts`. See [npm tooling](/guide/npm-tooling). 3. Move only the routes you want to Scala. A TypeScript route and a Scala route can share a path prefix. Give each Scala route its own path and method: two handlers for the same route and method conflict. 4. Read your existing `runtimeConfig` keys from Scala with `RuntimeConfig` (Kyo) or `ZRuntimeConfig` (ZIO). See [configuration](/guide/configuration). The h34s wire format wraps values in `{"data": ...}`. If existing clients expect the raw value, pass `HandlerOptions(success = SuccessEnvelope.Raw)` for those routes. `Raw` sends a string as `text/plain; charset=utf-8` and any other value as JSON (`application/json`), unless you set a content type. ## 3. Add Nuxt to an existing Scala.js project Keep your sbt build where it is, and put the Nuxt application in `web/` below it. Your `build.sbt` does not change: the h34s settings go in a new `h34s.sbt` next to it, and sbt loads both files. **Status.** `sbtRoot` is new in `@yuri4n/h34s-nuxt` `0.3.0-alpha.1`, which is not released yet. With `0.2.0-alpha.1`, create the Nuxt application at the root of your sbt build instead, as in path 1, and keep the h34s settings in `h34s.sbt` with the linker output `server/.scala`. 1. Create a Nuxt 4 application in `web/` with Nuxt's own tooling. 2. Check `project/`. If no file there adds `sbt-scalajs` 1.22.0 or later, add or update the line from [Configure the Scala.js module](#configure-the-scalajs-module). If `.jvmopts` is missing, add it from the same section. 3. Create `h34s.sbt` next to `build.sbt`, with a dedicated server module that links into `web/server/.scala`:```scala // h34s.sbt import org.scalajs.linker.interface.{ModuleKind, OutputPatterns} lazy val server = project.in(file("scala-server")) .enablePlugins(ScalaJSPlugin) .settings( scalaVersion := "3.9.0", libraryDependencies += "dev.yuri4n" %%% "kyo-h3-ext" % "0.2.0-alpha.1", scalaJSLinkerConfig ~= (_.withModuleKind(ModuleKind.ESModule) .withOutputPatterns(OutputPatterns.fromJSFile("%s.js"))), Compile / fastLinkJS / scalaJSLinkerConfig ~= (_.withSourceMap(true)), Compile / fastLinkJS / scalaJSLinkerOutputDirectory := (LocalRootProject / baseDirectory).value / "web" / "server" / ".scala", Compile / fullLinkJS / scalaJSLinkerOutputDirectory := (LocalRootProject / baseDirectory).value / "web" / "server" / ".scala", ) ```
Keep your models and services in their own modules. To use one from the server module, add `.dependsOn(LocalProject("core"))`, with the ID of your project instead of `core`. If your build already has a project named `server`, pick another name and use it in `h34s.config.mjs`. 4. In `web/`, add the npm package, the Nuxt module and the scripts, as in [npm tooling](/guide/npm-tooling). 5. Create `web/h34s.config.mjs` with `sbtRoot: '..'`, so that `h34s-scala` runs sbt in your build:```js import { defineConfig } from '@yuri4n/h34s-nuxt/build' export default defineConfig({ sbtRoot: '..', modules: [{ project: 'server', output: 'server/.scala' }], }) ``` 6. [Write, mount and run a handler](#write-mount-and-run-a-handler). Put the handler in `scala-server/src/main/scala/`, skip the step that writes `h34s.config.mjs`, and run the scripts in `web/`. Only Scala.js-compatible code can enter Nitro. Your shared models must cross-build for Scala.js. Use Scala 3.9.0 for the modules that h34s handlers depend on. If your backend is JVM-only, you have two choices: cross-build the models and services that can run on Scala.js, or keep the JVM server and call it over HTTP from Nuxt. JDBC libraries, including Doobie, do not run in Scala.js. For PostgreSQL inside Nitro, use kyo-sql or Skunk; see [databases](/guide/database). ## Configure the Scala.js module ```properties # project/build.properties sbt.version=1.11.7 ``` ```scala // project/plugins.sbt addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.22.0") ``` Create `.jvmopts` next to `build.sbt`, with the JVM options for sbt: ```text -Xss4m -Xmx4g ``` The repository's own builds use these options, and its fresh-consumer check (`scripts/check-consumers.mjs`) copies the same file into each consumer project. A build without them has not been tested. ```scala // build.sbt import org.scalajs.linker.interface.{ModuleKind, OutputPatterns} lazy val server = project.in(file("scala-server")) .enablePlugins(ScalaJSPlugin) .settings( scalaVersion := "3.9.0", libraryDependencies += "dev.yuri4n" %%% "kyo-h3-ext" % "0.2.0-alpha.1", scalaJSLinkerConfig ~= (_.withModuleKind(ModuleKind.ESModule) .withOutputPatterns(OutputPatterns.fromJSFile("%s.js"))), Compile / fastLinkJS / scalaJSLinkerConfig ~= (_.withSourceMap(true)), Compile / fastLinkJS / scalaJSLinkerOutputDirectory := (LocalRootProject / baseDirectory).value / "server" / ".scala", Compile / fullLinkJS / scalaJSLinkerOutputDirectory := (LocalRootProject / baseDirectory).value / "server" / ".scala", ) ``` For ZIO, replace `kyo-h3-ext` with `zio-h3-ext`. Run sbt with JDK 25. `OutputPatterns.fromJSFile("%s.js")` makes each exported module a `.js` file, which Nitro mounts. Keep only generated files in `server/.scala`: a production build clears it. ## Write, mount and run a handler 1. Write a handler, export it as the `default` export of its own module, and give it a route with `.at`:```scala import dev.yuri4n.exts.kyoh3.* 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(_ => Greeting("Hello from Scala")).at(Route.get("/api/hello")) ``` 2. Point `h34s.config.mjs` at the linked output:```js import { defineConfig } from '@yuri4n/h34s-nuxt/build' export default defineConfig({ modules: [{ project: 'server', output: 'server/.scala' }], }) ``` 3. Link and start Nuxt with the scripts from [npm tooling](/guide/npm-tooling): `pnpm dev:scala`. The link step prints `[h34s] 1 route and 0 models in server/.scala`. 4. Request `http://127.0.0.1:3000/api/hello`. Expected body: `{"data":{"message":"Hello from Scala"}}`. You write no route file: the Nuxt module mounts the route from the manifest that `h34s-scala` writes after linking, and `$fetch('/api/hello')` is typed from the generated declaration. `derives NativeConverter` types the body as `unknown`; derive `NativeSchema` from a schema to get its fields. See [routes and typed `$fetch`](/guide/routes). If the request returns 404, check that the link step printed the route; if Nuxt warns that `server/.scala/h34s-routes.json` is missing, link first and restart Nuxt. If Nitro cannot resolve `h3`, add `h3@1.15.4` to your app's dependencies. ## Grow from there - Wire your services in one `AppHandler`: [Kyo](/guide/kyo#compose-services-in-your-apphandler) or [ZIO](/guide/zio#compose-services-in-your-apphandler). - Read [configuration](/guide/configuration) and keep secrets in `Secret`. - Hold pools in `Shared` / `ZShared`: [lifetimes](/guide/lifetimes). - Validate input with [schemas](/guide/schema). - Add [streaming](/guide/streaming), [storage](/guide/storage), [tests](/guide/testing) and a [database](/guide/database). ## What is verified The repository's consumer check builds four fresh sbt projects (Kyo, ZIO, Kyo Schema, ZIO Schema) against a temporary Maven repository and serves real h3 requests. A complete Nuxt application created from scratch with these steps has not been verified yet, on any path. For path 3, the unit tests of `@yuri4n/h34s-nuxt` run the CLI with `sbtRoot` and a fake sbt. Once, in a cloud session on 2026-10-20, `h34s-scala dev` in `web/` linked a real sbt build of this shape: an unchanged `build.sbt`, a new `h34s.sbt` whose server module depends on a project of `build.sbt` through `LocalProject`, and the output in `web/server/.scala`. That run used no h34s library and no Nuxt. --- # Routes and typed $fetch > Mount Scala handlers from Scala with .at(Route...), and let Nuxt type $fetch from the generated declarations. Mount your Scala handlers from Scala, and let Nuxt type the calls to them. You write no route file in `server/api` and no TypeScript interface for the `{ data }` and `{ errors }` bodies. ## Mount a handler 1. Export each handler as the `default` export of its own Scala.js module, as before. The module ID becomes the linked file name. 2. Give the handler a route with `.at(...)`:```scala import dev.yuri4n.exts.kyoh3.* import scala.scalajs.js.annotation.JSExportTopLevel @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")) ```
With a braceless handler function, put `.at` on its own line after the function:```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 .at(Route.post("/api/kyo/greeting")) ``` 3. Link with `h34s-scala dev` (or `build`). The command prints how many routes it found. 4. Start Nuxt. The h34s Nuxt module mounts every route. Request it, for example `GET /api/notes`. `Route.get`, `post`, `put`, `patch`, `delete`, `head` and `options` match one method. `Route.any(path)` matches every method, and `Route(Method.Patch, path)` takes the method as a value. Paths use Nitro's syntax: `/api/notes`, a parameter in `/api/notes/:id` (read it with `request.param("id")`), and a last segment `**` for the rest of the path. A path that does not start with `/`, or that contains spaces, `?`, `#`, `\` or an empty segment, fails when the module loads, so `h34s-scala` reports it at link time. The ZIO API is the same: `ZHandler.define(program).at(Route.get("/api/notes"))`, with `Route` from `dev.yuri4n.exts.zioh3`. `.at` returns a new handler and leaves the original unchanged. One linked module mounts one route: export a separate module for each method of a path. ## What h34s does with your routes After linking, `h34s-scala build`, `dev` and `watch` load your linked modules in a separate Node process and read the route and response types of each default export. Then they write two kinds of files into each Scala.js output directory: - `h34s-routes.json`, the route manifest: the module, route and method of each mounted handler, and the models file with its model names. - `.d.ts` next to each handler module, for example `server/.scala/notes-get.d.ts`. It declares the module's default export with its real response body. - `h34s-models.d.ts`: one `export type` for each named model that the routes of the directory return. See [use the models in your app](#use-the-models-in-your-app). The Nuxt module reads the manifests and registers each route with Nitro (`nitro.handlers`) as a lazy handler: the module loads on its first request. Nitro's `routeRules` apply to these routes as to any other. In development, Nuxt loads the linked files directly with their source maps, as before. Loading the modules runs their top-level code. Keep top-level handler values free of side effects: build resources in `Shared` or `ZShared`, which acquire on first use, and read configuration inside the handler or the shared resource. When a route is added, removed or changed while `nuxt dev` runs, the next link writes a new manifest and Nuxt reloads its server routes without a restart. A code change alone reloads the Nitro worker, as before. The Scala.js linker deletes the generated files while it links; `h34s-scala` writes them again right after, and Nuxt keeps the previous routes until it does. ## Call your routes with typed `$fetch` Nuxt types `$fetch` and `useFetch` from the generated declarations: ```ts const { data } = await $fetch('/api/notes') // data: { id: number; text: string }[] const created = await $fetch( '/api/notes', { method: 'POST', body: { text: 'Hello' } } ) // created.data: { id: number; text: string } ``` The type includes the handler's envelope from `HandlerOptions`: `{ data: T }` by default, `T` with `SuccessEnvelope.Raw`. A `Unit` body has no content, and a stream sent with `Response.sendStream` is text (`string`). With `Raw`, a value that converts to `null` is sent as 204 without a body, so `$fetch` returns `undefined`: an `Option[Int]` is typed `number | undefined`, not `number | null`. Errors do not take part in Nitro's types. Each declaration exports the error body as `ErrorBody`, in the handler's `ErrorEnvelope`: ```ts import type { ErrorBody } from '~~/server/.scala/notes-post' await $fetch('/api/notes', { method: 'POST', body: { text: '' }, onResponseError({ response }) { const body: ErrorBody = response._data // body.errors: { message: string; path?: string }[] }, }) ``` `debug` appears only on internal errors in development or debug mode. See [debugging](/guide/debugging). ## Use the models in your app Your Scala models also have names in TypeScript. `h34s-scala` declares each model that your routes return once, in `h34s-models.d.ts` next to the route declarations, and the h34s Nuxt module maps `#h34s/models` to it. Import a model wherever your app needs it: in a composable, a component prop or `useAsyncData`. 1. Derive `NativeSchema` for the model that your route returns. The Notes example calls its wire model `NoteView` (`examples/nuxt/scala/zio/.../notes/Notes.scala`, region `guide-models-scala`):```scala /** The JSON that the Notes routes send. One derivation: the ZIO Schema and the native converter. */ final case class NoteView(id: Int, text: String) derives Schema, NativeSchema ``` 2. Link with `h34s-scala dev`. The command prints the routes and the models it found, for example `[h34s] 5 routes and 2 models in ...`. It writes the model into `server/.scala/h34s-models.d.ts`, and the route declaration refers to it:```ts // server/.scala/h34s-models.d.ts export type NoteView = { id: number; text: string } // server/.scala/notes-get.d.ts import type { NoteView } from './h34s-models' export type SuccessBody = { data: Array } ``` 3. Start `nuxt dev`, or run `nuxt prepare`, so that Nuxt writes the alias into `.nuxt/tsconfig.json`. 4. Import the model with `import type`. This is the example's composable (`examples/nuxt/app/composables/useNotes.ts`, region `guide-models-ts`):```ts import type { NoteView } from '#h34s/models' /** The notes of `GET /api/notes`, typed with the generated `NoteView`. */ export function useNotes() { return useAsyncData('notes', () => fetchNotes()) } ```
The example's `NoteList` component takes the notes as a typed prop: `defineProps<{ notes: NoteView[] }>()`. Always write `import type`. The models file has types only, so a value import of `#h34s/models` fails when Vite bundles your app. Which types get a name: - The Kyo bridge names each record, sealed trait and enum after its Scala type (`Note`). The variants of a sealed trait or an enum stay inside the sum's type. - The ZIO bridge names each record after its `TypeId` (`NoteView`). - Your own converter can name its type: `val wireType = WireType.Named("Email", WireType.StringValue)` gives `export type Email = string`. - A generic type is written out where it is used: `Page[Note]` gives `{ items: Array; total: number }`, and `Note` keeps its name inside. ZIO's `TypeId` has no type arguments, so the ZIO bridge names a generic record after its class (`Page`). When two of its instantiations meet, in one output or in two, `h34s-scala` writes `Page` out where it is used, and the routes that return it use their full type. - A model refers to itself by name: a recursive ZIO record gives `type Tree = { value: number; children: Array }`. A recursive Kyo model is `unknown`, as before. - A name that is not a TypeScript identifier, or that TypeScript or h34s reserve, is written out where it is used. The reserved names are the JavaScript and TypeScript keywords (such as `class` or `string`) and `Array`, `Record`, `Promise`, `SuccessBody`, `ErrorBody`, `EventHandler` and `EventHandlerRequest`. - A model with the same name and the same structure is declared once, even when two Scala types or two routes give it. Two Scala types with the same name and different structures stop the generation; see [troubleshooting](#troubleshooting). With one Scala.js output, `#h34s/models` is that output's `h34s-models.d.ts`. With several outputs, it is `.nuxt/h34s/models.d.ts`, which re-exports the models of every output, so a name must mean one structure across all your outputs. To point the alias somewhere else, set `alias['#h34s/models']` in `nuxt.config.ts`: the module keeps your value. ## Where the types come from h34s writes the type of what your converter really writes, not a type guessed from the Scala class: - A `NativeSchema` type (Kyo Schema or ZIO Schema) is a named model (see [use the models in your app](#use-the-models-in-your-app)) and gives its fields with their wire names, optional fields as `T | null` (Kyo: may also be absent), collections as arrays, string-keyed maps as `Record`, and sealed traits and enums as one-key objects or objects with the `@discriminator` field. The Kyo bridge also checks the type against its encoder with sample values; see [schemas](/guide/schema#typescript-types). - native-converter's built-in converters give theirs: `String`, `Char` and `Long` are strings (native-converter writes `Long` as text), the other numbers are numbers, `Boolean` is a boolean, and `List`, `Vector`, `Seq`, `Set`, `Iterable`, `Array`, `Option` and `Map[String, A]` follow their elements. - Any other converter, such as `derives NativeConverter` or a hand-written one, gives `unknown`. Mix `WireType.Described` into your own converter to type it:```scala given NativeConverter[Email] = new NativeConverter[Email] with WireType.Described: val wireType = WireType.StringValue extension (email: Email) def toNative: js.Any = email.value def fromNative(state: ParseState): Email = Email(state.json.asInstanceOf[String]) ```
Check your declared type in a test with `wireType.describes(value)`. `unknown` is safe: TypeScript makes you check the value before you use it. h34s never writes a type that the converter might not follow. ## Mount a handler with a route file instead The one-line route file still works, for example when you prefer Nuxt's file layout for a route: ```ts // server/api/notes.get.ts export { default } from '../.scala/notes-get.js' ``` Leave `.at` off that handler, or remove the route file when you add `.at`: Nitro would register both. The declaration next to the linked module also types the route file. Keep `entries` in `h34s.config.mjs` for outputs that have only such handlers, so that `h34s-scala` checks the linked files; without `entries`, an output must contain at least one h34s handler. ## Troubleshooting - **404 for a Scala route.** Run `h34s-scala dev` and check that it prints your route. If Nuxt says that `h34s-routes.json` is missing, it started before the first link: link, then restart Nuxt. - **Two Scala.js handlers mount GET /api/x.** Two exports have the same route and method. Change one route or method. - **... has no h34s handlers.** No module in that output has a default export from `KHandler.define` or `ZHandler.define`. Export one, or list `entries` for route files. - **Could not load the linked Scala.js module.** A module's top-level code failed while `h34s-scala` loaded it. The message names the module and shows the stack (with Scala lines after `dev`, which links with source maps). - **A response is unknown.** Its converter does not describe itself. Derive `NativeSchema`, or mix in `WireType.Described`. - **Cannot find module 'h3' in a declaration.** Add `h3@1.15.4` to your app's dependencies, as the linked modules need anyway. - **two Scala types have the TypeScript name Note and different structures** (or `several Scala types`). Two models with one name reach the same models file, or two outputs that `#h34s/models` exports together. The message names each Scala type and the routes that use it. A hand-written `WireType.Named` without a Scala type shows as `a type without a known Scala name`. Rename one of the Scala types. h34s does not rename a model for you, because a silent `Note2` would change your app's types without a warning. - **Cannot find module '#h34s/models'.** Link first, so that the models file exists, then restart `nuxt dev` or run `nuxt prepare`, so that Nuxt writes the alias into its tsconfig. ## Limits - Nitro types response bodies only. Request bodies and query strings have no generated type. - The models file holds the models that your routes return. A request-body model, such as the example's `CreateNote`, gets its name with the typed route registry ([#46](https://github.com/yuri4n/h34s/issues/46)). - Generic models such as `Page[A]` have no TypeScript generic (`Page`) yet: each use is written out. - Only default exports are mounted. A module ID can hold one route. - Generated types are recreated on each link. In development, your editor can show untyped calls for a moment while the linker runs. --- # 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](/reference/migration)). See [releases](/reference/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](/guide/npm-tooling). - The library dependency `"dev.yuri4n" %%% "kyo-h3-ext" % ""`. Registry access is in [Releases](/reference/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: ```scala 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 "}}`. 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`](/guide/routes). ## 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 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` (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 type Response
Any A with a NativeConverter 200 (or the status you set) with {"data": }
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: ```scala 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: ```scala 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: ```scala 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`): ```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](/guide/lifetimes#application-resources). Endpoints then ask for the service: ```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 .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](/guide/lifetimes). Read configuration with `RuntimeConfig`; see [configuration](/guide/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`: ```scala 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](https://github.com/yuri4n/h34s/issues/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](/guide/streaming#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](/guide/streaming#server-sent-events). Test such a handler with `Fetch.fake`; see [answer `$fetch` calls](/guide/testing#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](/guide/lifetimes#when-the-client-disconnects). ## 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: ```scala 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](/guide/getting-started) walks through it step by step. From the repository root, with JDK 25 selected for sbt: ```sh pnpm install --frozen-lockfile pnpm run scala:fast pnpm dev ``` Then, in another terminal: ```sh 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](/reference/compatibility). ## Next steps - [Configuration and secrets](/guide/configuration) - [Application and request lifetimes](/guide/lifetimes) - [Schemas](/guide/schema) for validated models - [Databases](/guide/database) with kyo-sql - [Streaming and cancellation](/guide/streaming) - [Testing](/guide/testing) handlers without h3 - [Debugging](/guide/debugging) and source maps --- # 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](/reference/migration) 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" % ""`, 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: ```scala 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`](/guide/routes). ## 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`): ```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](/guide/kyo#read-the-request) 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": }
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](/reference/migration). ## 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](/guide/streaming#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
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: ```scala ZHandler.define(HandlerOptions(defaults = ResponseHead.noStore)): request => ... ``` See [Kyo handlers](/guide/kyo#choose-the-wire-format) for the envelopes, defaults, body limit and `requireJsonContentType` (see [cross-site requests](/guide/kyo#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`): ```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](/guide/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`: ```scala 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](https://github.com/yuri4n/h34s/issues/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](/guide/streaming#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](/guide/streaming#server-sent-events). Test such a handler with `Fetch.fake`; see [answer `$fetch` calls](/guide/testing#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](/guide/kyo#run-the-demos-kyo-routes), then: ```sh curl http://127.0.0.1:3000/api/test ``` The body is `abc`. The Notes routes need PostgreSQL; see [databases](/guide/database) and the [development guide](https://github.com/yuri4n/h34s/blob/main/docs/development.md). ## Next steps [Configuration](/guide/configuration), [lifetimes](/guide/lifetimes), [schemas](/guide/schema), [databases](/guide/database), [streaming](/guide/streaming), [testing](/guide/testing), and [debugging](/guide/debugging). --- # Configuration and secrets > Decode Nuxt runtimeConfig into a Scala case class, override it with NUXT_* variables, and keep secrets out of logs. Read configuration from Scala. h34s decodes Nuxt `runtimeConfig` into your own case class, so you never touch `process.env`, `useRuntimeConfig` or JavaScript objects. Nitro applies `NUXT_*` environment overrides before h34s sees the values. **Status.** `RuntimeConfig` and `ZRuntimeConfig` are new in `0.2.0-alpha.1`. They need the h34s Nitro plugin, which the `@yuri4n/h34s-nuxt` module registers. ## 1. Declare the keys in `nuxt.config.ts` Nuxt only overrides keys that you declare. Declare every key with a safe development default, and leave secrets empty: ```ts export default defineNuxtConfig({ modules: ['@yuri4n/h34s-nuxt'], runtimeConfig: { database: { host: '127.0.0.1', port: 5432, user: 'h34s', password: '', name: 'h34s', }, }, }) ``` Keys outside `runtimeConfig.public` are private: Nuxt never sends them to the browser. h34s reads private and public keys. ## 2. Define a Scala type for the part you read ```scala import dev.yuri4n.exts.ntcvzioschema.{NativeSchema, secretSchema} import dev.yuri4n.exts.zioh3.Secret import zio.schema.{Schema, derived} final case class DatabaseConfig(host: String, port: Int, user: String, password: Secret, name: String) derives Schema, NativeSchema /** The part of the Nuxt runtime config that this application reads. Other keys are ignored. */ final case class AppConfig(database: DatabaseConfig) derives Schema, NativeSchema ``` This is the demo's `AppConfig.scala`: one derivation gives the ZIO Schema and the native converter. `secretSchema` is the bridge's `Schema[Secret]`; import it where you derive a schema that holds a `Secret`. For Kyo, import `dev.yuri4n.exts.ntcvkyoschema.{NativeSchema, secretSchema}` and `kyo.Schema` instead. The h34s APIs only need a `NativeConverter`, so `derives NativeConverter` works too when you use no schema library. The Kyo demo reads its own key, `runtimeConfig.greeting.salutation`, with `RuntimeConfig.load` inside a `Shared` (see [lifetimes](/guide/lifetimes#application-resources)). Its production build answered with the configured salutation, and with `NUXT_GREETING_SALUTATION` it answered with the override. ## 3. Read it
You need Kyo ZIO
The config for this request RuntimeConfig.get[AppConfig](request) ZRuntimeConfig.get[AppConfig](request)
The startup config, for application resources RuntimeConfig.load[AppConfig] ZRuntimeConfig.load[AppConfig]
`get` returns Nitro's per-request copy. `load` returns Nitro's shared startup copy; use it inside `Shared` or `ZShared` to build a pool once. The demo's `SessionPool.layer` does that: ```scala val layer: ZLayer[Any, Throwable, SessionPool] = ZLayer.scoped( for config <- ZRuntimeConfig.load[AppConfig] sessions <- pooled(config.database).toScopedZIO yield SessionPool(sessions), ) ``` The Kyo types are `A < (Sync & Abort[ConfigError])`; the ZIO types are `IO[ConfigError, A]`. ## 4. Override values at runtime Nitro maps each declared key to an environment variable: the `NUXT_` prefix, then the key path in upper snake case. For the config above:
Key Variable
database.host NUXT_DATABASE_HOST
database.port NUXT_DATABASE_PORT
database.password NUXT_DATABASE_PASSWORD
A camelCase key such as `apiToken` becomes `NUXT_API_TOKEN`. Set secrets this way, at runtime, in your shell, process manager or hosting platform. Nitro parses each value before h34s sees it (Nitro 2.13 uses `destr`). `5432` becomes a number and `true` becomes a boolean. A secret that looks like a number, such as `12345`, therefore arrives as a number, and decoding a `Secret` field fails with a `ConfigError`. This comes from Nitro's source; h34s has no test for it. Prefer secrets that are not numeric, or check the failing path in the server log. ## When configuration is wrong Every failure is a `ConfigError`. It is never shown to clients: an unhandled one is an opaque 500. Its message names only the failing path, never a value:
Situation Message
The h34s Nitro plugin is not installed (the module is missing) h34s Nitro plugin is not installed
A field has the wrong type Nuxt runtime config does not match the expected shape at root.database.port
Reading the config throws h34s could not read the Nuxt runtime config
Fix the key in `nuxt.config.ts` or the environment, then restart the server. Development mode shows the message in `debug.cause`; production logs it on the server. A derived `NativeConverter` (version 0.9) accepts a missing or `null` value for a plain `String` field. Declare every key in `nuxt.config.ts`, or use a [schema](/guide/schema) type when a missing value must fail. ## Keep secrets secret Wrap every secret in `Secret`: - `toString` prints `Secret()`, so a secret in a case class does not appear in logs or diagnostics. - A value of the wrong type (a number, for example) fails with a message that names the path, never the value or the input document. - `secret.reveal` returns the plain value. Call it only where the value must leave your program, such as a database client. - `secret.matches(candidate)` compares in constant time for a given candidate length. Use it to check tokens: ```scala final case class AdminConfig(adminToken: Secret) derives NativeConverter def isAdmin(request: Request, config: AdminConfig): Boolean = request.bearerToken.exists(config.adminToken.matches) ``` Follow these rules: - Never log a revealed secret, put it in a `SafeError` message, or return it in a response body. - Never commit secret values in `nuxt.config.ts`, `.env` files that are part of a build, or source files. Supply them at runtime. - Keep debug mode off in production. Debug diagnostics include exception messages, and a library can put connection details in them. ## What is tested - `RuntimeConfig.get` and `load` decoding, path-only error messages, a throwing read, the missing plugin, and the opaque 500: Kyo `RuntimeConfigSuite` and ZIO `ZRuntimeConfigSpec`, with a fake plugin record. - The demo's `AppConfig` decoding with a redacted password: `AppConfigSpec`. - `Request.fake(runtimeConfig = Some(json))` with `RuntimeConfig.get` and `ZRuntimeConfig.get`; `Secret` shape errors without the value (`ValuesSuite`); `secretSchema` in both schema bridges. - A production build of the demo (`nuxt build`, then the Node server, macOS x86_64): `ZRuntimeConfig.load` read the database settings, and a marker password supplied at runtime appeared neither in the server logs nor in the debug response. The Kyo demo's `NUXT_GREETING_SALUTATION` override was checked with `scripts/kyo-smoke.mjs`; the other `NUXT_*` overrides have no separate test. --- # Application and request lifetimes > Open request resources in the request scope and application resources once, with Shared or ZShared and the Nitro close hook. Every resource has an owner and a lifetime. h34s gives you two scopes:
Lifetime Use it for Kyo ZIO
One request A pooled connection, a transaction, a temporary file, a stream's source The request Scope in HandlerEffects The request Scope in HandlerEnv
The whole application A connection pool, an HTTP client, a cache handle Shared ZShared
Choose the shortest lifetime that works. Open request resources inside the handler; open application resources once. **Status.** `Shared` and `ZShared` are new in `0.2.0-alpha.1`. ## Request resources The core runs every handler in a request scope. Acquire a resource in that scope, and h34s releases it when the request is done: - Kyo: `Scope.acquireRelease(acquire)(release)` and `Scope.ensure(finalizer)`. - ZIO: `ZIO.acquireRelease(acquire)(release)`, `ZIO.addFinalizer`, or a scoped `ZLayer` you provide inside the handler. "Done" means: - For a value or `Unit` body: after your program finishes, before h34s writes the response. Asynchronous finalizers finish before the response settles. - For a streamed body: after the stream ends, fails, or is cancelled. - When the client disconnects: h34s interrupts the program, waits for the finalizers, and writes nothing. See [When the client disconnects](#when-the-client-disconnects). A finalizer that checks the outcome, to commit or roll back, needs a scope that sees it. In ZIO, the request `Scope` closes with your program's `Exit`, so `ZIO.addFinalizerExit` there sees a failure. In Kyo, h34s closes the request scope after your program, without its outcome: a `Scope.ensure` finalizer there always gets `Absent`. Write a `Scope.run` inside your handler around the steps whose outcome you need; see [roll back when a stream fails](/guide/streaming#roll-back-when-a-stream-fails). The demo's ZIO handlers take at most one pooled Skunk session per request this way. `NotesLive.layer` reads the request `Scope` from `HandlerEnv` and takes the session on first use (`memoize`), so a request that fails validation never touches the pool, and the session returns to the pool when the request ends: ```scala /** Notes over at most one pooled session per request. The session is taken on first use, so a * handler that rejects its input never touches the pool, and it returns to the pool when the * request scope closes. */ val layer: ZLayer[SessionPool & Scope, Nothing, Notes] = ZLayer.fromZIO( for pool <- ZIO.service[SessionPool] scope <- ZIO.service[Scope] session <- pool.session.provideEnvironment(ZEnvironment(scope)).memoize yield NotesLive(session), ) ``` `NotesHandlerSpec` checks this with a pool whose database is down: invalid input takes no session and is a 422; valid input takes one session and fails. A failing finalizer is logged on the server and does not change the response, in both runtimes. Kyo 1.0.0-RC7 logs a failing `Scope` finalizer. The ZIO adapter logs a dying ZIO finalizer as `h34s: a ZIO finalizer failed`. Do work that must affect the response in the program, not in a finalizer: the client never learns that a finalizer failed. ## When the client disconnects A client can leave before your handler finishes: the user closes the tab, or the frontend aborts its `fetch`. Kyo and ZIO handlers behave the same way: 1. h34s interrupts your program. In ZIO, the handler's fiber is interrupted. 2. Your finalizers run: `Scope.acquireRelease` and `Scope.ensure` in Kyo, `ZIO.acquireRelease`, `ZIO.addFinalizer` and scoped layers in ZIO. 3. h34s waits for those finalizers, including asynchronous ones, before it tells h3 and Nitro that the request is over. This includes work that your handler forks with Kyo's structured combinators (`Async.timeout`, `Async.race`, `Async.gather`, `Async.zip`, a concurrent `Async.foreach`) and ZIO child fibers: they are interrupted, and their cleanup finishes first. 4. h34s writes nothing to the closed connection. The exception is work on a fiber that nothing owns: Kyo's `Fiber.initUnscoped`, and the fiber that Kyo's `Async.uninterruptible` starts. h34s cannot wait for it, so do not use request resources there. Interruption can stop work that you expected to finish. If the client leaves during a database transaction, the transaction can roll back instead of committing. For a step that must complete even when the client leaves, make that step uninterruptible: - Kyo: `KHandler.uninterruptible(step)`. The step runs on its own fiber that the interrupt cannot reach, and the request scope waits for it before it releases resources acquired earlier (such as the connection the step uses). Do not use Kyo's `Async.uninterruptible` alone in a handler: it protects the step, but the request settles and releases its resources while the step still runs. - ZIO: `step.uninterruptible`, or `ZIO.uninterruptibleMask` when only part of the step must be protected. ZIO delays the interrupt until the step ends, and h34s waits for the fiber. ```scala // Kyo, with the kyo-sql service from the database guide: the insert and its commit finish even if // the client leaves; then earlier request resources are released; then the request settles. note <- KHandler.uninterruptible(notes.create(input.text)) ``` The step needs only `Async & Abort[Throwable]`: provide its other effects inside it (kyo-sql's `DB.run(client)` does). This line compiles against kyo-sql-postgres 1.0.0-RC7 (see [database](/guide/database)); the order is tested with a sleeping step, not with a database. Keep uninterruptible regions small. A client disconnect cannot stop them, so h34s waits for them to finish before it settles the request. Tests in both runtimes disconnect during such a step and check the order: the step ends, then the request finalizers run, then the request settles. A Kyo test also documents that `Async.uninterruptible` alone does not wait. Interruption does not cancel JavaScript work that has already started, for example a storage Promise. h34s stops waiting for it; the work itself finishes or fails on its own. ## Application resources `Shared` (Kyo) and `ZShared` (ZIO) hold one resource for all requests: - The first `get` acquires it. Concurrent first calls share one acquisition. - A failed acquisition is not cached. The next `get` tries again. - The acquisition runs on its own fiber. A request that is cancelled while it waits does not cancel the acquisition for the others. - When the acquisition succeeds, h34s registers the resource with the Nitro close hook. - `close` releases it and waits for its finalizers. A later `get` acquires it again. - A `close` during the first acquisition releases the value as soon as it is acquired. The `get` calls that waited for it fail with `IllegalStateException`; none receives a released value. Declare a shared resource once, as a member of an `object`. Nothing runs until the first `get`. Kyo, with any `A < (Async & Scope & Abort[Throwable])` acquisition. The `Scope` here is the resource's own scope, not a request's: ```scala object Resources: val pool: Shared[Pool] = Shared( Scope.acquireRelease(openPool)(pool => closePool(pool)), ) ``` `openPool`, `closePool` and `Pool` stand for your own code. Use it in a handler with `Resources.pool.get`, which is `Pool < (Async & Abort[Throwable])`. The Kyo demo (`AppHandler.scala`) builds its greeting service this way, from the startup config, and provides it to every handler: ```scala val greetings: Shared[Greetings] = Shared(RuntimeConfig.load[KyoConfig].map(config => Greetings.live(config.greeting))) 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)))) ``` ZIO, with any `ZLayer[Any, E, R]`. This is the demo's (`AppHandler.scala`): ```scala /** One Skunk pool for all requests: built on first use, released when Nitro closes. */ private val database = ZShared.layer(SessionPool.layer) ``` Then `database.provide(program)` supplies the shared environment to a program, and `database.get` returns the `ZEnvironment`. Load configuration inside the acquisition with `RuntimeConfig.load` or `ZRuntimeConfig.load`, not `get`. There is no request at startup. See [configuration](/guide/configuration). ## One copy per linked project A `Shared` value is a normal Scala value. Every handler that the same sbt project links shares one copy, because Scala.js puts shared code in one internal module. Two Scala.js projects linked separately have two copies, and each opens its own pool. ## When Nitro closes The h34s Nitro plugin listens to Nitro's `close` hook and releases every registered `Shared` and `ZShared` value. One failing release does not stop the others. In Nitro 2.13: - The `node-server` preset calls the hook on `SIGTERM` or `SIGINT` (graceful shutdown, 30 seconds by default; see `NITRO_SHUTDOWN_TIMEOUT` and `NITRO_SHUTDOWN_DISABLED`). - `nuxt dev` calls it when it replaces the development worker, for example after relinking. These facts come from Nitro's source. h34s tests the release path with a fake plugin record. In a production build of the demo, `SIGTERM` released the `ZShared` pool registered by the first Notes request, and the server exited with status 0. A `nuxt dev` worker replacement has not been checked. Without the h34s plugin (for example in unit tests), nothing is registered. Call `close` yourself in that case. ## What is tested `SharedSuite` (Kyo) and `ZSharedSpec` (ZIO): one acquisition for concurrent first calls, an interrupted waiter does not cancel the acquisition, failures are not cached, a `close` during the first acquisition never hands out a released value, `close` releases once and a later `get` acquires again, and the close hook releases every registered value. Request-scope tests in both runtimes cover finalizers on success, safe failure and defect, asynchronous finalizers, client disconnects, uninterruptible steps, and (Kyo) cleanup in work forked by `Async.timeout`, `race`, `gather`, `zip` and `foreach`. --- # Schemas > Derive the native value, JSON, YAML and validation from one schema per model, with Kyo Schema or ZIO Schema. Schemas are optional. `NativeConverter` is the type class every h34s API asks for, and a plain `derives NativeConverter` is a complete path. Add a schema when you want validation in the model, or JSON and YAML from the same definition. With a schema, define each model once: ```scala final case class Greeting(message: String) derives Schema, NativeSchema ``` That one derivation gives the native value h34s sends and reads, JSON text, YAML text (Kyo only), and validation that runs on every request body read. **Status.** This page describes both bridges in `0.2.0-alpha.1`. The older `0.1.0-alpha.1` had a different ZIO schema reader; see the [migration guide](/reference/migration). ## Choose a bridge
Kyo ZIO
Module native-converter-kyo-schema-ext native-converter-zio-schema-ext
Package dev.yuri4n.exts.ntcvkyoschema dev.yuri4n.exts.ntcvzioschema
Derive derives Schema, NativeSchema derives Schema, NativeSchema (needs import zio.schema.derived )
JSON kyo-schema-json: Json.encode , Json.decode zio-schema-json: JsonCodec
YAML kyo-schema-yaml: encodeString[Yaml] , decodeString[Yaml] Not on Scala.js: zio-json-yaml is published for the JVM only
Validation Schema[A].check(...) and friends Validation in the schema ( .validation , @validate )
Messages Your check messages Built from the rule and its bounds
The bridges depend on the native layer (`h3-ext`) and their schema library only. Neither installs a blanket instance: a type gets a `NativeSchema` only when it derives one or calls `NativeSchema[A]` (Kyo) or `NativeSchema.derived` (ZIO). A type can keep a separately derived `NativeConverter`; the two do not conflict. Add kyo-schema-json and kyo-schema-yaml (Kyo 1.0.0-RC7) to your application yourself when you need JSON or YAML text. The bridge does not depend on them. ## Kyo: derive, validate, use Derive both at once for a model without checks. For a model with checks, declare the schema in the companion and derive only `NativeSchema`; kyo-schema has no check annotations, so this keeps one schema. This is the demo's `Greetings.scala`: ```scala import dev.yuri4n.exts.ntcvkyoschema.NativeSchema import kyo.* /** The request model. Its one schema carries the validation; `NativeSchema` derives from it, so * `request.body.json[GreetingRequest]` answers 422 with these messages and the `name` path. */ 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) final case class Greeting(message: String) derives Schema, NativeSchema ``` The handler asks for the validated model and repeats no checks: ```scala for input <- request.body.json[GreetingRequest] greeting <- Env.use[Greetings](_.greet(input.name)) yield greeting ``` A blank name gets a 422 with `{"errors":[{"message":"must not be blank","path":"name"}]}`. An 81-character name gets `{"errors":[{"message":"length must be <= 80","path":"name"}]}`. The same schema gives JSON and YAML text: ```scala val json: String = Json.encode(greeting) val back = Json.decode[Greeting](json) // a Result val yaml: String = summon[Schema[Greeting]].encodeString[Yaml](greeting) ``` ### Kyo wire shapes The native value is what `JSON.parse(Json.encode(x))` would give, built without JSON text:
Scala Native value
None The key is absent. On input, absent, null and undefined all decode to None
Enum case, sealed trait case A single-key object: {"Red":{}} , {"Circle":{"radius":1.5}} . @discriminator("type") gives {"type":"Joined",...}
Map[String, A] An object
Map[Int, A] and other keys An array of {"key":...,"value":...}
BigDecimal , BigInt Strings. Decoding rejects (generic 422) a string longer than 1000 characters and a BigDecimal exponent beyond +/-10000, before parsing: Scala.js parses big numbers in quadratic time
Instant , Duration ISO strings
Bytes ( Span[Byte] ) A base64 string
Double NaN and infinities The strings "NaN" , "Infinity" , "-Infinity"
Long A number; decoding accepts only safe integers (up to 2^53 - 1)
You do not write TypeScript types for these shapes: h34s generates them. See [TypeScript types](#typescript-types). ### Kyo validation rules - Only the root schema's checks run, like `Schema.validate`. Put checks on the root with focus paths, such as `_.customer.name`. A nested type's own checks do not run. - Each failed check is one entry: your message, and the field path when the check is on a field (`customer.name`). A check on the whole value has no path. - A value with the wrong shape (missing field, wrong type, out-of-range integer, a non-plain object, invalid ISO or base64 text) is one generic entry: `Request body does not match the expected shape`. - An exception from a schema transform is a defect (opaque 500), never blamed on the input. - A fluent `Schema.rename`, `renameAllFields` or `.discriminator`, and adjacent, tuple or untagged unions, are converted generically; strict number checks then do not apply to those values. ## ZIO: derive, validate, use Import `zio.schema.derived` for `derives Schema`. Put normalization and validation in the schema. This is the demo's `Notes.scala`: ```scala import dev.yuri4n.exts.ntcvzioschema.NativeSchema import zio.schema.{DeriveSchema, Schema, StandardType, derived} import zio.schema.validation.Validation /** The JSON that the Notes routes send. One derivation: the ZIO Schema and the native converter. */ final case class NoteView(id: Int, text: String) derives Schema, NativeSchema /** The JSON that `POST /api/notes` reads. */ final case class CreateNote(text: String) derives NativeSchema object CreateNote: /** Trims the text, then requires 1-2000 characters. The native converter uses this schema. */ given Schema[CreateNote] = given Schema[String] = Schema.Primitive(StandardType.StringType) .transform(_.trim, identity) .validation(Validation.minLength(1) && Validation.maxLength(2000)) DeriveSchema.gen[CreateNote] ``` `request.body.json[CreateNote]` trims the text and checks its length. A blank text gets a 422 with `{"errors":[{"message":"must have at least 1 character","path":"text"}]}`. ZIO Schema's own validation messages quote the input, so the bridge builds messages from the rule and its bounds instead, such as `must have at least 3 characters` or `must be greater than 17`. Paths look like `lead.name` and `members[1].name`. A negated rule (`!`) is reported with the wording of the rule it negates; this is a zio-schema limitation. The ZIO bridge supports these shapes and fails with `UnsupportedSchema` when the converter is created for any other:
Schema form Native value
String , Boolean Strict primitives
Int An integer in the 32-bit range
Double A finite number
Records (case classes) Plain objects; field names, aliases, defaults, transient fields and rejectExtraFields are respected
Option Absent or null is None
Sequences Arrays
Lazy and transformed schemas Through their underlying schema
Enums, sealed traits, maps, sets, tuples, `Either` and other primitives are not supported by the ZIO bridge yet. `NativeSchema(schema)` also returns an explicit `Codec` with `decode`, `validate` and `encode` for code that holds a native value. Ordinary handlers do not need it. ## TypeScript types Both bridges' converters describe the TypeScript type of what they write (`WireType.Described`). A route that returns such a type gets that type in its generated declaration, so `$fetch` is typed without hand-written interfaces. See [routes and typed `$fetch`](/guide/routes). Each record (and, in Kyo, each sealed trait and enum) is a named model: `h34s-scala` declares it once as `export type Note = ...`, and your app imports it with `import type { Note } from '#h34s/models'`. A generic instantiation such as `Page[Note]` is written out where it is used. See [use the models in your app](/guide/routes#use-the-models-in-your-app).
Bridge TypeScript type
Kyo The shapes in the table above: records with their wire names ( @rename ), optional fields as name?: T | null , collections as arrays, Map[String, A] as Record , other maps as { key; value } arrays, sealed traits and enums as one-key objects or @discriminator objects, Double and Float as number | "NaN" | "Infinity" | "-Infinity" , big numbers, instants, durations and bytes as strings
ZIO Records with their wire names ( @fieldName ) and without @transientField fields, Option as T | null , sequences as arrays, String , Boolean , Int , Double ; a transform has its underlying type; a recursive record refers to its model by name where it repeats ( unknown when the type is written out)
The ZIO type follows the bridge's encoder case by case. The Kyo type follows the schema's `Structure`, which does not show fluent changes such as `Schema.rename`, `drop`, `add` or omit policies, also in the schema of a nested type. So the Kyo bridge checks its type against its encoder: it reads and writes sample values of every shape (each union member, present and absent options, filled and empty collections) and gives `unknown` when an output does not match. A Kyo type with fluent changes is therefore `unknown`; prefer annotations (`@rename`, `@discriminator`) for types you send to TypeScript clients. ## Secrets in a schema type `Secret` has a `NativeConverter`, but no schema library knows it. Each bridge provides a `Schema[Secret]` that is a string on the wire: import `dev.yuri4n.exts.ntcvkyoschema.secretSchema` (Kyo) or `dev.yuri4n.exts.ntcvzioschema.secretSchema` (ZIO) where you derive a schema that holds a `Secret`, such as a config model. A wrong value type is the generic 422 (or a `ConfigError` for runtime config), without the value. JSON or YAML that you write with this schema contains the plain value, so do not encode a secret into a response or a log. See [configuration](/guide/configuration). ## Request errors
Problem Status Entries
Body is not valid JSON 400 Request body is not valid JSON
Wrong shape 422 One generic entry, no input values
Failed checks 422 One entry per check, with path when known
Exception in a transform, validator or converter 500 Opaque; diagnostics only in development or debug mode
Do not catch every `Throwable` and turn it into a `SafeError`. That would show programming errors to clients as if they were input problems. ## Keep a NativeConverter-only path You never need a schema. `derives NativeConverter` works with every h34s API: body reads, responses, configuration, storage and streams. Shape errors are still a generic 422. A derived `NativeConverter` does not validate domain rules, and version 0.9 accepts a missing or `null` value for a `String` field; check such rules in your code. ## Dependencies and compatibility
Module Schema dependencies
native-converter-kyo-schema-ext kyo-schema 1.0.0-RC7. Add kyo-schema-json and kyo-schema-yaml yourself for text formats
native-converter-zio-schema-ext zio-schema 1.7.5. Add zio-schema-derivation 1.7.5 for derives Schema , and zio-schema-json for JSON text
kyo-h3-ext , zio-h3-ext None. The handler modules never bring a schema library
Both bridges use Scala 3.9.0 and NativeConverter 0.9.0. See [compatibility](/reference/compatibility) for the tested profile. What is tested: - Kyo bridge (`NativeSchemaSuite`, 30 tests with `H3BodySuite`): TypeScript types of records, sums, numbers, scalars and collections checked against the encoder output, and `unknown` for a fluent rename at the root and in a nested type; named models for records and sums, a generic instantiation written out, and a recursive model that stays `unknown`; wire equality with kyo-schema-json for records, options, enums, sealed traits, collections, numeric edges, big numbers (and their length and exponent limits), times and bytes; strict decoding; validation messages and paths; no input values in messages; no JSON text round trip; no blanket instance; JSON and YAML round trips from the same schema; `secretSchema`; HTTP 422 entries. - ZIO bridge (`NativeSchemaSpec`, 24 tests, with `secretSchema`, the TypeScript types of records, options, wire names, transient fields, transforms and recursion, named models after the `TypeId`, and sealed traits that stay unsupported) and ZIO request bodies (`RequestBodySpec`): supported shapes, transforms, accumulated entries with paths, and the 400/422 statuses. - The demo's Kyo routes answered 422 with `must not be blank` and `length must be <= 80` over an h3 server; see [compatibility](/reference/compatibility). --- # PostgreSQL > Use kyo-sql or Skunk directly inside Nitro, with one pool per application and one transaction or session per request. h34s has no database module, on purpose. Good PostgreSQL clients already run inside Scala.js on Node: use kyo-sql in a Kyo application and Skunk in a ZIO application, directly. h34s gives you the two pieces that connect them to handlers: - an application-scoped pool: `Shared` (Kyo) or `ZShared` (ZIO), built on first use and released when Nitro closes; - a request scope for one session or transaction per request. Your models, SQL, migrations and error mapping stay in your application. See [lifetimes](/guide/lifetimes) and [configuration](/guide/configuration) for the h34s side. ## The pattern 1. Declare the connection settings in `nuxt.config.ts` and read them with `RuntimeConfig.load` (Kyo) or `ZRuntimeConfig.load` (ZIO). Keep the password in a `Secret`, supplied at runtime. 2. Open one pool per application inside `Shared` or `ZShared`. 3. In each request, run your statements on that pool: one transaction (kyo-sql) or one pooled session (Skunk), released with the request scope. 4. Map database errors that the client may see, such as a unique-constraint violation, to your own `SafeError` inside the service. Let every other database error stay a defect: an opaque 500. Create the schema with your own migration tool or scripts. Handlers should never create or change tables. ## Kyo: kyo-sql and kyo-sql-postgres **Status: runs against PostgreSQL 16 in Node; not run inside Nitro.** The service code below (`NotesLive`, `Note`, `NoteView`, `DuplicateNote`) is copied without changes into the Kyo example's opt-in test, `KyoSqlPostgresSuite`. On 2026-10-19, with kyo-sql-postgres 1.0.0-RC7, it inserted and listed a note, mapped a unique violation to `DuplicateNote`, rolled back two failed transactions, and kept the outer work of a transaction whose nested transaction failed, against PostgreSQL 16.15 with SCRAM authentication. See [Run the kyo-sql tests](#run-the-kyo-sql-tests). The `Database` and `AppHandler` snippets compiled in a temporary test file on 2026-10-17 (see [compatibility](/reference/compatibility)); they were not run, because they need Nitro. The demo's Nuxt application does not use kyo-sql. Treat this code as a starting point. Add the client to your application: ```scala libraryDependencies += "io.getkyo" %%% "kyo-sql-postgres" % "1.0.0-RC7" ``` It brings kyo-sql, kyo-net and kyo-schema-json with it. ### Configure the URL kyo-sql takes one URL such as `postgres://user:password@host:5432/db`. Declare it as a private runtime config key, empty by default, and set `NUXT_DATABASE_URL` at runtime: ```scala import dev.yuri4n.exts.ntcvkyoschema.{NativeSchema, secretSchema} final case class DatabaseConfig(url: Secret, verifyTls: Boolean) derives Schema, NativeSchema final case class AppConfig(database: DatabaseConfig) derives Schema, NativeSchema ``` `secretSchema` is the bridge's `Schema[Secret]`: import it where you derive a schema that holds a secret. kyo-sql 1.0.0-RC7 parses this URL itself. Read these rules before you copy a URL from a hosting provider (they come from the kyo-sql source and are not tested by h34s): - **The port is required.** `postgres://u:p@host/db` fails with `SqlConnectionUrlParseException`. Add `:5432` when your provider leaves it out. - **The database name is required.** - **Credentials are not percent-decoded.** A password written as `p%40ss` is sent as those exact characters. Use a password that needs no encoding, or decode it before you build the URL. - **sslmode=require encrypts but does not verify.** `allow`, `prefer` and `require` accept any certificate and host name. `verify-ca` and `verify-full` need `sslrootcert`, a file path. To verify against the system certificate store without a file, set the TLS settings in code, as below. Settings in code win over the URL's trust choice; an `sslmode` in the URL still wins over the mode in code. ### Open one pool for the application ```scala import dev.yuri4n.exts.kyoh3.* import kyo.* import kyo.net.NetTlsConfig object Database: /** One pool for all requests: opened on first use, closed when Nitro closes. */ val client: Shared[PostgresClient] = Shared( RuntimeConfig.load[AppConfig].map { config => val sqlConfig = if config.database.verifyTls then SqlConfig.default.tlsMode(SqlConfig.TlsMode.Require).tls(NetTlsConfig.default) else SqlConfig.default PostgresClient.init(config.database.url.reveal, sqlConfig) }, ) ``` `PostgresClient.init` registers the pool in the `Scope` it runs in. Inside `Shared`, that is the resource's own scope, so the pool closes when Nitro closes. Do not call `DB.run(url)` per request: it opens and closes a whole pool on every call. Set `verifyTls` to `false` only for a local database without TLS. ### Create the table Create the table with your migration tool or setup script, not in a handler. The code below expects this table: ```sql CREATE TABLE IF NOT EXISTS notes ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, text text NOT NULL UNIQUE ); ``` `UNIQUE` gives the `23505` violation that `create` maps to `DuplicateNote`. ### Run statements per request Keep SQL in your service and map the errors a client may see. Keep three models apart: the table row (read by kyo-sql), the domain note (what the service returns), and the JSON that a route sends. A new column then reaches the JSON only when you add it to the wire model. ```scala /** The domain note. It has no table or wire format. */ final case class Note(id: Int, text: String) /** The JSON that the Notes routes send. */ final case class NoteView(id: Int, text: String) derives Schema, NativeSchema object NoteView: def from(note: Note): NoteView = NoteView(note.id, note.text) final case class DuplicateNote() extends SafeError: def message: String = "A note with this text already exists" override def status: Int = 409 final class NotesLive(client: PostgresClient): import NotesLive.NoteRow def create(text: String): Note < (Async & Abort[DuplicateNote | SqlException]) = Abort.recover[SqlServerConstraintViolationException](error => if error.sqlState == "23505" then Abort.fail(DuplicateNote()) else Abort.fail(error), )(DB.run(client)(DB.transaction( sql"INSERT INTO notes (text) VALUES ($text) RETURNING id, text".as[NoteRow].run.map(_.head.toNote), ))) def list: Chunk[Note] < (Async & Abort[SqlException]) = DB.run(client)(sql"SELECT id, text FROM notes ORDER BY id".as[NoteRow].run.map(_.map(_.toNote))) object NotesLive: /** The table row. It stays inside the service. */ private final case class NoteRow(id: Int, text: String) derives Schema: def toNote: Note = Note(id, text) ``` - `sql"..."` binds `$text` as a parameter. The value is never concatenated into the SQL text. - `DB.transaction` commits when the body succeeds and rolls back when it fails, also on a non-SQL `Abort`. A body that recovers from a failed statement still rolls back: the transaction then fails with `SqlRequestTransactionFailedStatementException`. To keep the rest of the work, wrap the step that may fail in a nested `DB.transaction` and recover from its failure. The nested transaction takes a savepoint, so only its own part rolls back and the outer transaction still commits; `KyoSqlPostgresSuite` tests this. A client disconnect interrupts the transaction too, so it can roll back. When the insert must finish anyway, run it with `KHandler.uninterruptible(notes.create(text))`; see [lifetimes](/guide/lifetimes#when-the-client-disconnects). - `SQLSTATE 23505` is a unique violation. Map only the states your client should see; `SqlException` details can contain values from the database, so never put them in a `SafeError` message. Provide the service in your `AppHandler`, the same way as any Kyo service: ```scala object AppHandler: def define[B: ResponseBody](program: Request => B < (HandlerEffects & Env[NotesLive]))(using Frame): EventHandler = KHandler.define(HandlerOptions(defaults = ResponseHead.noStore)): request => Database.client.get.map(client => Env.run(NotesLive(client))(program(request))) ``` ### Run kyo-sql inside Nitro kyo-net picks an I/O backend when it starts. It prefers native backends loaded through FFI and falls back to Node's `net` and `tls`. In a Nitro server, force the Node backend: ```sh KYO_NET_BACKEND=node node .output/server/index.mjs ``` **Unverified inside Nitro.** h34s has not run kyo-sql inside a Nitro bundle. In the Node test run, without `KYO_NET_BACKEND`, kyo-net selected its `node` backend by itself and logged a warning that the FFI backends were unavailable, because the `koffi` npm package was not installed. The FFI backends, the bundle size, and non-Node Nitro presets (which have no `net` or `tls`) are open questions. Test your deployment target before you rely on it. ### Run the kyo-sql tests `KyoSqlPostgresSuite` in `examples/nuxt/scala/kyo/src/test/scala/` runs the service code above against a real database. It runs only when `H34S_POSTGRES_URL` holds a kyo-sql URL. Without the variable, munit reports its five tests as skipped. 1. Start PostgreSQL 16 and create the role `h34s`, the database `h34s` and the password file `~/.h34s-db-password`, as in [Start PostgreSQL and Redis](https://github.com/yuri4n/h34s/blob/main/docs/maintainers/cloud.md#start-postgresql-and-redis). Use a database that has no `notes` table of your own: the suite creates `notes` if it does not exist, writes rows whose text starts with `h34s kyo-sql `, and deletes those rows when each test ends. The table stays. 2. Run the suite from the repository root:```sh H34S_POSTGRES_URL="postgres://h34s:$(cat ~/.h34s-db-password)@127.0.0.1:5432/h34s" sbt -batch kyoExample/test ``` Expected result: `Passed: Total 5, Failed 0, Errors 0, Passed 5`. The suite runs in Node through sbt, not inside Nitro. Set `H34S_POSTGRES_REQUIRED=true` to make the tests fail instead of skip when the URL is missing; the `postgres` CI job does this. ## ZIO: Skunk in a `ZShared` layer The demo's Notes example uses Skunk 1.0.0-M12 through zio-interop-cats. Its handlers are unit-tested. On 2026-10-19, a production build of the demo ran against PostgreSQL 16.15 with `scram-sha-256` authentication: `POST /api/notes` answered 201, invalid input answered 422, `GET /api/notes` listed the note, and after `SIGTERM` no session of the example's user stayed in `pg_stat_activity`. See the [development guide](https://github.com/yuri4n/h34s/blob/main/docs/development.md#cloud-session-production-build-and-postgresql-2026-10-19) for the steps and the output. ### Configure The settings come from `runtimeConfig.database` (see [configuration](/guide/configuration)): `NUXT_DATABASE_HOST`, `NUXT_DATABASE_PORT`, `NUXT_DATABASE_USER`, `NUXT_DATABASE_PASSWORD` and `NUXT_DATABASE_NAME`. An empty password means none. ### Build the pool once `SessionPool.scala` opens one Skunk pool from the startup config: ```scala /** Skunk sessions from one application-scoped pool. Each request takes its own session. */ final class SessionPool(sessions: Resource[Task, Session[Task]]): /** A pooled session that returns to the pool when `Scope` closes (the request scope in a handler). */ def session: ZIO[Scope, Throwable, Session[Task]] = sessions.toScopedZIO object SessionPool: /** Reads the startup runtime config and creates the pool. Connections open on first use. */ val layer: ZLayer[Any, Throwable, SessionPool] = ZLayer.scoped( for config <- ZRuntimeConfig.load[AppConfig] sessions <- pooled(config.database).toScopedZIO yield SessionPool(sessions), ) private def pooled(config: DatabaseConfig): Resource[Task, Resource[Task, Session[Task]]] = given Tracer[Task] = Tracer.noop given Console[Task] = Console.make[Task] given Network[Task] = Network.forAsync[Task] val password = Some(config.password.reveal).filter(_.nonEmpty) Session.Builder[Task] .withHost(config.host) .withPort(config.port) .withCredentials(Session.Credentials(config.user, password)) .withDatabase(config.name) .pooled(max = 4) ``` `AppHandler` wraps the layer in `ZShared`, so all requests share one pool: `ZShared.layer(SessionPool.layer)`. See [ZIO handlers](/guide/zio#compose-services-in-your-apphandler). ### Take one session per request `NotesLive.layer` takes at most one session per request, in the request scope, on first use. The session returns to the pool when the request ends, and a request that fails validation never takes one: invalid input is a 422 even when the database is down. A private `NoteRow` is the table row; the service returns the domain `Note`, and the handlers send `NoteView`: ```scala /** Notes in PostgreSQL. `session` gives the request's session; the first call takes it. */ final class NotesLive(session: Task[Session[Task]]) extends Notes: import NotesLive.{insert, selectAll} def create(value: String): Task[Note] = session.flatMap(_.unique(insert)(value)).map(_.toNote) def list: Task[List[Note]] = session.flatMap(_.execute(selectAll)).map(_.map(_.toNote)) object NotesLive: /** The table row. It stays inside this interpreter: a new column does not reach the JSON. */ private final case class NoteRow(id: Int, text: String): def toNote: Note = Note(id, text) private val row = (int4 *: text).to[NoteRow] private val insert: Query[String, NoteRow] = sql"INSERT INTO h34s_notes (text) VALUES ($text) RETURNING id, text".query(row) private val selectAll: Query[skunk.Void, NoteRow] = sql"SELECT id, text FROM h34s_notes ORDER BY id".query(row) val layer: ZLayer[SessionPool & Scope, Nothing, Notes] = ZLayer.fromZIO( for pool <- ZIO.service[SessionPool] scope <- ZIO.service[Scope] session <- pool.session.provideEnvironment(ZEnvironment(scope)).memoize yield NotesLive(session), ) ``` `NotesHandlerSpec` checks the order with a pool whose database is down: invalid input takes no session; valid input takes one. Skunk's `$text` is a parameter codec; the value is bound separately. For a transaction, open Skunk's `session.transaction` resource (`Resource[Task, Transaction[Task]]`) in the same request scope, for example with `.toScopedZIO`. To map a unique violation to your own `SafeError`, match `case SqlState.UniqueViolation(_) =>` on the failure inside the service (`skunk.SqlState`, SQLSTATE 23505). These two recipes come from the Skunk API; the demo does not use them. ### Keep the Skunk Node plugin Skunk 1.0.0-M12 calls `require('crypto')` for SCRAM authentication, even in ES-module output. The demo installs Node's loader in a Nitro plugin, `examples/nuxt/server/plugins/skunk-node-crypto.ts`. With that plugin, the production bundle logged in to PostgreSQL 16 with `scram-sha-256`; PostgreSQL's log showed `connection authenticated: identity="h34s" method=scram-sha-256`. Copy that plugin when you use Skunk in your own Nuxt app. If authentication ran before the plugin was installed, restart the server. ## Set up a local database The demo's PostgreSQL setup, initialization script and smoke test are in the [development guide](https://github.com/yuri4n/h34s/blob/main/docs/development.md). To use an installed PostgreSQL without Docker, as in a Claude Code cloud session, follow [Start PostgreSQL and Redis](https://github.com/yuri4n/h34s/blob/main/docs/maintainers/cloud.md#start-postgresql-and-redis). The demo's table is created by `examples/nuxt/scala/zio/src/main/resources/sql/01-init.sql`, which PostgreSQL runs only for an empty volume. ## Test without a database Keep the service behind a trait, and test handlers with an in-memory implementation. The demo's `NotesHandlerSpec` runs the ZIO handlers with a fake request and an in-memory `Notes`, and `NotesLive` with a pool whose database is down; see [testing](/guide/testing). Test the SQL itself against a real PostgreSQL in your own integration tests. --- # Streaming and cancellation > Send a Kyo Stream or a ZStream with Response.sendStream; h34s writes the head at once, keeps the request scope open and waits for cleanup when the client goes away. Send a stream with `Response.sendStream`, and h34s writes its elements as they are produced. The request scope stays open until the stream ends, so the resources that the stream needs stay alive. When the client goes away, h34s stops the stream and waits for its cleanup. **Status.** `Response.sendStream` and `Sent` are new in `0.3.0-alpha.1`, in both runtimes. They replace the stream bodies of `0.2.0-alpha.1`, which you returned from the handler, and `ZHandler.sendStream` of `0.1.0-alpha.1`, which had another signature. See the [migration guide](/reference/migration). ## Send a stream Make `Response.sendStream(stream)` the last step of your handler, and return the `Sent` value that it gives. `Sent` is the handler's body: it tells h34s that the response is already on its way, so h34s writes nothing more. Kyo: send a `kyo.Stream[String, S]`, where `S` needs at most `Async & Abort[Throwable] & Scope`: ```scala val letters: EventHandler = KHandler.define: _ => Response.status(Status.Accepted) .andThen(Response.header("content-type", "text/plain")) .andThen(Response.sendStream(Stream.init(Seq("a", "b", "c")))) ``` ZIO: send a `ZStream[Any, E, String]`, where `E <: Throwable` (or `Nothing`): ```scala val letters: EventHandler = ZHandler.define(_ => Response.status(Status.Accepted) *> Response.header("content-type", "text/plain") *> Response.sendStream(ZStream("a", "b", "c")), ) ``` Both send `abc` with status 202. These snippets come from the stream tests ("the guide's example sends abc with status 202" in `HttpStreamSuite` and `HttpStreamSpec`). A returned stream does not compile. The compiler names the fix: ```text h34s cannot send kyo.Stream[String, Any] as a response body. Return a value with a NativeConverter, return Unit for a 204, or end the handler with Response.sendStream(stream) to stream text or Response.sendEvents(events) to stream server-sent events ``` Call `sendStream` once. A second call is a defect: the first stream stays the response, and the server logs the failure. ## Know what is written - **The head leaves at the call.** `sendStream` writes the head as your program set it when you call it. Over a Node socket, h34s also flushes it at once, so the client has the status and the headers before the first element. Without a socket, h34s writes the head first too (see [in-process calls](#in-process-calls)). - **Set the head first.** Set the status, headers and cookies before you call `sendStream`. After the call, the head is fixed: a status, header or cookie that you set later does not reach the client, and `KHandler.test` and `ZHandler.test` show the head as it was at the call. The tests "a status, header or cookie set after sendStream is not on the wire" (`HttpStreamSuite`, `HttpStreamSpec`) prove it. - **Elements are strings.** Each one is written as UTF-8 text, as is: no envelope and no separator. Node can write only strings and bytes to a response, so h34s does not accept other element types. A `null` element fails the stream. - **Default content type.** Without a content type, h34s sends `text/plain; charset=utf-8` and `X-Content-Type-Options: nosniff`. Set `content-type` yourself for another format. - **The marker.** Every streamed response carries `x-h34s-stream: 1`, so a server-side `$fetch` can tell a stream from a buffered body. `KHandler.$fetch.raw` gives `FetchResponse.Streamed` for a response with this marker or a stream content type (`text/event-stream`, `application/x-ndjson`), and `FetchResponse.Plain` for every other response, in-process and over the network alike. A missing `content-length` does not make a response streamed: no in-process response has one. - **Your encoding.** Encode values yourself, one string per element. For newline-delimited JSON: `stream.map(value => value.toJson + "\n")` (`toJson` comes with `NativeConverter`) and `Response.header("content-type", "application/x-ndjson")`. For server-sent events, use `Response.sendEvents`, which encodes the frames for you; see [server-sent events](#server-sent-events). - **Chunks.** HTTP clients can merge or split writes. Do not rely on one client-side chunk per element. ## Validate and acquire before the call Because the head leaves at the call, a failure after it cannot change the status. Do the work that can fail with a proper error before you call `sendStream`: 1. Read and validate the request body. 2. Acquire the resources that the stream needs, such as a database session or a file. 3. Set the status and the headers. 4. Call `sendStream`. A failure in steps 1 to 3 uses the normal [failure rule](/guide/kyo#fail-safely): the client gets an approved `SafeError` response or an opaque 500. ## Keep resources alive for the transfer Acquire what the stream needs in the request scope, then send the stream. h34s keeps the scope open until the stream ends, fails or is cancelled: - Kyo: `Scope.acquireRelease(open)(close).map(source => Response.sendStream(...stream over source...))`, or use a stream that manages its own resources. - ZIO: `ZIO.acquireRelease(open)(close).flatMap(source => Response.sendStream(ZStream...))` in the handler (the request `Scope` is in `HandlerEnv`), or `ZStream.unwrapScoped(...)` / `ZStream.acquireReleaseWith(...)` for resources the stream owns. A resource that you close before you call `sendStream` is gone. h34s cannot restore it. ## Cancellation The bridge is pull-driven. One producer fiber owns the stream and advances only when the HTTP side asks for the next chunk. No chunk is computed before it is requested. When the client disconnects: 1. h34s interrupts the producer and the handler. 2. It waits for the stream's finalizers, including asynchronous ones, and for the finalizers of every scope around `sendStream`. 3. It closes the request scope and its resources, once, writes nothing more, and only then settles the request. A request whose response closed before the stream started never starts the stream. Interrupted work can roll back. To make a step finish anyway, use `KHandler.uninterruptible(step)` (Kyo) or `ZIO.uninterruptible` (ZIO); see [when the client disconnects](/guide/lifetimes#when-the-client-disconnects). ## Failures
When it fails Result
Before sendStream The normal failure rule : an approved SafeError response, or an opaque 500
Writing the head, at the call For example an invalid cookie domain. Nothing reached the client: h34s clears the head, the stream never starts, sendStream fails, and the failure rule answers with an opaque 500. If part of the head had already left, h34s closes the connection instead
The stream, before or after its first element The head is already out. h34s closes the connection without a final chunk, so the client sees an incomplete transfer; the elements already written stay. sendStream fails with the stream's error, your program sees it, and the server logs it once. Nothing more is written
The stream throws, even a SafeError , or emits null A defect of the stream: the same as above. The client never sees the error's text
Writing to the response h34s closes the connection. The log names the write error, not the producer's interruption
The stream, without a socket (an in-process $fetch ) The caller's read of the body fails after the elements it already read. sendStream fails with the stream's error
Clients should treat an incomplete transfer as a failure, even when the status was successful. ## Roll back when a stream fails `sendStream` fails with the stream's error, so the code around it sees the failure. Put a transaction, or a finalizer that checks the outcome, around the step. In these snippets, `commit` and `rollback` are your own steps. With kyo-sql, `DB.transaction` around the step does this for you. Kyo: a `Scope.run` around the step gives its finalizers the outcome, `Absent` on success: ```scala Scope.run( Scope.ensure((outcome: Maybe[Result.Error[Any]]) => if outcome.isEmpty then commit else rollback) .andThen(Response.sendStream(stream)), ) ``` Write that `Scope.run` inside your handler. A finalizer that you add directly to the request scope gets `Absent` even after a failure, because h34s closes the request scope after your program, without its outcome. Such a finalizer would commit. The test "after a stream failure, a finalizer of the request scope gets Absent, and one of a Scope.run around sendStream gets the failure" (`HttpStreamSuite`) shows the difference. ZIO: `ZIO.addFinalizerExit`, inside `ZIO.scoped` or in the request `Scope`, gets the `Exit` (the request `Scope` case is the test "a stream failure after the head: a finalizer of the request Scope sees the failure" in `HttpStreamSpec`): ```scala ZIO.scoped( ZIO.addFinalizerExit(exit => if exit.isSuccess then commit else rollback) *> Response.sendStream(stream), ) ``` The stream's own cause reaches your ZIO program: a `ZStream[Any, MyError, String]` fails `sendStream` with `MyError`. The tests "a stream failure after two elements fails the sendStream step; a Scope finalizer around it sees the failure" (`HttpStreamSuite`) and "a stream failure after two elements fails the sendStream effect; a finalizer of ZIO.scoped around it sees the failure" (`HttpStreamSpec`) prove both. A client disconnect still waits for the finalizers of such a scope: see the test "a disconnect waits for the asynchronous finalizers of a Scope.run around sendStream before the request settles" (`HttpStreamSuite`). ## Server-sent events Send server-sent events with `Response.sendEvents(events)`, where `events` is a stream of `ServerEvent` values. It is `Response.sendStream` for the `text/event-stream` format: h34s sets the event-stream headers and writes one frame per event. Everything above holds for it. The head leaves at the call, the request scope stays open until the stream ends, a disconnect waits for the stream's cleanup, and a failure after the head ends the response and fails the step. **Status.** `Response.sendEvents`, `ServerEvent` and `$fetch.events` are new in `0.3.0-alpha.1`, in both runtimes. ### Build the events A `ServerEvent` has four fields:
Field Meaning
data: String The event's text. Each line becomes one data: line, and the client joins them with LF, so a CR or CRLF in data arrives as LF
event: Option[String] The event type. None is the default type, which a browser's EventSource calls message . A named type reaches only addEventListener(type)
id: Option[String] The event's ID. A browser keeps it as lastEventId and sends it back in the Last-Event-ID header when it reconnects. Some("") clears it
retry: Option[Int] The time in milliseconds that a browser waits before it reconnects
`ServerEvent.json(value, event, id)` writes `value` as one line of JSON text with its `NativeConverter`. Read it back with `$fetch.events[A]` in Scala, or with `JSON.parse(message.data)` in the browser. A line break inside `event` or `id`, U+0000 inside `id`, an empty `event` or a negative `retry` would change the meaning of the frame. The `ServerEvent` constructor throws an `IllegalArgumentException` for them, so h34s never writes such a frame. Inside a stream, the throw fails the stream like any other defect. ### Send them In these snippets, `Countdown` is the example's event model. Each route sends 3, 2, 1 and 0, one event per second. Kyo: ```scala /** One event of the countdown: the seconds that remain. */ final case class Countdown(remaining: Int) derives Schema, NativeSchema /** `GET /api/kyo/countdown`: the events 3, 2, 1 and 0, one per second, as `tick` events. A chunk of * one element sends each event when it is ready, not the whole chunk at its end. */ @JSExportTopLevel("default", "kyo-countdown") val countdown: EventHandler = KHandler .define: _ => val ticks = Stream.init(3 to 0 by -1, chunkSize = 1).map: remaining => Async.sleep(1.second).andThen(ServerEvent.json(Countdown(remaining), event = Some("tick"))) Response.sendEvents(ticks) .at(Route.get("/api/kyo/countdown")) ``` ZIO: ```scala /** One event of the countdown: the seconds that remain. */ final case class Countdown(remaining: Int) derives Schema, NativeSchema /** `GET /api/zio/countdown`: the events 3, 2, 1 and 0, one per second, as `tick` events. */ @JSExportTopLevel("default", "zio-countdown") val countdown: EventHandler = ZHandler .define: _ => val ticks = ZStream(3, 2, 1, 0).mapZIO: remaining => ZIO.sleep(1.second).as(ServerEvent.json(Countdown(remaining), event = Some("tick"))) Response.sendEvents(ticks) .at(Route.get("/api/zio/countdown")) ``` These are the countdown routes of the example application (`examples/nuxt`), and docs/test checks that the snippets equal them. In Kyo, `Stream.map` with an effect emits a chunk only when every element of the chunk is mapped. A one-element chunk sends each event when it is ready; with the default chunk size, all four events would leave together after four seconds. `sendEvents` sets the headers of h3's `createEventStream`, as `Response.header` would: - `content-type: text/event-stream` - `cache-control: private, no-cache, no-store, no-transform, must-revalidate, max-age=0` - `x-accel-buffering: no`, so that nginx does not buffer the stream - `connection: keep-alive`, except over HTTP/2, where the header is not allowed They replace a value of the same name that you or your handler defaults set, such as `ResponseHead.noStore`. Your other headers, your cookies and your status stay. Keep the status 200: a browser's `EventSource` fails the connection on any other status. Each frame has an `id:`, `event:` and `retry:` line when the field is set, then the `data:` lines, then an empty line: `ServerEvent.json(Countdown(3), event = Some("tick"))` is `event: tick\ndata: {"remaining":3}\n\n`. h34s sends no heartbeat comments yet, so a proxy with an idle timeout can close a stream that stays quiet for a long time. h34s does not wrap h3's `createEventStream`: in-process, it leaks two unhandled rejections per cancel, and it notices a disconnect only through an event that never fires there. ### Read the events in the browser The example reads both routes with the browser's `EventSource`, in its `useCountdown` composable: ```ts /** One `tick` event: the JSON that `ServerEvent.json(Countdown(n))` writes. */ interface Countdown { remaining: number } type CountdownState = 'idle' | 'running' | 'done' | 'failed' /** The countdown of `GET /api//countdown`, read with `EventSource`. */ export function useCountdown(runtime: 'kyo' | 'zio') { const remaining = ref(null) const state = ref('idle') let source: EventSource | undefined function stop() { source?.close() source = undefined } function start() { stop() remaining.value = null state.value = 'running' source = new EventSource(`/api/${runtime}/countdown`) source.addEventListener('tick', (message) => { const tick: Countdown = JSON.parse(message.data) remaining.value = tick.remaining if (tick.remaining === 0) { state.value = 'done' stop() } }) // EventSource reconnects after an error. Stop instead: the run failed. source.addEventListener('error', () => { state.value = 'failed' stop() }) } onScopeDispose(stop) return { remaining, state, start } } ``` The example's countdown page (`/countdown`) shows one card per runtime. `EventSource` reconnects after an error and after the server ends the stream, so close it when you have what you need, as the composable does at 0. The generated TypeScript type of an event route is `string`, the type of a sent stream. The generator does not describe the events' data yet, so the composable declares `Countdown` itself. ### Read the events in a handler Read another route's events with `$fetch.events[E]`. Each event's `data` is decoded as JSON for `E` with its `NativeConverter`. In Kyo, the result is a `Stream[E, Async & Scope & Abort[FetchError]]`: ```scala val total: EventHandler = KHandler.define: _ => for ticks <- KHandler.$fetch.events[Countdown]("/api/kyo/countdown") seen <- ticks.run yield seen.map(_.remaining).sum ``` In ZIO, it is a `ZStream[Any, FetchError, E]`: ```scala val total: EventHandler = ZHandler.define: _ => for ticks <- ZHandler.$fetch.events[Countdown]("/api/zio/countdown") sum <- ticks.runFold(0)(_ + _.remaining) yield sum ``` Both answer `{"data": 6}`. These snippets come from the tests "the guide's events example: a handler reads the countdown's typed events with $fetch.events" (`NitroLocalFetchSuite`, `NitroLocalFetchSpec`). - **Head first.** `events` sends the request with `accept: text/event-stream`, unless you pass an `accept` header, and returns as soon as the head arrives, as `raw` does. The body lives in your `Scope`: when the scope closes, h34s cancels a body that you did not read to its end. - **Typed failures.** A status that is not 2xx fails with `FetchError.Status`. A 2xx response with another content type fails with `FetchError.NotEvents(status, contentType)`, and h34s does not read its body. The first event whose `data` is not JSON for `E` fails the stream with `FetchError.Decode`, which names the event by its position and type, never by its data; the events before it reach you. - **Events without a model.** `$fetch.events(FetchRequest(url))` gives the `ServerEvent` values themselves. Use it for a stream that mixes types, or for a JavaScript route that uses h3's `createEventStream`: such a route reaches you through its `text/event-stream` content type. In-process, stopping before the end of such a route's body makes h3 leak the unhandled rejections described above, so read a `createEventStream` route to its end. - **One frame, one event.** Each `ServerEvent` has the fields of its own frame. Its `id` is the frame's own `id:` line; a browser's `lastEventId` carries over to later events, but `ServerEvent.id` does not. - **The parsing rules.** h34s decodes the body as UTF-8 once, which removes one leading byte order mark, as a browser does. Then it reads the text with the WHATWG `EventSource` rules: lines end with CRLF, CR or LF, comment lines (`:`) are ignored, and one space after the colon is removed. A second byte order mark is part of the first line, which then names an unknown field. An event without a `data:` line, and an event that the stream does not end with an empty line, are not dispatched. A frame may be split anywhere between chunks. - **NDJSON.** There is no typed decoder for newline-delimited JSON yet. Read the body of `raw` and split it into lines yourself. `NitroLocalFetchSuite` and `NitroLocalFetchSpec` test this: "sendEvents in-process: $fetch.events E returns head-first and decodes the typed events as they arrive", "a JavaScript route with h3's createEventStream: the untyped $fetch.events reads its events by the content type", "events of a route that is not an event stream fails with FetchError.NotEvents; an error status with FetchError.Status", "a typed event whose data is not JSON for E fails the stream with FetchError.Decode, which names the event" and "over real HTTP (absolute URL), events split inside lines and inside a CRLF decode to the same typed events" and "events: the body is decoded as UTF-8 once, so of two leading byte order marks only the first is removed". `ServerEventSuite` checks the frame and the parsing rules, every split point of a text with several events, and that every event's frame decodes to the same event. ## In-process calls Nitro answers a server-side `$fetch` (`KHandler.$fetch` or `ZHandler.$fetch`) to your own route in-process, without a socket. A stream works there too: - **Head first.** h34s writes the head, hands the stream to h3, and settles h3's handler promise at once, so the caller gets the head before the first element. With the h34s test copy of Nitro 2.13.1's local fetch, a caller had the head after 16 to 23 ms and the elements one per 200 ms after that. - **Bytes.** Chunks are UTF-8 bytes, because a Web `Response` body accepts only `Uint8Array` chunks. - **Lifetime.** Your program and the request scope stay open until the caller reads the body to its end or cancels it. - **A cancel waits for cleanup.** A `$fetch` caller cancels an unread body when its `Scope` closes: the request scope, a Kyo `Scope.run` or a ZIO `ZIO.scoped` block. The cancel waits until the callee's stream and request finalizers have run, so the caller settles after the callee's cleanup. - **A failure after the head is typed.** When the callee's stream fails after its head, the caller's read fails with `FetchError.BodyFailed(status, reason)`: in Kyo, through `Abort[FetchError]` in the body's effect type; in ZIO, as the error of the body's `ZStream`. The `reason` is h34s' own text and never quotes the callee's error. - **An unread body stays open.** A JavaScript caller, or a socketless Nitro preset, that drops the body without cancelling it keeps the callee's program and request scope open. h34s has no idle timeout for this case yet. - **The base URL and the context.** h34s puts your app's `app.baseURL` in front of a path that starts with `/`, where Nitro mounts your routes, as Nuxt's `$fetch` does; a path that already starts with the base stays as it is. The call passes only the `_platform` and `waitUntil` entries of the caller's `event.context`, so the called route cannot read what your middleware put there, such as a session. - **Credentials stay in-process.** A `$fetch` call to a path that starts with `/` gets the incoming request's headers and cookies, as in Nuxt. A call to an absolute URL gets only the headers that you pass and the content type and length of the body that you send, so the user's cookies and `authorization` never reach another host. Nuxt's `event.$fetch` forwards them to any host; h34s does not. To call another service with a credential, pass that header yourself. - **A JavaScript stream needs a stream content type.** A JavaScript route that streams without the marker and without a stream content type is `Plain`, so `raw` reads it to its end, and never returns for an endless stream. Set `text/event-stream` or `application/x-ndjson` on such a route. `NitroLocalFetchSuite` measures this behavior: "(b) streamed Scala route in-process: $fetch returns head-first; the body is pulled in the caller's Scope", "(c) a caller that drops a Streamed body: its Scope cancels the callee, whose finalizers run before the caller settles" and "(c) a caller that reads part of a Streamed body: the rest is cancelled when its Scope closes". The same suite shows the head arriving before the first element over real HTTP: "(b) the same streamed route over real HTTP: the head arrives before the first element". Two more tests prove the `Plain` rule and the credential rule: "(a) JSON route in-process: no marker and no stream content type, so Plain without content-length; as A decodes it" and "an absolute URL gets only the headers the caller passes: the incoming cookie, authorization and entity headers stay behind". The base URL, the context and the typed failure have their own tests: "app.baseURL: an in-process $fetch reaches a route under a non-root base; a path that carries the base is not prefixed again", "an in-process call passes only the caller's _platform and waitUntil: the callee cannot read the caller's other context entries" and "a callee stream that fails after its head: the caller's read fails with a typed FetchError.BodyFailed, not a panic". `NitroLocalFetchSpec` repeats them for ZIO callers, with "a caller that drops a Streamed body: its Scope cancels the callee and waits for the callee's finalizers (Q16)". ## Test a streaming handler `KHandler.test` and `ZHandler.test` collect what your handler sends. The result's body is `TestResponse.Body.Streamed(elements)`; see [testing](/guide/testing#test-a-streaming-handler). ## Limits
Area Behavior
Direct Node HTTP responses Supported. Tested with real h3 and mocked Node responses, and with a real node:http server in NitroLocalFetchSuite
No socket (Nitro's in-process local fetch, for example a server-side $fetch to your own route) Supported. Tested with a copy of Nitro 2.13.1's local-fetch wiring ( NitroLocal ); not yet tested against nuxt dev or a built Nitro server. An unread body that nobody cancels keeps the callee open
Back pressure One chunk at a time from your stream. h3's sink does not wait for Node's drain , so a slow client can still grow Node's output buffers. There is no end-to-end memory bound
Other Nitro presets (serverless, edge) Not tested. Many of them serve every request without a socket, so no disconnect reaches h34s unless the platform cancels the body
Server-sent events No heartbeat comments yet: a proxy with an idle timeout can close a quiet stream. No typed decoder for NDJSON in $fetch . In-process, stopping before the end of a JavaScript createEventStream route makes h3 leak unhandled rejections
## What is tested Kyo `StreamSuite`, `HttpStreamSuite` and `NitroLocalFetchSuite`, ZIO `StreamSpec`, `HttpStreamSpec` and `NitroLocalFetchSpec`: pull-driven delivery, the head written and flushed at the call before the first element, empty streams, order of effectful, concatenated, filtered and mapped streams, finalization exactly once after the end, cancellation waiting for asynchronous finalizers, disconnects releasing stream and request resources once (also with a `Scope.run` around `sendStream`), failures before and after the first element (the step fails, the connection closes, one log line), thrown errors and `null` elements, a failed write, the default content type, the socketless path (head first, UTF-8 bytes, a cancel that waits for the request, a failure that errors the caller's reads and reaches a `$fetch` caller as `FetchError.BodyFailed`), and the compile error for a returned stream. For server-sent events, `ServerEventSuite` adds the frame encoder and the parsing rules, and the suites above add `sendEvents` (headers, frames, HTTP/2, a disconnect) and `$fetch.events` (head first, a JavaScript `createEventStream` route, the typed failures, frames split over real HTTP). A real client abort against a running Nuxt server has not been tested on this branch. --- # Testing > Test handler functions with Request.fake and KHandler.test or ZHandler.test, without h3, Nitro or a server. A handler is a plain function from `Request` to a program. Test it like any other function: build a fake request, run it with `KHandler.test` or `ZHandler.test`, and check the head and the body. You need no h3, no Nitro and no server. **Status.** `Request.fake` is new in `0.2.0-alpha.1`. `KHandler.test`, `ZHandler.test`, `TestResponse` and `Fetch.fake` are new in `0.3.0-alpha.1`; they replace the manual provisioning with `Var.runTuple` and `ZIO.stateful` that this guide showed for `0.2.0-alpha.1`. ## Keep handlers as values Define the handler function as a value, and pass it to `AppHandler.define` (or `KHandler.define`, `ZHandler.define`) separately. The demo's ZIO handlers do this in `NotesHandler.scala`: ```scala object NotesHandler: val create: Request => ZIO[HandlerEnv & Notes, Throwable, Note] = request => for input <- request.body.json[CreateNote] note <- ZIO.serviceWithZIO[Notes](_.create(input.text)) _ <- Response.status(Status.Created) yield note @JSExportTopLevel("default", "notes-post") val createNote: EventHandler = AppHandler.define(NotesHandler.create) ``` Put services behind traits, so a test can provide an in-memory implementation. ## Build a fake request `Request.fake` takes named parameters, all optional: ```scala Request.fake( method = "POST", path = "/api/notes", headers = Map("content-type" -> "application/json"), query = Map("page" -> List("2")), routerParams = Map("id" -> "7"), cookies = Map("session" -> "abc"), body = Some("""{"text":"hello"}"""), ) ``` `development`, `debug`, `bodyLimit`, `requireJsonContentType` and `runtimeConfig` are also available. Body reads follow the same rules as a real request: the content type, the limit, invalid JSON and schema validation behave the same. Like `HandlerOptions`, `Request.fake` requires a JSON content type by default, so give a fake request with a body that you read with `json[A]` a `content-type` header. Pass `requireJsonContentType = false` to test a handler that turns the option off. A handler that reads configuration per request with `RuntimeConfig.get(request)` (Kyo) or `ZRuntimeConfig.get(request)` (ZIO) needs the config in the fake request, as JSON text: ```scala val request = Request.fake(runtimeConfig = Some("""{"greeting":{"salutation":"Hi"}}""")) ``` Without `runtimeConfig`, those reads fail with `ConfigError("h34s Nitro plugin is not installed")`, as they do without the h34s module. Often it is simpler to read configuration in your `AppHandler` and pass values to handlers as services. ## Kyo: `KHandler.test` Call `KHandler.test(handler)(request)`. It provides what `KHandler.define` provides for a request: a request scope, an empty response head, a transport for `Response.sendStream` and a server-side `$fetch`. It gives a `TestResponse` with the head that your program set and the body. This example is the "guide's example" test of `HandlerTestSuite`: ```scala val handler: Request => Greeting < HandlerEffects = request => for input <- request.body.json[Input] _ <- Response.status(Status.Created) yield Greeting(s"Hello, ${input.name}") val request = Request.fake(method = "POST", headers = Map("content-type" -> "application/json"), body = Some("""{"name":"Scala"}""")) val program = KHandler.test(handler)(request) // program: TestResponse[Greeting] < (Async & Abort[Throwable]) import AllowUnsafe.embrace.danger Fiber.Unsafe.init(Abort.run[Throwable](program)).toFuture().map { result => val response = result.getOrThrow assertEquals(response.status, Some(Status.Created)) assertEquals(response.body, TestResponse.Body.Value(Greeting("Hello, Scala"))) } ``` The last part runs the program with munit, as the h34s suites do. Use your own Kyo test setup if you have one. Provide services inside the function, with `Env.run`, as you do for `KHandler.define`. `fake` is your in-memory `Greetings`: ```scala val handler: Request => Greeting < (HandlerEffects & Env[Greetings]) = request => Env.use[Greetings](greetings => Greeting(greetings.greet(request.query.getOrElse("name", "you")))) val program = KHandler.test(request => Env.run(fake)(handler(request)))(Request.fake(query = Map("name" -> List("Ada")))) ``` A failure of your program stays a failure of `program`. Run it with `Abort.run` to check an error, for example `Abort.run[HttpError](KHandler.test(handler)(request))`. ## ZIO: `ZHandler.test` Call `ZHandler.test(handler)(request)`. It provides the `ZState[ResponseHead]`, a request `Scope`, the transport for `Response.sendStream` and a server-side `$fetch`, and gives a `TestResponse`. Your program's typed error stays in the error channel. This example is the "guide's example" test of `HandlerTestSpec`: ```scala val handler: Request => ZIO[HandlerEnv, Throwable, Greeting] = request => for input <- request.body.json[Input] _ <- Response.status(Status.Created) yield Greeting(s"Hello, ${input.name}") val request = Request.fake(method = "POST", headers = Map("content-type" -> "application/json"), body = Some("""{"name":"Scala"}""")) for response <- ZHandler.test(handler)(request) yield assertTrue( response.status.contains(Status.Created), response.body == TestResponse.Body.Value(Greeting("Hello, Scala")), ) ``` Provide services inside the function, as you do for `ZHandler.define`. A rejected request fails with the same error that a client would see as its status: ```scala val handler: Request => ZIO[HandlerEnv & Greetings, HttpError, Greeting] = request => for name <- ZIO.fromOption(request.query.get("name")).orElseFail(HttpError(400, "name is required")) greeting <- ZIO.serviceWithZIO[Greetings](_.greet(name)) yield Greeting(greeting) val fake = new Greetings: def greet(name: String): UIO[String] = ZIO.succeed(s"Hi, $name") def run(request: Request) = ZHandler.test(request => handler(request).provideSomeEnvironment[HandlerEnv](_.add[Greetings](fake)))(request) for greeted <- run(Request.fake(query = Map("name" -> List("Ada")))) rejected <- run(Request.fake()).either yield assertTrue( greeted.body == TestResponse.Body.Value(Greeting("Hi, Ada")), rejected.left.toOption.map(_.status).contains(400), ) ``` The demo's `NotesHandlerSpec` tests its real handlers this way, with an in-memory `Notes`. ## Answer `$fetch` calls `KHandler.test` and `ZHandler.test` use `Fetch.unavailable` by default: a handler that calls `$fetch` fails (Kyo) or dies (ZIO), and the message names `Fetch.fake`. Pass `Fetch.fake` as the second argument to answer calls from a map of URL to response. It records every request, and a URL without an entry answers 404. Kyo, the `Fetch.fake` test of `HandlerTestSuite`: ```scala val fetch = Fetch.fake(Map( "/api/greeting" -> FetchResponse.Plain(200, Map("content-type" -> "application/json"), """{"data":{"message":"hi"}}"""), )) val handler: Request => String < HandlerEffects = _ => for greeting <- KHandler.$fetch.get[GreetingData]("/api/greeting") missing <- KHandler.$fetch.raw("/api/missing") yield s"${greeting.data.message} ${missing.status}" val program = KHandler.test(handler)(Request.fake(), fetch) ``` ZIO, the `Fetch.fake` test of `HandlerTestSpec`. `Fetch`, `FetchResponse` and `FetchError` come from `dev.yuri4n.exts.zioh3`: ```scala val fetch = Fetch.fake(Map( "/api/greeting" -> FetchResponse.Plain(200, Map("content-type" -> "application/json"), """{"data":{"message":"hi"}}"""), )) val handler: Request => ZIO[HandlerEnv, FetchError, String] = _ => for greeting <- ZHandler.$fetch.get[GreetingData]("/api/greeting") missing <- ZHandler.$fetch.raw("/api/missing") yield s"${greeting.data.message} ${missing.status}" for response <- ZHandler.test(handler)(Request.fake(), fetch) yield assertTrue( response.body == TestResponse.Body.Value("hi 404"), fetch.requests.map(_.url) == List("/api/greeting", "/api/missing"), ) ``` In both, the body is `TestResponse.Body.Value("hi 404")`, and `fetch.requests.map(_.url)` is `List("/api/greeting", "/api/missing")`. A fake streamed response takes the stream of its runtime: `FetchResponse.Streamed(200, headers, Stream.init(Seq("a", "b")))` in Kyo, `FetchResponse.Streamed(200, headers, ZStream("a", "b"))` in ZIO. `Fetch.fake` gives each response as you wrote it. It forwards no headers, adds no base URL, and does not apply the `Plain` or `Streamed` rule of the real `$fetch`: the response in your map is what the handler gets. A fake `Streamed` body is your stream as you wrote it, too. The cancel of a body that the handler does not read to its end, and the wait for the called route's cleanup, belong to the real `$fetch`; `NitroLocalFetchSuite` (Kyo) and `NitroLocalFetchSpec` (ZIO) test them. See [in-process calls](/guide/streaming#in-process-calls) for the rules of the real `$fetch`. ## Test a streaming handler The test helpers collect what your handler sends with `Response.sendStream`. The body is then `TestResponse.Body.Streamed(elements)`, the strings in order. The stream's resources are released when it ends, before the request scope closes, as in a real request. Kyo: ```scala val handler: Request => Sent < HandlerEffects = _ => Response.header("content-type", "application/x-ndjson").andThen(Response.sendStream(Stream.init(Seq("1\n", "2\n")))) val program = KHandler.test(handler)(Request.fake()) // the body of the result: TestResponse.Body.Streamed(List("1\n", "2\n")) ``` ZIO: ```scala val handler: Request => ZIO[HandlerEnv, Throwable, Sent] = _ => Response.header("content-type", "application/x-ndjson") *> Response.sendStream(ZStream("1\n", "2\n")) for response <- ZHandler.test(handler)(Request.fake()) yield assertTrue(response.body == TestResponse.Body.Streamed(List("1\n", "2\n"))) ``` A stream that fails makes the test program fail with the stream's error, as `sendStream` does in a real request. ### Test a handler that sends events `Response.sendEvents` is `sendStream` over frames, so the body is `TestResponse.Body.Streamed` with one frame per event, and the head has the event-stream headers that `sendEvents` sets. In these snippets, `Greeting` is a model with a `message` field. Kyo: ```scala val handler: Request => Sent < HandlerEffects = _ => Response.sendEvents(Stream.init(Seq(ServerEvent.json(Greeting("hi"), event = Some("greeting")), ServerEvent("bye")))) val program = KHandler.test(handler)(Request.fake()) // the body of the result: TestResponse.Body.Streamed(List("event: greeting\ndata: {\"message\":\"hi\"}\n\n", "data: bye\n\n")) ``` ZIO: ```scala val handler: Request => ZIO[HandlerEnv, Throwable, Sent] = _ => Response.sendEvents(ZStream(ServerEvent.json(Greeting("hi"), event = Some("greeting")), ServerEvent("bye"))) for response <- ZHandler.test(handler)(Request.fake()) yield assertTrue( response.body == TestResponse.Body.Streamed(List("event: greeting\ndata: {\"message\":\"hi\"}\n\n", "data: bye\n\n")), response.header("content-type").contains("text/event-stream"), response.header("cache-control").contains("private, no-cache, no-store, no-transform, must-revalidate, max-age=0"), response.header("x-accel-buffering").contains("no"), response.header("connection").contains("keep-alive"), ) ``` To test the caller side, give `Fetch.fake` a response with the content type `text/event-stream`, `Plain` or `Streamed`: `$fetch.events` decodes its text. The test "$fetch.events decodes a fake event stream, Plain or Streamed, by its content type and sends accept: text/event-stream" (`HandlerTestSuite`, `HandlerTestSpec`) does this. ## Read a `TestResponse`
Member Value
head The ResponseHead that your program set; after sendStream , the one at the call
status head.status ; None when your program set none
header(name) The header's values after your sets and appends, joined with ", " ; names ignore case
headerValues(name) The same values as a list
cookies The cookie entries that your program set
body TestResponse.Body.Value(value) , or TestResponse.Body.Streamed(elements)
`head` is what your program set. For a handler that sends a stream, it is the head at the `sendStream` call, as on the wire: a change after the call does not reach the client, so it is not in `head` either. Handler defaults (`HandlerOptions.defaults`), the default content type and the envelope are added by h34s when it writes a real response, so they are not in it. Check them end to end, as described below. ## Test the wire contract Your clients depend on the JSON shapes, the statuses and the error entries. Pin them in tests: - **Model shapes.** Convert a value and compare the JSON text with what your clients expect: `note.toJson` (from `NativeConverter`). For a converter that describes its TypeScript type, also check `converter.wireType.describes(js.JSON.parse(note.toJson))`; see [routes and typed `$fetch`](/guide/routes#where-the-types-come-from). A schema type gives the shapes listed in [schemas](/guide/schema#kyo-wire-shapes). - **Error entries.** Assert the exact `InvalidInput` entries, as above. They are your 422 contract. - **Statuses and headers.** Assert `status`, `header` and `cookies` of the `TestResponse`. Remember that on failure the core keeps only `OnFailure.Keep` entries. - **The envelope.** `{"data": ...}`, `{"errors": [...]}` and 204 come from the core and are covered by h34s's own tests. Check them end to end with an HTTP request against a running server, for example in a smoke script. ## Test configuration decoding Decode JSON text with your config's converter, without Nitro and without `scala.scalajs.js`. The demo's `AppConfigSpec`: ```scala val runtimeConfig = "{\"database\":{\"host\":\"127.0.0.1\",\"port\":5432,\"user\":\"h34s\",\"password\":\"hunter2\",\"name\":\"h34s\"}}" val config = summon[NativeConverter[AppConfig]].fromJson(runtimeConfig) assertTrue(config.database.password.reveal == "hunter2", !config.toString.contains("hunter2")) ``` ## Test shared resources Without the Nitro plugin, `Shared` and `ZShared` register nothing. Call `close` at the end of the test to release the resource. ## What h34s tests for you The h34s suites pin the behavior your handlers rely on. For `0.2.0-alpha.1`, on macOS x86_64 with Node 24.19.0: `h3Ext` 36 tests, `kyoH3Ext` 79, `zioH3Ext` 81, `nativeConverterKyoSchemaExt` 25, `nativeConverterZIOSchemaExt` 20, `kyoNuxtHubExt` 15, `zioNuxtHubExt` 14, the demo's `nuxtsvr` 6, and the Node tests of the npm package. After the `sendStream` change, on a Linux x86_64 cloud VM with Node 24.22.0: `h3Ext` 49, `kyoH3Ext` 122, `zioH3Ext` 104, `nativeConverterKyoSchemaExt` 29, `nativeConverterZIOSchemaExt` 22, `kyoNuxtHubExt` 15, `zioNuxtHubExt` 14 and `nuxtsvr` 7. After the server-side `$fetch` of both runtimes, on the same kind of VM: `h3Ext` 55, `kyoH3Ext` 131, `zioH3Ext` 119, `nativeConverterKyoSchemaExt` 30, `nativeConverterZIOSchemaExt` 24, `kyoNuxtHubExt` 15, `zioNuxtHubExt` 14 and `nuxtsvr` 7. They cover request snapshots and body limits, the wire format and envelopes, the failure rule, safe and opaque errors, diagnostics, request finalizers, client disconnects and uninterruptible steps, streaming, the server-side `$fetch`, the test helpers, `Shared`/`ZShared`, runtime config, storage and the schema bridges, through real h3 1.15.4 events with mocked Node responses, and (for streams and `$fetch`) a copy of Nitro 2.13.1's in-process local fetch. See [compatibility](/reference/compatibility) for the evidence. --- # Nitro storage and NuxtHub KV > Open a Nitro storage mount from Scala with KHubKv.mount or ZHubKv.mount, with no native objects. Use key-value storage from Scala without JavaScript. `KHubKv.mount(base)` (Kyo) and `ZHubKv.mount(base)` (ZIO) open a Nitro storage mount: the same storage that Nitro's `useStorage(base)` returns. You do not import `@nuxthub/kv` or pass a native object. **Status.** `mount` is new in `0.2.0-alpha.1`. It replaces the `KHubKv(native)` and `ZHubKv(native)` constructors of `0.1.0-alpha.1`; see the [migration guide](/reference/migration).
Module API Depends on
kyo-nuxthub-ext KHubKv (package dev.yuri4n.exts.kyonuxthub ) the Kyo core
zio-nuxthub-ext ZHubKv (package dev.yuri4n.exts.zionuxthub ) kyo-nuxthub-ext and the ZIO adapter
Neither module needs a schema. The h34s Nitro plugin, which the `@yuri4n/h34s-nuxt` module registers, supplies the storage. ## Choose a mount A mount is a named part of Nitro storage with its own driver. Configure mounts in `nuxt.config.ts` with Nitro's `storage` option, or let a module such as NuxtHub add them. For example, a Redis-backed `cache` mount: ```ts export default defineNuxtConfig({ modules: ['@yuri4n/h34s-nuxt'], nitro: { storage: { cache: { driver: 'redis', url: process.env.REDIS_URL }, }, }, }) ``` Keep connection URLs and passwords out of committed files; read them from the environment as shown. `mount("")` opens the root storage, which sees every mount. NuxtHub: whether NuxtHub 0.10.8 exposes its KV as a Nitro storage mount has not been verified. The bindings were tested with Unstorage 1.17.3's memory driver, the driver NuxtHub 0.10.8 generates for local KV. Check the mount name in your NuxtHub setup before you rely on it. ## Open and use a mount Models need only a `NativeConverter` (or a [schema](/guide/schema)): ```scala import org.getshaka.nativeconverter.NativeConverter final case class Profile(name: String, visits: Int) derives NativeConverter ``` Kyo: ```scala import dev.yuri4n.exts.kyonuxthub.KHubKv import kyo.* val visit: Option[Profile] < (Async & Abort[Throwable]) = for cache <- KHubKv.mount("cache") _ <- cache.set("profiles:42", Profile("Yuri", 1), ttlSeconds = Some(300.0)) profile <- cache.get[Profile]("profiles:42") yield profile ``` ZIO: ```scala import dev.yuri4n.exts.zionuxthub.ZHubKv import zio.* val visit: Task[Option[Profile]] = for cache <- ZHubKv.mount("cache") _ <- cache.set("profiles:42", Profile("Yuri", 1), ttlSeconds = Some(300.0)) profile <- cache.get[Profile]("profiles:42") yield profile ``` Both APIs offer `get[A](key)`, `set[A](key, value, ttlSeconds = None)`, `has(key)`, `delete(key)` and `keys(base = None)`. Kyo operations are `A < (Async & Abort[Throwable])`; ZIO operations are `Task[A]`. Each run calls the storage again. `mount` fails with `ConfigError` when the h34s plugin is not installed (`h34s Nitro plugin is not installed`) or when the mount is not available. An unhandled `ConfigError` is an opaque 500. ## Know the storage contract - **Missing values.** A missing key and a stored `null` are both `None`. Use `has` when the difference matters. - **Conversion.** Values are converted directly to native values; the driver serializes them for its backend. A stored value that the converter rejects fails `get` with `StoredValueError`, a typed failure that names only the failing path (`root.count`, or a schema path), never the key or the stored value: keys are often session ids, and values can hold tokens. It is server data, so an unhandled one is an opaque 500, never a 422, even when a schema converter rejected it. It is not treated as a missing key. - **Top-level strings.** Unstorage's default serialization can turn a stored top-level string such as `"42"`, `"true"` or `"null"` into another JavaScript type (a `Secret("12345")` comes back as a number and fails to decode). Store objects (a case class) for typed records. - **TTL.** `ttlSeconds` is forwarded as `{ ttl: seconds }`. Expiry depends on the driver. The memory driver and NuxtHub's fs-lite driver ignore it, so a successful `set` does not prove that a key expires. - **Keys.** Keys follow Unstorage normalization: `/` becomes `:`. `keys(Some("profiles"))` lists keys under `profiles:`. The order depends on the driver. - **Cancellation.** Storage calls cannot be cancelled. Interrupting a waiting fiber does not cancel or roll back a write that already started. ## Invalidate cached data h34s adds no cache abstraction. Common patterns with these operations: - **Delete on write.** After you change the source data, `delete` the cached key in the same handler. - **Generation keys.** Store a generation number under one key and include it in every cached key (`catalogue:v7:page:1`). Increment it to invalidate everything at once; old keys expire through the driver's TTL. - **Short TTLs.** For data that may be slightly stale, set `ttlSeconds` and accept the delay. Check that your driver supports TTL first. ## What is tested `KHubKvSuite` (15 tests) and `ZHubKvSpec` (14 tests) on macOS x86_64 with Node 24.19.0: `mount` through a fake h34s startup record, `ConfigError` without the plugin or the mount, round trips with Unstorage 1.17.3's memory driver, `false` and `0` as stored values, `null` and `undefined` as `None`, lazy operations, exact TTL forwarding, JavaScript errors and rejected Promises in the failure channel, value-free `StoredValueError`s for shape and schema mismatches (an opaque 500 in a handler), a `SafeError` from an encoder that stays unapproved, upstream string deserialization, and interruption during a write. No test starts Nuxt, NuxtHub or a cloud service. --- # Debug Scala handlers > See error causes in development or debug mode, keep production opaque, and map Nitro's JavaScript back to Scala. h34s keeps production errors opaque and shows full diagnostics while you develop. You do not add a debug flag to your handlers: the h34s Nitro plugin decides, and requests can never change its decision. Install the [npm Nuxt module](/guide/npm-tooling) first. It registers the plugin, the development loader and the source-map integration. ## What clients see An unexpected failure is any `Throwable` that is not an approved `SafeError`: a thrown exception, a panic, a converter defect, a non-safe ZIO failure, or a ZIO defect. With the default options, the client gets: ```json { "errors": [{ "message": "Internal server error" }] } ``` with status 500. When diagnostics are on, the same entry carries the cause: ```json { "errors": [{ "message": "Internal server error", "debug": { "cause": "java.lang.IllegalStateException: ...\n\tat ..." } }] } ``` - Kyo handlers: `cause` is the exception's class, message, cause chain and stack text. - ZIO handlers: `cause` is ZIO's full `Cause.prettyPrint`, with ZIO traces and Scala source lines. `SafeError` entries never carry `debug`. Their status and messages are the same in every mode. With `ErrorEnvelope.SingleOrList`, the one entry is under `"error"` instead of `"errors"`. The server logs every unexpected failure as `Unhandled h34s handler failure` with the same details, in every mode. ## When diagnostics are on Diagnostics are on when the request's development flag **or** debug flag is the boolean `true`:
Flag Set by When
development The plugin, from Nitro's import.meta.dev nuxt dev . A production build compiles it to false
debug The plugin, from runtimeConfig.h34s.debug , read once at startup NUXT_H34S_DEBUG=true at runtime, or runtimeConfig: { h34s: { debug: true } } in nuxt.config.ts
The Nuxt module declares `runtimeConfig.h34s.debug: false`, which is why `NUXT_H34S_DEBUG` can override it. If your `nuxt.config.ts` already sets `runtimeConfig.h34s.debug`, your value wins. Nuxt's own `debug` option does not turn on h34s debug mode: Nuxt resolves it to an object, and the `DEBUG` environment variable of a build machine can set it. Restart the server after you change the variable. Without the plugin, both flags are `false` and every failure is opaque. **Keep debug mode off in production.** Diagnostics send exception messages and stack text to anyone who can trigger a failure. Libraries can put connection details, SQL text or file paths in exception messages. Use debug mode only on a server that only you can reach, and turn it off again. If you turned it on by mistake, unset `NUXT_H34S_DEBUG` (or set it to `false`) and restart. These flags are tested with a stubbed Nitro runtime (`packages/nuxt/test`) and with fake plugin records in the Scala suites. In a production build of the demo (macOS x86_64), an unexpected failure was opaque without the variable and carried `debug.cause` with `NUXT_H34S_DEBUG=true`; the database password did not appear in either response. ## Map JavaScript back to Scala During `nuxt dev`, Node loads the linked Scala.js modules directly and reads their source maps: 1. `fastLinkJS` writes JavaScript and `.js.map` files to the output directories in your `h34s.config.mjs`. The demo uses `examples/nuxt/server/.scala` and `examples/nuxt/server/.scala-kyo`. 2. The module's development loader keeps those outputs out of Nitro's development bundle and imports them as native files. Nitro watches the directories and replaces its worker after you relink. 3. Start Nuxt with `node --enable-source-maps`, as in the `dev` script of [npm tooling](/guide/npm-tooling). Node then maps stack frames to Scala files and lines. Enable source maps in your Scala.js build: ```scala Compile / fastLinkJS / scalaJSLinkerConfig ~= (_.withSourceMap(true)), ``` Keep each `.js` file next to its `.js.map`. The package also has a source-map helper that composes Scala.js maps through Nitro/Rollup. It embeds your project's Scala sources, from the application root and from the config's `sbtRoot`, and never downloads dependency sources. ## Attach a debugger Start Nuxt with the inspector from your application root: ```sh pnpm dev --inspect ``` Attach a Node debugger to the address Nuxt prints, then open the original Scala file in Sources. Handlers run in Node, so use the server's inspector session, not the browser page. Source maps give locations and source views. They do not give Scala expression evaluation or a JVM-style debugger. Breakpoint stepping has not been verified. ## Rebuild after editing Run `pnpm run scala:fast` after a Scala change, or keep `pnpm run scala:watch` running. Wait until linking finishes and Nitro restarts its worker before you send the next request. If a stack frame looks stale, check that linking succeeded and the generated files changed. ## Checks ```sh node --test scripts/*.test.mjs packages/nuxt/test/*.test.mjs ``` These Node tests pass on macOS x86_64 with Node 24.19.0 (see [compatibility](/reference/compatibility)). They cover the development resolver, Rollup-to-Node source maps, map composition, the Nitro plugin's records and flags, the debug flag merge in the Nuxt module, and the close hook. The Scala suites cover opaque 500s, development and debug diagnostics, Kyo stack text and ZIO `Cause.prettyPrint` with Scala file names, and that `SafeError` entries never carry `debug`. On this branch (macOS x86_64, Node 24.19.0, Nuxt 4.3.1), `pnpm dev` after `pnpm run scala:fast` answered `/api/notes` without a database with a 500 whose `debug.cause` had Scala stack frames and no password, and a Kyo handler that threw a JavaScript `TypeError` had its stack mapped to `RequestLifecycle.scala` and `KHandler.scala`. `scripts/check-npm-nuxt.mjs` checked development diagnostics, source maps and a relink reload with the packed module on a synthetic project. Relinking the real Scala.js example under `nuxt dev` and breakpoint stepping have not been checked. --- # npm build tooling > Configure the shared Scala.js CLI and Nuxt module, including the Nitro plugin that supplies config, storage and shutdown to Scala. `@yuri4n/h34s-nuxt` owns the reusable JavaScript build and development integration. Applications configure their Scala.js modules once, then use its `h34s-scala` CLI and Nuxt module. They do not copy build scripts, source-map loaders, or Nitro plugins. **@yuri4n/h34s-nuxt@0.2.0-alpha.1 is published** in the private GitHub npm registry. Its `npm-v0.2.0-alpha.1` tag is separate from the Maven [`v0.2.0-alpha.1`](https://github.com/yuri4n/h34s/releases/tag/v0.2.0-alpha.1) release; both tags point at the same commit. The older [`@yuri4n/h34s-nuxt@0.1.0-alpha.1`](https://github.com/yuri4n/h34s/releases/tag/npm-v0.1.0-alpha.1) stays published for the `0.1.0-alpha.1` libraries. **Use the package version that matches your Scala libraries.** Install `@yuri4n/h34s-nuxt@0.2.0-alpha.1` with the `0.2.0-alpha.1` Scala libraries. The Kyo core reads runtime config, storage and the close hook from the package's new Nitro plugin. The `0.1.0-alpha.1` package only supplies the development flag, so with it `RuntimeConfig`, `KHubKv.mount` and their ZIO versions fail with `ConfigError("h34s Nitro plugin is not installed")`, and `Shared` resources are not released when Nitro closes. ## Install the tooling The package is a build-time dependency. Keep `h3` as an application runtime dependency and Scala libraries as sbt/Maven dependencies; [Releases](/reference/releases) documents the latter. Configure the private GitHub npm registry in the application's `.npmrc`: ```ini @yuri4n:registry=https://npm.pkg.github.com //npm.pkg.github.com/:_authToken=${GITHUB_PACKAGES_TOKEN} ``` Provide `GITHUB_PACKAGES_TOKEN` from your shell or secret manager using a classic PAT with `read:packages` and access to the private package. Commit only the environment-variable placeholder. This npm scope mapping does not configure sbt's Maven resolver. In GitHub Actions, save the token as `PACKAGES_READ_TOKEN` and map `${{ secrets.PACKAGES_READ_TOKEN }}` into the `GITHUB_PACKAGES_TOKEN` process environment variable. Save the username as `PACKAGES_USER` when the same workflow also resolves Maven artifacts. GitHub reserves `GITHUB_` for saved secret and variable names. Install the package and h3 with the version of your Scala libraries: ```sh pnpm add -D @yuri4n/h34s-nuxt@0.2.0-alpha.1 pnpm add h3@1.15.4 ``` With the older `0.1.0-alpha.1` Scala libraries, install the matching package instead: `pnpm add -D @yuri4n/h34s-nuxt@0.1.0-alpha.1`. For local source review, run this from the h34s checkout: ```sh npm pack ./packages/nuxt --pack-destination /tmp ``` Then install the resulting tarball in a disposable consumer: ```sh pnpm add -D /tmp/yuri4n-h34s-nuxt-0.2.0-alpha.1.tgz ``` The repository example uses the local package. A packed-tarball check verifies that consumers receive the runtime plugin and helpers without referencing files outside the package. A local tarball path is a review setup; use the pinned registry dependency for an independently installable application. Run `pnpm test:package` after installing the repository dependencies to verify the archive in an isolated consumer and a real Nuxt development/production server. The HTTP check covers development diagnostics, Scala source maps, relinking, routes mounted from the route manifest (typed, and added while Nuxt runs), and a production bundle that runs without the original generated files. It also builds the consumer with `scalaPrerender: 'stub'`: the prerender bundle must get the stub, the server output must keep the linked code, and a prerendered route that calls a Scala.js route must stop the build. For `0.2.0-alpha.1`, the [successful npm release workflow](https://github.com/yuri4n/h34s/actions/runs/39144809255) downloaded the published archive with registry authentication, compared its bytes with the tested tarball, and installed that download offline in a clean consumer. It checked the CLI and all three JavaScript entry points without installing Nuxt as a peer. The [release](https://github.com/yuri4n/h34s/releases/tag/npm-v0.2.0-alpha.1) attaches the tarball and `registry.json`, which records its stable registry URL and integrity. The [`0.1.0-alpha.1` workflow](https://github.com/yuri4n/h34s/actions/runs/38937318206) ran the same checks for that version. Nuxt 4.3.1 passes the packed-package development and production HTTP checks in CI, including the `0.2.0-alpha.1` release workflow. Nuxt 4.4.2 passed the same checks locally on Linux x86_64 with Node 24.19.0 for `0.1.0-alpha.1`; h34s's own checks have not repeated that profile for `0.2.0-alpha.1`. Package-level tests also cover Node 22.18.0 and 24. These results establish those explicit profiles, not every Nuxt 4.x version or a Nix/NixOS build. ## Configure the Scala.js outputs Create `h34s.config.mjs` beside the application's `package.json`: ```js import { defineConfig } from '@yuri4n/h34s-nuxt/build' export default defineConfig({ modules: [ { project: 'server', output: 'server/.scala', }, ], }) ``` `project` is the sbt project ID, not a filesystem directory. Omit it for a root-project task such as `fullLinkJS`. `output` is the generated directory relative to the application root. Add another module for an independent Kyo application if needed. Each module needs its own generated output directory. `entries` is optional. Without it, an output must contain at least one h34s handler (a default export from `KHandler.define` or `ZHandler.define`). List `entries: ['notes-get.js']` when an output has only modules that you mount with route files; the CLI then checks that linking produced each file. Keep only generated files in these directories: the production command removes previous output before linking. Do not point `output` at a source directory or an application root. The package does not change `build.sbt`. Configure both linking modes to emit into the same directory and enable development source maps. For an sbt server project, these settings are relative to the root sbt build: ```scala import org.scalajs.linker.interface.ModuleKind // Inside the Scala.js server project's .settings(...): scalaJSLinkerConfig ~= (_.withModuleKind(ModuleKind.ESModule)), Compile / fastLinkJS / scalaJSLinkerConfig ~= (_.withSourceMap(true)), Compile / fastLinkJS / scalaJSLinkerOutputDirectory := (LocalRootProject / baseDirectory).value / "server" / ".scala", Compile / fullLinkJS / scalaJSLinkerOutputDirectory := (LocalRootProject / baseDirectory).value / "server" / ".scala", ``` The npm package requires Node 22.18 or later and Nuxt 4.3.1 or 4.4.2. These are explicit compatibility profiles; other Nuxt versions need validation. Use the compiler, linker, and JDK versions from the [toolchain profile](/reference/releases#toolchain); the combined repository profile uses Node 24. The package does not install Java or sbt. ### Point the CLI at your sbt build **Status.** `sbtRoot` is new in `@yuri4n/h34s-nuxt` `0.3.0-alpha.1`, which is not released yet. `0.2.0-alpha.1` ignores the key and runs sbt in the application root. `sbtRoot` is optional. It names the directory of your sbt build, where `h34s-scala` runs sbt. A relative path is relative to the directory of `h34s.config.mjs`; an absolute path also works. Use `/` as the separator, so the config works on every platform. The default, `'.'`, is that directory, so a config next to `build.sbt` needs no `sbtRoot`. Set `sbtRoot` when the Nuxt app is not at the root of your sbt build. For a Nuxt app in `web/` below the sbt build, write `web/h34s.config.mjs`: ```js import { defineConfig } from '@yuri4n/h34s-nuxt/build' export default defineConfig({ sbtRoot: '..', modules: [{ project: 'server', output: 'server/.scala' }], }) ``` For an sbt build next to the app, for example in `../scala-server`, write `sbtRoot: '../scala-server'`. The `output` paths stay relative to the application root, and the outputs must stay inside it. Point the linker of your sbt build at them, for example `(LocalRootProject / baseDirectory).value / "web" / "server" / ".scala"` for the app in `web/`. In development, the module embeds the Scala sources from both the application root and `sbtRoot` in the source maps. Before it starts sbt or removes any output, the CLI checks that `sbtRoot` is a directory with `build.sbt` or a `project/` directory, the files that sbt looks for. Otherwise it stops with an error that names `sbtRoot`, the resolved directory and the config file. For example, a config in `web/` without `sbtRoot` gives: ```text [h34s] sbtRoot is not set, so the sbt build is the config file's directory, /home/you/shop/web, which has no build.sbt and no project/ directory. Set sbtRoot in /home/you/shop/web/h34s.config.mjs to the directory of your sbt build, relative to that file, for example '..'. ``` The CLI also stops, with an error that names the output, when `sbtRoot` is an `output` directory or lies inside one: a production build removes the outputs, so it would remove your sbt build too. ## Add the Nuxt module and scripts In `nuxt.config.ts`: ```ts export default defineNuxtConfig({ modules: ['@yuri4n/h34s-nuxt'], }) ``` The module reads the same config, maps `#h34s/models` to the generated models (see [use the models in your app](/guide/routes#use-the-models-in-your-app)), mounts the routes of each output's route manifest (`h34s-routes.json`) as lazy Nitro handlers, watches the generated output, installs the development loader/source-map integration, and registers the h34s Nitro plugin (`runtime/h34s.mjs`). The plugin gives your Scala handlers, through internal records that requests cannot change:
Record entry Used by
development (Nitro's import.meta.dev ) Development diagnostics
debug ( runtimeConfig.h34s.debug , read once at startup) Debug diagnostics; see debugging
runtimeConfig (per request and at startup) RuntimeConfig / ZRuntimeConfig ; see configuration
storage(base) (Nitro useStorage ) KHubKv.mount / ZHubKv.mount ; see Nitro storage
onClose (Nitro's close hook) Shared / ZShared release; see lifetimes
The module also declares `runtimeConfig.h34s.debug: false`, so `NUXT_H34S_DEBUG=true` can turn on diagnostics at runtime. A value your `nuxt.config.ts` already sets wins. Nuxt's own `debug` option does not turn them on. Keep it off in production. Remove copied versions of those hooks and the old `h34s-development` plugin when adopting the module. If you listed `runtime/development.mjs` in `nitro.plugins` yourself, remove that entry: the module registers the new plugin, and the old file only re-exports it for one release. Add these scripts to `package.json`: ```json { "scripts": { "scala:fast": "h34s-scala dev", "scala:watch": "h34s-scala watch", "scala:full": "h34s-scala build", "dev": "node --enable-source-maps node_modules/nuxt/bin/nuxt.mjs dev", "dev:scala": "pnpm run scala:fast && pnpm run dev", "build": "pnpm run scala:full && nuxt build" } } ``` Run `pnpm dev:scala` for the first link and Nuxt startup. Keep `pnpm scala:watch` in a second terminal while editing Scala. The watcher runs sbt's continuous linking task and collects the routes again after each link; it does not start Nuxt. Wait for linking and Nitro's worker restart before requesting edited code. When the list of routes changes, Nuxt reloads its server routes without a restart. After each link, `dev`, `build` and `watch` load the linked modules in a separate Node process, read the route and response types of each h34s handler, and write `h34s-routes.json`, one `.d.ts` per handler module and `h34s-models.d.ts`, the named models that the routes return, into the output directory. The command prints the number of routes and models. See [routes and typed `$fetch`](/guide/routes). For production, `pnpm build` clears each generated output directory, runs `fullLinkJS`, verifies the configured entry files, writes the route manifests and declarations, and only then starts the Nuxt build. A linker failure, a missing entry, an output without handlers, or two handlers for the same route stops the command. The Nuxt module alone does not run a production link; keep that ordering in the application build script. The CLI accepts `--root `, the application root (default: the current directory), and `--config `, relative to that root. Configure an alternate file in Nuxt with `modules: [['@yuri4n/h34s-nuxt', { configFile: 'custom.mjs' }]]`. Keep the Nuxt integration and CLI pointed at the same application configuration. To run sbt in another directory, set [`sbtRoot`](#point-the-cli-at-your-sbt-build) instead of these flags. sbt must be available on `PATH`. The `./build` entry point also exports `loadConfig`, `buildScala`, `linkScala`, `watchScala`, `writeRoutes` (collect routes and write the manifests and declarations after your own link step) and `readRoutes` for custom orchestration. The `./rollup` entry point exports `scalaDevModules`, `scalaSourceMaps` and `scalaPrerenderStub` (the plugin behind `scalaPrerender: 'stub'`) for custom integrations. Ordinary Nuxt applications use the module above; they do not need to wire these helpers individually. Large linked outputs make Nitro's production bundling memory-hungry. When your application prerenders a route, Nitro 2.13.1 bundles the linked Scala.js twice in one build process: once for its prerenderer and once for the server output. The repository demo, which links a Kyo and a ZIO application, sets the `scalaPrerender: 'stub'` option below and still runs `nuxt build` with `node --max-old-space-size=8192`. If your build stops with a JavaScript heap error, raise the limit the same way, try the option, or remove one of the causes in the [development guide](https://github.com/yuri4n/h34s/blob/main/docs/development.md#production-build). ### Keep Scala.js out of the prerender bundle Set the module option `scalaPrerender: 'stub'` in `nuxt.config.ts` when you prerender routes and none of them needs Scala.js: ```ts export default defineNuxtConfig({ modules: [['@yuri4n/h34s-nuxt', { scalaPrerender: 'stub' }]], }) ``` The option decides what Nitro's prerender bundle gets from your Scala.js outputs in a production build:
Value Prerender bundle Server output ( .output/server )
'bundle' (the default) The linked Scala.js The linked Scala.js
'stub' One stub that answers 501 The linked Scala.js
With `'stub'`, every import of a file in a configured output directory resolves to the stub while Nitro builds its prerenderer. Every export that your code imports from it is an h3 event handler that throws a 501 error with the status message `Scala.js is not bundled for prerendering (h34s scalaPrerender: 'stub')`. h3 treats it as a handled error, so Nitro does not log it as unhandled. The option changes nothing when no route is prerendered, in `nuxt dev`, or in the server output. The stub covers the output directories in `h34s.config.mjs`; Scala.js that your code imports from another directory is still bundled. For example, Nuxt Content prerenders its database dump, which runs no Scala.js code, so the demo sets `'stub'`. Keep the default when a prerendered page fetches data from a Scala.js route. While Nuxt prerenders that page, the request answers 501 instead of running your handler, and h34s prints a `[h34s]` warning that names the request by its method and path; it leaves out the query string and fragment, which can hold tokens. After prerendering, the build stops with an error that lists these requests: each one once, sorted, at most 20, and then the number of the others. The `[h34s]` warnings name every request. Without that error, a page that catches the 501, as `useFetch` does, would be saved with its error state and the build would pass. Nuxt 4 shares `useFetch` and `useAsyncData` results, failed ones too, between the pages it prerenders (`experimental.sharedPrerenderData`), so other pages that use the same data, for example through a shared layout or component, may hold the same error; check every prerendered page that renders a listed call. When Nitro stops the build itself, because a prerendered route answered with an error, h34s logs the list as an error and Nitro shows its own summary of the failed routes. With `nitro.prerender.failOnError: false` (Nuxt sets it to `true`), h34s only warns, and those pages keep their error state. A Nitro plugin that calls Scala.js code at startup gets the stub in the prerenderer too. The stub then throws while Nitro loads the prerender bundle, before any page is prerendered, so the build stops with the stub's own message, not with the list of requests. `nuxt generate` prerenders every page that it finds by following links from `/`, so check those pages before you turn the option on. Any other value stops Nuxt with a `TypeError` that names the option. The option removes the second copy of the Scala.js, not every heap problem. In the demo on a 15.7 GiB cloud VM, a build with the option passed with a 4096 MiB heap limit, and prerendering took 3.6 seconds instead of 47 to 59. At Node's default limit on that VM, 2240 MiB, a build with an equivalent temporary hook still ran out of memory while it built the server output. The [development guide](https://github.com/yuri4n/h34s/blob/main/docs/development.md#production-build) has the measurements. ## Set up a project with `h34s-scala init` **Status.** `init` is new in `@yuri4n/h34s-nuxt` `0.3.0-alpha.1`, which is not released yet. `0.2.0-alpha.1` does not have it. Until the release, set up your project by hand with the [adoption guide](/guide/adoption). `h34s-scala init [directory]` sets up h34s once in a project. You run it without installing anything first, after the `@yuri4n` scope and its token line are in your `~/.npmrc` (see [Install the tooling](#install-the-tooling)): ```sh pnpm dlx @yuri4n/h34s-nuxt@ init my-app ``` `init` finds the adoption path from the files in the directory. The directory defaults to the current one. The first row that matches wins:
Files in the directory Path What init writes
nuxt.config.* , or a package.json that lists nuxt An existing Nuxt app The sbt build at the Nuxt root: build.sbt , or h34s.sbt next to an existing build.sbt . It merges the scripts and dependencies into package.json and adds the module.
build.sbt An existing sbt build A new Nuxt app in web/ with sbtRoot: '..' , and h34s.sbt next to your build.sbt , which init does not edit.
Nothing yet A new project A minimal Nuxt app, pinned to Nuxt 4.3.1, and its sbt build.
Other files None Nothing. init stops and asks you to run it in your Nuxt app or sbt build, or with a new directory.
"Nothing yet" means that the directory is missing, empty, or holds only repository files: `.git`, `.gitignore`, `.gitattributes`, `.editorconfig`, a README and a license. So you can run `init` in a fresh clone. When the parent directory has a `build.sbt`, a Nuxt app or a directory with nothing yet is the Nuxt app of that sbt build, with `sbtRoot: '..'`. So `init web` in your sbt build gives the same result as `init`, and so does `init` in `web/`. When `h34s.config.mjs` exists in the directory, in its Nuxt app or in its sbt build, `init` prints "Already set up" and changes nothing: it runs once per project. Every path gets `h34s.config.mjs`, the sbt project `server` in `scala-server/` with one hello handler for `GET /api/hello`, `.jvmopts` and the `project/` files when they are missing, the `.npmrc` lines and the `.gitignore` entries. The dependency versions are the version of the package that runs `init`, so the npm and Maven versions match. Kyo is the default API; add `--zio` for ZIO. Add `--dry-run` to print the plan and write nothing. `init` checks every file before it writes any. It creates missing files only and never overwrites a file. It changes an existing file only in these ways: it appends lines to `.gitignore` and `.npmrc`, merges scripts and dependencies into `package.json`, and adds `'@yuri4n/h34s-nuxt'` to `modules` in `nuxt.config`. When it cannot make that edit safely, it prints the line to add. It keeps your own `dev` and `build` scripts and prints the values to set. On a conflict, for example a file that it would create or a script of the same name with another value, it lists the conflicts, writes nothing and exits with status 1. It installs no packages and runs no sbt. It writes no token: `.npmrc` and the sbt build only name `GITHUB_PACKAGES_TOKEN` and `GITHUB_PACKAGES_USER`, and `init` prints only whether they are set. Run `h34s-scala init --help` for the options. **What is verified.** The unit tests of `@yuri4n/h34s-nuxt` run `init` on each path from clean temporary directories, without sbt and without a package install, and the packed-package check runs it from the packed tarball. A test checks that the files `init` writes match the blocks of this guide and the [adoption guide](/guide/adoption). A project that `init` wrote has not yet been built with real sbt and the published packages; that end-to-end check is the next step of [issue #54](https://github.com/yuri4n/h34s/issues/54). ## Keep application policy in the application The npm package owns build orchestration, development loading, source maps, and the Nitro plugin. It has no dependency on a particular effect API. Configure your own `build.sbt` and Maven credentials. Define models and services in your application, compose their environment in an optional `AppHandler` wrapper, and configure any database resources, migrations, secrets, and deployment policy there. Declare each route in Scala with `.at(Route...)`. A one-line route file (`export { default } from '../.scala/notes-get.js'`) still works when you prefer Nuxt's file layout for a route. Schema remains optional. Adopting the npm package does not add ZIO, Kyo, or Schema to an application's Scala dependency graph. Nix and other future build profiles should call the same CLI pipeline. Windows, Nix/NixOS, and additional deployment targets still need their own validation. --- # Set up h34s with a coding assistant > A prompt that a coding assistant follows to add h34s to a new project, an existing Nuxt app, or an existing Scala.js project. Copy the prompt from the code block below into your coding assistant. The prompt asks the assistant to perform the steps of the [adoption guide](/guide/adoption) on any of its three paths. Nobody has run this prompt end to end with an assistant yet, so review every change that the assistant reports. ## Before you start 1. Create a classic GitHub personal access token with `read:packages`. [Authenticate package reads](/reference/releases#authenticate-package-reads) says which account and access it needs. 2. Set `GITHUB_PACKAGES_USER` to the GitHub username that owns the token, and `GITHUB_PACKAGES_TOKEN` to the token, in your shell or secret manager. sbt reads both variables. npm reads the token through `.npmrc`, as in [Install the tooling](/guide/npm-tooling#install-the-tooling). 3. Start the assistant from a shell where both variables are set. Never paste the token into the chat: the assistant needs only the variable names. ## The prompt ```text You are adding h34s 0.2.0-alpha.1 (Scala.js server handlers for Nuxt) to this project. Follow the guides `docs/content/1.guide/02.adoption.md`, `docs/content/2.reference/2.releases.md` and `docs/content/1.guide/13.npm-tooling.md` in the `yuri4n/h34s` GitHub repository, and these rules. If you cannot read a guide, ask me to paste the section. 1. Ask me three things first, and wait for my answers: the effect API (Kyo or ZIO; default Kyo), the Scala directory (default `scala-server`) and the Scala package. The default package is the project directory's name in lower case, letters and digits only, if the result starts with a letter and is not a Scala keyword. Otherwise, there is no default: ask me for a name. 2. Never write, print or commit a token, including one you find in a file. Use only environment-variable references: `${GITHUB_PACKAGES_TOKEN}` in `.npmrc`, and `sys.env.get("GITHUB_PACKAGES_USER")` and `sys.env.get("GITHUB_PACKAGES_TOKEN")` in sbt. Check whether a variable is set without printing it, for example `test -n "$GITHUB_PACKAGES_USER"`. If one is not set, tell me its name and continue; skip the steps that download packages. 3. Detect the path in the directory I name (default: this one). The first match wins: - `h34s.config.mjs` exists: stop. The project is already set up. - The directory is missing, empty, or holds only `.git`: a new project. Create a minimal Nuxt 4 app with `nuxt` pinned to 4.3.1, then add the sbt build in it. - `nuxt.config.*` exists: an existing Nuxt app. Add the sbt build at the Nuxt root. If `build.sbt` exists there too, put the h34s settings in a new `h34s.sbt`. - `build.sbt` exists and no `nuxt.config.*`: an existing sbt build. Create a minimal Nuxt 4 app with `nuxt` pinned to 4.3.1 at the sbt root, and put the h34s settings in a new `h34s.sbt` next to `build.sbt`. In your report, remind me that the modules my handlers depend on must cross-build for Scala.js and use Scala 3.9.0. - Anything else: stop and ask me. 4. Change a file that existed before you started only by adding lines or entries; never overwrite it. Never edit an existing `build.sbt`. If a file you must create, such as `server/api/hello.get.ts`, already exists, ask me. 5. Use these versions: JDK 25 to run sbt, sbt 1.11.7, Scala 3.9.0, `sbt-scalajs` 1.22.0, Node 24, `"dev.yuri4n" %%% "kyo-h3-ext" % "0.2.0-alpha.1"` (for ZIO, `zio-h3-ext`), pnpm 10.34.4, `@yuri4n/h34s-nuxt@0.2.0-alpha.1` and `h3@1.15.4`. If `java -version` differs, tell me. If an existing app's Nuxt version is not 4.3.1 or 4.4.2, stop and ask me before you change anything. 6. Take the sbt files and the server project from "Configure the Scala.js module" in the adoption guide, with my Scala directory. Add the resolver and credentials block from "Authenticate package reads" on the Releases page. If `project/` sets another `sbt.version` or `sbt-scalajs` version, or the build already has a project named `server`, ask me. 7. Add the two `.npmrc` lines from "Install the tooling" in the npm tooling guide. Skip a line that `.npmrc` already holds. If `.npmrc` configures the `@yuri4n` scope or `npm.pkg.github.com` in another way, ask me. 8. Do not run `nuxi module add`. Run `pnpm add -D @yuri4n/h34s-nuxt@0.2.0-alpha.1` and `pnpm add h3@1.15.4`. 9. Write `h34s.config.mjs` from "Write, mount and run a handler" before you add `'@yuri4n/h34s-nuxt'` to `modules` in the Nuxt config. The module stops Nuxt when that file is missing. Edit the Nuxt config yourself. 10. Use the scripts from "Add the Nuxt module and scripts" in the npm tooling guide. Add the missing ones, and replace the ones you created, such as the `dev` and `build` scripts of a new Nuxt app. If `dev` or `build` existed before you started, do not replace it; show me the change instead, because `build` must run `pnpm run scala:full` before `nuxt build`. 11. Write the handler from "Write, mount and run a handler" in my package, as `Hello.scala` in `src/main/scala/` of my Scala directory, and its route file `server/api/hello.get.ts`. For ZIO, import `dev.yuri4n.exts.zioh3.*` instead of `dev.yuri4n.exts.kyoh3.*`, add `import zio.ZIO`, and define the handler as `ZHandler.define(_ => ZIO.succeed(Greeting("Hello from Scala")))`. 12. Keep `server/.scala` for generated files only: a production build deletes it. If it already holds files, ask me. Add the missing `.gitignore` entries: `server/.scala/`, `target/`, `project/target/`, `project/project/`, `.bsp/` and `.metals/`, and for a new Nuxt app also `.nuxt/`, `.output/` and `node_modules/`. 13. Verify: run `pnpm install`, start `pnpm dev:scala` in the background, wait until Nuxt listens, request `http://127.0.0.1:3000/api/hello` (or the port Nuxt prints), then stop the server. Expect `{"data":{"message":"Hello from Scala"}}`. 14. Do not commit. Report the result, every file you created or changed, and every step you skipped or that I must do myself. ``` ## If something fails Start with the recovery notes at the end of [Write, mount and run a handler](/guide/adoption#write-mount-and-run-a-handler): they cover a 404, an `h3` that Nitro cannot resolve, and a missing linked module. If Nuxt stops with `Missing h34s config`, create `h34s.config.mjs` next to `package.json`, as in the adoption guide. For HTTP 401 or 403 from a registry, check the token and its access in [Authenticate package reads](/reference/releases#authenticate-package-reads). For a 500 or a stack trace, see [debugging](/guide/debugging). [What is verified](/guide/adoption#what-is-verified) says which parts of this setup have been tested. --- # Architecture > An effect-free native layer, a Kyo core, a hand-written ZIO adapter, optional schema and storage bridges, and your application. h34s is a toolbox for running Scala.js server code in your Nuxt/Nitro application. You add the modules you need, write your models and services, and mount your handlers as Nuxt routes. The repository also holds a runnable demo in `examples/nuxt/` and the guides and the documentation site in `docs/`. Neither is part of the toolbox. **Status.** This page describes the design of `0.2.0-alpha.1`. The older `0.1.0-alpha.1` release has a different module graph with two independent runtimes; the [migration guide](/reference/migration) lists every change. ## Layers One runtime does the hard work. The h3 transport, the request lifecycle, cancellation, streaming, error rendering, configuration access and application-scoped resources are implemented once, in Kyo. The ZIO API is a hand-written adapter over that core.
Layer sbt project / artifact What it gives you Depends on
Native layer h3Ext / h3-ext Pure values shared by every runtime: EventHandler , Request , RequestBody , BodyLimit , ResponseHead , Status , SetCookie , OnFailure , HandlerOptions , SafeError , HttpError , InvalidInput , ErrorEntry , ConfigError , Secret , NativeConverter[Unit] . The h3, Node and Nitro facades are internal. native-converter. No effect library.
Kyo core kyoH3Ext / kyo-h3-ext KHandler.define , the request lifecycle, body reading with limits, Response , error rendering, diagnostics, RuntimeConfig , Shared , Response.sendStream and sendEvents h3-ext , kyo-core 1.0.0-RC7
ZIO adapter zioH3Ext / zio-h3-ext ZHandler.define , Response over ZState with sendStream and sendEvents , ZRuntimeConfig , ZShared , and the awaiting ZIO-to-Kyo bridge kyo-h3-ext , kyo-zio, ZIO 2.1.26, zio-prelude, zio-json, native-converter-zio-ext
Kyo Schema bridge nativeConverterKyoSchemaExt / native-converter-kyo-schema-ext derives Schema, NativeSchema for Kyo Schema h3-ext , kyo-schema
ZIO Schema bridge nativeConverterZIOSchemaExt / native-converter-zio-schema-ext derives Schema, NativeSchema for ZIO Schema h3-ext , zio-schema 1.7.5
ZIO converters nativeConverterZIOExt , nativeConverterZIOJsonExt NonEmptyList and zio-json Json converters, eimap h3-ext , ZIO
Storage kyoNuxtHubExt / kyo-nuxthub-ext , zioNuxtHubExt / zio-nuxthub-ext KHubKv.mount , ZHubKv.mount over Nitro storage Kyo core; the ZIO one also needs the ZIO adapter
npm tooling @yuri4n/h34s-nuxt Build CLI, Nuxt module, development loading, source maps, and the Nitro plugin that supplies flags, runtime config, storage and the close hook Node, Nuxt. No Scala dependency.
Your application your sbt project and Nuxt app Models, services, AppHandler , resources, route mounts, database and deployment the modules you choose
Dependencies point one way. Kyo modules never depend on ZIO. ZIO modules get Kyo transitively. ```text h3-ext (no effect library) ├── kyo-h3-ext (Kyo core) │ ├── kyo-nuxthub-ext │ └── zio-h3-ext (also uses native-converter-zio-ext, kyo-zio, ZIO) │ └── zio-nuxthub-ext (also uses kyo-nuxthub-ext) ├── native-converter-kyo-schema-ext (also uses kyo-schema) ├── native-converter-zio-schema-ext (also uses zio-schema) └── native-converter-zio-ext (also uses ZIO) └── native-converter-zio-json-ext ``` Each module depends on its parent. A build check during the refactor found no `dev.zio` artifact on the Kyo core's compile classpath. Pick the smallest set for your application: - A Kyo application adds `kyo-h3-ext`. Add `native-converter-kyo-schema-ext` for schemas and `kyo-nuxthub-ext` for storage. - A ZIO application adds `zio-h3-ext`. Add `native-converter-zio-schema-ext` for schemas and `zio-nuxthub-ext` for storage. - Every application adds the npm package `@yuri4n/h34s-nuxt` and the runtime dependency `h3@1.15.4`. ## Follow one request 1. Nitro routes the request to a linked Scala.js module. The h34s Nuxt module registered that module for the route that your handler declares with `.at(Route...)`; `h34s-scala` read the route after linking and wrote it into the output's route manifest. A one-line route file in `server/api/` that re-exports the module works too. 2. The h34s Nitro plugin has attached a frozen record to `event.context.h34s`: the development and debug flags, the request's runtime config, and Nitro storage. Requests cannot change this record. 3. `KHandler.define` takes one immutable `Request` snapshot. It runs your function `Request => B < HandlerEffects` once, on its own fiber, in a request `Scope`, with a `Var[ResponseHead]` that starts empty. 4. Your program reads the body, calls your services, sets status, headers and cookies with `Response.*`, and returns the body. 5. When the program and its finalizers have finished, the core writes the response once. On success it applies the handler defaults, then everything the program set. On failure it applies the [failure rule](/guide/kyo#fail-safely). It renders the body with the handler's `HandlerOptions`. 6. If the client disconnects first, the core interrupts the program, waits for its cleanup, and writes nothing. `ZHandler.define` adds one step. It runs your ZIO program in one ZIO fiber per request, on the given ZIO `Runtime`, with a `ZState[ResponseHead]` and a ZIO `Scope`. It then hands the result and the head to the core, which does steps 5 and 6. Interrupting a request interrupts the ZIO fiber and waits for its finalizers. ## Where your code goes The functional-design layers map to code like this:
Layer What lives there Who owns it
Business logic Your services and programs, as Kyo A < S or ZIO effects You
Interfaces Your service traits ( Greetings , Notes ) and the h34s values ( Request , SafeError , Secret ) You and h34s
Implementation The h34s runtime, its internal facades, and your service interpreters ( NotesLive ) h34s and you
Application Your AppHandler : configuration, then Shared / ZShared resources, then Env / ZLayer wiring You
`AppHandler` is a name the demo uses for this one wiring point. It is your code, not an h34s export. Call `KHandler.define` or `ZHandler.define` directly when you do not need a wrapper. ## Errors and diagnostics A handler is an error boundary. Only a `SafeError` that your program fails with through the typed channel reaches the client, with its status and entries. Everything else becomes an opaque 500. Diagnostics appear only in Nuxt development mode or in debug mode. See [Kyo handlers](/guide/kyo#fail-safely), [ZIO handlers](/guide/zio#fail-safely) and [debugging](/guide/debugging). A finalizer that fails behaves the same in both runtimes: the failure is logged on the server, and the response that the program computed does not change. The log messages differ. Kyo 1.0.0-RC7 logs `Scope finalizer failed` through its `Log`. The ZIO adapter writes `h34s: a ZIO finalizer failed`, with `Cause.prettyPrint`, through `console.error`, for value, `Unit` and streamed bodies and on a client disconnect. Since the next release (provisionally `0.3.0-alpha.1`), a dying ZIO finalizer no longer turns a value or `Unit` response into a 500. ## What h34s does not do - Infer TypeScript types from Scala classes. The generated declarations describe what each converter writes; a converter that does not describe itself gives `unknown`. See [routes and typed `$fetch`](/guide/routes). - Supply a database client, a connection pool or migrations. Use kyo-sql or Skunk directly; see [database](/guide/database). - Package or deploy your application. Nix and sbt-native-packager support are planned. - Run anywhere but Node. Streaming is tested over Node HTTP responses and over a copy of Nitro's in-process local fetch; other Nitro presets are untested. ## Source locations
Path What to read
scala-modules/h3 The native layer and its tests
scala-modules/kyo-h3 The Kyo core and its tests
scala-modules/zio-h3 The ZIO adapter and its tests
scala-modules/native-converter-kyo-schema , native-converter-zio-schema Schema bridges
scala-modules/native-converter-zio , native-converter-zio-json ZIO converters
scala-modules/kyo-nuxthub , zio-nuxthub Storage
packages/nuxt CLI, Nuxt module, Nitro plugin
examples/nuxt/scala/kyo , examples/nuxt/scala/zio The demo's Kyo and ZIO applications
docs/ The guides and this documentation site
`examples/zio-migrations` is an unpublished example that derives Skunk codecs and PostgreSQL DDL from ZIO Schema. It is not a supported API. ## Next coherent slices Routes declared in Scala and the `.d.ts` declarations for typed `$fetch` are on `main` and ship with the next release; see [routes and typed `$fetch`](/guide/routes). The next slices are: 1. The planned Nix and sbt-native-packager paths over the same build pipeline. 2. Interactive documentation: compiler-derived types, symbol links and runnable examples. --- # Releases > Published versions, registry access and installation, what 0.2.0-alpha.1 provides, and what was verified. **dev.yuri4n version 0.2.0-alpha.1 is published** in the GitHub Packages Maven registry for `yuri4n/h34s`. The [prerelease](https://github.com/yuri4n/h34s/releases/tag/v0.2.0-alpha.1) and [successful release workflow](https://github.com/yuri4n/h34s/actions/runs/39145732257) record publication of all nine artifacts. The same workflow then resolved the Kyo, ZIO, Kyo Schema and ZIO Schema consumers from the authenticated registry, and each one served real h3 requests. `0.2.0-alpha.1` changes the module set and the handler API: Kyo is the core runtime, the ZIO modules adapt it, and every module builds with Scala 3.9.0. Its [release notes](https://github.com/yuri4n/h34s/blob/main/.github/release-notes/0.2.0-alpha.1.md) summarize the changes. The tables and commands below describe `0.2.0-alpha.1`. Where `0.1.0-alpha.1` differs, this page says so. The older **0.1.0-alpha.1 stays published**, with eleven artifacts and the older API. Its [prerelease](https://github.com/yuri4n/h34s/releases/tag/v0.1.0-alpha.1) and [release workflow](https://github.com/yuri4n/h34s/actions/runs/38779659501) record that publication, and its [release notes](https://github.com/yuri4n/h34s/blob/main/.github/release-notes/0.1.0-alpha.1.md) list what that version contains. The reusable JavaScript tooling is published separately as `@yuri4n/h34s-nuxt@0.2.0-alpha.1` in its own [npm release](https://github.com/yuri4n/h34s/releases/tag/npm-v0.2.0-alpha.1). See [npm build tooling](/guide/npm-tooling) for package installation, shared configuration, and local tarball review. This package version registers a new Nitro plugin that the `0.2.0-alpha.1` Scala libraries need for runtime config, storage mounts, and application-scoped resources. The `0.1.0-alpha.1` package does not have it. Use `@yuri4n/h34s-nuxt` at the Scala libraries' version or later. With the `0.1.0-alpha.1` libraries, use the matching [`@yuri4n/h34s-nuxt@0.1.0-alpha.1`](https://github.com/yuri4n/h34s/releases/tag/npm-v0.1.0-alpha.1). The [npm release workflow](https://github.com/yuri4n/h34s/actions/runs/39144809255) checked that the registry download matches the tested archive. The [migration guide](/reference/migration) maps the `0.1.0-alpha.1` API to the new one. ## What 0.2.0-alpha.1 provides
Capability Kyo ZIO
Handlers as Request => body functions KHandler.define ZHandler.define
Response head as local state, written once Var[ResponseHead] ZState[ResponseHead]
Wire format {data} / {errors:[{message,path}]} / 204, configurable HandlerOptions HandlerOptions
Bounded body reads with 400/413/415/422 request.body.json , text same
Safe errors, opaque 500s, development and debug diagnostics yes yes, with Cause.prettyPrint
Runtime config from Scala, Secret RuntimeConfig ZRuntimeConfig
Application-scoped resources released on Nitro close Shared ZShared
Streams with cancellation that waits for cleanup kyo.Stream ZStream
Nitro storage KHubKv.mount ZHubKv.mount
One schema for native values, JSON, YAML and validation Kyo NativeSchema ZIO NativeSchema (no YAML on Scala.js)
Testing without h3 Request.fake + Var.runTuple Request.fake + ZIO.stateful
On `main`, for the next release: handlers declare their routes in Scala with `.at(Route...)`, and Nuxt types `$fetch` from generated declarations. See [routes and typed `$fetch`](/guide/routes). In `0.2.0-alpha.1`, mount each handler with a one-line route file. Also on `main`: handlers send streams with `Response.sendStream` instead of returning them, and tests call `KHandler.test` or `ZHandler.test`; see [streaming](/guide/streaming) and [testing](/guide/testing). The models that your routes return also get TypeScript names, which your app imports from `#h34s/models`; see [use the models in your app](/guide/routes#use-the-models-in-your-app). ## Toolchain Use Node 24, pnpm 10.34.4, sbt 1.11.7, Scala.js 1.22.0, and Java 25. Every module compiles with Scala 3.9.0. The Kyo modules use Kyo 1.0.0-RC7; the ZIO modules use ZIO 2.1.26, the version that kyo-zio pins. Kyo's macros require Java 25 at build time, so build your application with Java 25 too. Java and sbt are build tools; the application runs inside Nitro/Node. In `0.1.0-alpha.1`, the native and ZIO modules used Scala 3.5.2 and only the Kyo modules used Scala 3.9.0. Scala 3 dependency names have the `_sjs1_3` suffix. Use sbt's `%%%` syntax to select it. A Kyo-only application never resolves ZIO. A ZIO application resolves Kyo too, because the ZIO adapter runs on the Kyo core. ## Authenticate package reads The repository is public, and so are its Maven packages: GitHub's Maven registry gives a package the visibility of its repository. Every download from GitHub Packages still needs a token with package-read permission, also for a public package. Installing without a token waits for the public registries planned after the coordinate decision in [issue #21](https://github.com/yuri4n/h34s/issues/21) ([ADR-0013](https://github.com/yuri4n/h34s/blob/main/docs/design/adr/0013-public-repository-and-registries.md)). On a developer machine, use a GitHub personal access token (classic) with `read:packages` from any GitHub account. If organization SSO applies, authorize the token for that organization. Provide `GITHUB_PACKAGES_USER` and `GITHUB_PACKAGES_TOKEN` through your shell or secret manager. Keep the token out of committed sbt files, `.env` files included in builds, and command output. In a consumer's `build.sbt`, add: ```scala ThisBuild / resolvers += "h34s GitHub Packages" at "https://maven.pkg.github.com/yuri4n/h34s" ThisBuild / credentials ++= (for { user <- sys.env.get("GITHUB_PACKAGES_USER") token <- sys.env.get("GITHUB_PACKAGES_TOKEN") } yield Credentials( "GitHub Package Registry", "maven.pkg.github.com", user, token, )).toSeq ``` GitHub's Maven registry uses [repository-scoped permissions](https://docs.github.com/en/packages/learn-github-packages/about-permissions-for-github-packages#permissions-for-repository-scoped-packages). The h34s repository's own workflows can publish and verify these packages with their `GITHUB_TOKEN`. A different repository, including `yurian.land`, needs a classic PAT with `read:packages`. A token from any GitHub account works, because the Maven packages are public. Store it as that consumer repository's `PACKAGES_READ_TOKEN` Actions secret and provide the token owner's username as the `PACKAGES_USER` Actions variable. GitHub reserves the `GITHUB_` prefix for saved secret and variable names. Map the saved values into the environment names read by sbt: ```yaml env: GITHUB_PACKAGES_TOKEN: ${{ secrets.PACKAGES_READ_TOKEN }} GITHUB_PACKAGES_USER: ${{ vars.PACKAGES_USER }} ``` The consumer's own `GITHUB_TOKEN` and the granular package “Manage Actions access” settings do not establish cross-repository Maven access. HTTP 401 usually means missing or invalid credentials. HTTP 403, or a package appearing absent despite a successful release, can mean that the account or workflow repository lacks access. Check the registry URL, package version, token scope, and package access before changing dependency versions. ## Add the API to a Scala.js application Pin the build tools: ```properties # project/build.properties sbt.version=1.11.7 ``` ```scala // project/plugins.sbt addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.22.0") ``` For ZIO, add the registry and credentials above, then configure your application module: ```scala import org.scalajs.linker.interface.ModuleKind lazy val server = project.in(file("scala-server")) .enablePlugins(ScalaJSPlugin) .settings( scalaVersion := "3.9.0", libraryDependencies += "dev.yuri4n" %%% "zio-h3-ext" % "0.2.0-alpha.1", scalaJSLinkerConfig ~= (_.withModuleKind(ModuleKind.ESModule)), ) ``` For Kyo, replace `zio-h3-ext` with `kyo-h3-ext`. Both APIs use Scala `3.9.0`; in `0.1.0-alpha.1` the ZIO API used Scala `3.5.2`. Keep Java 25 selected when compiling. These dependencies bring their runtime and the shared native layer transitively. Also create the `.jvmopts` file from the [adoption guide](/guide/adoption#configure-the-scalajs-module). The verified consumer builds use it; a build without it has not been tested. The current `h3-ext` has hand-written, internal h3 and Nitro facades. Your handlers receive a `Request` value and return a body; they do not call facades. `0.1.0-alpha.1` instead packaged ScalablyTyped-generated facades under `dev.yuri4n.h34s.facades.h3` and `dev.yuri4n.h34s.facades.std`. `0.2.0-alpha.1` removes those packages. Install h3 in the Nuxt application as a runtime dependency: ```sh pnpm add h3@1.15.4 ``` Maven dependencies supply Scala.js code, while pnpm supplies the JavaScript module imported by that code. See [adoption](/guide/adoption) for linking output, mounting exports alongside TypeScript routes, and composing application services outside handlers. See [development](https://github.com/yuri4n/h34s/blob/main/docs/development.md) for the complete repository example and [Kyo](/guide/kyo) for its independent application entry point. ## Independently consumable modules Every row uses organization `dev.yuri4n` and Scala.js coordinates selected by `%%%`. `0.2.0-alpha.1` publishes these nine library modules:
Artifact Purpose Brings
h3-ext Native layer: request snapshot, response head, safe errors, Secret , internal h3/Nitro facades No effect runtime
kyo-h3-ext The core: KHandler , body reading, RuntimeConfig , Shared , streaming Kyo
kyo-nuxthub-ext Optional Kyo storage over Nitro mounts ( KHubKv.mount ) Kyo
native-converter-kyo-schema-ext Optional derives NativeSchema from a Kyo Schema Kyo Schema
zio-h3-ext The ZIO adapter: ZHandler , ZRuntimeConfig , ZShared , ZStream bodies ZIO and Kyo
zio-nuxthub-ext Optional ZIO storage over Nitro mounts ( ZHubKv.mount ) ZIO and Kyo
native-converter-zio-ext NativeConverter support for ZIO data types ZIO, no Kyo
native-converter-zio-json-ext NativeConverter support for ZIO JSON values ZIO JSON, no Kyo
native-converter-zio-schema-ext Optional derives NativeSchema from a ZIO Schema ZIO Schema, no Kyo
`0.1.0-alpha.1` published eleven artifacts. `0.2.0-alpha.1` removes three of them and adds one: - `zio-js-ext` is removed. It no longer contained code. - `nuxthub-ext` is removed. The storage adapters open a Nitro mount by name (`KHubKv.mount("kv")`) through the h34s Nitro plugin, so your application does not supply a native KV object. - `zio-h3-schema-ext` is removed. Use `native-converter-zio-schema-ext` with `derives Schema, NativeSchema` and read the body with `request.body.json[A]`. - `native-converter-kyo-schema-ext` is new. See [NuxtHub KV](/guide/storage) for storage configuration and driver limitations. Schema support stays optional. Add `native-converter-zio-schema-ext` (with `"dev.zio" %%% "zio-schema-derivation" % "1.7.5"`) or `native-converter-kyo-schema-ext` next to your handler module when your models need one schema for validation and conversion. Plain `derives NativeConverter` models need neither. The schema bridges do not choose a JSON or YAML library for you: add `kyo-schema-json`, `kyo-schema-yaml`, or `zio-schema-json` yourself when you need those formats. See [schema](/guide/schema) for supported forms and validation behavior. The root project, aggregates, applications, and examples, including `zioMigrationsExt` in `examples/zio-migrations`, are excluded from publication. Database services, configuration, models, and `AppHandler` composition stay in application modules. The reusable libraries do not require the example's Skunk database. Releases include binary and source jars. `build.sbt` still omits Scaladoc jars. In `0.1.0-alpha.1` the reason was that Scala 3.5's documentation generator failed on the generated inline h3 facades. Those facades are gone, but publishing Scaladoc again has not been tested. The [documentation website](https://github.com/yuri4n/h34s/tree/v0.2.0-alpha.1/website/content) and source jars remain the API guides. ## Verified, and how On macOS x86_64 with JDK 25 and Node 24.19.0: 276 Scala tests across the native layer, the Kyo core, the ZIO adapter, both schema bridges, storage and the ZIO demo; the npm package's Node tests; fresh Maven consumers for Kyo, ZIO and both schema bridges (Kyo consumers resolve no ZIO); a production build of the demo serving the Kyo and ZIO routes, debug mode with `NUXT_H34S_DEBUG=true`, and releasing the shared pool on `SIGTERM`; and `nuxt dev` with development diagnostics and Kyo stacks mapped to Scala sources. For the generated routes on `main`, the demo has no route files: a production build served the Kyo and ZIO routes from the manifests, a type check proved typed `$fetch` from the declarations, and the packed module mounted a route added while `nuxt dev` ran. In a Claude Code cloud session on Ubuntu 24.04.5, x86_64 (2026-10-19): a production build of the demo; the ZIO Notes routes against PostgreSQL 16 with `scram-sha-256`, and the release of the pooled sessions on `SIGTERM`; and the kyo-sql code of the [database guide](/guide/database#run-the-kyo-sql-tests), which passed its 5 tests against PostgreSQL 16 in Node through sbt. In CI, on GitHub's Linux x86_64 runners: every Scala test suite, production linking and the four fresh Maven consumers, on the merge commit and again in the release workflow; the packed npm module in Nuxt 4.3.1 with development and production HTTP checks; and, after publication, the four consumers resolved from GitHub Packages and a byte-for-byte check of the downloaded npm archive. The `postgres` job runs the kyo-sql suite, the demo's production build and the Notes routes against PostgreSQL 16 on pull requests and on pushes to `main` that touch the libraries or the example. It skips release tags, so it does not gate a release. Details and run links are in [compatibility](/reference/compatibility). ## Not verified yet - Relinking the real demo under `nuxt dev`, and breakpoint stepping (diagnostics and source maps under `nuxt dev` were checked). - kyo-sql inside Nitro: the [database guide](/guide/database) code ran in Node through sbt, not in a Nitro bundle. - NuxtHub KV as a Nitro storage mount. - Linux beyond the CI checks, Windows, Nix, and non-Node Nitro presets. ## Limits to plan around - **Generated types:** response bodies only. Request bodies and query strings have no generated type, and a converter that does not describe itself gives `unknown`. - **Streaming:** in `0.2.0-alpha.1`, direct Node HTTP only; on `main`, also Nitro's in-process local fetch. No end-to-end memory bound, because h3 does not wait for `drain`. - **ZIO Schema:** no enums, sealed traits or maps yet; no YAML on Scala.js. - **Finalizers:** failures are logged and do not change the response, in Kyo and in ZIO. - **Locale data:** Kyo brings a large currency database; exclude it in applications that do not need it. - **Build memory:** Nitro bundles the linked Scala.js twice. With `scalaPrerender: 'stub'`, which the demo sets, the demo's `nuxt build` passed with a 4096 MiB heap limit. The demo's scripts keep `--max-old-space-size=8192` as a margin; see [build memory](/reference/compatibility#known-gaps). ## Planned 1. Nix and sbt-native-packager paths over the same build pipeline. 2. Interactive documentation: compiler-derived types, symbol links and runnable examples. The documentation shows static source examples. It has no semantic hovers, no editable compiler and no hosted playground yet. ## About this documentation This documentation is written with AI assistance under the maintainer's direction, and errors are tracked as [issues](https://github.com/yuri4n/h34s/issues). ## Publish a release Maintainers validate, tag and publish releases with the procedure in [the maintainer release guide](https://github.com/yuri4n/h34s/blob/main/docs/maintainers/releases.md). --- # Compatibility > Tested profiles with their evidence, kept apart into locally verified, CI-validated and untested combinations. A compatibility profile is a set of versions that was tested together, on a stated platform, with stated evidence. This page keeps three kinds of evidence apart: - **Locally verified:** run by hand or by an agent on one machine. Useful, but not repeatable by others yet. - **CI-validated:** run by a GitHub Actions workflow, with a link to the run. - **Untested:** configured or expected to work, with no evidence. Do not rely on it. Listing a library here does not make it a dependency of your application. Add only the modules you use. ## Profile `alpha-0.2.0` (`0.2.0-alpha.1`, published) Version `0.2.0-alpha.1`, with the Kyo core and the ZIO adapter. It was published on 2026-10-18 from commit `433ac9d`, the merge of [#14](https://github.com/yuri4n/h34s/pull/14): the [Maven prerelease](https://github.com/yuri4n/h34s/releases/tag/v0.2.0-alpha.1) and the [npm release](https://github.com/yuri4n/h34s/releases/tag/npm-v0.2.0-alpha.1). The [release evidence](#release-workflows) below links the workflows that published and verified the packages.
Component Version
Artifacts nine dev.yuri4n modules, 0.2.0-alpha.1 , GitHub Packages (public)
Scala (every module) 3.9.0
Scala.js linker 1.22.0
Build JDK 25 (checked with Temurin 25.0.4.1+1)
sbt 1.11.7
Kyo 1.0.0-RC7 (kyo-core, kyo-zio, kyo-schema; kyo-schema-json and kyo-schema-yaml in tests)
ZIO 2.1.26; zio-prelude 1.0.0-RC41; zio-json 0.7.44; zio-schema 1.7.5
NativeConverter 0.9.0
h3 1.15.4 (tests and the demo's runtime dependency)
Skunk (demo only) 1.0.0-M12
Node / pnpm 24 (checked with 24.19.0) / 10.34.4
Nuxt (demo lockfile) 4.3.1, with Nitro 2.13.1
npm tooling @yuri4n/h34s-nuxt@0.2.0-alpha.1 , private GitHub Packages (the repository demo uses the workspace link)
### Locally verified On macOS 15.7.5, x86_64, during the refactor (2026-10-17):
Check Result
h3Ext/test 36 passed
kyoH3Ext/test 79 passed
kyoNuxtHubExt/test 15 passed
nativeConverterKyoSchemaExt/test 25 passed
zioH3Ext/test 81 passed
zioNuxtHubExt/test 14 passed
nativeConverterZIOSchemaExt/test 20 passed
nuxtsvr/test (ZIO demo) 6 passed
nativeConverterZIOJsonExt , zioMigrationsExt compile
kyoExample/fastLinkJS , nuxtsvr/fastLinkJS link
releaseCheck (POMs for the 9 release modules) passed
Node tests: scripts/*.test.mjs , packages/nuxt/test/*.test.mjs All 47 passed (after the Phase D review fixes)
Documentation snippets that do not come from compiled sources (Kyo and ZIO guides, configuration, lifetimes, storage, streaming, testing, adoption, and the kyo-sql code with kyo-sql-postgres 1.0.0-RC7) Compiled in a temporary test file against this branch ( kyoNuxtHubExt/Test/compile , zioNuxtHubExt/Test/compile ); not run
Fresh Maven consumers ( scripts/check-consumers.mjs ) for Kyo, ZIO, Kyo Schema and ZIO Schema, from a temporary Maven repository Compiled, linked and served real h3 requests with the new wire format; the Kyo consumers resolved no dev.zio artifact
Kyo demo over a plain h3 server with a preemptive router (Nitro's router mode) GET and POST 200 with {"data":...} ; 422 entries with path for a blank or 81-character name; generic 422 for a wrong shape; 400 for invalid JSON; Cache-Control: no-store on every response
ZIO demo over the same h3 server /api/test answers abc ; /api/notes without the plugin is an opaque 500; with a fake plugin record and no database, a 500 with debug.cause and no password in it, and one close hook registered and released
Production build of the demo (Scala full link, nuxt build , then the Node server from .output/ ) Built with NODE_OPTIONS=--max-old-space-size=8192 (the default heap ran out of memory). Kyo routes: the same 200, 422, 400 responses, and 415 for text/plain . /api/test streamed abc . /api/notes without a database: opaque 500. With NUXT_H34S_DEBUG=true : debug.cause present, password absent. SIGTERM : the ZShared pool registered on the first Notes request was released, exit status 0
Production build after the review fixes (same machine, same day) kyo-smoke.mjs passed with the default salutation and with NUXT_GREETING_SALUTATION=Hi (Kyo RuntimeConfig.load in a Shared ); smoke.mjs with H34S_SMOKE_SKIP_DATABASE=true passed without PostgreSQL (invalid Notes input is a 422 before any database work); streams carry text/plain; charset=utf-8 and nosniff by default
nuxt dev on the demo (Nuxt 4.3.1, pnpm dev after pnpm run scala:fast ) Kyo GET 200 with {"data":...} and no-store ; a blank name 422 with path name ; /api/test answered abc ; /api/notes without a database answered a 500 with debug.cause (Scala stack frames, no password). A Kyo handler that threw a JavaScript TypeError (a temporary probe route) had its stack mapped to RequestLifecycle.scala and KHandler.scala
scripts/check-npm-nuxt.mjs (the packed module in a synthetic Nuxt 4.3.1 project) Development diagnostics, direct loading, Scala source maps, a relink reload, production bundling, and NUXT_H34S_DEBUG=true at runtime
The Scala tests use real h3 1.15.4 events with mocked Node responses. They do not start Nuxt or Nitro. The consumer and production-build checks come from Phase C1 of the refactor, on the same machine. ### Locally verified in a cloud session (2026-10-19) In a Claude Code cloud session: Ubuntu 24.04.5, x86_64, 4 vCPUs, 15.7 GiB memory, Node 24.22.0, pnpm 10.34.4, OpenJDK 25.0.4.1, sbt 1.11.7, Nuxt 4.3.1, Nitro 2.13.1, Nuxt Content 3.11.2, and PostgreSQL 16.15 (`psql (PostgreSQL) 16.15 (Ubuntu 16.15-0ubuntu0.24.04.1)`), from commit `f1fb8e1`; the kyo-sql row with `KyoSqlPostgresSuite` from commit `314546f` (the first four tests also from `70688b2`). The [development guide](https://github.com/yuri4n/h34s/blob/main/docs/development.md#cloud-session-production-build-and-postgresql-2026-10-19) has the full record.
Check Result
Production build of the demo ( pnpm run scala:full , then nuxt build with --max-old-space-size=8192 ) Passed; 2:14 for pnpm run scala:full (cold sbt start, compile and link), 2:15 for the Nuxt build, 6.5 GiB peak RSS
ZIO Notes against PostgreSQL 16 with scram-sha-256 (production server, .env.example variables) smoke.mjs passed with the database. POST /api/notes 201; blank text 422 with path text ; {"text":42} the generic 422; GET /api/notes 200 with the note
Skunk 1.0.0-M12 SCRAM login in the production bundle PostgreSQL logged connection authenticated: identity="h34s" method=scram-sha-256
SIGTERM with 4 pooled sessions open Exit status 0; 4 disconnections logged; 0 sessions of the user in pg_stat_activity
kyo-sql code of the database guide , kyo-sql-postgres 1.0.0-RC7, in Node through sbt kyoExample/test with H34S_POSTGRES_URL 5 passed, twice: insert and list, unique violation to DuplicateNote , rollback on a non-SQL Abort , rollback (with SqlRequestTransactionFailedStatementException ) when the body recovers from a failed statement, and a failed nested transaction that rolls back only its own part. The nested test also checks that the nested step failed with SQLSTATE 23505. After each run, no suite row was left in notes . kyo-net selected its node backend by itself. Without the variable, the 5 tests are skipped; with H34S_POSTGRES_REQUIRED=true and no variable, they fail
pnpm run test:package with the scalaPrerender: 'stub' checks (commit ae4c502 ) Passed. check-npm-nuxt.mjs set the option in the packed consumer. A build that also prerendered a route calling a Scala.js route through event.$fetch , and catching the 501, failed with the [h34s] warning and the list of requests. The build that prerendered only a route without Scala.js passed: its prerender bundle had the stub and no linked code, its server output had the linked code and no stub, and the production server ran a directly mounted linked handler. Without the prerender:done report or without the stub plugin, the check failed
pnpm run test:package after the review fixes (commit 68f6100 ) Passed in 44 s. The build that prerendered the route calling a Scala.js route failed with the [h34s] warning and the list, and its log had no [request error] or [unhandled] line. A third build also prerendered the Scala.js route itself: it failed with Nitro's Errors prerendering: summary and Exiting due to prerender errors. , h34s logged the list of both requests, and the log had no unhandled-error line
#### Heap measurement (2026-10-19) Each row is one `nuxt build` of the demo, run alone, on the production link above. A variant is a temporary change that was reverted afterwards, except the last three rows: the module option `scalaPrerender: 'stub'`, which the demo now uses. The 4096 MiB limit stands for Node's default heap on a 16 GB Mac (about 4 GiB). On this VM, Node's own default limit is 2240 MiB (`env -u NODE_OPTIONS node -e 'console.log(require("v8").getHeapStatistics().heap_size_limit/1048576)'`). The session's environment set `NODE_OPTIONS=--max-old-space-size=8192`; a command-line `--max-old-space-size` overrides it for the build process. In a full 8192 MiB build sampled every 10 seconds (14 samples), the only child processes were two esbuild services, which have no Node heap, so the limits in the table apply to the build's only Node process. Peak RSS is the largest process, from GNU `time -v` (KiB), given in GiB.
Variant Heap limit Result Peak RSS Wall time
Baseline (both applications, server source maps, prerendering) 8192 MiB (the scripts' option) Passed 6.50 GiB 2:15
Baseline Node's real default on this VM (2240 MiB; NODE_OPTIONS removed) Out of memory while it built the prerender bundle ("Initializing prerenderer") 2.58 GiB 1:00
Baseline 6144 MiB Passed 5.46 GiB 2:06
Baseline 4096 MiB Out of memory in "Building Nuxt Nitro server", after prerendering 4.66 GiB 2:36
No server source maps ( sourcemap.server=false in .nuxtrc ) 4096 MiB Passed; no .map file in .output/server 4.34 GiB 1:56
No prerendering (a temporary prerender:routes hook that clears the one route) 4096 MiB Passed 4.02 GiB 1:16
Kyo application only (the ZIO route files moved away) 4096 MiB Passed; prerendering took 17 s 3.41 GiB 1:01
ZIO application only (the Kyo route files moved away) 4096 MiB Passed; prerendering took 33 s 4.46 GiB 1:46
No full locale database ( locales-full-currencies-db excluded from nuxtsvr too) Not built The database jar is on the ZIO application's classpath ( show nuxtsvr/Compile/fullClasspath ), but excluding it did not change the linked output (same name, size and time stamp), so the build input does not change Not measured Not measured
Baseline, a second run on the same day 8192 MiB Passed; prerendering took 54 s 6.42 GiB 2:13
Stub through a temporary nuxt.config hook that added the same Rollup plugin to the prerender build, before the option existed 4096 MiB Passed; prerendering took 5.7 s. The plugin replaced exactly the 5 Scala.js route entries ( kyo-greeting-get , kyo-greeting-post , notes-get , notes-post , test ). The prerendered sql_dump.txt had the baseline's SHA-256 ( 15069d1d1d010d84d8b3f879a12c2efaf4335d6e5feb5c154127f0df1574573d ), and the stub's text was not in .output/server . The postgres CI job's serve step, run on this VM ( smoke.mjs with PostgreSQL, 4 sessions before SIGTERM , exit status 0, 0 after), passed on this build 4.13 GiB 1:41
The same hook Node's real default on this VM (2240 MiB; NODE_OPTIONS removed) Prerendering passed (3.5 s), then out of memory in "Building Nuxt Nitro server" 2.58 GiB 0:43
scalaPrerender: 'stub' , the module option (commit cca91f5 ) 4096 MiB Passed; prerendering took 3.8 s. sql_dump.txt had the same SHA-256. The same serve step passed on this build. The contents of the prerender bundle and of .output/server were not recorded for this build 4.05 GiB 1:23
scalaPrerender: 'stub' , the module option with the request report (commit ae4c502 ) 4096 MiB Passed; prerendering took 3.6 s, and no request reached the stub. The prerender bundle (90 files, 1,207,913 bytes, about 1.2 MB; du -sh reports 1.5M of disk use) mounts the stub for all 5 Scala.js routes; .output/server bundles the Scala.js and has no stub text. sql_dump.txt had the same SHA-256. The same serve step passed, and kyo-smoke.mjs passed against the same server build, which then exited with status 0 on SIGTERM 4.07 GiB 1:18
scalaPrerender: 'stub' , the stub's 501 as a handled h3 error (commit 68f6100 ) 4096 MiB Passed; prerendering took 3.9 s, and no request reached the stub (no [h34s] , [request error] or [unhandled] line). The prerender bundle (90 files, 1,208,871 bytes, about 1.2 MB) mounts the stub for all 5 Scala.js routes and has no Scala.js classes; .output/server bundles the Scala.js and has no stub text. sql_dump.txt had the same SHA-256. This build was not served 4.06 GiB 1:18
Cause: Nitro 2.13.1 bundles the linked Scala.js (16.5 MB of JavaScript and 13.2 MB of source maps) twice in one process. First it builds a complete `nitro-prerender` server for the one prerendered Nuxt Content route and imports it, which took 47 to 59 s across the baseline runs. Then it builds the `node-server` output. Removing the prerendered route, the server source maps, or one application each let the build pass with a 4096 MiB heap limit; peak RSS was then 3.4 to 4.5 GiB. The measurement does not separate how much memory each Rollup build keeps. A first attempt at the source-map row appended to `.nuxtrc` without a newline, so Nuxt ignored the setting; those two runs are not in the table. The module option `scalaPrerender: 'stub'` keeps the Scala.js out of the first bundle. With it, the build passed with a 4096 MiB heap limit. With the equivalent temporary hook, it did not pass with 2240 MiB: the stub removes the duplicate bundle, not the need for more than about 2.2 GiB. ### CI-validated Pull request CI for [#14](https://github.com/yuri4n/h34s/pull/14) passed on GitHub's `ubuntu-24.04` runners (Linux x86_64) for commit `8f9e222`, with version `0.2.0-SNAPSHOT`: - The [Scala.js libraries workflow](https://github.com/yuri4n/h34s/actions/runs/39075718558): the Node tests, every Scala test suite, production linking of both demo applications, `releaseCheck`, `check-release.mjs --allow-snapshot`, and the four fresh Maven consumers. - The [Nuxt build tooling workflow](https://github.com/yuri4n/h34s/actions/runs/39075718554): package tests and the packed-archive check on Node 22.18.0 and 24, and the Nuxt 4.3.1 development and production HTTP checks. - The [documentation website workflow](https://github.com/yuri4n/h34s/actions/runs/39075718873). The same workflows passed again on the merge commit `433ac9d` on `main`, with version `0.2.0-alpha.1`: [Scala.js libraries](https://github.com/yuri4n/h34s/actions/runs/39144782174), [Nuxt build tooling](https://github.com/yuri4n/h34s/actions/runs/39144782180) and the [documentation website](https://github.com/yuri4n/h34s/actions/runs/39144782493). #### Release workflows Both tags point at `433ac9d`. The workflows ran on GitHub's Linux x86_64 runners, and the npm release finished before the Maven release started. - The [npm release workflow](https://github.com/yuri4n/h34s/actions/runs/39144809255) for `npm-v0.2.0-alpha.1`: package tests and the packed-archive check on Node 22.18.0 and 24, then the packed module in a Nuxt 4.3.1 project with development and production HTTP checks. It published `@yuri4n/h34s-nuxt@0.2.0-alpha.1`, downloaded it again with registry authentication, and required the download to be byte-identical to the tested archive before an offline install checked the CLI and the JavaScript entry points. The [npm release](https://github.com/yuri4n/h34s/releases/tag/npm-v0.2.0-alpha.1) attaches that archive and `registry.json` with its registry location and integrity. - The [Maven release workflow](https://github.com/yuri4n/h34s/actions/runs/39145732257) for `v0.2.0-alpha.1`: the validation job (development integration, every Scala test suite, production linking, publication metadata, and the four fresh consumers from a staged repository), then publication of the nine artifacts to GitHub Packages. `node scripts/check-consumers.mjs --published` then resolved the Kyo, ZIO, Kyo Schema and ZIO Schema consumers from the authenticated registry, and each one served real h3 requests. The [prerelease](https://github.com/yuri4n/h34s/releases/tag/v0.2.0-alpha.1) was created only after that check passed. #### Downstream consumer The private `yurian.land` application consumes the published packages in its pull request [#15](https://github.com/yuri4n/yurian.land/pull/15). It resolves `kyo-h3-ext`, `kyo-nuxthub-ext` and `native-converter-kyo-schema-ext` `0.2.0-alpha.1` from GitHub Packages and installs `@yuri4n/h34s-nuxt@0.2.0-alpha.1` with Nuxt 4.4.2. Its [CI run](https://github.com/yuri4n/yurian.land/actions/runs/39147055600) for commit `0337ccb` passed on GitHub's Ubuntu 24.04 runner: Node and Scala tests, a production Nuxt build, and API checks against PostgreSQL 16 and Redis. This is evidence for one Kyo application, not for the ZIO modules or other Nuxt setups. ### Untested
Area Status
Relinking the real Scala.js demo under nuxt dev , and breakpoint stepping Not checked (the relink reload was checked on a synthetic project)
The Nitro close hook when nuxt dev replaces its worker Not checked; SIGTERM in production was
kyo-sql / kyo-sql-postgres inside Nitro, including KYO_NET_BACKEND=node Not run. The database guide service code ran against PostgreSQL 16 in Node through sbt (see above), not in a Nitro bundle
kyo-sql guide code vs KyoSqlPostgresSuite Copied by hand; no check keeps them identical (byte-identical on 2026-10-19). The postgres CI job compiles and runs the suite, not the guide
PostgreSQL in CI The postgres job of the Scala.js libraries workflow runs PostgreSQL 16 with SCRAM, the kyo-sql suite ( H34S_POSTGRES_REQUIRED=true , so it cannot skip), a check that the suite left no rows, the demo's production build, smoke.mjs and the SIGTERM session check. It runs on pull requests and pushes to main that touch the libraries or the example. It skips release tags ( release.yml reuses this workflow), so it does not gate a release. Its first run on #45 failed: the SIGTERM check counted PostgreSQL's logical replication launcher, which runs as the bootstrap superuser h34s in the job's container. The corrected job passed on 042cef3 on GitHub's ubuntu-24.04 runner: the suite 5 of 5 with 0 rows left, pnpm build in 1:43, smoke.mjs , and 4 sessions before SIGTERM , exit status 0, 0 after. No run on main or a release tag yet
NuxtHub KV as a Nitro storage mount Not verified; storage tests use Unstorage 1.17.3's memory driver
Nitro presets other than Node (serverless, edge, Workers) Not tested; streaming needs a Node HTTP response
Linux beyond the CI checks above, Windows Not run
Nix / NixOS No Nix profile exists yet
Other Scala, Kyo, ZIO or h3 versions Not supported; Kyo 1.0.0-RC7 needs Scala 3.9.0 and JDK 25
### Known gaps - **ZIO YAML on Scala.js.** zio-json-yaml is published for the JVM only (no `_sjs1_3` artifact on Maven Central), so a ZIO model has no YAML codec in Nitro. Kyo models get YAML from kyo-schema-yaml. - **ZIO Schema shapes.** The ZIO bridge supports strings, booleans, `Int`, `Double`, records, options, sequences, lazy and transformed schemas. Enums, sealed traits and maps are not supported yet. See [schemas](/guide/schema). - **Finalizer failures** are logged and do not change the response, in Kyo and in ZIO. Before the next release (provisionally `0.3.0-alpha.1`), a dying ZIO finalizer turned a value response into a 500. See [lifetimes](/guide/lifetimes). - **Build memory.** The demo's `nuxt build` keeps `--max-old-space-size=8192`. Nitro bundles the linked Scala.js twice (once for the prerendered Nuxt Content route, once for the server) with its source maps. Without `scalaPrerender: 'stub'`, a 4096 MiB heap limit runs out of memory, and a 6144 MiB limit passed in one run. The demo now sets `scalaPrerender: 'stub'`: with it, a 4096 MiB limit passed. With an equivalent temporary hook, Node's 2240 MiB default on a 15.7 GiB cloud VM ran out of memory. Without the prerendered route, without server source maps, or with one application, the build also passed with a 4096 MiB heap limit; see the [heap measurement](#heap-measurement-2026-10-19). The scripts keep 8 GiB as a margin. A single-runtime application links less code, but check your own build. ## Profile `alpha-0.1.0` (published) The published `0.1.0-alpha.1` release, with the older API and two independent runtimes. See the [migration guide](/reference/migration) to move off it.
Component Version
Artifacts eleven dev.yuri4n modules, 0.1.0-alpha.1 , GitHub Packages (public)
Scala 3.5.2 for native and ZIO modules; 3.9.0 for Kyo modules
Scala.js / Kyo / ZIO 1.22.0 / 1.0.0-RC7 / 2.1.23
Build JDK / sbt 25 / 1.11.7
npm tooling @yuri4n/h34s-nuxt@0.1.0-alpha.1 , Node 22.18 or later, Nuxt 4.3.1 or 4.4.2
CI-validated: the [Maven release workflow](https://github.com/yuri4n/h34s/actions/runs/38779659501) checked authenticated ZIO, Kyo and Schema consumers on Linux x86_64. The [npm release workflow](https://github.com/yuri4n/h34s/actions/runs/38937318206) checked the registry archive in an offline consumer. Nuxt 4.3.1 passed the packed-package HTTP checks in CI; Nuxt 4.4.2 passed them locally on Linux x86_64 with Node 24.19.0. ## Nix No Nix profile exists. When one does, record its lock revision in the profile, run the same sbt/pnpm pipeline inside it, and validate the packaged Node application. A working development shell alone does not establish reproducible builds. ## Add a profile Add a row only with exact versions, a platform and evidence. Promote a profile to "supported" only after CI runs its compilation, handler tests, optional integrations and consumer setup, and link those runs here. --- # Migration guide > Every removed or renamed API with its replacement, the new wire format, and the new toolchain. Each section lists every removed or renamed API with its replacement. Start with the section for the version you use now. ## Upgrade from 0.2.0-alpha.1 to 0.3.0-alpha.1 **Status.** `0.3.0-alpha.1` is not released yet. This section collects its changes while they land on `main`. ### Send streams with `Response.sendStream` A handler no longer returns a stream. It sends the stream with `Response.sendStream(stream)` as its last step and returns the `Sent` value that this gives. A handler that returns a `kyo.Stream` or a `ZStream` stops compiling, with a message that names `Response.sendStream(stream)`.
0.2.0-alpha.1 0.3.0-alpha.1
Kyo: return a kyo.Stream[String, S] (the ResponseBody.stream given) Response.sendStream(stream) , which gives Sent < HandlerEffects ; return the Sent . The ResponseBody.stream given is removed
ZIO: return a ZStream[Any, E, String] (the ZResponseBody.stream and infallibleStream givens) Response.sendStream(stream) on dev.yuri4n.exts.zioh3.Response , which gives ZIO[ZState[ResponseHead] & HandlerTransport, Throwable, Sent] ; return the Sent . Both givens are removed, with StreamZResponseBody , the public trait that held them
HandlerEffects = Async & Scope & Abort[Throwable] & Var[ResponseHead] HandlerEffects also has Env[Fetch & HandlerTransport] , which h34s provides. Code that passes a program to KHandler.define compiles unchanged
HandlerEnv = ZState[ResponseHead] & Scope HandlerEnv = ZState[ResponseHead] & Scope & HandlerTransport & Fetch , which h34s provides
Test with Var.runTuple or ZIO.stateful and a Scope KHandler.test(handler)(request) or ZHandler.test(handler)(request) , which give a TestResponse . The manual provisioning no longer compiles: it lacks the transport, which only h34s can build
There is no `ZHandler.sendStream`. `0.1.0-alpha.1` had one with another signature, `ZHandler.sendStream[A: NativeConverter](stream: TaskStream[A]): TaskHandler[Unit]`, and `0.2.0-alpha.1` removed it. If you upgrade from `0.1.0-alpha.1`, use `Response.sendStream` with a stream of strings. The wire behavior changes too: - **The head leaves at the call.** `sendStream` writes the head as your program set it when you call it, and over a socket it flushes it at once. Before, h34s wrote the head with the first element. Set the status and headers, validate input and acquire resources before the call: after it, the head is fixed, and a later change is not written. - **A failure before the first element no longer changes the status.** Before, it was rendered with the failure rule, for example as a 409 with an error body. Now h34s closes the connection, as it already did for a failure after the first element. - **A stream failure fails the sendStream step.** Your program, and a scope or transaction around the step, see the stream's error and can roll back. In Kyo, that scope is a `Scope.run` inside your handler: a finalizer of the request scope itself gets `Absent`. The server logs the failure once. Before, the program never saw it. - **In-process calls stream.** A server-side `$fetch` to a streaming route answered an opaque 500 before. Now the caller gets the head first, then the elements. To upgrade: 1. Replace `.andThen(stream)` (Kyo) or `ZIO.succeed(stream)` (ZIO) at the end of each streaming handler with `Response.sendStream(stream)`. 2. Move validation and resource acquisition before the call, if they ran inside the stream. 3. Update tests that read the head or body of a streaming handler: use `KHandler.test` or `ZHandler.test`, and check `TestResponse.Body.Streamed(elements)`. See [testing](/guide/testing). 4. Update clients that expected an error body for a stream that fails before its first element. They now see a successful status and an incomplete transfer. See [streaming](/guide/streaming) for the details. ### Call other routes with `$fetch` `0.2.0-alpha.1` removed the server-side `$fetch`. It returns, with a new signature, in both runtimes: `KHandler.$fetch` and `ZHandler.$fetch`, with the verbs `raw`, `text`, `as`, `get` and `post`. It answers a path that starts with `/` in-process through Nitro, and forwards the incoming cookies and `authorization` only to such paths. See [Kyo handlers](/guide/kyo#call-other-routes-with-fetch) and [ZIO handlers](/guide/zio#call-other-routes-with-fetch).
0.2.0-alpha.1 0.3.0-alpha.1
No server-side $fetch ( 0.1.0-alpha.1 had ZHandler.$fetch and SH3Event.$fetch ) KHandler.$fetch (Kyo, through Env[Fetch & HandlerTransport] ) and ZHandler.$fetch (ZIO, through the Fetch of HandlerEnv ). raw gives FetchResponse.Plain or FetchResponse.Streamed
HandlerEnv = ZState[ResponseHead] & Scope HandlerEnv = ZState[ResponseHead] & Scope & HandlerTransport & Fetch . Code that passes a program to ZHandler.define compiles unchanged
No test helper ZHandler.test(handler)(request) keeps working; ZHandler.test(handler)(request, fetch) answers $fetch calls, for example with Fetch.fake , as KHandler.test(handler)(request, fetch) does
Keep three rules in mind. The first differs from Nuxt's `event.$fetch`, which throws on an error status and retries a failed GET once: - **Statuses do not throw, and nothing retries.** `raw` gives any status as a response. `text`, `as`, `get` and `post` fail with the typed `FetchError.Status` unless the status is 2xx. - **A streamed body is typed.** `FetchResponse.Streamed.body` is `Stream[String, Async & Scope & Abort[FetchError]]` in Kyo and `ZStream[Any, FetchError, String]` in ZIO. A body that breaks after its head fails with `FetchError.BodyFailed(status, reason)`. In the unreleased `main` before this change, the Kyo body was `Stream[String, Async & Scope]`, and the failure was a panic: add `Abort[FetchError]` where you name that type. A `Plain` body whose read fails after the head is `BodyFailed` too (it was `Failed`). A `match` on `FetchError` needs a case for the new `BodyFailed`. - **The JSON is decoded as it arrives.** `get[A]` on an h34s route with the default options decodes `{"data": ...}`, so `A` must model that envelope. `FetchResponse.headers` joins repeated values with `", "`, `set-cookie` included; `headerValues(name)` gives each `set-cookie` value apart. In the unreleased `main` before this change, `headers` kept only the last `set-cookie`. ### Mount handlers from Scala Mount your handlers from Scala, then delete the route files: 1. Add `.at(Route.get("/api/notes"))` (with the route and method of the file) to each exported handler. 2. Remove `entries` from `h34s.config.mjs`, or keep them for outputs that still have route files. 3. Link with `h34s-scala dev`. It prints the routes it found and writes `h34s-routes.json`, the `.d.ts` files and `h34s-models.d.ts`. 4. Delete the `server/api` files that re-export the linked modules, and the hand-written `declare module` types for them. A route file and `.at` for the same route register the handler twice. Route files keep working if you want to move later. See [routes and typed `$fetch`](/guide/routes). ### Non-finite numbers are an opaque 500 A value body that contains `NaN` or an infinite number (`Double`, `Float`) is now the opaque 500. `0.1.0-alpha.1` and `0.2.0-alpha.1` sent these numbers as `null`. If a value can be non-finite, return an `Option` or a string for it instead. ### JSON reads require a content type `HandlerOptions.requireJsonContentType` now defaults to `true`. `request.body.json[A]` answers 415 (`Request body must be JSON`) for a request that has a body but no `Content-Type`. A request without a body needs no content type: `json[Option[A]]` still gives `None` and a model still answers 422. In `0.2.0-alpha.1`, a body without a content type was read as JSON. Requests with `application/json` or `application/*+json` behave as before, and `text()` never checks the content type. 1. Check your clients. `$fetch` with an object body and `curl --json` send `application/json`. A `fetch` or `navigator.sendBeacon` with a byte body (`Blob` without a type, `ArrayBuffer`, typed array) and no `content-type` header sends no content type; these clients now get 415. Clients that send another content type, such as `fetch` with a string body (`text/plain`) or `curl -d` (`application/x-www-form-urlencoded`), already got 415 in `0.2.0-alpha.1`. 2. Make each client send `content-type: application/json`. This is the recommended fix. 3. If a client cannot send it, keep the old behavior for that handler only: `KHandler.define(HandlerOptions(requireJsonContentType = false))(program)`, or the same options with `ZHandler.define`. Do not do this for a cookie-authenticated handler that changes state; see [cross-site requests](/guide/kyo#cross-site-requests). 4. In unit tests, add `headers = Map("content-type" -> "application/json")` to each `Request.fake` with a body that you read with `json[A]`. `Request.fake` follows the same default; pass `requireJsonContentType = false` to test a handler that turns the option off. ### A dying ZIO finalizer keeps the response In `0.2.0-alpha.1`, a ZIO finalizer that died after a value or `Unit` program turned the response into an opaque 500, and after a safe failure it replaced the approved error with the opaque 500. Now the ZIO adapter behaves like Kyo: it logs `h34s: a ZIO finalizer failed` with `Cause.prettyPrint` on the server, and the client gets the response that your program produced. This also applies to a streaming handler, before and after it calls `Response.sendStream`. Streamed bodies already behaved this way. You do not need to change code that compiles. Check your handlers if you relied on the old 500: 1. Find cleanup in `ZIO.acquireRelease`, `ZIO.addFinalizer` or a scoped `ZLayer` that must succeed for the response to be correct, for example a commit in a finalizer. 2. Move that step into the program, before the value is returned. A failure there follows the failure rule as before. 3. Watch the server log for `h34s: a ZIO finalizer failed` to see finalizers that fail. The rule applies to the 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. ### `h34s-scala` runs sbt in `sbtRoot` `h34s.config.mjs` has a new optional key, `sbtRoot`: the directory of your sbt build, relative to the config file. Its default is the config file's directory. `h34s-scala` runs sbt there; in `0.2.0-alpha.1`, it ran sbt in the application root (`--root`). The outputs stay relative to the application root. See [npm tooling](/guide/npm-tooling#point-the-cli-at-your-sbt-build). Nothing changes when `h34s.config.mjs` sits next to your `build.sbt`, the usual setup. Check your scripts if they pass `--config` with a file in another directory: 1. Set `sbtRoot` in that file to the directory of your sbt build, relative to the file. For example, `sbtRoot: '..'` for a config in `config/h34s.config.mjs` of an app whose `build.sbt` is in the app root. 2. Run your `scala:fast` script, or `h34s-scala dev --config `. If the directory has no `build.sbt` and no `project/` directory, the command now stops before sbt starts, with an error that names `sbtRoot`, the directory and the config file. `loadConfig` from `@yuri4n/h34s-nuxt/build` now returns a copy of the config with `sbtRoot` as an absolute path. ## Upgrade from 0.1.0-alpha.1 to 0.2.0-alpha.1 The Kyo core refactor changes the module graph, the handler shape and the wire format. This section lists every removed or renamed API with its replacement. Work through it in order: toolchain, dependencies, handlers, clients. **Status.** The new API is [`0.2.0-alpha.1`, which is published](https://github.com/yuri4n/h34s/releases/tag/v0.2.0-alpha.1). The `0.1.0-alpha.1` artifacts stay published and unchanged, so you can upgrade when you are ready. ### 1. Update the toolchain
0.1.0-alpha.1 Now
Scala 3.5.2 for native and ZIO modules, 3.9.0 for Kyo 3.9.0 for every module
Build JDK 21 was enough for ZIO-only builds 25 for every build (Kyo 1.0.0-RC7 macros)
ZIO 2.1.23 2.1.26 (pinned by kyo-zio 1.0.0-RC7)
zio-json as resolved 0.7.44
Kyo 1.0.0-RC7 1.0.0-RC7
Scala.js, NativeConverter, h3 1.22.0, 0.9.0, 1.15.4 unchanged
Set `scalaVersion := "3.9.0"` and run sbt with JDK 25. Production still runs on Node only. ### 2. Update dependencies
Module Change
zio-js-ext Removed. It was an empty package. Delete the dependency
nuxthub-ext Removed. HubKv and NativeKvSetOptions are gone; storage comes from KHubKv.mount / ZHubKv.mount
zio-h3-schema-ext Removed. Use native-converter-zio-schema-ext with request.body.json[A] (step 3)
native-converter-kyo-schema-ext New. Kyo Schema bridge: derives Schema, NativeSchema
zio-h3-ext Now depends on kyo-h3-ext and kyo-zio. ZIO applications get Kyo transitively
zio-nuxthub-ext Now depends on kyo-nuxthub-ext and zio-h3-ext
native-converter-zio-ext , native-converter-zio-schema-ext Now depend on h3-ext
h3-ext No longer exposes ScalablyTyped-generated h3 facades. The facades are internal
A ZIO application now pulls Kyo's dependency graph, including scala-java-locales' full currency database. If you do not need currency data, exclude it as described in [Kyo handlers](/guide/kyo#choose-locale-data-in-your-build). ### 3. Rewrite handlers The handler is now a function from an immutable `Request` snapshot to the body. There is no event object in the environment, and no API changes the native response while your program runs. #### Kyo
0.1.0-alpha.1 Now
KHandler.define(program) with a by-name A < HandlerEffects KHandler.define(request => program)
KHandler.define[A: NativeConverter] KHandler.define[B: ResponseBody] ; any NativeConverter value, Unit and kyo.Stream[String, S] are bodies
HandlerEffects with Env[KH3Event] HandlerEffects = Async & Scope & Abort[Throwable] & Var[ResponseHead]
KH3Event , KHandler.getEvent , rawEvent Removed. Use the Request argument
KHandler.readBody[A] , KH3Event.readBody[A] request.body.json[A]
KHandler.setResponseStatus(code) , KH3Event.setResponseStatus Response.status(Status.Created) or Response.status(Status.unsafe(code))
dev.yuri4n.exts.kyoh3.SafeError Same name, now defined in h3-ext and re-exported. It adds headers and errors
KHubKv(native) KHubKv.mount(base)
CommonEvHandler EventHandler (the old alias is deprecated)
Before and after: ```scala // 0.1.0-alpha.1 val personalizedGreeting = AppHandler.define: for input <- KHandler.readBody[GreetingRequest] greeting <- Env.use[Greetings](_.greet(input.name)) yield greeting // Now val personalizedGreeting: EventHandler = AppHandler.define: request => for input <- request.body.json[GreetingRequest] greeting <- Env.use[Greetings](_.greet(input.name)) yield greeting ``` Your `AppHandler.define` now takes `Request => B < (HandlerEffects & ...)` and calls `KHandler.define(options)(request => ...)`. See [Kyo handlers](/guide/kyo#compose-services-in-your-apphandler). #### ZIO
0.1.0-alpha.1 Now
ZHandler.define(program) with ZIO[SH3Event & Scope & R, E, A] ZHandler.define(request => program) with ZIO[HandlerEnv, ErrorResponse | Throwable, B]
SH3Event , ZHandler.getEvent Removed. Use the Request argument; HandlerEnv = ZState[ResponseHead] & Scope
ZHandler[R, E, A] , Handler , TaskHandler , URHandler , UHandler , RHandler Same names, now over HandlerEnv instead of SH3Event
ZHandler.readBody[A] , readBody[A](strict) , SH3Event.readBody request.body.json[A] . Parsing is always strict; a non-JSON content type is a 415
ZHandler.getQuery , getQuery[T](using Conversion) request.query , request.queryAll
ZHandler.getRouterParams , getRouterParam(name) request.routerParams , request.routerParam(name) , or request.param(name) (400 when absent)
GetRouterParamsError , SH3Event.MissingRouterParamError Removed. A missing parameter is HttpError(400, "Missing router parameter: ")
SH3Event.parseCookies request.cookies , request.cookie(name)
ZHandler.setResponseStatus(code) Response.status(...)
ZHandler.sendStream(stream) , SH3Event.sendStream Return the ZStream as the body. Elements are strings now: sendStream converted each element with its NativeConverter , which Node cannot write. Encode values yourself, for example stream.map(_.toJson + "\n")
respondWith , respondWithZIO Removed. Return the body; set the head with Response.*
ZHandler.$fetch , SH3Event.$fetch Removed. Call your service directly, or use an HTTP client
mkHandler , top-level defineEventHandler(h) and defineEventHandler(runtime, h) ZHandler.define . Put a given Runtime[Any] in scope for a custom runtime
ZIOH3.Console Removed. Use ZIO's Console or ZIO.log
taskStreamToReadableStream , taskStreamToReadableStreamReader , streamconversion Removed. Streams are bodies now
fromNative_ extension on js.Any Removed. Decode request bodies with request.body.json[A]
ErrorResponse(errors) ErrorResponse(errors, status = 400) . A one-field pattern match needs _ ; its JsonEncoder output now has status
ZIO SafeError with val message , status , maybeUnderlying The shared SafeError ( message , status , headers , errors ). maybeUnderlying is gone; a val message still works
ZHubKv(native) ZHubKv.mount(base)
ZHandler.readBodyWithSchema[A] , InvalidBody (zio-h3-schema) derives Schema, NativeSchema on the model, then request.body.json[A] ; failures are InvalidInput
NativeSchema(schema).converter returned a converter that failed with a generic error It now throws InvalidInput with entries; Codec gains validate ; derives NativeSchema is new
Before and after, from the demo's Notes route: ```scala // 0.1.0-alpha.1 val createNote = AppHandler.define: for input <- ZHandler.readBodyWithSchema[CreateNote] note <- ZIO.serviceWithZIO[Notes](_.create(input.text)) _ <- ZHandler.setResponseStatus(201) yield note // Now val create: Request => ZIO[HandlerEnv & Notes, Throwable, Note] = request => for input <- request.body.json[CreateNote] note <- ZIO.serviceWithZIO[Notes](_.create(input.text)) _ <- Response.status(Status.Created) yield note val createNote: EventHandler = AppHandler.define(create) ``` `CreateNote` now uses `derives NativeSchema` with its ZIO Schema in the companion. See [schemas](/guide/schema). ### 4. Update your clients The wire format changed. Update your `fetch` code, and remove the TypeScript interfaces you wrote for these bodies: the generated declarations type `$fetch` (see [routes and typed `$fetch`](/guide/routes)):
Response 0.1.0-alpha.1 Now (default options)
A value The value as is {"data": }
Unit undefined (h3 decided the status) 204 with no body
A stream Through sendStream The stream of strings as the body, as UTF-8 text; text/plain; charset=utf-8 unless you set a content type
A safe error {"errors": ["message"]} {"errors": [{"message": "..."}]} , with path for schema entries
An internal error {"errors": ["Internal server error"]} , plus top-level debug in development {"errors": [{"message": "Internal server error"}]} ; in development or debug mode the entry carries debug.cause
To keep the old success shape for a handler, pass `HandlerOptions(success = SuccessEnvelope.Raw)`. A `Raw` string is sent as `text/plain; charset=utf-8` and any other value as JSON text with `application/json`, unless you set a content type; h3 no longer guesses (it sent strings as `text/html`). There is no option for the old string-array errors. Body problems now have their own statuses: 415 for a non-JSON content type, 413 for a body above the limit (1 MiB by default), 400 for invalid JSON, and 422 for a body that does not match the converter or schema. The old schema reader answered 422 for invalid JSON and `Request body does not match schema` for every rejection. A plain `readBody` failure used to be an opaque 500. ### 5. Move configuration and storage into Scala - Read Nuxt runtime config with `RuntimeConfig` / `ZRuntimeConfig` instead of reading environment variables yourself. The demo moved from Ciris and `MY_APP_DB_*` to `runtimeConfig.database` and `NUXT_DATABASE_*`. See [configuration](/guide/configuration). - Replace application-side `@JSImport("@nuxthub/kv", "kv")` objects with `KHubKv.mount(base)` or `ZHubKv.mount(base)`. See [Nitro storage](/guide/storage). - Replace per-request database connections with an application pool in `Shared` or `ZShared`. See [lifetimes](/guide/lifetimes). ### 6. Check the Nuxt module Keep `@yuri4n/h34s-nuxt` in `modules`. Its Nitro plugin is now `runtime/h34s.mjs` and supplies the development and debug flags, runtime config, storage and the close hook. The module registers it for you. If you registered `runtime/development.mjs` yourself, remove that entry; the old file still re-exports the new plugin for one release. The module also declares `runtimeConfig.h34s.debug: false`, so `NUXT_H34S_DEBUG=true` turns on diagnostics at runtime. Nuxt's own `debug` option does not turn them on. Keep debug mode off in production; see [debugging](/guide/debugging). Use the package version that matches your Scala libraries: `@yuri4n/h34s-nuxt@0.2.0-alpha.1`. The `0.1.0-alpha.1` package lacks the new plugin records, and `RuntimeConfig` and `KHubKv.mount` then fail with `ConfigError`. ### Roll back To stay on the old API, keep your dependencies at `0.1.0-alpha.1`, Scala 3.5.2 for ZIO code, and `@yuri4n/h34s-nuxt@0.1.0-alpha.1`. Both stay published. Do not mix modules from the two versions in one application: their `SafeError` types and module graphs differ.