Server frameworks

H3

Mount native H3 routes with event context and middleware across H3-supported runtimes.

@hulla/api-h3 registers a complete server implementation or deployable fragment on a caller-owned H3 v2 application. H3 continues to own native routing, middleware, event context, and deployment; @hulla/api owns contract input decoding and declared response serialization.

This package targets H3 v2’s Web Standards API (new H3()). Nuxt currently uses H3 v1 internally and should use the dedicated @hulla/api-nuxt integration instead.

Prerequisites

This guide starts at the adapter boundary. First define the shared contract from contract authoring, then turn it into the server value mounted below:

TypeScript
// src/api/server.ts
import { defineServer } from '@hulla/api/server'
import { contract } from './contract'

const server = defineServer(contract)

export const implementation = server.implement({
  health: ({ response }) => response(200, 'ok'),
  users: {
    byId: ({ params, response }) =>
      response(200, { id: params.id, name: 'Ada' }),
    rename: ({ params, body, response }) =>
      response(200, { id: params.id, name: body.name }),
  },
})

defineServer(contract) creates the server authoring scope. implement() requires the handler tree to cover the contract and returns the complete implementation accepted by mount(). See server authoring for context, middleware, declared errors, and independently deployable fragments.

Mount on H3

TypeScript
import { h3Adapter } from '@hulla/api-h3'
import { H3, serve } from 'h3'
import { implementation } from './api/server'

const app = new H3()
h3Adapter(app).mount(implementation)

serve(app)

Install H3 alongside the adapter:

Terminal
bun add @hulla/api @hulla/api-h3 h3

The returned value is the same H3 application. Use app.request() in tests, pass the app to serve(), export app.fetch, or select another H3 runtime adapter as required by the deployment target.

Client consumption

H3 only hosts the server implementation. A browser, mobile application, or another service consumes the contract through the ordinary typed Fetch client:

TypeScript
import { createClient } from '@hulla/api/client'
import { fetchTransport } from '@hulla/api/fetch'
import { contract } from './api/contract'

export const api = createClient(contract, {
  transport: fetchTransport({ baseUrl: 'https://api.example.com' }),
})

The host framework does not change the client call surface. The returned value is a status-discriminated union, so each declared response narrows its body and headers. Put loading state, caching, retries, and mutations in the consuming application; see client authoring and client integrations.

Native routes and middleware

mount() registers each selected contract route through H3’s native app.on(method, path, handler) API. A mounted fragment registers only its selected routes, and mount() returns the same app instance:

TypeScript
const users = server.implement(contract.routes.users, userHandlers)

app.use('/api/users/**', requireUser)
h3Adapter(app).mount(users)
app.get('/healthz', () => 'ok')

H3 middleware remains responsible for request interception, response interception, and native errors outside the contract runtime. Unmatched requests continue through H3’s router and not-found behavior. H3 automatically routes a HEAD request through a matching contract GET route and removes the response body, and it registers contract QUERY routes as native H3 QUERY routes.

The adapter reads decoded path parameters with H3’s safe router-parameter decoder. That decoder preserves encoded slashes: a parameter containing %2F reaches the contract as %2F, while ordinary percent-encoded text is decoded. Avoid slash-containing identifiers when the same contract must have identical parameter values across native routers. Query input comes from event.url.searchParams, preserving repeated values, while headers and bodies use the native Web Request. When a native H3 context factory is present, contract body decoding uses a clone so the context factory can still inspect the original request body. Middleware that consumes event.req before the mounted route cannot make that already-consumed stream readable again; clone the request first when multiple consumers need its body.

Declared results become native Web Response objects, including streaming responses. H3 middleware can intercept or adjust the response returned by the contract handler through its normal next() flow.

H3 event in server context

Use adapter.context() only when context construction needs H3-specific request state:

TypeScript
import { defineServer } from '@hulla/api/server'
import { h3Adapter } from '@hulla/api-h3'
import { H3 } from 'h3'
import { contract } from './api/contract'

const app = new H3()
const adapter = h3Adapter(app)

const server = defineServer(contract, {
  context: adapter.context(({ request, h3Event, route }) => ({
    request,
    actor: h3Event.context.actor,
    runtime: h3Event.runtime,
    route,
  })),
})

The input contains:

  • request: the Web-compatible request exposed by h3Event.req;
  • h3Event: H3’s native event, including its mutable context, parsed URL, prepared response, runtime details, and waitUntil();
  • route: @hulla/api’s structural route key, HTTP method, and full contract path.

The event’s context.params belongs to H3 and contains the router’s wire-form parameters. Contract handler params are separately decoded and validated by @hulla/api.

Wrapping a context factory with adapter.context() binds that server definition to the h3 adapter. Mounting the result with the generic Fetch adapter, another framework adapter, or the in-process transport fails immediately. Keep a plain context factory when it only needs route metadata or portable services.

Error handling

The optional second argument to h3Adapter() sets defaults for every mounted implementation or fragment:

TypeScript
const adapter = h3Adapter(app, {
  onError({ error, phase, request, h3Event, defaultResponse }) {
    console.error(phase, request.url, h3Event.url.pathname, error)
    return defaultResponse
  },
})

The hook handles request decoding, @hulla/api context, route handlers, middleware, and declared response serialization errors. It receives the request’s H3 event and a protocol-safe default Response. Return a replacement Response or return undefined to keep the default. Exceptions raised while the adapter converts that result to a Web Response continue to H3’s own error pipeline. Errors emitted later by a streaming response body propagate to the stream consumer.

Options passed to mount() shallowly override adapter defaults for that fragment. Passing onError: undefined explicitly disables an inherited hook.