Web boundaries
Run better-effect Programs at a framework-neutral Request to Response boundary.
The better-effect/web entry point provides a small, framework-neutral
Request to Response boundary. It uses the standard Web Request and
Response APIs, so it does not depend on Hono or another HTTP framework.
The published Runtime entry point is officially supported on Node.js and Bun. The Web APIs here are standard, but the Runtime still needs a supported context storage implementation.
Handle a request
Create a long-lived Runtime during application startup, then pass its
non-owning executor to WebEffect.handleWith from your framework's request callback:
import { Result } from 'better-result'
import { CurrentAbortSignal, Effect, Layer, Runtime, Service } from 'better-effect'
import { CurrentRequest } from 'better-effect/standard-services'
import { WebEffect } from 'better-effect/web'
class UserService extends Service<UserService>()('UserService') {
find(): Promise<Result<{ readonly id: string }, never>> {
return Promise.resolve(Result.ok({ id: 'user-1' }))
}
}
const runtime = await Runtime.make(Layer.make(UserService))
const handleRequest = (request: Request): Promise<Response> =>
WebEffect.handleWith(
runtime.executor,
request,
Effect.fn(async function* () {
const currentRequest = yield* CurrentRequest
const signal = yield* CurrentAbortSignal
const userService = yield* UserService
const user = yield* Result.await(userService.find())
return Result.ok({
id: user.id,
url: (currentRequest.request as Request).url,
aborted: signal.aborted
})
})
)
const response = await handleRequest(new Request('https://example.test/users'))
// { "data": { "id": "user-1", ... } }
await runtime.dispose()WebEffect.handleWith invokes the lazy Program once. It supplies
CurrentRequest for the current request and forwards request.signal to
CurrentAbortSignal. The request execution uses a child Scope; resources
acquired by the Program are released before the returned Promise resolves.
Resources owned by the Runtime's root Layer remain available to later requests
and are released by runtime.dispose().
Bun.serve adapter
For Bun applications, better-effect/bun is Layer-first. BunEffect.handler
captures the active Runtime.Executor while its enclosing Layer is acquired,
and each native Bun request crosses one WebEffect boundary.
BunEffect.server owns one Bun.serve acquisition and exposes the raw server
as a Service with a .layer:
import { Result } from 'better-result'
import { CurrentAbortSignal, Effect, Layer, Runtime, Service, ServiceRuntime } from 'better-effect'
import { BunEffect } from 'better-effect/bun'
import { CurrentRequest } from 'better-effect/standard-services'
class AppService extends Service<AppService>()('AppService') {}
const ApiServer = BunEffect.server('@docs/ApiServer', async function* () {
const fetch = yield* BunEffect.handler((request, server) =>
Effect.fn(async function* () {
const app = yield* AppService
const currentRequest = yield* CurrentRequest
const signal = yield* CurrentAbortSignal
return Result.ok({
app,
url: (currentRequest.request as Request).url,
requestUrl: request.url,
port: server.port,
aborted: signal.aborted
})
})
)
return { port: 3000, fetch }
})
const runtime = await Runtime.make(Layer.merge(Layer.make(AppService), ApiServer.layer))
const server = await runtime.run(() =>
Effect.gen(async function* () {
const apiServer = yield* ApiServer
return Result.ok(apiServer)
})
)The handler supplies CurrentRequest, forwards request.signal to
CurrentAbortSignal, composes request Layers, applies response policies, keeps
thrown defects rejected, and releases request resources before its Promise
resolves. Runtime shutdown quiesces Bun, drains active requests, and releases
the server and root Layers. Repeated runtime.dispose() calls share one
Promise:
await runtime.dispose()Use BunEffect.layer(service, factory) when the server should be mapped into an
application-chosen Service. This Bun-specific entrypoint makes no Node
compatibility claim.
Request-local Layers
Use requestLayer when a dependency belongs to one request rather than to the
shared application Runtime:
class RequestId extends Service<RequestId>()('RequestId') {
constructor(readonly id: string) {
super()
}
}
const response = await WebEffect.handleWith(
runtime.executor,
request,
Effect.fn(async function* () {
const requestId = yield* RequestId
return Result.ok({ requestId })
}),
{
requestLayer: (request) =>
Layer.scoped(
RequestId,
() => new RequestId(request.headers.get('X-Request-Id') ?? crypto.randomUUID()),
(requestId) => {
// Release request-local state here.
void requestId
}
)
}
)The factory receives the same Web Request passed to WebEffect.handleWith and runs once
per request. Its Layer is composed over the built-in CurrentRequest Layer,
so a compatible same-tag provider can intentionally override it. Request-Layer
external requirements must be provided by the Runtime. Concrete Layer unions
and incompatible overrides are rejected by TypeScript; use the exact Layer.Any
erasure only when an unchecked boundary is intentional.
Response and failure policies
The default policy is deliberately constrained:
- a standards-compatible successful
Responseis returned unchanged; - top-level
undefinedsucceeds with a204Response; null, booleans, strings, finite numbers, arrays, and plain objects become{ data: value }JSON with status200when every nested value satisfies the same rules;- the serialized JSON value graph must be acyclic. Arrays must be dense and
unaugmented: their own string properties must be exactly
lengthand one property for every index from0throughlength - 1, with each index an enumerable data property. Symbol keys, extra own properties (including non-enumerable ones), sparse holes, and accessor elements are rejected; - object records must have
Object.prototypeornullas their prototype, and every own string-keyed property must be an enumerable data property. Own symbol keys, non-enumerable properties, accessors, custom prototypes, and other non-plain objects are rejected. Shared references in separate branches are serialized as repeated values; - nested
undefined,bigint, functions, symbols,NaN, and infinities reject the boundary withWebEffectSerializationError(aTypeError), rather than being silently dropped or coerced; - use an explicit
onSuccesspolicy when a value is outside that JSON contract; - a failed standards-compatible
Responseis passed through unchanged; - every other typed failure is redacted to
{ error: 'Internal Server Error' }with status500.
A supported Web Response is checked structurally, so native cross-realm
Responses do not need to pass instanceof Response. The checked protocol
requires an integer status of 0 or 200 through 599, a matching boolean
ok, boolean redirected and bodyUsed, string statusText and url, a
standard type, and callable arrayBuffer(), blob(), bytes(), clone(),
formData(), json(), and text() methods. headers must expose callable
append(), delete(), get(), getSetCookie(), has(), set(), and
forEach() operations.
body must be null (including legitimate null-body Responses such as 204)
or a ReadableStream-compatible object with a boolean locked property and
callable cancel(), getReader(), pipeThrough(), pipeTo(), and tee()
methods. A missing capability, forged Response.prototype object, or other
malformed value is rejected with TypeError before it is returned.
Provide asynchronous policies when a framework needs its own response helper:
const response = await WebEffect.handleWith(runtime.executor, request, program, {
onSuccess: async ({ value }) => Response.json(value, { status: 201 }),
onFailure: async (error) => {
console.error(error)
return new Response('Request failed', { status: 422 })
}
})The Program's typed failure channel is checked against onFailure. A policy
must return a Web Response; a bad runtime value raises a TypeError rather
than leaking an arbitrary object as a response. Thrown or rejected defects
remain rejected by WebEffect.handleWith, while Result.err is converted by the failure
policy. In both cases request cleanup still runs. The original typed failure
is supplied to request finalizers through the execution's failure outcome.
The boundary is intentionally only an adapter. It does not parse request
bodies, choose routes, install framework middleware, or catch framework errors.
Framework integrations such as better-effect/hono provide that framework
composition and delegate the shared Request, Runtime, Result, and Scope
lifecycle here. A framework can also use handleRequest anywhere it already
receives a standard Web Request and must return a standard Web Response.