Server adapters
Elysia
Add your API routes to an Elysia app.
On this page
Add your contract’s routes to an existing Elysia app with @hulla/api-elysia. They run alongside your other routes and use the app’s hooks and plugins.
The Elysia adapter supports Elysia 1.4 and later. This example uses Zod for its request and response schemas.
bun add @hulla/api @hulla/api-elysia elysia zodnpm install @hulla/api @hulla/api-elysia elysia zodpnpm add @hulla/api @hulla/api-elysia elysia zodyarn add @hulla/api @hulla/api-elysia elysia zodStart with a contract that describes the HTTP boundary. This route accepts a user ID in the path and a new name in a JSON body:
// src/api/contract.ts
import { defineContract, request, response, route } from '@hulla/api'
import { z } from 'zod'
export const contract = defineContract({
basePath: '/api',
routes: {
renameUser: route.patch('/users/:id', {
params: z.object({ id: z.string() }),
body: request.json(z.object({ name: z.string().min(1) })),
responses: {
200: response.json(
z.object({ id: z.string(), name: z.string(), actor: z.string() })
),
},
}),
},
})Implement the route with defineServer(). The handler receives validated inputs, and response() only accepts statuses
and body shapes declared by the contract:
// src/api/server.ts
import { defineServer } from '@hulla/api/server'
import { contract } from './contract'
export const implementation = defineServer(contract, {
context: () => ({ actor: 'Ada' }),
}).implement({
renameUser: ({ params, body, context, response }) =>
response(200, {
id: params.id,
name: body.name,
actor: context.actor,
}),
})Create the Elysia app, pass it to elysiaAdapter(), and mount the implementation:
// src/app.ts
import { elysiaAdapter } from '@hulla/api-elysia'
import { Elysia } from 'elysia'
import { implementation } from './api/server'
const app = new Elysia()
const adapter = elysiaAdapter(app)
adapter.mount(implementation)
export default appmount() returns that same app. You can keep adding native Elysia routes, call app.listen(), export the app for a
runtime integration, or exercise it directly with app.handle():
const result = await app.handle(
new Request('http://localhost/api/users/42', {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'Grace' }),
})
)
console.log(result.status) // 200
console.log(await result.json())
// { id: '42', name: 'Grace', actor: 'Ada' }That request enters through Elysia’s native router. It is not a direct call to the server implementation.
Keep the context factory portable when it only needs services or route metadata. Use adapter.context() when handlers
need values supplied by Elysia, such as state, decorators, derives, resolves, or the native request context.
Register Elysia extensions before calling
elysiaAdapter(app)when the context factory needs their inferred types.
import { defineServer } from '@hulla/api/server'
import { elysiaAdapter } from '@hulla/api-elysia'
import { Elysia } from 'elysia'
import { contract } from './api/contract'
const app = new Elysia()
.state('requestCount', 0)
.decorate('serviceName', 'users')
.derive(({ request }) => ({
actor: request.headers.get('x-actor') ?? 'anonymous',
}))
const adapter = elysiaAdapter(app)
const implementation = defineServer(contract, {
context: adapter.context(({ request, elysiaContext, route }) => ({
actor: elysiaContext.actor,
serviceName: elysiaContext.serviceName,
requestCount: elysiaContext.store.requestCount,
requestId: request.headers.get('x-request-id'),
routeKey: route.key.join('.'),
})),
}).implement({
renameUser: ({ params, body, context, response }) =>
response(200, {
id: params.id,
name: body.name,
actor: context.actor,
}),
})
adapter.mount(implementation)The factory receives:
request: the native WebRequest;elysiaContext: Elysia’s typed context, including path parameters, state, decorators, derives, and resolves;route: the contract route key, HTTP method, and full path.
adapter.context() binds the implementation to the Elysia adapter. Mounting it with Fetch, another framework adapter,
or the in-process transport fails immediately. This prevents a handler from running without the host context it expects.
Each selected contract route is registered with Elysia’s native app.route(method, path, handler) API:
- Elysia matches the route and runs the lifecycle that precedes its handler.
- The adapter reads path parameters, the raw query string, headers, and the declared body representation.
@hulla/apivalidates those inputs, creates server context, runs middleware, and calls the handler.- The declared result is serialized and converted to a Web
Response. - Elysia’s response lifecycle can inspect or replace that response.
This native registration has a few practical consequences:
- an Elysia prefix, such as
new Elysia({ prefix: '/v1' }), also prefixes mounted contract routes; - native Elysia routes can live beside contract routes;
- mounting a server fragment registers only that fragment’s routes;
- custom contract
QUERYroutes use the same Elysia router; - repeated query values are preserved because the adapter reads the raw URL query string.
Elysia may parse a request body before the mounted handler runs. The adapter checks whether the Web request has already been consumed and chooses the available source:
| Request state | Body source | Limit owner |
|---|---|---|
| No earlier hook consumed the body | The adapter reads the Web Request in the contract’s JSON, text, bytes, or form-data representation. | maxBodyBytes limits the adapter-owned read. |
| Elysia already consumed the body | The adapter reuses elysiaContext.body. | Elysia’s parser and limits own the earlier read. |
For an Elysia-parsed body, JSON and text values are reused directly. Bytes must be an ArrayBuffer or Uint8Array,
and form data must still be a native FormData. A body-aware hook that turns form data into a plain object therefore
cannot feed a form-data contract route.
The adapter does not impose a body limit by default. Set maxBodyBytes when adapter-owned reads need one:
const adapter = elysiaAdapter(app, { maxBodyBytes: 1_048_576 })The limit is enforced while reading, including when Content-Length is missing. It cannot retroactively measure a body
that Elysia already consumed.
Contract body decoding happens before the server context factory. If an Elysia-bound context factory must also read the
original request body, mount with preserveRequestBody: true:
adapter.mount(implementation, { preserveRequestBody: true })For an adapter-owned read, this decodes a cloned request and leaves the original request readable. Use it only when a second consumer needs the body, because cloning can require buffering.
Pass onError to elysiaAdapter() for failures inside the adapter pipeline: input decoding, server context, middleware,
the route handler, declared response serialization, or conversion to the Web response:
const adapter = elysiaAdapter(app, {
onError({ error, phase, request, elysiaContext, defaultResponse }) {
console.error(phase, request.url, elysiaContext.path, error)
return defaultResponse
},
})The hook receives a protocol-safe default Response. Return another Response to replace it, or return undefined to
keep the default. Options passed to mount() shallowly override adapter defaults for that mount. Passing
onError: undefined explicitly disables an inherited hook.
Failures raised by Elysia outside the adapter call remain part of Elysia’s error lifecycle. If a response body is a
stream, a failure after streaming starts reaches the stream consumer; it cannot replace the response already in flight.
The request’s AbortSignal is also passed through, so cancelling the request cancels contract work and response streams.
- Server context and middleware explains portable request context and route policies.
- Server fragments and composition shows how to mount selected route groups.
- Calling the API explains how a browser or another service calls the same contract.
- Elysia documentation covers deployment and framework lifecycle details.