Migration guide
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.
sendStreamwrites 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
sendStreamstep. Your program, and a scope or transaction around the step, see the stream's error and can roll back. In Kyo, that scope is aScope.runinside your handler: a finalizer of the request scope itself getsAbsent. The server logs the failure once. Before, the program never saw it. - In-process calls stream. A server-side
$fetchto a streaming route answered an opaque 500 before. Now the caller gets the head first, then the elements.
To upgrade:
- Replace
.andThen(stream)(Kyo) orZIO.succeed(stream)(ZIO) at the end of each streaming handler withResponse.sendStream(stream). - Move validation and resource acquisition before the call, if they ran inside the stream.
- Update tests that read the head or body of a streaming handler: use
KHandler.testorZHandler.test, and checkTestResponse.Body.Streamed(elements). See testing. - 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.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.
rawgives any status as a response.text,as,getandpostfail with the typedFetchError.Statusunless the status is 2xx. - A streamed body is typed.
FetchResponse.Streamed.bodyisStream[String, Async & Scope & Abort[FetchError]]in Kyo andZStream[Any, FetchError, String]in ZIO. A body that breaks after its head fails withFetchError.BodyFailed(status, reason). In the unreleasedmainbefore this change, the Kyo body wasStream[String, Async & Scope], and the failure was a panic: addAbort[FetchError]where you name that type. APlainbody whose read fails after the head isBodyFailedtoo (it wasFailed). AmatchonFetchErrorneeds a case for the newBodyFailed. - The JSON is decoded as it arrives.
get[A]on an h34s route with the default options decodes{"data": ...}, soAmust 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:
- Add
.at(Route.get("/api/notes"))(with the route and method of the file) to each exported handler. - Remove
entriesfromh34s.config.mjs, or keep them for outputs that still have route files. - Link with
h34s-scala dev. It prints the routes it found and writesh34s-routes.json, the.d.tsfiles andh34s-models.d.ts. - Delete the
server/apifiles that re-export the linked modules, and the hand-writtendeclare moduletypes for them. A route file and.atfor 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.
- Check your clients.
$fetchwith an object body andcurl --jsonsendapplication/json. Afetchornavigator.sendBeaconwith a byte body (Blobwithout a type,ArrayBuffer, typed array) and nocontent-typeheader sends no content type; these clients now get 415. Clients that send another content type, such asfetchwith a string body (text/plain) orcurl -d(application/x-www-form-urlencoded), already got 415 in0.2.0-alpha.1. - Make each client send
content-type: application/json. This is the recommended fix. - If a client cannot send it, keep the old behavior for that handler only:
KHandler.define(HandlerOptions(requireJsonContentType = false))(program), or the same options withZHandler.define. Do not do this for a cookie-authenticated handler that changes state; see cross-site requests. - In unit tests, add
headers = Map("content-type" -> "application/json")to eachRequest.fakewith a body that you read withjson[A].Request.fakefollows the same default; passrequireJsonContentType = falseto 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:
- Find cleanup in
ZIO.acquireRelease,ZIO.addFinalizeror a scopedZLayerthat must succeed for the response to be correct, for example a commit in a finalizer. - Move that step into the program, before the value is returned. A failure there follows the failure rule as before.
- Watch the server log for
h34s: a ZIO finalizer failedto 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:
- Set
sbtRootin that file to the directory of your sbt build, relative to the file. For example,sbtRoot: '..'for a config inconfig/h34s.config.mjsof an app whosebuild.sbtis in the app root. - Run your
scala:fastscript, orh34s-scala dev --config <your file>. If the directory has nobuild.sbtand noproject/directory, the command now stops before sbt starts, with an error that namessbtRoot, 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.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.
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:
// 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.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: <name>") |
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:
// 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):
| Response | 0.1.0-alpha.1 | Now (default options) |
|---|---|---|
| A value | The value as is | {"data": <value>} |
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/ZRuntimeConfiginstead of reading environment variables yourself. The demo moved from Ciris andMY_APP_DB_*toruntimeConfig.databaseandNUXT_DATABASE_*. See configuration. - Replace application-side
@JSImport("@nuxthub/kv", "kv")objects withKHubKv.mount(base)orZHubKv.mount(base). See Nitro storage. - Replace per-request database connections with an application pool in
SharedorZShared. 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.