Hono
Execute better-effect Programs inside one Hono request boundary.
The optional better-effect/hono entry point connects Hono to a long-lived
Runtime. Hono's web Request and Response values do not extend the
published Runtime entrypoint's official support beyond Node.js and Bun. Install
Hono alongside the two core packages:
For an upstream HTTP client stream, see the HTTP client guide
before using the managed routes.stream boundary below. It documents the
request Scope lifetime, downstream cancellation, and the difference between
buffered and streaming responses.
bun add better-effect better-result honoThe adapter does not resolve Services into the Hono Context and does not wrap
individual methods with a Proxy. It keeps Hono's middleware, validator, and
Context behavior local, then hands the standard Web Request to the shared
better-effect/web boundary. That boundary owns the single Runtime execution,
Result/defect policy, and request Scope cleanup for the whole request.
Configure the request boundary
Declare the Hono application as a Layer and compose it into the Runtime once during application startup:
import { Hono } from 'hono'
import { Result } from 'better-result'
import { Effect, Layer, Runtime, Service } from 'better-effect'
import { HonoEffect } from 'better-effect/hono'
class WorkOrderService extends Service<WorkOrderService>()('WorkOrderService') {
list() {
return Promise.resolve(Result.ok([{ id: 'wo-1' }]))
}
}
const AppLive = Layer.make(WorkOrderService)
const App = HonoEffect.app(
'@app/HonoApp',
{
onFailure: (_error, c) => c.json({ error: 'Request failed' }, 500)
},
async function* (http) {
const app = new Hono()
app.use('/api/*', yield* http.middleware())
app.get(
'/api/work-orders',
yield* http.gen(async function* () {
const workOrders = yield* WorkOrderService
const items = yield* Result.await(workOrders.list())
return Result.ok(items)
})
)
return app
}
)
const runtime = await Runtime.make(Layer.merge(AppLive, App.layer))
const appResult = await runtime.run(
Effect.fn(async function* () {
return Result.ok(yield* App)
})
)
if (Result.isError(appResult)) throw new Error(String(appResult.error))
const app = appResult.value
await runtime.dispose()The remaining route examples below are the contents of the HonoEffect.app
factory, after its const app = new Hono() declaration. For an existing
application Service, use HonoEffect.layer(ServiceToken, options, factory) and
compose the returned Layer in the same way.
The request boundary supplies CurrentRequest and links the request's
AbortSignal to the Runtime execution. Services are resolved lazily through
yield* Service, and resources acquired with Effect.acquireRelease belong to
that request's Scope. Registering the middleware more than once for a request
still creates only one execution. The default failure policy redacts every
non-Response
failure value to { error: 'Internal Server Error' } with status 500, including
non-Error values. An explicitly returned Response failure is intentionally
passed through unchanged. A custom onFailure should expose only safe,
intentional domain details.
Registering the same boundary more than once for a request does not create a
second execution. Middleware registered before http.middleware() runs outside
the boundary; place the boundary before any middleware that needs better-effect
Services or request-local resources.
Programs and generators
Use http.gen for a generator written at the route boundary:
app.get(
'/api/work-orders/:id',
http.gen(async function* (c) {
const workOrders = yield* WorkOrderService
const workOrder = yield* Result.await(workOrders.get(c.req.param('id')))
return Result.ok(workOrder)
})
)Use http.handler when the Program is defined outside the HTTP layer:
import { Result } from 'better-result'
import { Effect } from 'better-effect'
const getWorkOrder = (id: string) =>
Effect.fn(async function* () {
const workOrders = yield* WorkOrderService
const workOrder = yield* Result.await(workOrders.get(id))
return Result.ok(workOrder)
})
app.get(
'/api/work-orders/:id',
http.handler((c) => getWorkOrder(c.req.param('id')))
)Both APIs validate the Program's Service requirements against the application
Layer. A route that yields a Service absent from AppLive fails at compile
time; no manual Service catalog is needed. The Failure type passed to
onFailure and the RequestLayer returned by requestLayer become Layer
requirements, so incompatible failure handlers or request Layers are rejected
at the public boundary. Request-Layer external requirements must be provided
by the Runtime.
Hono validation and Standard Schema
http.gen and http.handler accept one or more Hono input middlewares before
the callback. They run in the order provided, and each inferred value from
c.req.valid(...) is preserved in the route Context. There is no JsonInput<T>
helper to pass manually.
For a Standard Schema-compatible validator, use Hono's Standard Schema adapter:
bun add @hono/standard-validatorimport { sValidator } from '@hono/standard-validator'
const validateCreateWorkOrder = sValidator('json', createWorkOrderSchema)
app.post(
'/api/work-orders',
http.gen(
validateCreateWorkOrder,
async function* (c) {
const input = c.req.valid('json')
const workOrders = yield* WorkOrderService
const workOrder = yield* Result.await(workOrders.create(input))
return Result.ok(workOrder)
},
{ status: 201 }
)
)The same overload works with http.handler:
app.post(
'/api/work-orders',
http.handler(validateCreateWorkOrder, (c) => {
const input = c.req.valid('json')
return createWorkOrder(input)
})
)List multiple validators before the callback when a route needs several input categories:
app.post(
'/api/work-orders/:id',
http.gen(validateParam, validateHeader, validateCreateWorkOrder, async function* (c) {
const id = c.req.valid('param').id
const key = c.req.valid('header')['X-Idempotency-Key']
const input = c.req.valid('json')
return Result.ok({ id, key, input })
})
)Validation failures are handled by the validator middleware and return its
Response before the Program runs. The adapter only connects the validated
Hono input to the typed Program.
Responses and serialization
The default success policy returns { data: value } as JSON. Use route options
to set a status or serialize a domain value before it is returned:
app.get(
'/api/work-orders/:id',
http.gen(
async function* (c) {
const workOrders = yield* WorkOrderService
return Result.ok(yield* Result.await(workOrders.get(c.req.param('id'))))
},
{
serialize: (workOrder) => ({
id: workOrder.id,
createdAt: workOrder.createdAt.toISOString()
})
}
)
)The serialized value is returned as { data: serializedValue }. A successful
Response bypasses the default policy. For complete control, use respond on
the route or provide a global onSuccess policy:
app.get(
'/download',
http.gen(async function* () {
return Result.ok(new Response('file contents'))
})
)
app.get(
'/api/summary',
http.handler(() => summaryProgram, {
respond: (summary, c) => c.json(summary, 200)
})
)Result.err(error) is passed to onFailure and converted to the configured
HTTP response. The original error is also retained as the request Scope's
failure outcome, so finalizers and Runtime observers see the typed cause.
Under the default policy, every non-Response failure value is redacted to
{ error: 'Internal Server Error' } with status 500. An explicitly returned
Response failure is intentionally passed through unchanged. Custom policies
are responsible for deliberately choosing any safe domain details to expose.
Thrown defects continue through Hono's app.onError().
Guards and request-local Services
Use http.guard for authentication or other Result-based middleware:
app.use(
'/api/private/*',
http.guard(async function* (c) {
const token = c.req.header('Authorization')
if (token === undefined) {
return Result.err(new Error('Authentication required'))
}
const auth = yield* AuthService
c.set('user', yield* Result.await(auth.verify(token)))
return Result.ok()
})
)An http.guard that returns Result.ok() calls the next middleware. A
Result.err uses the central failure policy and stops the chain.
Request-local providers can be declared in the application Layer:
const App = HonoEffect.app(
'@app/HonoApp',
{
requestLayer: (c) =>
Layer.succeed(RequestId, new RequestId(c.req.header('X-Request-Id') ?? crypto.randomUUID()))
},
async function* (http) {
const app = new Hono()
app.use('*', yield* http.middleware())
app.get(
'/request-id',
yield* http.gen(async function* () {
const requestId = yield* RequestId
return Result.ok(requestId)
})
)
return app
}
)Those providers are closed with the request Scope. Shared infrastructure stays
in the Runtime's root Layer and is released by runtime.dispose() during
shutdown.
Hono request-scoped sessions
The optional public better-effect-better-auth/hono subpath provides
request-scoped Better Auth sessions. Hono is an optional peer required only by
this subpath (>=4.0.0); it is not needed by the framework-neutral . or
/hooks subpaths.
Install the package from its package-qualified npm release and keep the Web handler on its normal route:
bun add better-effect-better-auth better-auth honoConfigure Better Auth's basePath to match the raw Hono route, then create one
current-session token for the Better Auth Service and include the Hono app Layer
in the Runtime:
import { betterAuth } from 'better-auth'
import { memoryAdapter } from 'better-auth/adapters/memory'
import { Hono } from 'hono'
import { Effect, Layer, Runtime, Service } from 'better-effect'
import { HonoEffect } from 'better-effect/hono'
import { BetterAuth } from 'better-effect-better-auth'
import { BetterAuthHono } from 'better-effect-better-auth/hono'
import { Result } from 'better-result'
class AppService extends Service<AppService>()('@app/AppService') {}
const AppLive = Layer.make(AppService)
const rawAuth = betterAuth({
basePath: '/api/auth',
database: memoryAdapter({
account: [],
session: [],
user: [],
verification: []
}),
emailAndPassword: { enabled: true },
secret: 'replace-this-example-secret'
})
const Auth = BetterAuth.from('@app/Auth', rawAuth)
const CurrentSession = BetterAuthHono.session('@app/CurrentSession', Auth, {
disableCookieCache: true
})
const App = HonoEffect.app(
'@app/HonoApp',
{
requestLayer: CurrentSession.requestLayer,
onFailure: (_error, c) => c.json({ error: 'Request failed' }, 500)
},
async function* (http) {
const app = new Hono()
// Register Better Auth before the catch-all boundary; it receives the original Web Request.
app.all('/api/auth/*', (c) => rawAuth.handler(c.req.raw))
app.use('*', yield* http.middleware())
app.use('/api/private/*', yield* http.guard(CurrentSession.guard))
app.get(
'/api/private/me',
yield* http.gen(async function* () {
const session = yield* CurrentSession.require()
return Result.ok({ userId: session.user.id })
})
)
return app
}
)
const runtime = await Runtime.make(Layer.merge(AppLive, Auth.layer, App.layer))
const appResult = await runtime.run(
Effect.fn(async function* () {
return Result.ok(yield* App)
})
)
if (Result.isError(appResult)) throw new Error(String(appResult.error))
const app = appResult.valueThe request Layer requires the exact Auth Service from the Runtime and
provides only CurrentSession. Session lookup is lazy: a request that never
yields CurrentSession.get() or .require() does not call Better Auth. The
first lookup is cached for the request, so get(), require(), and
CurrentSession.guard share one settlement, including null, API failures,
and defects. A sign-in or sign-out performed after that first lookup leaves the
request snapshot intentionally stale. To read the changed state deliberately,
use the Auth Service with the original request: yield* auth.session.get(request).
get() preserves a missing session as null; require() maps only that absence
to Unauthenticated. Better Auth API failures stay typed, while unexpected
throws and rejections become a new UnhandledException whose .cause holds
the original defect, not the failure itself. HonoEffect's onFailure callback
owns the response policy. The core package and the framework-neutral Better
Auth entry point do not require Hono.