js/net/http/client

js/net/http/client.ts

fino:net/http/client — reusable HTTP clients and logical sessions.

This module provides a lower-level client API for code that needs more control and observability than global fetch(). HttpClient owns reusable policy such as a base URL, default headers, protocol preferences, and logical sessions. HttpSession represents an origin-scoped relationship that can survive transport replacement. HttpResponse wraps a Fetch-compatible Response with request, session, connection, protocol, trailer, and timing metadata.

The first implementation intentionally layers on Fino's existing fetch, EventSource, and WebSocket transports. That preserves current redirect, abort, decompression, integrity, referrer, TLS, and HTTP/2 pool behavior while establishing the public client/session surface. HTTP/1.1 sessions are logical policy containers and still use one connection per request. Explicit HTTP/3 sessions keep one QUIC/H3 transport active until reconnect() or close(). HTTP/2 and HTTP/3 WebSocket attempts reject with a clear Extended CONNECT error until those transports support it.

import { HttpClient } from 'fino:net/http/client';

const client = new HttpClient({
  baseUrl: 'https://api.example.com',
  headers: { authorization: 'Bearer token' },
});

const response = await client.request('/users');
console.log(response.status, response.protocol, await response.json());

const fetchResponse = await client.fetch('/users');
const events = client.sse('/events');
const socket = await client.websocket('/chat', { protocols: ['chat.v1'] });

Learn more:

Types

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

Application-level protocol a client or session may speak.

'http/1.1' uses one connection per request and is the default. 'h2' multiplexes over a shared HTTP/2 pool entry keyed by origin. 'h3' runs over a persistent QUIC transport that survives until reconnect() or close(). The value pins how a session behaves; the actual protocol a given response was served on is reported separately by HttpResponse.protocol, which may differ if the peer negotiated down.

import type { HttpClientProtocol } from 'fino:net/http/client';

const preferred: readonly HttpClientProtocol[] = ['h3', 'h2', 'http/1.1'];

type HttpClientTransport = 'tcp' | 'tls' | 'quic'

Physical transport carrying a connection, reported by HttpConnectionInfo.

'tcp' is cleartext HTTP/1.1, 'tls' is HTTP/1.1 or HTTP/2 over TLS, and 'quic' is HTTP/3 over a QUIC datagram flow. The transport is derived from the negotiated protocol and the request URL scheme, not chosen directly.

type HttpHeadersInit = Headers | string[][] | Record<string, string> | null | undefined

Header collection accepted anywhere the client takes headers.

Any form the Headers constructor understands is allowed: an existing Headers instance, an array of [name, value] pairs, or a plain object. null and undefined mean "no headers" so callers can pass an optional value straight through without branching.

import type { HttpHeadersInit } from 'fino:net/http/client';

const a: HttpHeadersInit = { authorization: 'Bearer t0ken' };
const b: HttpHeadersInit = [['accept', 'application/json']];
const c: HttpHeadersInit = new Headers({ 'x-trace': 'abc' });

type HttpSessionEvent = { type: 'connecting'; session: HttpSession; } | { type: 'connected'; session: HttpSession; connection: HttpConnectionInfo; } | { type: 'reconnecting'; session: HttpSession; reason: unknown; } | { type: 'reconnected'; session: HttpSession; connection: HttpConnectionInfo; } | { type: 'closed'; session: HttpSession; reason?: unknown; }

Discriminated lifecycle event emitted by a logical HTTP session.

A session records these as it moves through its life: connecting and connected (carrying the new HttpConnectionInfo) around establishing a transport, reconnecting and reconnected around a transport replacement, and closed on teardown. The HTTP/1.1 and HTTP/2 paths emit connected per request since they do not hold a dedicated transport; HTTP/3 sessions emit connected once when the QUIC transport comes up. Consume the stream via HttpSession.events and switch on type.

for await (const event of session.events) {
  switch (event.type) {
    case 'connected':
      console.log('up on', event.connection.protocol);
      break;
    case 'closed':
      console.log('down:', event.reason);
      break;
  }
}

Interfaces

