@unireq/http
@unireq/http provides Unireq's standard HTTP(S) transport built on undici, along with an arsenal of serializers, parsers, policies, and protocol-specific helpers.
Installation
pnpm add @unireq/httpExport Overview
| Category | Symbols | Purpose |
|---|---|---|
| Transport & connectors | http, UndiciConnector, UndiciConnectorOptions | HTTP/1.1 transport with keep-alive support, custom proxies, TLS tuning. |
| Body serializers | body.json, body.form, body.text, body.binary, body.multipart (+ validation options) | Encode requests and automatically set Content-Type. |
| Response parsers | parse.json, parse.text, parse.blob, parse.stream, parse.sse, parse.raw | Decode responses and handle Accept. |
| Policies | headers, query, timeout, redirectPolicy | Add headers, query params, timeouts, redirect handling. |
| Conditional caching | conditional, etag, lastModified, ETagPolicyOptions, LastModifiedPolicyOptions | Handle If-None-Match / If-Modified-Since and update local caches. |
| Range & resume | range, resume, parseContentRange, supportsRange, RangeOptions, ResumeState | Partial downloads and stream resumption. |
| Rate limiting | rateLimitDelay, parseRetryAfter, RateLimitDelayOptions | Respect Retry-After headers and client-side backpressure. |
| Retry predicate | httpRetryPredicate, HttpRetryPredicateOptions | HTTP-specific condition for @unireq/core's generic retry. |
| Interceptors | interceptRequest, interceptResponse, interceptError, combine*, RequestInterceptor, ResponseInterceptor | Hook logging/metrics or modify requests/responses without writing a full policy. |
| Legacy helpers | multipart (will be replaced by body.multipart), accept/json/raw/text (prefer parse.*). |
Transport & Connector
import { client } from '@unireq/core';
import { http, UndiciConnector } from '@unireq/http';
const connector = new UndiciConnector({
keepAlive: true,
connectTimeout: 5_000,
tls: { rejectUnauthorized: true },
});
const api = client(
http('https://api.example.com', connector),
headers({ 'user-agent': 'MyApp/1.0' }),
);http(baseUrl?, connector?)returns an inspectable transport with thehttpcapability.- The
connectorlets you customize TCP pool, HTTP/HTTPS proxies, keep-alive, DNS, shared sockets, etc. - Passing
undefinedasbaseUrlallows using full URLs per request.
Base URL Support
The http() transport factory accepts an optional base URL that combines with relative request paths:
import { client } from '@unireq/core';
import { http, json } from '@unireq/http';
// Create client with base URL
const api = client(http('https://api.example.com'), json());
// These relative URLs are automatically resolved:
await api.get('/users'); // → https://api.example.com/users
await api.get('/users/123'); // → https://api.example.com/users/123
await api.post('/users', body); // → https://api.example.com/users
// Absolute URLs bypass the base URL:
await api.get('https://other.api.com/data'); // → https://other.api.com/dataURL Resolution Rules
- Relative paths (starting with
/) are combined with the base URL - Absolute URLs (containing
://) are used as-is - No base URL - URLs must be absolute
// Without base URL - each request needs full URL
const api = client(http(), json());
await api.get('https://api.example.com/users');Policy Execution Order
Policies are executed in a middleware/onion pattern where:
- Request flows left to right through policies
- Response flows right to left back through the same policies
Request: client.get() → [policy1] → [policy2] → [transport] → Server
Response: client.get() ← [policy1] ← [policy2] ← [transport] ← ServerRecommended Policy Order
For optimal behavior, compose policies in this order:
import { client, retry, backoff } from '@unireq/core';
import { http, accept, headers, timeout, redirectPolicy, json } from '@unireq/http';
const api = client(
http('https://api.example.com'),
// 1. OUTER: Retry (wraps everything, catches all errors)
retry(predicate, [backoff()], { tries: 3 }),
// 2. HEADERS: Set accept/content-type headers
accept(['application/json']),
headers({ 'X-API-Key': 'secret' }),
// 3. TIMEOUT: Request timeout (should be inside retry)
timeout(5000),
// 4. REDIRECTS: Follow redirects
redirectPolicy({ allow: [307, 308] }),
// 5. INNER: Response parsing
json(),
);This ensures:
- Retry wraps timeout failures, so timeouts trigger retries
- Headers are set before the request is made
- Timeout applies to each retry attempt, not the total
- Parsing happens last on the response
Body Serializers
The body.* helpers create descriptors understood by serializationPolicy():
body.json(payload, { compress: false });
body.form({ search: 'unireq', page: 2 });
body.text('plain text', 'text/plain; charset=utf-8');
body.binary(arrayBuffer, 'application/octet-stream');
body.multipart(
{ name: 'file', part: body.binary(fileBuffer, 'application/pdf'), filename: 'quote.pdf' },
{ name: 'meta', part: body.json({ customerId: 42 }) },
{
maxFileSize: 25 * 1024 * 1024,
allowedMimeTypes: ['application/pdf'],
sanitizeFilenames: true,
},
);Auto-detection with body.auto()
For convenience, body.auto() automatically detects the appropriate serializer:
body.auto({ name: 'value' }); // → body.json() for objects
body.auto('plain text'); // → body.text() for strings
body.auto(new FormData()); // → multipart/form-data
body.auto(new URLSearchParams()); // → application/x-www-form-urlencoded
body.auto(new Blob([data])); // → binary with blob's type
body.auto(new ArrayBuffer(8)); // → application/octet-stream
body.auto(null); // → empty bodyDetection priority:
null/undefined→ empty bodystring→body.text()FormData→ multipart/form-data (pass-through)URLSearchParams→ application/x-www-form-urlencodedBlob→ binary with blob's MIME typeArrayBuffer→ application/octet-streamReadableStream→ streaming body- Objects/arrays →
body.json()
Note: XML cannot be auto-detected (objects look like JSON). Use xmlBody() from @unireq/xml explicitly.
Response Parsers & Streaming
import { parse } from '@unireq/http';
const getJson = parse.json();
const getText = parse.text();
const getBlob = parse.blob();
const streamDownload = parse.stream(); // Web ReadableStream
const streamEvents = parse.sse(); // AsyncIterable<SSEEvent>- Each parser automatically adjusts the
Acceptheader. parse.stream(options?)exposes aReadableStream<Uint8Array>usable withfor awaitorgetReader().parse.sse(options?)convertstext/event-streamintoAsyncIterable<SSEEvent>with retry, multi-line, and comment support.
Core Policies
headers(record | (ctx) => record): adds or computes headers.query(record | (ctx) => record): merges query parameters.timeout(ms | options): cancels the request if duration is exceeded (throwsTimeoutError). Supports per-phase timeouts.redirectPolicy({ allow, follow303, max }): controls which codes to follow (default 307/308). Enablefollow303for POST→GET backward compatibility.
redirectPolicy — Security Defaults
redirectPolicy enforces two security behaviours by default:
allowDowngrade (default: false) — blocks any redirect that downgrades the scheme from HTTPS to HTTP. Set to true only when you explicitly need to follow such redirects (not recommended in production).
Cross-origin header stripping — when a redirect crosses origins (different host or scheme), the policy automatically strips Authorization, Cookie, and Proxy-Authorization from the forwarded request to prevent credential leakage to third-party servers.
import { redirectPolicy } from '@unireq/http';
// Defaults: allowDowngrade=false, sensitive headers stripped on cross-origin
const api = client(
http('https://api.example.com'),
redirectPolicy({ allow: [301, 302, 307, 308] }),
);
// Opt-in to HTTPS→HTTP downgrades (rare, use with caution)
const legacyApi = client(
http('https://legacy.example.com'),
redirectPolicy({
allow: [301, 302],
allowDowngrade: true, // explicitly allow HTTPS→HTTP
}),
);Timeout Configuration
The timeout policy supports both simple and per-phase configurations:
import { timeout } from '@unireq/http';
// Simple timeout (5 seconds total)
timeout(5000);
// Per-phase timeouts
timeout({
request: 5000, // 5s for connection + TTFB (until headers received)
body: 30000, // 30s for body download after headers
total: 60000, // 60s total safety limit
});
// Combine with user abort signal
const controller = new AbortController();
const api = client(
http('https://api.example.com'),
timeout(5000),
);
// User signal is automatically combined with timeout using AbortSignal.any()
await api.get('/data', { signal: controller.signal });Timeout Phases Diagram
gantt
title HTTP Request Timeline with Per-Phase Timeouts
dateFormat X
axisFormat %s
section Request Phase
DNS + TCP + TLS :req1, 0, 2
Send Request :req2, after req1, 1
Wait for Headers :req3, after req2, 2
section Body Phase
Download Body :body1, after req3, 6
section Timeouts
request timeout (5s) :crit, timeout_req, 0, 5
body timeout (30s) :crit, timeout_body, 5, 35
total timeout (60s) :milestone, timeout_total, 0, 60┌─────────────────────────────────────────────────────────────────────────────┐
│ Total Timeout (60s) │
├───────────────────────────────────┬─────────────────────────────────────────┤
│ Request Phase (5s) │ Body Phase (30s) │
├───────────────────────────────────┼─────────────────────────────────────────┤
│ DNS → TCP → TLS → Send → Headers │ Download response body (streaming) │
│ (uses AbortSignal) │ (uses reader.cancel() for true │
│ │ interruption mid-download) │
└───────────────────────────────────┴─────────────────────────────────────────┘
↑
Headers received
(phase transition)Phase timeouts:
request: Time for connection + sending request + receiving headers (TTFB)body: Time allowed to download the response body after headers are receivedtotal: Overall request timeout (safety net that overrides phases)
Implementation notes:
- Uses native
AbortSignal.timeout()for efficient timer management - Multiple signals are combined using
AbortSignal.any()(with fallback for Node < 20) - Body timeout uses
ReadableStream.getReader().cancel()for true mid-download interruption - All cleanup is handled automatically to prevent memory leaks
Quick Examples by HTTP Verb
- Declare a single client, then compose request-specific policies.
- The examples below use
parse.json()globally; adapt as needed (streaming, text, binary, etc.).
import { client } from '@unireq/core';
import { body, headers, http, parse } from '@unireq/http';
const api = client(
http('https://api.example.com'),
headers({ 'user-agent': 'docs-example/1.0' }),
parse.json(),
);GET – Simple Read
const user = await api.get('/users/42', parse.json());- Add one-off policies for this request:
await api.get('/users/42', query({ include: 'profile' })); - Ideal with
retry()sinceGETis idempotent.
HEAD – Inspect Headers
const head = await api.head('/files/report.pdf', parse.raw());
const size = Number(head.headers['content-length'] ?? 0);
if (size > 10 * 1024 * 1024) {
console.log('Prepare chunked download before GET');
}- HEAD returns only headers: use it to check
Content-Length,ETag, or last modified before executing aGET. - Combine with
get()and anIf-None-Matchheader to implement a respectful conditional fetch.
POST – Creation
const payload = { email: 'jane@example.com', name: 'Jane' };
const created = await api.post('/users', body.json(payload), parse.json());body.json()automatically setsContent-Typeand handles encoding.- Add
headers({ 'x-idempotency-key': crypto.randomUUID() })if you want to make the operation server-side safe.
PUT – Full Replacement
await api.put('/users/42', body.json({ id: 42, name: 'Jane Updated' }), parse.json());- Combine with
etag()to avoid overwriting concurrent modifications:await api.put(..., etagPolicy);. - Idempotent by nature → compatible with
retry(httpRetryPredicate()).
PATCH – Partial Update
await api.patch('/users/42', body.json({ name: 'Jane v2' }), parse.json());- Add
headers({ 'content-type': 'application/merge-patch+json' })orapplication/json-patch+jsondepending on your API. - Convenient to combine with
either()to select the patch format.
DELETE – Removal
await api.delete('/users/42', parse.raw());- Many APIs return
204 No Content; addparse.raw()if you don't expect a body. - Secure via
headers({ 'x-confirm-delete': 'true' })or a CSRF token.
OPTIONS – Preflight/Capabilities
const preflight = await api.options('/users', headers({ Origin: 'https://app.example.com' }), parse.raw());
console.log(preflight.headers['access-control-allow-methods']);- Useful for dynamically checking endpoint capabilities (CORS, versioning, webdav…).
- Combine with
cachePolicyto cache long-duration preflights.
Conditional Requests & Client-side Cache
import { etag, lastModified, conditional } from '@unireq/http';
const cachePolicy = conditional();
const useEtag = etag({ get: cache.getEtag, set: cache.setEtag });
const useLastModified = lastModified({ get: cache.getDate, set: cache.setDate });conditional()combines ETag + Last-Modified if available.etagandlastModifiedaccept your own stores (synchronous or asynchronous) to applyIf-None-Match/If-Modified-Sinceand update values when a 200 returns.
Cache — Vary, Privacy, and Authorization Awareness
The caching layer respects HTTP cache semantics beyond simple ETag matching:
Vary header support — when a response includes a Vary header, the cache stores separate entries for each distinct combination of the nominated request headers (e.g. Vary: Accept-Encoding, Accept-Language). Two requests that differ on a Vary-indicated header never share a cache entry.
Cache-Control: private — responses carrying this directive are never written to the shared cache. They are served directly to the caller without touching the store.
Authorization-aware cache keys — requests that carry an Authorization or Cookie header get an isolated cache key. The key is derived from a hash of the credential value; the credential itself is never written to the cache store.
import { conditional, etag } from '@unireq/http';
const cache = new Map<string, string>();
const api = client(
http('https://api.example.com'),
// etag store is keyed by the Vary-aware, auth-aware cache key automatically
etag({
get: (key) => cache.get(key),
set: (key, value) => cache.set(key, value),
}),
);
// Responses with Cache-Control: private bypass the store entirely
// Responses with Vary: Accept-Language get per-language entries
// Authenticated requests get hashed, isolated keysRange Requests & Resume
import { range, resume, supportsRange, parseContentRange } from '@unireq/http';
const resumeDownload = client(
http('https://files.example.com'),
range({ start: 0, end: 1023 }),
);
const nextChunk = client(
http(),
resume({ downloaded: previousBytes }),
);supportsRange(response)checks for the presence ofAccept-Ranges.parseContentRange(header)helps determine how many bytes remain.resume({ downloaded })automatically resumes a partial download by sendingRange: bytes={downloaded}-.
Rate Limiting Helpers
parseRetryAfter(headers)returns either a date or a delay in milliseconds.rateLimitDelay({ maxWait })is designed to be the first strategy forretry(): it readsRetry-Afterand returns a delay if present.
import { retry } from '@unireq/core';
import { httpRetryPredicate, rateLimitDelay } from '@unireq/http';
const smartRetry = retry(
httpRetryPredicate({ statusCodes: [429, 503] }),
[rateLimitDelay({ maxWait: 60_000 })],
);Proxy
The proxy() policy routes all requests through an HTTP or HTTPS proxy using undici's ProxyAgent under the hood. Proxy credentials are stored exclusively in ctx.proxy and are never forwarded to the target server as request headers.
import { proxy } from '@unireq/http';
const api = client(
http('https://api.example.com'),
proxy({
url: 'https://proxy.corp.example.com:8080',
// Credentials go into ctx.proxy — never leaked to the target
username: process.env.PROXY_USER,
password: process.env.PROXY_PASS,
}),
);
// All requests through this client are routed via the ProxyAgent.
// The Authorization header sent to the target is unaffected.
await api.get('/data', parse.json());- The underlying
ProxyAgentis created once and reused across requests for connection pooling. - Proxy credentials in
ctx.proxyare not serialised into outgoing headers, preventing accidental exposure to the origin server. - TLS verification applies to both the proxy tunnel and the end-to-end connection unless overridden in
UndiciConnector.
HTTP-aware Retry Predicate
httpRetryPredicate({ methods, statusCodes, maxBodySize }) encapsulates HTTP best practices (retry only idempotent methods by default, ignore payloads that are too large, etc.). Plug it directly into retry() for consistent behavior across all HTTP clients.
Interceptors
Use interceptRequest, interceptResponse, or interceptError to instrument without creating a full policy:
import { interceptRequest, interceptResponse } from '@unireq/http';
const withLogging = client(
http('https://api.example.com'),
interceptRequest((ctx) => {
console.log('→', ctx.method, ctx.url);
return ctx;
}),
interceptResponse((res, ctx) => {
console.log('←', res.status, ctx.url);
return res;
}),
);combineRequestInterceptors(...interceptors)/combineResponseInterceptors(...)facilitate composition.interceptErrorallows you, for example, to convert certain errors into business values.
Legacy Exports
multipart(outsidebody.*) remains available but will be removed in favor ofbody.multipart().accept,json,text,rawremain for compatibility but preferparse.*which integrates with the slot system.
Troubleshooting
"NetworkError" or connection refused
Cause: Server unreachable, DNS failure, or TLS issues.
Fix: Verify network connectivity and server availability:
import { client } from '@unireq/core';
import { http } from '@unireq/http';
// Check with explicit error handling
try {
const api = client(http('https://api.example.com'));
await api.get('/health');
} catch (error) {
if (error.code === 'NETWORK_ERROR') {
console.error('Server unreachable:', error.cause);
}
}Response body is empty or undefined
Cause: Missing parser policy or wrong parser type.
Fix: Add the appropriate parser:
// Wrong - no parser
const res = await api.get('/users');
console.log(res.data); // undefined!
// Correct - add parse.json()
const res = await api.get('/users', parse.json());
console.log(res.data); // { users: [...] }
// For non-JSON responses
const text = await api.get('/readme', parse.text());
const blob = await api.get('/image.png', parse.blob());"TimeoutError" after short delay
Cause: Timeout too aggressive or slow network.
Fix: Increase timeout or use per-phase timeouts:
import { timeout } from '@unireq/http';
// Simple timeout
timeout(30_000); // 30 seconds
// Per-phase timeouts for fine control
timeout({
request: 10000, // 10s for connection + TTFB
body: 60000, // 60s for body download
total: 120000, // Overall limit
});Multipart upload fails with "Invalid MIME type"
Cause: File MIME type not in allowed list.
Fix: Add the MIME type to allowedMimeTypes:
body.multipart(
{ name: 'file', part: body.binary(buffer, 'application/vnd.ms-excel'), filename: 'data.xls' },
{
allowedMimeTypes: [
'application/pdf',
'image/*',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
],
},
);Redirects not followed
Cause: redirectPolicy not configured or redirect code not allowed.
Fix: Configure redirect handling:
import { redirectPolicy } from '@unireq/http';
const api = client(
http('https://api.example.com'),
redirectPolicy({
allow: [301, 302, 307, 308],
follow303: true, // Follow 303 with GET
max: 10, // Maximum redirects
}),
);Rate limiting (429) causing failures
Cause: Not respecting Retry-After header.
Fix: Use rateLimitDelay with retry:
import { retry } from '@unireq/core';
import { httpRetryPredicate, rateLimitDelay } from '@unireq/http';
const api = client(
http('https://api.example.com'),
retry(
httpRetryPredicate({ statusCodes: [429, 503] }),
[rateLimitDelay({ maxWait: 120_000 })], // Wait up to 2 minutes
{ tries: 5 },
),
);SSE stream closes unexpectedly
Cause: Connection timeout or server-side close.
Fix: Handle reconnection in your SSE consumer:
const events = await api.get('/events', parse.sse({
reconnect: true,
reconnectInterval: 3000,
}));
for await (const event of events) {
if (event.type === 'error') {
console.error('SSE error, will reconnect:', event.data);
continue;
}
handleEvent(event);
}ETag/Last-Modified not working
Cause: Cache store not properly configured.
Fix: Ensure your cache store implements get/set correctly:
import { etag, lastModified } from '@unireq/http';
const etagCache = new Map<string, string>();
const dateCache = new Map<string, string>();
const api = client(
http('https://api.example.com'),
etag({
get: (url) => etagCache.get(url),
set: (url, value) => etagCache.set(url, value),
}),
lastModified({
get: (url) => dateCache.get(url),
set: (url, value) => dateCache.set(url, value),
}),
);