app

js/net/http/app.ts

fino:net/http/app — middleware, routing, and OpenAPI for HTTP services.

This module builds a small application framework on top of Fino's existing Request, Response, and serve() APIs. It is intended for APIs that need composable middleware, URLPattern routing, request-scoped values, and a machine-readable OpenAPI document without giving up direct access to the underlying Fetch-compatible HTTP primitives.

The middleware model has two explicit forms. use() installs one-way branch middleware that may short-circuit by returning a Response or continue by returning nothing. layer() installs Koa-style wrappers that receive (ctx, next) and can run before and after downstream dispatch. Builders (App, Router, RouteBuilder, and MethodBuilder) share .use(), .layer(), .value(), and .meta() as immutable enrichments of a routing tree: each call records a node, terminals such as .handle() resolve the branch they hang off, and a later value() with the same name shadows an earlier one. Everything that binds a routing path goes through route() — HTTP verbs fork method branches finished by .handle(), while websocket(), sse(), webtransport(), rpc(), and mount() are terminals that register directly.

Routing uses the platform URLPattern implementation with pathname patterns such as /users/:id. Path parameters are available through the built-in schema.params() producer, which reserves the usual params slot and validates the matched parameter object. app.context() returns the active request context from anywhere in the async call chain by using fino:context.

OpenAPI generation targets OpenAPI 3.1 and embeds JSON Schema objects from fino:validate directly. Middleware can describe documentation effects with defineMiddleware(fn, meta) and defineProducer(fn, meta), making runtime logic independent from documentation generation.

import { App, body, schema } from 'fino:net/http/app';
import { v } from 'fino:validate';

const app = new App({ name: 'Example API' });
const api = app.layer(errorHandler());
api.route('/users/:id')
  .meta({ tags: ['users'] })
  .value('params', schema.params(v.object({ id: v.string() })))
  .post()
  .value('body', body.json(v.object({ name: v.string() })))
  .handle((ctx) => Response.json({ id: ctx.params.id, name: ctx.body.name }));

app.get('/openapi.json').handle(app.openapiHandler({ version: '1.0.0' }));
app.listen({ port: 3000 });

Learn more:

Types

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'

Standard HTTP methods supported by route builders.

const method: HttpMethod = 'GET';

type Middleware = (ctx: HttpContext) => HttpHandlerResult | void | Promise<HttpHandlerResult | void>

One-way branch middleware run before downstream handlers.

Each middleware receives the shared request context. Returning a HttpHandlerResult short-circuits the chain and skips everything downstream; returning undefined (or nothing) continues to the next branch item. Use LayerMiddleware with .layer() when code needs a next callback or needs to inspect the downstream response.

const requireUser: Middleware = async (ctx) => {
  if (ctx.user === undefined) return Response.json({ error: 'Unauthorized' }, { status: 401 });
};
app.route('/account').use(requireUser).get().handle(showAccount);

type LayerMiddleware = ( ctx: HttpContext, next: ( ) => Promise<HttpHandlerResult> ) => HttpHandlerResult | void | Promise<HttpHandlerResult | void>

Koa-style wrapper layer run around downstream dispatch.

A layer receives the shared request context and a next callback. Calling await next() continues dispatch and yields the downstream result, which the layer may inspect or mutate before returning. Returning a response without calling next() short-circuits the rest of dispatch.

next() must be called at most once per invocation — calling it a second time throws next() called multiple times.

const timing: LayerMiddleware = async (ctx, next) => {
  const res = await next();
  if (res instanceof Response) res.headers.set('x-route', ctx.route);
  return res;
};
app.layer(timing);

type Handler = (ctx: HttpContext) => HttpHandlerResult | Promise<HttpHandlerResult>

Terminal route handler.

Handlers must return a Response or a protocol takeover such as a WebSocket connection. Throwing is normally handled by errorHandler().

const handler: Handler = (ctx) => Response.json({ id: ctx.params?.id });

type WebSocketHandler = (socket: WebSocketConnection, ctx: WebSocketContext) => void | Promise<void>

Terminal handler for a WebSocket route.

The upgrade is already accepted by the time the handler runs, so it receives a live WebSocketConnection and the request context. Register event listeners and start reading before the handler returns; returning does not close the socket, so keep the promise pending only as long as setup requires.

const echo: WebSocketHandler = (socket) => {
  socket.addEventListener('message', (event) => socket.send(event.data));
};
app.route('/echo').websocket(echo);