interface HttpClientOptions {

Configuration for new HttpClient().

Every field is optional; an empty object yields an HTTP/1.1 client with no base URL and no default headers, so requests must pass absolute URLs. The base URL resolves relative paths for request(), sse(), websocket(), and webtransport(). Default headers are merged into every request and can be overridden per call. protocols lists preferences in priority order — the first entry drives explicit sessions and the default for request(). tls is forwarded to the underlying fetch, EventSource, and QUIC transports.

import { HttpClient } from 'fino:net/http/client';

const client = new HttpClient({
  baseUrl: 'https://api.example.com/v1',
  headers: { authorization: 'Bearer t0ken', accept: 'application/json' },
  protocols: ['h2', 'http/1.1'],
  tls: { rejectUnauthorized: true },
});

Properties

baseUrl?: string | URL

Base URL used to resolve relative request, SSE, WebSocket, and WebTransport paths.

When omitted, calls that take paths must use absolute URLs.

headers?: HttpHeadersInit

Headers applied to every request unless overridden.

Per-request headers are merged on top with the same names replacing these defaults.

protocols?: readonly HttpClientProtocol[]

Preferred protocols, in priority order.

The first entry is used for request() and for sessions that do not pass an explicit protocol. Defaults to ['http/1.1'].

tls?: { ca?: string; rejectUnauthorized?: boolean; cert?: string; key?: string; }

TLS options forwarded to the underlying fetch, EventSource, and QUIC transports.

interface HttpRequestInit {

Per-request overrides for HttpClient.request() and the underlying send.

These mirror the familiar Fetch options — method, headers, body, signal, redirect, integrity, and the referrer controls — and add trailers (a Headers value or a function producing one, applied after the body) plus transport-specific tls and quic overrides. Headers here are merged on top of the client's defaults. tls/quic fall back to the client's TLS options when omitted.

import { HttpClient } from 'fino:net/http/client';

const client = new HttpClient({ baseUrl: 'https://api.example.com' });
const controller = new AbortController();
const res = await client.request('/users', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ name: 'Ada' }),
  signal: controller.signal,
  redirect: 'error',
});

Properties

method?: string

HTTP method to send. Defaults to GET.

headers?: HttpHeadersInit

Headers merged on top of the client's defaults for this request.

body?: unknown

Request body forwarded to the selected transport.

Streaming bodies are treated as non-replayable in HttpRequestInfo.

signal?: MinimalAbortSignal | null

Abort signal observed by the underlying transport.

redirect?: 'follow' | 'error' | 'manual'

Redirect mode forwarded to fetch-backed transports.

integrity?: string

Subresource integrity metadata forwarded to fetch-backed transports.

referrerPolicy?: 'no-referrer' | 'no-referrer-when-downgrade' | 'origin' | 'origin-when-cross-origin' | 'same-origin' | 'strict-origin' | 'strict-origin-when-cross-origin' | 'unsafe-url' | ''

Referrer policy forwarded to fetch-backed transports.

referrer?: string

Referrer value forwarded to fetch-backed transports.

trailers?: Headers | (() => Headers | Promise<Headers>)

Trailer headers, or a function producing trailer headers after the body.

tls?: HttpClientOptions['tls']

TLS overrides for this request.

quic?: H3FetchInit['quic']

QUIC overrides for HTTP/3 requests.

