Routing and Middleware

js/net/http/routing.md

fino:net/http/app provides App and Router — a composable routing and middleware framework built on top of fino:net/http/server. It targets APIs that need URLPattern routing, request-scoped values, middleware composition, and a machine-readable OpenAPI document without abandoning direct access to Request and Response.

The routing tree

Route configuration is an immutable enrichment tree. Every non-terminal call — use(), layer(), value(), meta(), route(), and the verb methods — records a node pointing at its parent and returns a new builder around that node. A terminal — .handle() for HTTP methods, or websocket(), sse(), webtransport(), rpc(), and mount() on a route — resolves the branch it hangs off by walking back to the root and freezing the result into an operation.

Two properties follow:

A later value() with the same name shadows an earlier one along a branch, and later meta() overrides earlier metadata key by key — last wins.

The middleware model

There are two middleware forms:

import { App, defineMiddleware } from 'fino:net/http/app';

const app = new App({ name: 'Example API' });

const timing = defineMiddleware(async (ctx, next) => {
  const start = Date.now();
  const res = await next();
  res.headers.set('x-response-time', `${Date.now() - start}ms`);
  return res;
});

const timed = app.layer(timing);
timed.get('/health').handle(() => Response.json({ ok: true }));

const server = app.listen({ port: 3000 });
await server.close();

use() and layer() accept one or more functions and append them in call order. Middleware is never passed inline to a verb — it always attaches through the branch it should cover.

Registering routes

The verb methods get, post, put, patch, delete, head, and options take only a path and return a MethodBuilder; the handler always registers through the explicit .handle() terminal:

app.get('/users').handle((ctx) => Response.json([]));
app.post('/users').handle(async (ctx) => {
  const body = await ctx.request.json();
  return Response.json(body, { status: 201 });
});

For fluent configuration, start from route() and enrich the branch before the terminal:

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

app.route('/users/:id')
  .meta({ tags: ['users'] })
  .value('params', schema.params(v.object({ id: v.string() })))
  .put()
  .value('body', body.json(v.object({ name: v.string() })))
  .handle((ctx) => {
    return Response.json({ id: ctx.params.id, name: (ctx.body as { name: string }).name });
  });

.handle() returns the parent route builder, so sibling methods chain on the same route:

app.route('/items')
  .get().handle(() => Response.json([]))
  .post().handle((ctx) => Response.json({}, { status: 201 }));

A request that matches a route's path with no matching method returns 405 Method Not Allowed with an Allow header listing the methods that would have matched.

Nested routes

route() on a route builder nests: the child path appends to the parent's, and the child branch inherits everything accumulated above it. This is the natural shape for REST controllers:

const users = app.route('/users').value('db', openDatabase);
users.get().handle(listUsers);                       // GET /users
users.post().handle(createUser);                     // POST /users

const user = users.route('/:id')
  .value('params', schema.params(v.object({ id: v.string() })));
user.get().handle(showUser);                         // GET /users/:id
user.delete().handle(removeUser);                    // DELETE /users/:id

Context values and producers

A producer is an async function that runs at its position in the middleware chain and stores its result under a named slot in HttpContext:

import { defineProducer } from 'fino:net/http/app';

const currentUser = defineProducer(async (ctx) => {
  const token = ctx.request.headers.get('authorization') ?? '';
  return verifyToken(token); // returns a user object or throws
});

app.route('/account')
  .value('user', currentUser)
  .get()
  .handle((ctx) => Response.json(ctx.user));

app.context() returns the active HttpContext for the current request from anywhere in the async call chain, using fino:context propagation:

function getUser() {
  const ctx = app.context();
  return ctx?.user;
}

Schema helpers

schema.params, schema.query, schema.headers, and schema.response wrap fino:validate schemas and generate matching OpenAPI metadata automatically:

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

app.route('/search')
  .use(schema.query(v.object({ q: v.string(), limit: v.optional(v.number()) })))
  .get()
  .handle((ctx) => {
    const { q, limit } = ctx.query as { q: string; limit?: number };
    return Response.json({ q, limit: limit ?? 10 });
  });

schema.response validates downstream JSON responses and contributes the response schema to OpenAPI:

app.route('/status')
  .get()
  .layer(schema.response(v.object({ ok: v.boolean() })))
  .handle(() => Response.json({ ok: true }));

Body producers

body.json, body.text, body.bytes, and body.form consume the request body exactly once and contribute request body metadata to OpenAPI:

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

app.route('/messages')
  .post()
  .value('body', body.json(v.object({ text: v.string() })))
  .handle((ctx) => {
    const message = ctx.body as { text: string };
    return Response.json({ id: crypto.randomUUID(), text: message.text }, { status: 201 });
  });

body.json() without a schema argument accepts any JSON value without validation.

Cookies and sessions