type WebTransportHandler = (session: WebTransport, ctx: WebTransportContext) => void | Promise<void>

Terminal handler for a WebTransport route.

Runs after the session upgrade is accepted, receiving a live WebTransport session and the request context. Await session.ready before opening or accepting streams; as with WebSocket handlers, returning does not close the session.

const handler: WebTransportHandler = async (session) => {
  await session.ready;
};
app.route('/wt').webtransport(handler);

type SseHandler = (events: EventSourceWriter, ctx: SseContext) => void | Promise<void>

Terminal handler for a server-sent events route.

The handler receives an EventSourceWriter connected to the response body. The response streams while the handler runs and ends when it returns. Unlike WebSocket and WebTransport handlers, there is no protocol upgrade to accept: SSE is a normal HTTP response with a long-lived body.

const clock: SseHandler = async (events) => {
  await events.write({ event: 'tick', data: new Date().toISOString() });
};
app.route('/clock').sse(clock);

type Producer = (ctx: HttpContext) => unknown | Promise<unknown>

Context value producer used by .value(name, producer).

The producer runs at its position in the middleware stack and stores its return value under the declared context key.

const currentUser: Producer = async (ctx) => loadUser(ctx.request);

type HttpHandlerResult = Response | WebSocketConnection | WebTransport

Re-exported from js/net/http/server.HttpHandlerResult.

Interfaces

