Skip to content
mareszharPublic

Latest commit

 

History

8 Commits

Folders and files

Repository files navigation

@mszr/h3-dux

A DX-first h3 v2 and Nitro v3 route kit with honest typed clients, validation, streaming, and OpenAPI docs.

h3-dux was inspired by h3-route-tools, then reimagined around one question: what would feel most delightful to use? It keeps h3 and Nitro as the runtime foundation while making route authoring, client calls, validation, errors, file routes, and docs feel like one coherent system.

Highlights

💞 Server and client read as counterparts — createServer() builds the routes; createClient<typeof app>() consumes them. Same verbs on both sides (app.get ↔ api.get), so the route and the call site mirror each other.

💫 Responses are inferred, not asserted — the handler's return is the client's type. No request<Receipt>(...) to drift out of sync. Add validate.response only when you want runtime response validation too.

😇 Honest by default — a call resolves to { data, error }, so transport failures and non-2xx responses are visible at the cursor. .orThrow() is the deliberate opt-out; .raw() gives you the native Response plus kind-aware .parse().

👻 Typed errors, narrowed by status — declare errors: { 409: ConflictSchema }, throw with event.error(409, data), and the client sees error.data narrowed by error.status.

⛰️ Path params, both ways — interpolated params are automatically detected, or pass them explicitly in a plain object, whichever reads best at the call site.

🤖 Validation you control — eager and sequential by default; flip validate.eager to false for deliberate, on-demand validation, like event.valid('body').

🫀 Response kinds are part of the contract — JSON, text, empty, binary, and sse() streams decode to the right client type. typedResponse() keeps native Response bodies typed when you need the platform object.

🧩 Composition that scales — split domains with createRouter('/fruits'), mount them into a server, and the client still sees one flat route map. Duplicate route+method definitions fail at the cursor.

🤝 Typed middleware bindings — defineMiddleware({ bindings }) publishes request-scoped capabilities to downstream handlers as event.bindings; requires consumes parent capabilities without re-running providers.

📂 Nitro file routes with no hand-written route map — defineFileRoute carries the same validation, response, error, middleware, and streaming model into filesystem routes. The Nitro module generates #h3-dux/routes, so createClient<Routes>() is typed from the files.

🌱 OpenAPI documentation for free — standalone apps and Nitro file routes document the same statuses, validation envelopes, response kinds, errors, and metadata.

Install

npm install @mszr/h3-dux h3

h3 is the required peer. nitro is optional and needed only when importing @mszr/h3-dux/nitro; typescript is optional and needed only for codegen helpers.

The Shape

One package, four entrypoints. The root is the runtime authoring and client surface; the client plane, Nitro, and codegen stay in explicit subpaths.

Entrypoint What it is
@mszr/h3-dux createServer, createRouter, createClient, defineFileRoute, defineMiddleware, typedResponse, sse, validation/error helpers, response-kind helpers, typed-fetch types
@mszr/h3-dux/client the typed client alone (createClient, createTypedFetch, the error classes) — no runtime dependency on h3, so it bundles in any consumer, including an h3 v1 / Nitro v2 app
@mszr/h3-dux/nitro the Nitro module for generated file-route types and OpenAPI overlay
@mszr/h3-dux/codegen route declaration and OpenAPI file writers for build tooling

The client/server surface is schema-library neutral through Standard Schema, so Valibot, Zod, and other compatible validators can feed the same contract.

A Taste

// server.ts
import { createServer, sse } from '@mszr/h3-dux'
import { ConflictSchema, FruitSchema, NewFruitSchema, RipenTickSchema } from '@orchard/domain'

export const app = createServer()
  .get('/fruits/:id', {
    validate: { response: FruitSchema },
    handler: event => orchard.get(event.params.id),
  })
  .post('/fruits', {
    status: 201,
    validate: {
      body: NewFruitSchema,
      response: FruitSchema,
    },
    errors: {
      409: ConflictSchema,
    },
    handler: (event) => {
      if (orchard.has(event.body.name))
        throw event.error(409, { reason: 'already_exists' })

      return orchard.create(event.body)
    },
  })
  .get('/fruits/:id/ripen', {
    validate: { response: sse(RipenTickSchema) },
    handler: async function* (event) {
      for (const tick of orchard.ripen(event.params.id))
        yield tick
    },
  })

export type App = typeof app
// client.ts
import { createClient } from '@mszr/h3-dux'
import type { App } from './server'

const api = createClient<App>({ baseURL })

const { data, error } = await api.get(`/fruits/${id}`)

if (error?.status === 404)
  error.data // narrowed to the 404/error body when that status is declared

if (data)
  data.name // Fruit wire shape

const created = await api.post('/fruits', { body: mango }).orThrow()

for await (const tick of api.get(`/fruits/${id}/ripen`))
  console.log(tick.ripeness)

Consuming the client from another app

createClient is a typed fetch wrapper — at runtime it speaks the wire (fetch/Response) and its own H3DuxHTTPError; it needs h3 only for types, which are erased at build. When the API and its client live in the same h3 v2 project, import both from @mszr/h3-dux. When the consumer is a separate app — a frontend, or anything on a different h3 / Nitro line, such as a Nuxt 4 app (h3 v1 / Nitro v2) — import the client from the @mszr/h3-dux/client subpath:

import { createClient } from '@mszr/h3-dux/client'
import type { App } from '@your-scope/api' // the server's `typeof app`, type-only

const api = createClient<App>({ baseURL })

