js/net/http/server

js/net/http/server.ts

fino:net/http/server — HTTP server with negotiated protocol dispatch.

This module is the low-level entry point for standing up an HTTP server in Fino. It binds a listening socket, terminates TLS when configured, sniffs or negotiates the wire protocol per connection, and hands each request to one of two handler shapes you supply. Everything above this — routing, middleware, OpenAPI — lives in fino:net/http/app, which is built on top of serve().

There are two entry points. serveHttp() is the request/response convenience: your handler receives a Request and returns a HttpHandlerResult (a Response, or an upgraded WebSocketConnection / WebTransport). serve() is the accept-based form: your handler receives an IncomingHttp describing the connection attempt and must explicitly accept() or reject() it before returning, which is what makes protocol upgrades (WebSocket, WebTransport) first-class rather than bolted on. Both return the same ServeServer handle.

Protocol selection is automatic. serve() wraps Socket.listen() and routes each accepted connection to the HTTP/1.1 driver, to the HTTP/2 driver when a plaintext connection opens with the HTTP/2 client preface (prior-knowledge h2c), or to the HTTP/2 driver when a TLS connection negotiates h2 through ALPN. Setting h3 additionally opens an HTTP/3 (QUIC) listener on the same host and port. The selected driver owns per-connection keep-alive, pipelining, and protocol-upgrade logic.

HTTP/1.1 connections are kept alive by default: the driver loops over requests on the same TCP connection until the client sends Connection: close, the handler returns a response carrying that header, or the connection is reset. If a response includes neither Content-Length nor Transfer-Encoding, the driver buffers the body eagerly and injects Content-Length; set one of those headers yourself for a truly streaming response. If a handler throws an unhandled error the driver replies with a bare 500 Internal Server Error and closes the connection, so application-level error handling is the handler's responsibility.

import { serveHttp } from 'fino:net/http/server';

const server = serveHttp({ port: 3000 }, async (req) => {
  return new Response('hello');
});
console.log(`listening on ${server.port}`);

// Graceful shutdown drains in-flight connections:
await server.close();

Learn more:

Types

type HttpProtocol = 'http/1.1' | 'h2' | 'h3'

Wire protocol negotiated for a connection.

'http/1.1' covers both plain TCP and TLS connections that did not negotiate a newer protocol, 'h2' is HTTP/2 (via the plaintext preface or ALPN), and 'h3' is HTTP/3 over QUIC. The value is surfaced on HttpSession.protocol and on each IncomingHttp, so a handler can branch on it without inspecting the transport directly.

type HttpTransport = 'tcp' | 'tls' | 'quic'

Underlying transport carrying a connection.

'tcp' is plaintext TCP, 'tls' is a TLS-terminated TCP connection, and 'quic' is the UDP/QUIC transport used by HTTP/3. HttpSession.secure is true for everything except 'tcp'.

type HttpHandlerResult = Response | WebSocketConnection | WebTransport

A value a handler may return for a single request.

Returning a Response sends an ordinary HTTP reply. Returning a WebSocketConnection or WebTransport completes a protocol upgrade and hands ownership of the connection to that object instead of writing a response body.

type HttpRequestHandler = ( request: Request, session: HttpSession ) => HttpHandlerResult | Promise<HttpHandlerResult>

Request/response handler for serveHttp().

Called once per request with the parsed Request and the connection's HttpSession. It returns (or resolves to) a HttpHandlerResult. An unhandled rejection is turned into a bare 500 by the driver, so catch application errors and return an explicit Response for them.

import { serveHttp, type HttpRequestHandler } from 'fino:net/http/server';

const handler: HttpRequestHandler = async (req, session) => {
  return Response.json({ path: new URL(req.url).pathname, proto: session.protocol });
};
serveHttp({ port: 3000 }, handler);

type HttpTlsPeerInfo = TlsPeerInfo

TLS peer information exposed on HttpSession.tls.

This is an alias for the TlsPeerInfo produced by fino:net/tls, re-exported here so consumers of the server API can name the type without importing the TLS module directly. It carries details such as the negotiated protocol and, under client-certificate authentication, the peer certificate.

