Reference

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.10.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] & ScopeHandlerEnv = ZState[ResponseHead] & Scope & HandlerTransport & Fetch, which h34s provides
Test with Var.runTuple or ZIO.stateful and a ScopeKHandler.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.
  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 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 and ZIO handlers.

0.2.0-alpha.10.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] & ScopeHandlerEnv = ZState[ResponseHead] & Scope & HandlerTransport & Fetch. Code that passes a program to ZHandler.define compiles unchanged
No test helperZHandler.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.

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.
  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.

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 <your file>. 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. 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.1Now
Scala3.5.2 for native and ZIO modules, 3.9.0 for Kyo3.9.0 for every module
Build JDK21 was enough for ZIO-only builds25 for every build (Kyo 1.0.0-RC7 macros)
ZIO2.1.232.1.26 (pinned by kyo-zio 1.0.0-RC7)
zio-jsonas resolved0.7.44
Kyo1.0.0-RC71.0.0-RC7
Scala.js, NativeConverter, h31.22.0, 0.9.0, 1.15.4unchanged

Set scalaVersion := "3.9.0" and run sbt with JDK 25. Production still runs on Node only.

2. Update dependencies

ModuleChange
zio-js-extRemoved. It was an empty package. Delete the dependency
nuxthub-extRemoved. HubKv and NativeKvSetOptions are gone; storage comes from KHubKv.mount / ZHubKv.mount
zio-h3-schema-extRemoved. Use native-converter-zio-schema-ext with request.body.json[A] (step 3)
native-converter-kyo-schema-extNew. Kyo Schema bridge: derives Schema, NativeSchema
zio-h3-extNow depends on kyo-h3-ext and kyo-zio. ZIO applications get Kyo transitively
zio-nuxthub-extNow depends on kyo-nuxthub-ext and zio-h3-ext
native-converter-zio-ext, native-converter-zio-schema-extNow depend on h3-ext
h3-extNo 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.

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.1Now
KHandler.define(program) with a by-name A < HandlerEffectsKHandler.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, rawEventRemoved. Use the Request argument
KHandler.readBody[A], KH3Event.readBody[A]request.body.json[A]
KHandler.setResponseStatus(code), KH3Event.setResponseStatusResponse.status(Status.Created) or Response.status(Status.unsafe(code))
dev.yuri4n.exts.kyoh3.SafeErrorSame name, now defined in h3-ext and re-exported. It adds headers and errors
KHubKv(native)KHubKv.mount(base)
CommonEvHandlerEventHandler (the old alias is deprecated)

Before and after:

// 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.

ZIO

0.1.0-alpha.1Now
ZHandler.define(program) with ZIO[SH3Event & Scope & R, E, A]ZHandler.define(request => program) with ZIO[HandlerEnv, ErrorResponse | Throwable, B]
SH3Event, ZHandler.getEventRemoved. Use the Request argument; HandlerEnv = ZState[ResponseHead] & Scope
ZHandler[R, E, A], Handler, TaskHandler, URHandler, UHandler, RHandlerSame names, now over HandlerEnv instead of SH3Event
ZHandler.readBody[A], readBody[A](strict), SH3Event.readBodyrequest.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.MissingRouterParamErrorRemoved. A missing parameter is HttpError(400, "Missing router parameter: <name>")
SH3Event.parseCookiesrequest.cookies, request.cookie(name)
ZHandler.setResponseStatus(code)Response.status(...)
ZHandler.sendStream(stream), SH3Event.sendStreamReturn 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, respondWithZIORemoved. Return the body; set the head with Response.*
ZHandler.$fetch, SH3Event.$fetchRemoved. 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.ConsoleRemoved. Use ZIO's Console or ZIO.log
taskStreamToReadableStream, taskStreamToReadableStreamReader, streamconversionRemoved. Streams are bodies now
fromNative_ extension on js.AnyRemoved. 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, maybeUnderlyingThe 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 errorIt now throws InvalidInput with entries; Codec gains validate; derives NativeSchema is new

Before and after, from the demo's Notes route:

// 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.

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):

Response0.1.0-alpha.1Now (default options)
A valueThe value as is{"data": <value>}
Unitundefined (h3 decided the status)204 with no body
A streamThrough sendStreamThe 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.
  • Replace application-side @JSImport("@nuxthub/kv", "kv") objects with KHubKv.mount(base) or ZHubKv.mount(base). See Nitro storage.
  • Replace per-request database connections with an application pool in Shared or ZShared. See 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.

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.