cookies() parses the request Cookie header into a mutable CookieJar and applies queued Set-Cookie headers to the response automatically:

import { App, cookies, sessions, memorySessionStore } from 'fino:net/http/app';

const store = memorySessionStore();

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

stateful.get('/me').handle((ctx) => {
  const session = ctx.session as { id: string; data: Record<string, unknown>; isNew: boolean };
  session.data.visits = Number(session.data.visits ?? 0) + 1;
  return Response.json({ visits: session.data.visits });
});

memorySessionStore() is suitable for single-process apps and tests. For production use, implement SessionStore with a shared backing store.

Error handling middleware

errorHandler converts uncaught errors to a JSON 500 response. Install it early so it wraps all downstream middleware:

import { App, errorHandler } from 'fino:net/http/app';

const guarded = app.layer(errorHandler({ expose: false })); // set expose: true in development

Static files

staticFiles serves files from a local directory and falls through to downstream middleware on misses:

import { App, staticFiles } from 'fino:net/http/app';

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

Routers and mounting

Router is a reusable route collection. mount() is a terminal on a route builder: it resolves the router's operations into the target under the route's path, with anything accumulated on the branch prepended. A mounted router carries all of its operations — protocol routes included — and cannot be changed afterwards; registering on an already-mounted router throws.

import { App, Router } from 'fino:net/http/app';

const usersRouter = new Router();
const tenantUsers = usersRouter.value('tenant', () => 'default');

tenantUsers.route('/users').get().handle(() => Response.json([]));

const app = new App({ name: 'API' });
app.route('/v1').mount(usersRouter);

Routers mount into other routers the same way, so route trees compose:

const admin = new Router();
admin.route('/stats').get().handle(showStats);

const api = new Router();
api.route('/admin').mount(admin);

app.route('/v2').mount(api);                         // GET /v2/admin/stats

WebSocket, SSE, and WebTransport routes

The protocol methods are terminals on a route builder — they take the handler directly, and everything enriched on the branch (middleware, values) runs before the connection is established. Middleware can reject an upgrade by returning a Response instead of calling next().

sse() registers a server-sent events operation. The handler receives an EventSourceWriter wired to the response body; the stream ends when the handler returns. One operation matches both GET and POST:

import { App } from 'fino:net/http/app';

const app = new App();

app.route('/events').sse(async (events, ctx) => {
  await events.write({ data: 'connected' });
  await events.write({ event: 'done', data: '{}' });
});

See the server-sent events guide for the event format, heartbeats, and client-side parsing.

websocket() registers a WebSocket operation. The handler receives the WebSocketConnection and the context:

app.route('/chat')
  .use(requireSession)
  .websocket(async (socket, ctx) => {
    socket.addEventListener('message', (e) => socket.send((e as MessageEvent).data));
  });

webtransport() registers a WebTransport operation. The session is already accepted when the handler is called:

app.route('/session').webtransport(async (session, ctx) => {
  const stream = await session.createBidirectionalStream();
  // ... use stream.readable and stream.writable
});

A plain HTTP request to a path served only by WebSocket or WebTransport operations returns 426 Upgrade Required with an Upgrade header.

JSON-RPC

rpc() is a terminal that mounts a JsonRpcService as a POST operation. Other methods on the same path return 405 with Allow: POST:

import { App } from 'fino:net/http/app';
import { JsonRpcService } from 'fino:jsonrpc';

const svc = new JsonRpcService();
svc.method('ping').handle(() => 'pong');

app.route('/rpc').rpc(svc);

Dispatching manually

app.handle() dispatches a synthetic request for testing or embedding inside another handler:

const res = await app.handle(new Request('http://internal/health'));
console.log(res.status);

OpenAPI

app.openapi() generates an OpenAPI 3.1 document from all registered operations and accumulated metadata. Metadata from defineMiddleware and defineProducer is merged automatically; HTTP, SSE, and RPC operations are documented (SSE paths emit GET and POST operations with a text/event-stream response), while WebSocket and WebTransport operations are not part of the OpenAPI surface.

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

app.openapiHandler() returns a Handler that serves the document as JSON:

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

Route-level metadata is added with .meta():

app.route('/users/:id')
  .meta({
    tags: ['users'],
    summary: 'Get a user by ID',
    responses: {
      '200': { description: 'User found' },
      '404': { description: 'Not found' },
    },
  })
  .get()
  .handle((ctx) => Response.json({ id: ctx.params?.id }));

Starting the server

app.listen() is a shortcut for serve() that dispatches requests through the app:

const server = app.listen({ port: 3000, hostname: '127.0.0.1' });
await server.ready;
// ... server is running
await server.close();

All ServeOptionstls, h3, headersTimeoutMs, idleTimeoutMs, reuseAddr, reusePort — work the same as with serve() directly.