type IncomingHttp = IncomingHttpRequest | IncomingWebSocketRequest | IncomingWebTransportRequest

The discriminated union of connection attempts delivered to a serve() handler.

Branch on kind ('request', 'websocket', or 'webtransport') to narrow to the concrete variant, then accept or reject it.

type ServerAcceptHandler = (incoming: IncomingHttp, session: HttpSession) => void | Promise<void>

Accept-based handler passed to serve().

Invoked once per incoming connection attempt with the IncomingHttp and its HttpSession. The handler must resolve the incoming exactly once — accept and respond, or reject — before it returns; failing to decide, or accepting a request without responding, is turned into a 500 by the driver.

import { serve, type ServerAcceptHandler } from 'fino:net/http/server';

const onIncoming: ServerAcceptHandler = async (incoming) => {
  switch (incoming.kind) {
    case 'websocket': { const ws = await incoming.accept(); ws.send('hi'); break; }
    case 'webtransport': await incoming.reject(); break;
    default: await (await incoming.accept()).respond(new Response('ok'));
  }
};
serve({ port: 3000 }, onIncoming);

Interfaces

interface ServeOptions {

Configuration for serve() and serveHttp().

Only port is required. Leaving hostname unset binds all interfaces (0.0.0.0 for IPv4, :: for explicit IPv6), and port: 0 requests an ephemeral port whose value you read back from ServeServer.port. Supplying tls upgrades the listener to HTTPS and, when libnghttp2 is available, advertises HTTP/2 through ALPN. Supplying h3 additionally opens an HTTP/3 listener on the same address and requires tls.

import { serveHttp } from 'fino:net/http/server';

const server = serveHttp({
  port: 8443,
  hostname: '127.0.0.1',
  tls: { cert: '/etc/tls/cert.pem', key: '/etc/tls/key.pem' },
  h3: true,
  idleTimeoutMs: 30_000,
}, async () => new Response('ok'));
await server.ready;

Properties

port: number

TCP/UDP port to bind. Use 0 to request an ephemeral port.

hostname?: string

Host or interface to bind. Defaults to all interfaces (0.0.0.0 / ::).

family?: 'ipv4' | 'ipv6'

Explicit IP family for the listening socket. Defaults from hostname.

backlog?: number

Listen backlog passed through to Socket.listen().

reuseAddr?: boolean

Set SO_REUSEADDR before bind. Defaults to Socket.listen() behavior.

reusePort?: boolean

Set SO_REUSEPORT before bind where supported.

tls?: { cert: string; key: string; ca?: string; clientAuth?: 'none' | 'request' | 'require'; rejectUnauthorized?: boolean; protocols?: readonly Extract<HttpProtocol, 'http/1.1' | 'h2'>[]; }

Enable TLS. Presence upgrades the listener to HTTPS and enables ALPN-negotiated HTTP/2.

allowH2cUpgrade?: boolean

Enable the HTTP/1.1 → h2c Upgrade dance (RFC 7540 §3.2) on plain TCP.

headersTimeoutMs?: number

HTTP/1 header timeout in milliseconds. 0 or undefined disables it.

idleTimeoutMs?: number

HTTP/1 keep-alive idle timeout in milliseconds. 0 or undefined disables it.

h3?: boolean | { quic?: Partial<Omit<H3ServeOptions, 'port' | 'hostname' | 'certificateFile' | 'privateKeyFile'>>; }

Enable an HTTP/3 UDP listener on the same host and port. Requires tls.

interface ServeServer {

Handle to a running server returned by serve() and serveHttp().

The server begins accepting connections immediately and keeps the event loop alive until it is closed. It implements Symbol.asyncDispose, so an await using binding closes it automatically when the scope exits.

import { serveHttp } from 'fino:net/http/server';

await using server = serveHttp({ port: 0 }, async () => new Response('hi'));
console.log(server.address.ip, server.port);
await server.ready;
// server.close() runs at scope exit via asyncDispose

Properties

address: { family: string; ip: string; port: number; }

The bound socket address (IP family, IP, and actual port).

Readonly Properties

readonly port: number

The actual bound port. Read this after port: 0 to learn the ephemeral port.

readonly ready: Promise<void>

Resolves when all requested listeners, including the optional HTTP/3 listener, are ready.

Methods

close(): Promise<void>

Stop accepting, close the listener, release TLS state, and resolve once in-flight connections finish. Safe to call more than once.

interface HttpSession {

Per-connection metadata shared by every request on that connection.

A session is created when a connection is dispatched and is passed to the handler alongside each request. For accepted requests the same session object is reachable through AcceptedHttpRequest.session, so all requests multiplexed over one HTTP/2 or HTTP/3 connection observe an identical session.

import { serveHttp } from 'fino:net/http/server';

serveHttp({ port: 3000 }, async (_req, session) => {
  const who = session.tls?.peerCertificate ? 'mTLS client' : 'anonymous';
  return new Response(`${session.protocol} over ${session.transport} (${who})`);
});

Readonly Properties

readonly id: string

Process-unique identifier for this connection, e.g. http-session-7.

readonly protocol: HttpProtocol

Negotiated wire protocol for the connection.

readonly transport: HttpTransport

Underlying transport carrying the connection.

readonly secure: boolean

true for TLS and QUIC connections, false for plaintext TCP.

readonly localAddress: unknown | null

Local socket address the connection was accepted on, or null if unavailable.

readonly remoteAddress: unknown | null

Remote peer's socket address, or null if unavailable.

readonly tls: TlsPeerInfo | null

TLS peer metadata for secure transports, or null for plain TCP.

readonly closed: Promise<void>

Resolves when the connection is closed.

interface IncomingBase<TKind extends string> {

Fields shared by every kind of incoming connection attempt.

Each variant of IncomingHttp extends this base, discriminated by kind. An incoming must be resolved exactly once — either by calling the variant's accept() or by calling reject() — before the handler returns.

Readonly Properties

readonly kind: TKind

Discriminant identifying the variant: 'request', 'websocket', or 'webtransport'.

readonly request: Request

The parsed request that opened this connection attempt.

readonly protocol: HttpProtocol

Negotiated wire protocol for the connection.

readonly session: HttpSession

Metadata for the connection this request arrived on.

readonly tls: TlsPeerInfo | null

TLS peer metadata for secure transports, or null for plain TCP.

Methods

reject(response?: Response): Promise<void>

Decline the connection attempt. With no argument a default response is sent (404 for requests, 400 for WebSocket upgrades); pass a Response to control the reply. Throws if the incoming was already accepted or rejected.

interface IncomingHttpRequest extends IncomingBase<'request'> {

A plain HTTP request awaiting a decision in a serve() handler.

Call accept() to obtain an AcceptedHttpRequest you then respond() on, or reject() to decline. This is the kind === 'request' variant of IncomingHttp and is the common case for ordinary GET/POST traffic.

import { serve } from 'fino:net/http/server';

serve({ port: 3000 }, async (incoming) => {
  if (incoming.kind !== 'request') return incoming.reject();
  const accepted = await incoming.accept();
  await accepted.respond(new Response('hello'));
});

Methods

accept(): Promise<AcceptedHttpRequest>

Accept the request, yielding a handle on which exactly one respond() must be called. Throws if the incoming was already accepted or rejected.

interface AcceptedHttpRequest {

An accepted plain request, obtained from IncomingHttpRequest.accept().

Call respond() exactly once with the reply. The session here is the same object passed to the handler, so metadata observed during acceptance stays consistent through the response.

import { serve } from 'fino:net/http/server';

serve({ port: 3000 }, async (incoming) => {
  if (incoming.kind !== 'request') return incoming.reject();
  const accepted = await incoming.accept();
  await accepted.respond(Response.json({ ok: true, proto: accepted.protocol }));
});

Readonly Properties

readonly kind: 'request'

Always 'request'; identifies this as an accepted plain request.

readonly request: Request

The request that was accepted.

readonly protocol: HttpProtocol

Negotiated wire protocol for the connection.

readonly session: HttpSession

The connection's session; identical to the one handed to the handler.

Methods

respond(response: HttpHandlerResult): Promise<void>

Send the reply for this request. Must be called exactly once; throws if called again.

interface IncomingWebSocketRequest extends IncomingBase<'websocket'> {

A WebSocket upgrade request awaiting a decision in a serve() handler.

Produced for HTTP/1.1 requests carrying Upgrade: websocket. Call accept() to complete the handshake and obtain a WebSocketConnection, or reject() to refuse (default 400). This is the kind === 'websocket' variant.

import { serve } from 'fino:net/http/server';

serve({ port: 3000 }, async (incoming) => {
  if (incoming.kind !== 'websocket') return incoming.reject();
  const ws = await incoming.accept({ protocol: incoming.subprotocols[0] });
  ws.send('welcome');
});

Readonly Properties

readonly subprotocols: readonly string[]

Subprotocol tokens the client offered via Sec-WebSocket-Protocol.

Methods

accept(options?: WebSocketAcceptOptions): Promise<WebSocketConnection>

Complete the WebSocket handshake and take over the connection. options can pick a negotiated subprotocol. Throws if already accepted or rejected.

interface IncomingWebTransportRequest extends IncomingBase<'webtransport'> {

An HTTP/3 extended-CONNECT request opening a WebTransport session, awaiting a decision in a serve() handler.

Produced only for HTTP/3 connections. Call accept() to establish the session and take over the request stream, or reject() to refuse. This is the kind === 'webtransport' variant.

import { serve } from 'fino:net/http/server';

serve({ port: 8443, tls: { cert: 'c.pem', key: 'k.pem' }, h3: true }, async (incoming) => {
  if (incoming.kind !== 'webtransport') return incoming.reject();
  const wt = await incoming.accept();
  const stream = await wt.createBidirectionalStream();
  void stream;
});

Readonly Properties

readonly protocols: readonly string[]

Application protocol tokens requested by the client.

Methods

accept(options?: WebTransportOptions): Promise<WebTransport>

Accept the WebTransport session and take over the request stream.

Functions

function serve(options: ServeOptions, handler: ServerAcceptHandler): ServeServer

Start an accept-based HTTP server.

Each incoming connection is handled concurrently. The event loop is implicitly kept alive as long as the server is open.

hostname defaults to 0.0.0.0 for IPv4 and :: for explicit IPv6, and port may be 0 to request an ephemeral port. When tls is present, the server loads the certificate and key paths and advertises HTTP/2 through ALPN when libnghttp2 is available. close() stops accepting, closes the listening socket, releases TLS state, and resolves after in-flight connections finish. backlog, reuseAddr, and reusePort are passed to the underlying socket listener.

import { serve } from 'fino:net/http/server';

const server = serve({ port: 3000 }, async (incoming) => {
  const accepted = await incoming.accept();
  await accepted.respond(new Response('hello'));
});
console.log(server.port);
await server.close();

function serveHttp(options: ServeOptions, handler: HttpRequestHandler): ServeServer

Start a request/response HTTP server.

This is the convenience wrapper over serve(): instead of the accept-based protocol, your handler receives the parsed Request and the connection's HttpSession and returns a HttpHandlerResult. Ordinary requests are accepted and responded to automatically; anything that arrives as a WebSocket or WebTransport upgrade attempt is rejected with a default response, since those flows require the accept-based serve() API. All listener, TLS, HTTP/2, and HTTP/3 options behave exactly as they do for serve(), and the same ServeServer handle is returned.

Returning a WebSocketConnection or WebTransport from the handler for a request that was itself an upgrade is not possible here; use serve() when you need to accept upgrades.

import { serveHttp } from 'fino:net/http/server';

const server = serveHttp({ port: 3000 }, async (req, session) => {
  const url = new URL(req.url);
  if (url.pathname === '/health') return new Response('ok');
  return Response.json({ method: req.method, protocol: session.protocol });
});
console.log(`listening on ${server.port}`);
await server.close();