Http Client
Outbound HTTP client with SSRF gate, retry, circuit breaker, wall-clock + idle timeouts, AbortSignal propagation, connection pooling, streaming, and ALPN-negotiated HTTP/2. Built on node:http, node:https, and node:http2 with zero npm runtime dependency.
Every outbound request flows through b.ssrfGuard out of the box: hostname → DNS lookup is pinned to vetted IP literals, RFC 1918 / loopback / link-local / IPv6 ULA destinations are refused, and the redirect chain is re-validated at every hop so a 302 to http://169.254.169.254/ (cloud metadata) can't smuggle past the first-hop gate. The same DNS pinning applies to retries — there's no retry path that bypasses the guard.
Protocol selection is automatic. HTTPS origins handshake with ALPN ['h2', 'http/1.1'] and cache the resulting transport per . While a transport is mid-negotiate the cache holds the in-flight Promise so concurrent calls to a new origin coalesce onto a single connection. h2 GOAWAY or session error evicts the entry; the next request reconnects.
Resiliency defaults: TLS 1.3 minimum, PQC-preferred ecdhCurve group order, split wall-clock vs zero-progress idle timeouts, request-body stream errors propagated to the returned Promise, and h2 stream cancellation via NGHTTP2_CANCEL (clean, not stream.destroy) when the AbortSignal fires.
b.httpClient.cache.memoryStore(opts) #
{
maxBytes: number, // total stored body bytes; default: 64 MiB
maxEntries: number, // count cap; default: 1024
evictionPolicy: "lru", // currently the only policy; reserved
}
In-memory bounded-LRU cache store implementing the Store shape: get(key), set(key, entry), delete(key), clear(). Eviction runs when the byte total or entry count exceeds the configured caps; eviction emits an audit event when an audit sink is wired (via b.httpClient.cache.create({ audit })). Stored values include the response body buffer, so the byte total reflects real memory pressure rather than a rough estimate.
Suitable for single-process workloads. For shared-cache semantics across a fleet, wire your own Store against a shared backing service (Redis, filesystem, etc.) — the same shape applies.
var store = b.httpClient.cache.memoryStore({
maxBytes: 16 * 1024 * 1024,
maxEntries: 256,
});
var cache = b.httpClient.cache.create({ store: store });
await b.httpClient.request({ url: "https://example.com/", cache: cache });
b.httpClient.cache.create(opts) #
{
store: , // Store: { get, set, delete, clear }
sharedCache: true, // honor s-maxage; refuse Cache-Control: private
defaultMaxStale: 0, // seconds — serve stale up to this far past expiry
revalidateInBackground: true, // s-w-r kicks off background revalidation
audit: undefined, // audit sink with safeEmit({...})
observability: undefined, // optional { event, safeEvent }
statusHeader: "x-blamejs-cache", // response header carrying the cache decision; null/false to suppress, or a custom name (e.g. "x-cache")
}
Builds an RFC 9111 cache instance for b.httpClient.request. The returned object plugs into a request via opts.cache. Without opts.cache, the request path is unchanged — no overhead for non-caching callers. The cache evaluates each response per RFC 9111 §3 (storage decision: method / status / Cache-Control / Vary), tracks freshness per §4.2 (s-maxage > max-age > Expires > heuristic 10% of (Date - Last-Modified) capped at 24h), revalidates conditionally per §4.3 (If-None-Match / If-Modified-Since), and merges 304 headers per §5.
sharedCache: true (default) honors s-maxage over max-age and refuses to store responses with Cache-Control: private — operator services share a cache with each other, so a per-user private response must not leak across users via the cache. Single-tenant scripts pass sharedCache: false to behave as a private cache.
defaultMaxStale lets the cache return a stored entry past its freshness lifetime (within the configured number of seconds) even without an explicit upstream stale-while-revalidate / stale-if-error. Default 0 — operators opt in.
revalidateInBackground (default true): when an entry is fresh within its stale-while-revalidate window the stale response is returned immediately and a background revalidation kicks off so the next caller sees a refreshed entry. Pass false to revalidate inline (lower memory churn, higher request latency).
var cache = b.httpClient.cache.create({
store: b.httpClient.cache.memoryStore({ maxBytes: 32 * 1024 * 1024 }),
sharedCache: true,
defaultMaxStale: 5,
audit: b.audit,
});
var res = await b.httpClient.request({
url: "https://api.example.com/users/42",
cache: cache,
});
// res.headers["x-blamejs-cache"] === "MISS" (first call)
b.httpClient.configurePool(opts) #
{
keepAlive: true, // boolean — whether to reuse sockets
keepAliveMsecs: 1000, // positive integer ms between keep-alive probes
maxSockets: 16, // positive integer — concurrent sockets per origin
maxFreeSockets: 8, // positive integer — idle sockets retained per origin
scheduling: "lifo", // "lifo" | "fifo"
}
Updates the keepAlive Agent options used for new h1 transports and tears down the per-origin transport cache so subsequent requests pick up the fresh values. Existing in-flight responses keep their old transport. Throws on unknown keys, non-positive integers, or a non-boolean keepAlive. Use at boot when the default 16/8 socket caps don't match the operator's downstream concurrency budget.
b.httpClient.configurePool({ maxSockets: 64, maxFreeSockets: 32 });
// → undefined (cache cleared; next request builds a 64-socket pool)
b.httpClient.pinnedClient(client, allowedHosts) #
Wrap an HTTP client so an allowedHosts pin is ENFORCED rather than merely advertised, and return the wrapper.
The contract for a caller-supplied client is a request method and nothing more, so it need not know what allowedHosts on a request object means. Passing the pin through as a request field therefore leaves it advisory: a client that ignores the field fetches a disallowed host while the operator believes the pin is in force. This wrapper checks the destination itself — using the same membership test request() applies, so an entry means the same thing either way — and refuses before delegating. A destination that cannot be parsed is refused rather than passed through unchecked.
Redirects are the pin's blind spot: an allowed host can answer with a redirect to a disallowed or private one, and a client that follows it internally would fetch that without ever returning here. The framework's own client re-checks every hop. The wrapper therefore asks for redirect following to be OFF on each request it forwards — maxRedirects: 0, followRedirects: false and redirect: "manual", covering the spellings in common use, and all three are accepted by request for that reason — and a redirect then comes back as an ordinary 3xx response for the caller to act on. A client that follows redirects regardless of all three is outside what this can enforce: it must apply the pin per hop itself, exactly as it must apply anything else its own transport decides.
The pin is optional; the redirect control is not. Every caller that hands a client here validated ONE url — its scheme, its host, or both — so an absent pin still returns a wrapper, or a 307 from https to http would be free to resend the credentials that were checked onto the first hop.
The framework primitives that accept an injected client (b.auth.oauth.create, b.auth.ciba.client.create, b.auth.saml.fetchMdq) route it through here.
var pinned = b.httpClient.pinnedClient(myClient, ["api.partner.com"]);
pinned.request({ url: "https://elsewhere.example/x" });
// → rejects HOST_DISALLOWED; myClient is never called
b.httpClient.request(opts) #
{
method: "GET", // HTTP method
url: , // string or URL — destination
headers: {}, // request headers
body: undefined, // Buffer | string | Readable | undefined
timeoutMs: undefined, // wall-clock cap; no default — operator chooses
idleTimeoutMs: 30000, // zero-progress cap
responseMode: "buffer", // "buffer" | "stream" | "always-resolve"
maxResponseBytes: undefined, // 16 MiB control / 1 GiB GET defaults; ignored in "stream"
onChunk: undefined, // (chunk: Buffer) => void — fires per response chunk
maxBytesPerSec: undefined, // token-bucket bandwidth cap (bytes/sec) — paces BOTH the download response and the upload body with backpressure
downloadTransform: undefined, // Transform | () => Transform | array — interpose on the response stream (e.g. a hashing or progress Transform)
uploadTransform: undefined, // Transform | () => Transform | array — interpose on the request body before the wire
signal: undefined, // AbortSignal — propagated to req / stream
errorClass: HttpClientError, // FrameworkError subclass for thrown errors
observer: undefined, // (stage, info) => void — lifecycle hook
agent: undefined, // override per-origin Agent (h1 only)
preferH2: false, // attempt h2c against an HTTP origin (no ALPN)
before: undefined, // array of (opts) => opts | Promise — request mutators
after: undefined, // array of (response) => response | Promise — response mutators
onUploadProgress: undefined, // (bytesSent, totalBytes?) => void
}
Promise-returning, AbortSignal-aware HTTP request. Negotiates h2 / h1 per-origin via ALPN, reuses transports from the cache, runs every destination through b.ssrfGuard before connecting, and re-validates each redirect hop. Returns { statusCode, headers, body } for the default "buffer" mode; "stream" returns a Readable for the body. Sensitive headers (Authorization / Cookie / Proxy-Authorization) are stripped on cross-origin redirect. Body-stream errors propagate to the rejected Promise.
// requires: outbound HTTPS to the URL below
var res = await b.httpClient.request({
method: "GET",
url: "https://example.com/health",
timeoutMs: 5000,
});
// → { statusCode: 200, headers: { "content-type": "application/json", ... }, body: }
b.httpClient.downloadStream(opts) #
{
url: , // string — source
dest: , // absolute filesystem path — final landing
hash: "sha3-512", // "sha3-512" | "sha-256" | "sha-512" | "shake256"
expected: undefined, // hex digest; when set, verified before rename
timeoutMs: undefined, // wall-clock cap
maxBytes: undefined, // positive integer — abort past this size
audit: undefined, // audit sink with safeEmit({...})
}
Streams a remote resource to disk while hashing the bytes in flight, then atomically renames the tmp file to opts.dest only after the hash matches opts.expected (when supplied). Hash mismatch deletes the tmp file and throws http-client/hash-mismatch. Composes through request({ responseMode: "stream" }) so the SSRF gate, allowedHosts filter, network proxy, and per-origin transport cache all apply.
A non-2xx response throws http-client/http-error. The tmp file is removed and http-client/pipeline-failed thrown when the transfer itself fails, http-client/truncated-body when the body ends short of the Content-Length the response declared, and http-client/rename-failed when the bytes landed but the fsync or the rename onto dest did not.
A transfer that fails part-way is reported as transient, so b.retry retries it and a b.circuitBreaker counts it, the way the same failure through a buffered request already behaved. A short body is the shape a canceled HTTP/2 stream takes, where the readable side ends without an error: without the length check the partial bytes were hashed and renamed onto dest, and the call resolved unless expected was set.
Because it composes request, that primitive's refusals reach the caller too: http-client/bad-arg for a missing or malformed url, http-client/bad-opts for an option this client does not accept, http-client/host-disallowed when the SSRF gate or allowedHosts refuses the target, and http-client/before-threw when an operator before hook throws. Following a redirect adds two: a Location header that is not a valid URL refuses with http-client/bad-redirect, and an onRedirect hook that throws or rejects refuses with http-client/redirect-aborted.
A transfer that fails once it is under way rejects rather than throwing, with the code naming which side failed: http-client/req-error and http-client/res-error for a socket error on the request or the response, http-client/h2-request-error and http-client/h2-stream-error for the HTTP/2 equivalents, http-client/req-body-error when the body stream being sent errors, and http-client/response-too-large when the response exceeds maxBytes. Each carries the driver's own code where it has one.
var result = await b.httpClient.downloadStream({
url: "https://example.com/release.tar.gz",
dest: "/var/lib/blamejs/release.tar.gz",
hash: "sha3-512",
expected: "9f86d081884c7d65...d4e5",
});
// → { statusCode: 200, bytesWritten: 1048576, hash: "9f86d081884c7d65...d4e5" }
b.httpClient.uploadMultipartStream(opts) #
{
url: , // string — destination
file: , // { path, fieldName, filename?, contentType? }
fields: undefined, // object — extra form fields { name: value, ... }
timeoutMs: undefined, // wall-clock cap
maxBytes: undefined, // positive integer — refuse files larger than this
audit: undefined, // audit sink with safeEmit({...})
}
POSTs a file body via multipart/form-data without buffering the file in memory. Streams from disk through the request body using fs.createReadStream + node:stream/promises pipeline. Throws http-client/missing-file when opts.file.path doesn't exist or isn't a regular file. Composes through request() so SSRF gating, proxy routing, and the per-origin transport cache apply unchanged, and that primitive's refusals reach the caller too: http-client/bad-arg for a missing or malformed url, http-client/bad-opts for an option this client does not accept, http-client/host-disallowed when the SSRF gate or allowedHosts refuses the target, and http-client/before-threw when an operator before hook throws. Following a redirect adds two: a Location header that is not a valid URL refuses with http-client/bad-redirect, and an onRedirect hook that throws or rejects refuses with http-client/redirect-aborted.
A transfer that fails once it is under way rejects rather than throwing, with the code naming which side failed: http-client/req-error and http-client/res-error for a socket error on the request or the response, http-client/h2-request-error and http-client/h2-stream-error for the HTTP/2 equivalents, http-client/req-body-error when the file being uploaded errors mid-stream, http-client/response-too-large when the response exceeds maxBytes, and http-client/http-error for a non-2xx reply.
var res = await b.httpClient.uploadMultipartStream({
url: "https://example.com/upload",
file: {
path: "/var/lib/blamejs/release.tar.gz",
fieldName: "artifact",
contentType: "application/gzip",
},
fields: { releaseTag: "v1.2.3" },
});
// → { statusCode: 200, headers: { ... }, body: }