It is the same createClient with the same behavior — only the entrypoint differs. The /client bundle carries no runtime h3 value import, so it bundles cleanly no matter which h3 (v1, v2, or none) the consuming app resolves. The root @mszr/h3-dux entry pulls in the server surface (which does depend on h3 v2), so a consumer on a different h3 line should import the client from /client, not from the root.

Publishing a package that re-exports the contract

A package can re-export an inferred h3-dux type for downstream consumers — export const app = createServer(...), then export type App = typeof app — and emit declarations for it. tsc --declaration / --emitDeclarationOnly, TypeScript project references (composite), and isolatedDeclarations all resolve typeof app, because the kernel endpoint it is built from (H3DuxEndpoint) is a named public export — no TS4023 … cannot be named, and nothing to annotate by hand.

Nitro File Routes

// server/routes/fruits/[id].get.ts
import { defineFileRoute } from '@mszr/h3-dux'
import { FruitSchema } from '@orchard/domain'

export default defineFileRoute({
  validate: { response: FruitSchema },
  handler: event => orchard.get(event.params.id),
})
// nitro.config.ts
export default defineNitroConfig({
  modules: ['@mszr/h3-dux/nitro'],
})
// app/api.ts
import { createClient } from '@mszr/h3-dux'
import type { Routes } from '#h3-dux/routes'

export const api = createClient<Routes>({ baseURL: '/api' })

The filename owns the path and method; h3-dux owns the contract projected from the route definition. No hand-written Routes interface.

Mounting a whole app under Nitro

Prefer file routes above, but you can also serve a full createServer() app through one Nitro catch-all. Mount it with toNitroHandler(app) — not app.native.handler(event):

// server/routes/[...].ts
import { toNitroHandler } from '@mszr/h3-dux'
import { app } from '../app.ts'

export default toNitroHandler(app)

toNitroHandler re-enters the app through dux's own h3 while forwarding Nitro's event.context. Nitro pins an exact, older h3 than h3-dux resolves, so a raw app.native.handler(event) hands Nitro's event into newer h3 helpers and a handleCors-style middleware can crash on a response field that event never had; re-entering by the Request sidesteps the skew and keeps Cloudflare bindings / waitUntil intact. The @mszr/h3-dux/nitro module is only for file routes — a pure programmatic app doesn't need it.

Get Started

Check out our minimal demo! (public link | local fork path)

It provides usage examples for all the main features of h3-dux ^-^ 💜

Existing h3 Utilities

Concrete h3-dux handler events keep the native h3 event surface, so ordinary helpers that accept H3Event keep working when the route's context.params stays in h3's string-shaped model:

import { createServer } from '@mszr/h3-dux'
import type { H3Event } from 'h3'

function readSession(event: H3Event) {
  return event.req.headers.get('authorization')
}

createServer().get('/me', event => readSession(event))

Use H3DuxEvent when a helper wants dux additions such as event.error, event.bindings, event.params, event.query, or event.body:

import type { H3DuxEvent } from '@mszr/h3-dux'

function requireUser(event: H3DuxEvent<{ user: User }>) {
  return event.bindings.user
}

For helpers shared by plain h3 and h3-dux, prefer the narrow structural surface the helper actually reads. That avoids coupling the utility to either framework's full event type:

import type { H3Event } from 'h3'

type EventWithRequest = Pick<H3Event, 'req' | 'url'>

function authHeader(event: EventWithRequest) {
  return event.req.headers.get('authorization')
}

If the shared helper needs context.params, widen only that slot:

import type { H3Event } from 'h3'

type EventWithParams = Omit<H3Event, 'context'> & {
  context: Omit<H3Event['context'], 'params'> & {
    params?: Record<string, unknown>
  }
}

That shape accepts plain h3 params (Record<string, string>) and h3-dux routes whose params schema coerces values.

Transport Middleware (CORS, and friends)

Cross-cutting transport concerns — CORS, auth, compression — attach as ordinary h3 middleware with .use(). h3-dux adds no primitive for them: an allowed origin is a handleCors call, a protected route is middleware: [...]. Three patterns keep them predictable, whatever your file layout.

A middleware that can answer early must return its response. h3 reaches the route only if no middleware returns a value; a middleware short-circuits by returning a response. handleCors follows this protocol — it returns h3's 204 for a preflight and false otherwise — so return the truthy value, or preflight OPTIONS requests fall through to routing (a 404/405) while simple requests still succeed and hide the gap:

import { handleCors } from 'h3'

app.use((event) => {
  const preflight = handleCors(event, { origin: ['https://app.example.com'], methods: ['GET', 'POST'] })
  if (preflight)
    return preflight
})

Per-deployment policy belongs at the entry, not on the shared app. An app is the routes you build (and may export a type from for a client); an entry is where you mount or serve it. When those are one object in one place, there is nothing to separate. When one app value is reused across entries — a typed-client export, several deployments — register deployment-specific middleware (CORS origins, trusted proxies) at each entry, so one deployment's policy is not frozen into the value the others share.

Keep a single h3. Any h3 helper that touches the event's response — handleCors among them — must come from the same h3 that built the event. toNitroHandler guarantees the event is dux's own; resolving one h3 in your install keeps the helpers you import matched to it, and rules out the missing-response-field crash a duplicated h3 can cause.

The One Hard Contract

h3-dux owes behavioral compatibility to h3 and Nitro, not API compatibility to any SDK. Everything it emits is something h3/Nitro already understand. Inside that envelope, h3-dux is free to make the authoring and client experience more delightful.

Development

The public h3-dux repo is the package face. Design docs, maintainer scripts, and sandbox comparisons live in the dux workspace inside the h3-route-tools fork (public link | local fork path).

Docs

Releases

Packages

Contributors

Languages