interface HttpContext {

Request context object passed through app middleware and handlers.

Producers can add additional keys, so application-specific context values are exposed through the index signature. Built-in helpers commonly add params, query, headers, body, cookies, and session.

const handler: Handler = (ctx) => Response.json({ route: ctx.route });

Properties

request: Request

Fetch-compatible request being handled.

console.log(ctx.request.method);
app: App

App instance that is dispatching this request.

console.log(ctx.app.context());
route: string

Matched route pathname pattern, such as /users/:id.

console.log(ctx.route);
method: string

Matched HTTP method.

console.log(ctx.method);
protocol: HttpProtocol

Protocol carrying the current request.

session?: HttpSession

Transport session carrying the current request.

incoming?: IncomingHttp

Accept object that produced this request, when dispatched by App.listen().

params?: Record<string, string>

URLPattern path parameters when a route matched.

console.log(ctx.params?.id);

interface WebSocketContext extends HttpContext {

Request context passed to WebSocket route handlers.

Identical to HttpContext except that incoming is narrowed to the pending WebSocket upgrade, which the route framework accepts on the handler's behalf before the handler runs. Middleware on the branch still sees the request as a plain HttpContext, so producers and values populate this context the same way they do for HTTP routes.

app.route('/chat').websocket((socket, ctx: WebSocketContext) => {
  console.log('upgraded', ctx.route, ctx.params);
  socket.addEventListener('message', (e) => socket.send(e.data));
});

Properties

incoming: IncomingWebSocketRequest

Pending WebSocket upgrade the route accepted to produce socket.

interface WebTransportContext extends HttpContext {

Request context passed to WebTransport route handlers.

Like WebSocketContext, this narrows incoming to the pending WebTransport upgrade the route accepts before the handler runs. All other context values — matched route, params, and anything added by producers on the branch — are populated exactly as they are for HTTP routes.

app.route('/wt/:room').webtransport(async (session, ctx: WebTransportContext) => {
  console.log('room', ctx.params?.room);
  await session.ready;
});

Properties

incoming: IncomingWebTransportRequest

Pending WebTransport upgrade the route accepted to produce session.

interface SseContext extends HttpContext {

Request context passed to server-sent events route handlers.

SSE is not an HTTP upgrade. It is an ordinary HTTP request whose response body remains open with text/event-stream, so incoming is narrowed to the plain request variant when the app is dispatching from listen(). Middleware can still reject before the SSE stream starts by returning a Response.

app.route('/events').sse(async (events, ctx: SseContext) => {
  console.log(ctx.request.headers.get('last-event-id'));
  await events.write({ data: 'connected' });
});

Properties

incoming?: IncomingHttpRequest

Incoming plain HTTP request, when dispatched by App.listen().

This is not an upgrade request; it has already been accepted as ordinary HTTP before the route handler starts streaming.

interface OperationMeta {

OpenAPI metadata that can be attached to builders, middleware, or producers.

Metadata is merged as routes are built. Later metadata overrides earlier metadata key by key: parameters replace by (in, name), request bodies and per-status responses replace outright.

app.route('/users').meta({ tags: ['users'], summary: 'List users' });

Properties

operationId?: string

Explicit OpenAPI operationId.

route.meta({ operationId: 'getUser' });
summary?: string

Short OpenAPI summary.

route.meta({ summary: 'Create a user' });
description?: string

Longer OpenAPI description.

route.meta({ description: 'Creates a user account.' });
tags?: string[]

OpenAPI tags for grouping operations.

route.meta({ tags: ['users'] });
security?: unknown

OpenAPI security requirement object or array.

route.meta({ security: [{ bearerAuth: [] }] });
parameters?: OpenApiParameter[]

Additional OpenAPI parameters.

route.meta({ parameters: [{ name: 'id', in: 'path', required: true }] });
requestBody?: OpenApiRequestBody

OpenAPI requestBody metadata.

route.meta({ requestBody: { required: true, content: { 'application/json': { schema } } } });
responses?: Record<string, OpenApiResponse>

OpenAPI responses keyed by status code.

route.meta({ responses: { '200': { description: 'OK' } } });

interface OpenApiOptions {

Options passed to App.openapi().

version is required and becomes info.version. title defaults to the app name passed to new App().

const doc = app.openapi({ version: '1.0.0', title: 'Admin API' });

Properties

title?: string

OpenAPI info title; defaults to the app name.

app.openapi({ title: 'Example API', version: '1.0.0' });
version: string

OpenAPI info version.

app.openapi({ version: '1.0.0' });
servers?: Array<Record<string, unknown>>

Optional OpenAPI servers array.

app.openapi({ version: '1.0.0', servers: [{ url: 'https://api.example.com' }] });

interface OpenApiParameter {

OpenAPI parameter object accepted by OperationMeta.parameters.

Parameters are merged by (in, name) when metadata from builders, middleware, and producers is combined. schema should be an OpenAPI 3.1 JSON Schema object or a fino:validate schema converted through toJSON().

const parameter: OpenApiParameter = {
  name: 'id',
  in: 'path',
  required: true,
  schema: { type: 'string' },
};

Properties

name: string

Parameter name as it appears in the path, query string, header, or cookie.

in: 'path' | 'query' | 'header' | 'cookie'

Location of the parameter in the HTTP request.

required?: boolean

Whether the parameter is required. Path parameters should set this true.

schema?: unknown

OpenAPI 3.1 schema for the parameter value.

description?: string

Human-readable parameter description.

interface OpenApiRequestBody {

OpenAPI request body object accepted by OperationMeta.requestBody.

The content map is keyed by media type. Each entry carries a schema object that is copied into the generated OpenAPI document.

const body: OpenApiRequestBody = {
  required: true,
  content: {
    'application/json': { schema: { type: 'object' } },
  },
};

Properties

required?: boolean

Whether the request body is required.

description?: string

Human-readable request body description.

content: Record<string, { schema: unknown; }>

Media-type map for request body schemas.

interface OpenApiResponse {

OpenAPI response object accepted by OperationMeta.responses.

Responses are keyed by status code in OperationMeta.responses. content is optional for status codes that do not return a body.

const response: OpenApiResponse = {
  description: 'OK',
  content: {
    'application/json': { schema: { type: 'object' } },
  },
};

Properties

description: string

Required human-readable response description.

content?: Record<string, { schema: unknown; }>

Optional media-type map for response body schemas.

interface Session {

Session data persisted by a SessionStore.

session.data.userId = 'u_123';

Properties

id: string

Stable session ID.

console.log(session.id);
data: Record<string, unknown>

Mutable session payload.

session.data.count = Number(session.data.count ?? 0) + 1;
isNew: boolean

True when this session was created for the current request.

if (session.isNew) console.log('new session');

interface SessionStore {

Minimal async session store interface.

Store methods may be synchronous or async. Returned sessions should be safe for request-local mutation.

const store = memorySessionStore();

Methods

get(id: string): Session | null | Promise<Session | null>

Load a session by ID, or return null when missing.

const session = await store.get(id);
set(id: string, session: Session): void | Promise<void>

Persist a session by ID.

await store.set(session.id, session);
delete(id: string): void | Promise<void>

Delete a session by ID.

await store.delete(id);

Classes

abstract class BuilderBranch<TSelf extends BuilderBranch<TSelf>> {

Methods

use(...middleware: Middleware[]): TSelf

Return a new builder with one-way middleware appended to this branch.

The middleware runs only when dispatch is flowing toward a candidate terminal below this branch. Returning a response short-circuits; returning nothing continues.

const authed = app.use(requireUser);
authed.get('/account').handle(showAccount);
layer(...layers: LayerMiddleware[]): TSelf

Return a new builder with wrapper layers appended to this branch.

Layers receive (ctx, next) and may wrap downstream dispatch, including fallback responses when this branch's routing constraints match.

const logged = app.layer(accessLog);
value(name: string, producer: Producer): TSelf

Return a new builder with a context value appended to this branch.

const withSession = app.value('session', sessions({ store }));
meta(meta: OperationMeta): TSelf

Return a new builder with OpenAPI metadata appended to this branch.

const users = app.meta({ tags: ['users'] });

abstract class RouterBase<TSelf> extends BuilderBranch<RouterBranch> {

Shared route-container surface for App, Router, and immutable root branches.

Container builders can start path branches with route() or path-taking verb shortcuts. Enrichments such as use(), layer(), value(), and meta() are inherited from BuilderBranch and return immutable branch builders; they do not mutate the original container.

Constructors

constructor()

Methods

route(path: string): RouteBuilder

Start a route branch for one URLPattern pathname.

app.route('/users/:id').get().handle((ctx) => Response.json(ctx.params));
get(path: string, ...rest: never[]): MethodBuilder

Start a GET method branch; register the handler with .handle().

app.get('/health').handle(() => Response.json({ ok: true }));
post(path: string, ...rest: never[]): MethodBuilder

Start a POST method branch; register the handler with .handle().

app.post('/users').handle((ctx) => Response.json({}, { status: 201 }));
put(path: string, ...rest: never[]): MethodBuilder

Start a PUT method branch; register the handler with .handle().

app.put('/users/:id').handle((ctx) => Response.json(ctx.params));
patch(path: string, ...rest: never[]): MethodBuilder

Start a PATCH method branch; register the handler with .handle().

app.patch('/users/:id').handle((ctx) => Response.json(ctx.params));
delete(path: string, ...rest: never[]): MethodBuilder

Start a DELETE method branch; register the handler with .handle().

app.delete('/users/:id').handle(() => new Response(null, { status: 204 }));
head(path: string, ...rest: never[]): MethodBuilder

Start a HEAD method branch; register the handler with .handle().

app.head('/health').handle(() => new Response(null, { status: 204 }));
options(path: string, ...rest: never[]): MethodBuilder

Start an OPTIONS method branch; register the handler with .handle().

app.options('/users').handle(() => new Response(null, { status: 204 }));

class RouterBranch extends BuilderBranch<RouterBranch> {

Immutable app/router branch with path-taking route helpers.

Instances are returned by root-level enrichments such as app.use(auth) or router.layer(log). Hold the returned branch to register multiple routes under the same inherited chain.

const authed = app.use(requireUser);
authed.get('/account').handle(showAccount);
authed.post('/logout').handle(logout);

Methods

route(path: string): RouteBuilder

Start a route branch for one URLPattern pathname.

branch.route('/users/:id').get().handle((ctx) => Response.json(ctx.params));
get(path: string, ...rest: never[]): MethodBuilder

Start a GET method branch; register the handler with .handle().

post(path: string, ...rest: never[]): MethodBuilder

Start a POST method branch; register the handler with .handle().

put(path: string, ...rest: never[]): MethodBuilder

Start a PUT method branch; register the handler with .handle().

patch(path: string, ...rest: never[]): MethodBuilder

Start a PATCH method branch; register the handler with .handle().

delete(path: string, ...rest: never[]): MethodBuilder

Start a DELETE method branch; register the handler with .handle().

head(path: string, ...rest: never[]): MethodBuilder

Start a HEAD method branch; register the handler with .handle().

options(path: string, ...rest: never[]): MethodBuilder

Start an OPTIONS method branch; register the handler with .handle().

class App extends RouterBase<App> {

HTTP application with middleware, routes, async context, serving, and docs.

const app = new App({ name: 'Example API' });
app.get('/').handle(() => new Response('ok'));

Constructors

constructor(options: { name?: string; } = {})

Methods

context(): HttpContext | undefined

Return the active request context from anywhere in the async call chain.

const ctx = app.context();
async handle(req: Request, info: HandleInfo = {}): Promise<HttpHandlerResult>

Dispatch one request through the matching operation chain.

A path that matches with no matching method returns 405 with an Allow header. A path served only by WebSocket or WebTransport operations returns 426 Upgrade Required. Otherwise unmatched requests return a JSON 404 after running the app-level middleware chain.

const response = await app.handle(new Request('http://local/health'));
listen(options: Parameters<typeof serve>[0]): ReturnType<typeof serve>

Start an HTTP server that dispatches requests to this app.

The returned server is the same object returned by serve().

const server = app.listen({ port: 3000 });
openapi(options: OpenApiOptions): Record<string, unknown>

Generate an OpenAPI 3.1 document from registered operations and metadata.

HTTP and SSE operations are documented; SSE paths emit GET and POST operations with a text/event-stream response. WebSocket and WebTransport operations are not part of the OpenAPI surface. Throws when generated or explicit operation IDs collide.

const doc = app.openapi({ version: '1.0.0' });
openapiHandler(options: OpenApiOptions): Handler

Return a handler that serves this app's OpenAPI document as JSON.

app.get('/openapi.json').handle(app.openapiHandler({ version: '1.0.0' }));

class Router extends RouterBase<Router> {

Reusable route collection mountable under a route prefix.

Mounting resolves the router's operations into the target; a router cannot be changed after it has been mounted.

const router = new Router();
router.get('/users').handle(() => Response.json([]));
app.route('/api').mount(router);

Methods

override _assertMutable(): void

class RouteBuilder extends BuilderBranch<RouteBuilder> {

Builder for one URLPattern pathname and its enrichment branch.

Enrichment methods return a new builder; a held reference is a fixed point in the routing tree, so chain or reassign to accumulate. Verb methods start HTTP method branches finished by .handle(); websocket(), sse(), webtransport(), rpc(), and mount() are terminals that register directly.

app.route('/users/:id').meta({ tags: ['users'] }).get().handle((ctx) => Response.json(ctx.params));

Methods

use(...middleware: Middleware[]): RouteBuilder

Return a new builder with middleware appended to this branch.

app.route('/admin').use(requireAdmin).get().handle(showAdmin);
layer(...layers: LayerMiddleware[]): RouteBuilder

Return a new builder with wrapper layers appended to this branch.

app.route('/admin').layer(auditLog).get().handle(showAdmin);
value(name: string, producer: Producer): RouteBuilder

Return a new builder with a context value appended to this branch.

app.route('/users/:id').value('params', schema.params(idSchema));
meta(meta: OperationMeta): RouteBuilder

Return a new builder with OpenAPI metadata appended to this branch.

app.route('/users').meta({ tags: ['users'] });
route(path: string): RouteBuilder

Start a nested route branch under this one.

The nested path appends to this route's path and the nested branch inherits everything accumulated above it.

const users = app.route('/users');
users.get().handle(listUsers);
users.route('/:id').get().handle(showUser);
get(...rest: never[]): MethodBuilder

Start a GET method branch on this route.

app.route('/items').get().handle(() => Response.json([]));
post(...rest: never[]): MethodBuilder

Start a POST method branch on this route.

app.route('/items').post().value('body', body.json()).handle((ctx) => Response.json(ctx.body));
put(...rest: never[]): MethodBuilder

Start a PUT method branch on this route.

app.route('/items/:id').put().handle((ctx) => Response.json(ctx.params));
patch(...rest: never[]): MethodBuilder

Start a PATCH method branch on this route.

app.route('/items/:id').patch().handle((ctx) => Response.json(ctx.params));
delete(...rest: never[]): MethodBuilder

Start a DELETE method branch on this route.

app.route('/items/:id').delete().handle(() => new Response(null, { status: 204 }));
head(...rest: never[]): MethodBuilder

Start a HEAD method branch on this route.

app.route('/items').head().handle(() => new Response(null, { status: 204 }));
options(...rest: never[]): MethodBuilder

Start an OPTIONS method branch on this route.

app.route('/items').options().handle(() => new Response(null, { status: 204 }));
websocket(handler: WebSocketHandler): RouteBuilder

Register a WebSocket operation at this route. Terminal.

Middleware on the branch runs before the upgrade is accepted and can reject it by returning a Response.

app.route('/chat').websocket(async (socket, ctx) => {
  socket.addEventListener('message', (event) => socket.send(event.data));
});
webtransport(handler: WebTransportHandler): RouteBuilder

Register a WebTransport operation at this route. Terminal.

app.route('/wt').webtransport(async (session, ctx) => {
  await session.ready;
});
sse(handler: SseHandler): RouteBuilder

Register a server-sent events operation at this route. Terminal.

The handler receives an EventSourceWriter wired to the response body. The route responds with text/event-stream immediately, streams every event the handler writes, and ends the stream when the handler returns. This uses the ordinary HTTP request path rather than the WebSocket or WebTransport upgrade path. The operation matches GET (for EventSource clients) and POST (for fetch-based clients that send a request body).

app.route('/events').sse(async (events, ctx) => {
  await events.write({ data: 'connected' });
});
rpc(service: { httpHandler(): (req: Request) => Promise<Response>; }): RouteBuilder

Mount a JSON-RPC service at this route. Terminal.

POST requests dispatch as JSON-RPC 2.0 messages; other methods return 405 with an Allow header.

import { JsonRpcService } from 'fino:jsonrpc';
const svc = new JsonRpcService();
svc.method('add').handle((p) => (p as { a: number; b: number }).a + (p as { a: number; b: number }).b);
app.route('/rpc').rpc(svc);
mount(router: Router): RouteBuilder

Mount a router's operations under this route. Terminal.

The router's operations are resolved into the owning container with this route's path prefixed and this branch's chain prepended. The router cannot be changed afterwards.

const api = new Router();
api.get('/users').handle(() => Response.json([]));
app.route('/v1').mount(api);

class MethodBuilder extends BuilderBranch<MethodBuilder> {

Builder for one HTTP method branch. Call .handle() to register.

app.route('/items').post().value('body', body.json()).handle((ctx) => Response.json(ctx.body));

Methods

use(...middleware: Middleware[]): MethodBuilder

Return a new builder with middleware appended to this method branch.

app.route('/items').post().use(rateLimit).handle(createItem);
layer(...layers: LayerMiddleware[]): MethodBuilder

Return a new builder with wrapper layers appended to this method branch.

app.route('/items').get().layer(cacheHeaders).handle(listItems);
value(name: string, producer: Producer): MethodBuilder

Return a new builder with a context value appended to this method branch.

app.route('/items').post().value('body', body.json());
meta(meta: OperationMeta): MethodBuilder

Return a new builder with OpenAPI metadata appended to this method branch.

app.route('/items').get().meta({ summary: 'List items' });
handle(handler: Handler): RouteBuilder

Register the handler for this method branch. Terminal.

Resolves the branch into a frozen operation and returns the parent route builder for further registrations on the same route.

app.route('/items').get().handle(() => Response.json([]));

class CookieJar {

Re-exported from cookie.CookieJar.

Functions

function defineMiddleware<T extends Middleware | LayerMiddleware>( fn: T, meta: OperationMeta = { } ): T

Attach static OpenAPI metadata to a middleware or layer function.

The returned function is the original function with non-enumerable metadata attached. Metadata is read when the middleware is installed in a route stack.

const auth = defineMiddleware((ctx) => {
  if (ctx.user === undefined) return Response.json({ error: 'Unauthorized' }, { status: 401 });
}, {
  security: [{ bearerAuth: [] }],
});

function defineProducer<T extends Producer>(fn: T, meta: OperationMeta = {}): T

Attach static OpenAPI metadata to a context value producer.

The returned producer is the original function with metadata attached. Use this for reusable body, params, query, or session producers.

const user = defineProducer(async (ctx) => loadUser(ctx), {
  parameters: [{ name: 'user-id', in: 'header' }],
});

function cookies(): Producer

Producer that creates a CookieJar and appends queued cookies downstream.

Use with .value('cookies', cookies()) so handlers can read and mutate cookies through ctx.cookies.

app.value('cookies', cookies());

function memorySessionStore(): SessionStore

Create an in-memory session store suitable for tests and single-process apps.

Data is lost when the process exits and is not shared across workers.

const store = memorySessionStore();
app.value('session', sessions({ store }));

function sessions(opts: { store: SessionStore; cookie?: string; cookieOptions?: CookieOptions; }): Producer

Producer that loads a cookie-backed session and saves it after response creation.

Expects ctx.cookies to be a CookieJar when installed after .value('cookies', cookies()); otherwise it creates a private jar. New sessions are assigned UUIDs and persisted after the response is produced.

app.value('cookies', cookies()).value('session', sessions({ store: memorySessionStore() }));

function errorHandler(opts: { expose?: boolean; } = {}): LayerMiddleware

Middleware that converts uncaught errors to a JSON error response.

By default, error messages are hidden. Pass { expose: true } for development or trusted internal APIs.

app.layer(errorHandler({ expose: false }));

function staticFiles(root: string, opts: { index?: string; prefix?: string; } = {}): LayerMiddleware

Serve files from a local root directory, short-circuiting matched requests.

The request path must start with opts.prefix (default /). Paths are normalized and .. traversal is rejected with 403. Missing files fall through to downstream middleware.

app.layer(staticFiles('/var/www', { index: 'index.html', prefix: '/' }));

Constants

const schema

Validation and OpenAPI helpers for parameters and responses.

These helpers wrap fino:validate schemas and attach matching OpenAPI metadata. Runtime validation failures throw from middleware or producer execution.

app.get('/users').use(schema.query(v.object({ q: v.string() }))).handle((ctx) => Response.json(ctx.query));

Methods

params(schemaValue: unknown): Producer

Validate URLPattern path parameters and expose them as ctx.params.

The returned producer parses ctx.params against the schema and stores the validated object back under the reserved params slot; install it with .value('params', schema.params(...)). The schema's object properties are emitted as required OpenAPI path parameters. Throws from producer execution when a parameter is missing or fails validation.

app.route('/users/:id').value('params', schema.params(v.object({ id: v.string() })));
query(schemaValue: unknown): Middleware

Validate the request query string and expose it as ctx.query.

The returned middleware parses the URL search parameters into an object (repeated keys become arrays), validates it, and assigns the result to ctx.query. Properties become OpenAPI query parameters, required when the schema marks them required. Throws when validation fails.

app.get('/search').use(schema.query(v.object({ q: v.string() }))).handle((ctx) => Response.json(ctx.query));
headers(schemaValue: unknown): Middleware

Validate request headers and expose them as ctx.headers.

Header names are lowercased before validation and in the generated OpenAPI header parameters. The validated object is assigned to ctx.headers. Throws when a required header is missing or malformed.

app.get('/me').use(schema.headers(v.object({ authorization: v.string() }))).handle((ctx) => Response.json(ctx.headers));
response(schemaValue: unknown, opts: { status?: number; description?: string; contentType?: string; } = {}): LayerMiddleware

Validate the outgoing response body and document it in OpenAPI.

The returned middleware runs downstream, then — only when the response status and content type match opts (defaults: status 200, application/json) — clones the response and validates its parsed body, throwing on mismatch. The response itself is passed through unchanged. The schema is recorded as the response body schema for the given status.

app.get('/users/:id')
  .layer(schema.response(v.object({ id: v.string() }), { status: 200 }))
  .handle((ctx) => Response.json({ id: ctx.params?.id }));

const body

Request body producers for .value('body', body.json(...)) and friends.

Body producers consume the request body exactly once. Install them at the point in the stack where the parsed body should become available.

app.post('/items').value('body', body.json()).handle((ctx) => Response.json(ctx.body));

Methods

json(schemaValue?: unknown): Producer

Parse the request body as JSON, optionally validating it against a schema.

The producer stores the parsed value under the slot it is bound to (conventionally body). When a schema is passed, the parsed value is validated and the validated value is stored. Throws when the request declares a content type other than application/json, and when the body is not valid JSON or fails schema validation.

app.post('/items').value('body', body.json(v.object({ name: v.string() }))).handle((ctx) => Response.json(ctx.body));
text(): Producer

Read the request body as a UTF-8 string.

The producer resolves to the decoded body text and documents a required text/plain request body in OpenAPI.

app.post('/notes').value('body', body.text()).handle((ctx) => new Response(ctx.body as string));
bytes(): Producer

Read the request body as raw bytes.

The producer resolves to a Uint8Array and documents a required application/octet-stream binary request body in OpenAPI.

app.put('/blob').value('body', body.bytes()).handle((ctx) => Response.json({ size: (ctx.body as Uint8Array).byteLength }));
form(): Producer

Parse a URL-encoded or multipart form body into FormData.

The producer resolves to the request's FormData and documents both application/x-www-form-urlencoded and multipart/form-data request bodies. Throws when the underlying request does not support formData().

app.post('/upload').value('body', body.form()).handle((ctx) => Response.json({ ok: true }));