Guide

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(...):
    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:
    @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.
  • <module>.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.

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:

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:

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.

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):
    /** 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:
    // 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<NoteView> }
    
  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):
    import type { NoteView } from '#h34s/models'
    
    /** The notes of `GET /api/notes`, typed with the generated `NoteView`. */
    export function useNotes() {
      return useAsyncData<NoteView[]>('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<Note>; 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<Tree> }. 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.

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) 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<string, T>, 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.
  • 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:
    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:

// 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).
  • Generic models such as Page[A] have no TypeScript generic (Page<A>) 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.