interface HttpSessionRequest extends HttpRequestInit {

Request descriptor accepted by HttpSession.request().

Extends HttpRequestInit with the target location, since a session already knows its origin. Supply url (absolute or relative) or path (relative to the session origin); url wins if both are present, and when neither is given the request targets /.

import { HttpClient } from 'fino:net/http/client';

const client = new HttpClient();
const session = await client.session('https://api.example.com');
const res = await session.request({ path: '/users/42', method: 'GET' });

Properties

path?: string | URL

Path or URL resolved against the session origin.

Ignored when url is also provided.

url?: string | URL

Absolute or relative URL to request.

Wins over path; defaults to / when both are omitted.

interface HttpSessionOptions {

Options for HttpClient.session().

protocol pins the returned session to a single protocol; when omitted the session adopts the client's first configured protocol. Sessions are cached per protocol:origin, so requesting the same origin with a different protocol yields a distinct session.

import { HttpClient } from 'fino:net/http/client';

const client = new HttpClient({ protocols: ['h3'] });
const session = await client.session('https://api.example.com', { protocol: 'h3' });

Properties

protocol?: HttpClientProtocol

Pin the session to one protocol.

Defaults to the client's first configured protocol.

interface ReconnectOptions {

Options for HttpSession.reconnect().

reason is an opaque value recorded on the emitted reconnecting lifecycle event so observers can attribute the transport replacement (for example, a caught error or a rotation signal).

await session.reconnect({ reason: 'idle timeout' });

Properties

reason?: unknown

Opaque reason attached to the reconnecting lifecycle event.

interface CloseOptions {

Options for HttpClient.close() and HttpSession.close().

reason is an opaque value attached to the emitted closed lifecycle event, useful for logging why a session was torn down.

await session.close({ reason: 'shutdown' });

Properties

reason?: unknown

Opaque reason attached to the closed lifecycle event.

interface HttpRequestInfo {

Snapshot of the request that produced an HttpResponse.

method and url are the final, normalized values actually sent. headers is the merged header set after client defaults and per-request overrides. idempotent reflects whether the method is safe to retry per HTTP semantics (GET, HEAD, OPTIONS, TRACE, PUT, DELETE). replayable is true only when the body can be re-sent — it is false for streaming bodies such as ReadableStream or async iterables, which are consumed once. attempt is the 1-based try count.

const res = await client.request('/users');
if (res.request.idempotent && res.request.replayable) {
  // safe to retry this request
}

Readonly Properties

readonly method: string

Final uppercase HTTP method sent on the wire.

readonly url: string

Final absolute request URL.

readonly headers: Headers

Merged request headers after client defaults and per-call overrides.

readonly idempotent: boolean

Whether the method is considered idempotent for retry decisions.

readonly replayable: boolean

Whether the request body can be sent again.

readonly attempt: number

One-based attempt number for this send.

interface HttpConnectionInfo {

Metadata about the transport connection a response was served on.

id is a per-process synthetic connection identifier, distinct from the logical HttpSession.id. protocol and transport describe what was actually negotiated. localAddress/remoteAddress and alpnProtocol are populated for HTTP/3 (QUIC) connections and left null for the HTTP/1.1 and HTTP/2 paths, which layer on the shared fetch pool. connectedAt is a Date.now() millisecond timestamp.

const res = await client.request('/users');
const conn = res.connection;
if (conn !== null) {
  console.log(conn.protocol, conn.transport, conn.alpnProtocol);
}

Readonly Properties

readonly id: string

Synthetic per-process connection id.

readonly protocol: HttpClientProtocol

Negotiated application protocol for this connection.

readonly transport: HttpClientTransport

Physical transport used by the connection.

readonly localAddress: unknown | null

Local address when the transport exposes one, otherwise null.

readonly remoteAddress: unknown | null

Remote address when the transport exposes one, otherwise null.

readonly alpnProtocol: string | null

Negotiated ALPN protocol when available.

readonly connectedAt: number

Millisecond timestamp recorded when the connection metadata was created.

interface HttpResponseTiming {

Coarse timing marks captured while sending a request and reading its body.

All values are Date.now() millisecond timestamps. startTime is recorded just before the request is sent. responseHeadersEnd is set once the status line and headers arrive. bodyEnd is set when the response body is fully consumed or the response is closed — so it stays undefined until you read the body via text(), json(), bytes(), arrayBuffer(), iterate body, or call close().

const res = await client.request('/users');
const body = await res.text();
const total = (res.timing.bodyEnd ?? Date.now()) - res.timing.startTime;
console.log(`request took ${total}ms`);

Readonly Properties

readonly startTime: number

Millisecond timestamp taken immediately before the request is sent.

readonly responseHeadersEnd?: number

Millisecond timestamp taken after response status and headers arrive.

readonly bodyEnd?: number

Millisecond timestamp set when the body is consumed or closed.

interface SseOptions extends EventSourceInit {

Options for HttpClient.sse() and HttpSession.sse().

Extends the standard EventSourceInit with a headers field so the stream inherits and can extend the client's default headers (for example, an authorization token). TLS falls back to the client's configuration when not set on the options.

const events = client.sse('/events', {
  headers: { 'last-event-id': '42' },
  withCredentials: true,
});
events.addEventListener('message', (e) => console.log(e.data));

Properties

headers?: HttpHeadersInit

Headers merged with client or session defaults for the stream request.

interface HttpWebSocketOptions extends WebSocketConnectOptions {

Options for HttpClient.websocket() and HttpSession.websocket().

Extends the transport's WebSocketConnectOptions (subprotocols, and so on) with extra headers merged into the upgrade request. WebSocket is only available over HTTP/1.1; attempting it on an h2 or h3 session rejects, because Extended CONNECT is not yet supported.

const socket = await client.websocket('/chat', {
  protocols: ['chat.v1'],
  headers: { authorization: 'Bearer t0ken' },
});
socket.send('hello');

Properties

headers?: Record<string, string> | Headers

Headers merged with client or session defaults for the WebSocket upgrade.

interface HttpWebTransportOptions extends WebTransportOptions {

Options for HttpClient.webtransport() and HttpSession.webtransport().

Extends WebTransportOptions with request headers plus tls/quic overrides for the underlying QUIC connection. Any protocols are sent as the sec-webtransport-protocol header. WebTransport requires an h3 session and an https: URL.

const wt = await client.webtransport('https://api.example.com/wt', {
  protocols: ['app.v1'],
});
await wt.ready;

Properties

headers?: Record<string, string> | Headers

Headers merged with client or session defaults for the CONNECT request.

tls?: HttpClientOptions['tls']

TLS overrides for the underlying HTTP/3 transport.

quic?: H3FetchInit['quic']

QUIC overrides for the underlying HTTP/3 transport.

Classes

class HttpResponse {

Response returned by HttpClient.request() and HttpSession.request().

Wraps a Fetch-compatible Response and augments it with the request that produced it, the logical session, the transport connection, the actual protocol served, incoming trailers, and coarse timing marks. The body helpers (text(), json(), bytes(), arrayBuffer()) and the streaming body iterable are single-consumption: the first that runs marks the body consumed, and any later read — including toFetchResponse() — throws TypeError('Body already consumed'). close() drains or cancels an unread body without throwing so responses can be discarded safely.

Applications rarely construct this directly; obtain one from a request.

import { HttpClient } from 'fino:net/http/client';

const client = new HttpClient({ baseUrl: 'https://api.example.com' });
const res = await client.request('/users/42');
if (res.status === 200) {
  const user = await res.json();
  console.log(res.protocol, user);
} else {
  await res.close();
}

Readonly Properties

readonly status: number

HTTP status code copied from the underlying response.

readonly statusText: string

HTTP reason phrase copied from the underlying response.

readonly headers: Headers

Response headers.

readonly url: string

Final response URL after redirect handling.

readonly redirected: boolean

Whether the underlying transport followed at least one redirect.

readonly request: HttpRequestInfo

Metadata for the request that produced this response.

readonly session: HttpSession

Logical session used for the request.

readonly connection: HttpConnectionInfo | null

Transport connection metadata, or null when unavailable.

readonly protocol: HttpClientProtocol

Protocol that served this response.

readonly trailers: Promise<Headers>

Incoming trailer headers.

The promise resolves after the response body has been read when the transport supports trailers.

readonly timing: HttpResponseTiming

Request/response timing metadata.

readonly body: AsyncIterable<Uint8Array> | null

Streaming response body.

Iterating this consumes the body and prevents later use of body helper methods or toFetchResponse().

Constructors

constructor(init: { response: Response; request: HttpRequestInfo; session: HttpSession; connection: HttpConnectionInfo | null; protocol: HttpClientProtocol; timing: HttpResponseTiming; markBodyEnd: () => void; })

Wrap a Fetch-compatible Response together with client-level metadata.

Called by the client machinery; applications receive ready-made HttpResponse values from request() and do not build them by hand.

Methods

async arrayBuffer(): Promise<ArrayBuffer>

Consume the body and return an ArrayBuffer.

async bytes(): Promise<Uint8Array>

Consume the body and return bytes.

async text(): Promise<string>

Consume the body and decode it as UTF-8 text.

async json(): Promise<unknown>

Consume the body and parse it as JSON.

Throws TypeError('Body already consumed') if any body helper, body iteration, toFetchResponse(), or close() already ran, and rejects if the payload is not valid JSON.

toFetchResponse(): Response

Hand back the underlying Fetch-compatible Response.

Marks this HttpResponse consumed and transfers body ownership to the returned Response, so read the body through that object afterward. Throws TypeError('Body already consumed') if the body was already read here.

async close(): Promise<void>

Release the response without reading it.

Marks the body consumed and cancels the underlying stream if it is still open. Safe to call more than once and never throws, making it the right way to discard a response you do not intend to read (for example, on a status you do not handle).

class HttpSession {

Logical, origin-scoped relationship with a server.

A session pins one origin and one protocol and carries the client's default headers, giving a stable identity (id) that outlives any single transport connection. For HTTP/1.1 and HTTP/2 it is a policy container — each request still flows through the shared fetch pool — while an h3 session keeps one QUIC/H3 transport alive across requests until reconnect() or close(). Sessions expose their lifecycle through the events async iterable and can additionally open SSE streams, WebSockets, and WebTransport bound to the same origin and headers.

Obtain sessions from HttpClient.session() rather than constructing them.

import { HttpClient } from 'fino:net/http/client';

const client = new HttpClient({ protocols: ['h3'] });
const session = await client.session('https://api.example.com', { protocol: 'h3' });
try {
  const a = await session.request({ path: '/a' });
  const b = await session.request({ path: '/b' });
  console.log(a.session.id === b.session.id); // true — stable identity
} finally {
  await session.close();
}

Readonly Properties

readonly id: string

Stable logical session identity.

This differs from HttpConnectionInfo.id; it remains stable across reconnects and multiple requests on the same logical session.

readonly origin: string

Origin URL for the session, such as https://api.example.com.

readonly protocol: HttpClientProtocol

Protocol pinned for every request sent through this session.

Constructors

constructor(client: HttpClient, origin: URL, protocol: HttpClientProtocol, headers: Headers)

Bind a session to a client, origin, protocol, and default headers.

Applications should call HttpClient.session() instead, which caches and reuses sessions per protocol:origin.

Getters

get state(): 'connecting' | 'ready' | 'draining' | 'closed'

Current lifecycle state.

ready sessions can send requests. closed sessions reject new requests. connecting and draining are reserved lifecycle states for transport transitions.

get currentConnection(): HttpConnectionInfo | null

Current transport connection, if one is active or last used.

HTTP/3 sessions keep this as the active QUIC connection. HTTP/1.1 and HTTP/2 sessions update it with the most recent request's connection metadata.

get events(): AsyncIterable<HttpSessionEvent>

Async iterable replaying the lifecycle events recorded so far.

Iterating yields the buffered HttpSessionEvent history and completes; it is a snapshot, not a live subscription, so re-iterate to observe events appended after the loop finished.

Methods

async _attachH3TransportForTest(conn: QuicConnection): Promise<void>
async _request(input: string | URL, init: HttpRequestInit = {}): Promise<HttpResponse>
request(init: HttpSessionRequest = {}): Promise<HttpResponse>

Send a request over this session and resolve to a rich HttpResponse.

Resolves the target from init.url or init.path against the session origin (defaulting to /), merges the session's default headers, and sends on the session's pinned protocol. Throws if the session has been closed.

sse(path: string | URL, options: SseOptions = {}): EventSource

Open an EventSource pinned to this session's origin and headers.

The returned EventSource uses merged session and per-call headers and inherits the client's TLS policy unless options.tls overrides it.

async websocket( path: string | URL, options: HttpWebSocketOptions = { } ): Promise<WebSocketConnection>

Open a WebSocket pinned to this session's origin and headers.

Rewrites the URL scheme to ws:/wss:, merges session headers into the upgrade request, and resolves once the socket is open. Only HTTP/1.1 sessions are supported; an h2 or h3 session throws because WebSocket over those protocols needs Extended CONNECT, which is not yet implemented.

async webtransport(path: string | URL, options: HttpWebTransportOptions = {}): Promise<WebTransport>

Open a WebTransport session over this session's QUIC transport.

Reuses the session's H3 transport (establishing it on first use), sends any protocols as sec-webtransport-protocol, and resolves once the transport is ready. Requires an h3 session and an https: URL; other protocols throw.

async reconnect(options: ReconnectOptions = {}): Promise<void>

Drop the current transport while keeping the session's logical identity.

Emits a reconnecting event carrying options.reason, tears down any live H3 transport, and clears the current connection so the next request re-establishes one. The session id and origin are unchanged. Throws if the session is already closed.

async close(options: CloseOptions = {}): Promise<void>

Close the session and release its transport.

Transitions to the closed state, tears down any H3 transport, evicts the shared HTTP/2 pool entry for the origin when the session is h2, and emits a final closed event with options.reason. Idempotent: closing an already-closed session is a no-op. After closing, request() throws.

class HttpClient {

Reusable HTTP client that owns shared policy and logical sessions.

An HttpClient bundles a base URL, default headers, protocol preferences, and TLS settings, then applies them across request(), fetch(), sse(), websocket(), and webtransport(). It transparently manages a pool of HttpSession objects keyed by protocol:origin, creating one on demand and reusing it for subsequent requests to the same origin, which is what lets HTTP/2 and HTTP/3 keep their multiplexed transports warm. Use it instead of global fetch() when you want reuse, protocol control, or the request, connection, and timing metadata carried on HttpResponse.

Close the client when done to release every session and pooled connection.

import { HttpClient } from 'fino:net/http/client';

const client = new HttpClient({
  baseUrl: 'https://api.example.com',
  headers: { authorization: 'Bearer t0ken' },
  protocols: ['h2', 'http/1.1'],
});
try {
  const res = await client.request('/users');
  console.log(res.status, await res.json());
} finally {
  await client.close();
}

Readonly Properties

readonly tls?: HttpClientOptions['tls']

TLS options inherited by requests, SSE helpers, and HTTP/3 sessions.

Constructors

constructor(options: HttpClientOptions = {})

Create a client with the given default policy.

All options are optional; the defaults are no base URL, no headers, and the single protocol http/1.1. Throws TypeError if baseUrl is not an http: or https: URL.

Methods

async request(input: string | URL | Request, init: HttpRequestInit = {}): Promise<HttpResponse>

Send a request and resolve to a rich HttpResponse.

Accepts a string, URL, or Fetch Request; a Request contributes its method, headers, and body, which init can still override. The target is resolved against the client base URL, routed through the pooled session for its origin, and sent on the client's first protocol. Throws TypeError for non-HTTP(S) URLs and throws if the client has been closed.

const res = await client.request('/users', { method: 'POST', body: '{}' });
console.log(res.status, res.protocol, res.connection?.transport);
async fetch(input: string | URL | Request, init: HttpRequestInit = {}): Promise<Response>

Send a request and return a plain Fetch-compatible Response.

A convenience wrapper over request() for code that only wants the standard Response and none of the extra client metadata. The returned Response owns the body, so read it from that object.

const res = await client.fetch('/health');
console.log(await res.text());
async session(origin: string | URL, options: HttpSessionOptions = {}): Promise<HttpSession>

Get the pooled logical session for an origin, creating it if needed.

The origin is resolved against the base URL and the session is cached per protocol:origin; passing an explicit options.protocol selects (and keys) a distinct session. Reuse the returned session to send correlated requests and to observe lifecycle events. Throws for non-HTTP(S) URLs or if the client is closed.

sse(input: string | URL, options: SseOptions = {}): EventSource

Open an EventSource using this client's base URL and default headers.

The stream is not pooled as an HttpSession, but it shares client headers and TLS options.

async websocket( input: string | URL, options: HttpWebSocketOptions = { } ): Promise<WebSocketConnection>

Open a WebSocket using this client's base URL and default headers.

Resolves once the socket reaches OPEN. Client-level WebSockets use the HTTP/1.1 WebSocket transport; use session helpers when protocol pinning is important.

async webtransport( input: string | URL, options: HttpWebTransportOptions = { } ): Promise<WebTransport>

Open a WebTransport session using this client's base URL and default headers.

This uses or creates an HTTP/3 session for the target origin and rejects non-HTTPS URLs.

async closeIdleSessions(): Promise<void>

Close pooled sessions and drop them from the cache.

Currently closes every logical session the client holds, releasing their transports. The client itself stays usable and will lazily recreate sessions on the next request.

async close(): Promise<void>

Close the client and every session it owns.

Idempotent. After closing, request(), session(), and the SSE/WebSocket/ WebTransport helpers throw. Always call this when finished to avoid leaking pooled HTTP/2 and HTTP/3 connections.