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:
// 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
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:
bun add @hulla/api @hulla/api-h3 h3The 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:
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:
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:
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 byh3Event.req;h3Event: H3’s native event, including its mutable context, parsed URL, prepared response, runtime details, andwaitUntil();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:
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.