ultraton
v2.11.0
Published
Ultra safe HTTP client for critical environments in Node.js
Readme
UltraTon
A secure HTTP client for Node.js. The safe choice is the default, and the risky ones are opt-in.
UltraTon is an HTTP/1.1 and HTTP/2 client built only on Node's own modules (node:https, node:http2, node:dns, node:net, node:tls, node:zlib), with zero runtime dependencies. It is built for code that sends requests to URLs it does not fully control: webhooks, link previews, integrations, internal service clients. It blocks SSRF (including DNS rebinding), caps memory and time, and never silently ignores a mistake in its options.
It is also a complete everyday client, and every feature keeps the same protections:
- Requests:
queryparameters, JSON,formfields,FormDatauploads, and streamed uploads from a file or any stream (recipes). - Responses: gzip, deflate and brotli decoded with the size limit applied to the decoded bytes, and
stream()for large downloads. client.fetch(): a standardfetchyou can hand to any SDK.- Retries: opt-in, idempotent methods only,
Retry-Afterrespected, one time budget for all attempts. - Proxies: HTTP CONNECT and SOCKS5, tunnelled to the IP UltraTon validated, so the proxy cannot resolve the name elsewhere.
npm install ultratonRequires Node.js 22.12 or later. Works from ESM (import), CommonJS (require) and TypeScript.
Zero dependencies: npm install ultraton installs exactly one package, with no runtime, peer or optional dependencies. The compat/ folder of this repository holds axios, got and node-fetch only to test createSecureAgents() against them. It is a separate workspace that is never part of the published package, so a dependency scanner reading this repository may list those libraries; they are not dependencies of ultraton.
Quickstart
import { UltraTonClient } from 'ultraton';
const api = new UltraTonClient({
baseUrl: 'https://api.example.com/v1/',
headers: { authorization: `Bearer ${process.env.API_TOKEN}` },
});
const res = await api.get('users/7');
if (res.ok) {
const user = res.json<{ id: number; name: string }>();
console.log(user.name);
}
const name = (await api.get('users/8')).assertOk().json<{ name: string }>().name; // throws UltraTonHttpError on a non-2xx
await api.get('users', { query: { page: 2, tags: ['a', 'b'] } }); // users?page=2&tags=a&tags=b
await api.post('users', { json: { name: 'Ada' } });
await api.close(); // when the client is no longer needed; or `await using api = new UltraTonClient(...)` (Node 24+, TypeScript 5.2+) and it closes when the block ends- Create one client and reuse it. Each client has its own connection pool.
- Every method is
(url, options):get,post,put,patch,delete,headandoptions. Any other method (PROPFIND,PURGE,REPORT, ...) goes throughrequest(method, url, options), in uppercase. - A non-2xx response resolves. Check
res.okorres.statusCode. The promise only rejects when no complete response arrives, or when a policy or a limit stops the request.
The first five minutes
Three defaults surprise people on day one. Each one is a decision, and each has a one-line answer:
- Localhost and private addresses are blocked (
ERR_ULTRATON_RESERVED_IP), because a URL from outside must never reach your network. For a local server:new UltraTonClient({ permitReservedIps: ['127.0.0.1'] }); for an internal client, list its ranges, ortrue. - Bodies stop at 2 MB (
UltraTonMemoryError), because a response must not be able to exhaust the process. RaisemaxBodySizeon the request, or read it withstream(). - Redirects come back as responses (a 3xx with
ok: false), because every hop is a new target to judge.maxRedirects: 3follows them, andredirectHeaders: 'safe'keeps your custom headers from following a redirect to another origin.
Not sure why something was blocked? await client.explain(url) tells you before connecting (see Why was this blocked?).
Secure defaults
| Protection | Default | How to change it |
|---|---|---|
| Private, loopback, link-local, cloud-metadata and other reserved IPs | Blocked. This covers IP literals in every spelling, DNS answers, and every redirect hop. | permitReservedIps: true or an allowlist such as ['10.0.0.0/8'] |
| DNS rebinding | Blocked. Every address is validated once and the connection is pinned to it. | — |
| Plain http: | Only to loopback hosts, and only if they resolve to loopback addresses. | allowPlainHttp: true on the client, for an internal service without TLS (the address policy still applies) |
| Redirects | Not followed. The 3xx comes back as a response. | maxRedirects (0–20) |
| https → http redirect | Rejected. | — |
| Headers on a cross-origin redirect | authorization, cookie, proxy-authorization, auth and host are dropped; every other header follows, as with fetch. | redirectHeaders: 'safe' lets only accept*, user-agent and the body framing follow |
| Response body size | 2 MB, then the response is aborted. | maxBodySize |
| Response header size | 16 KiB per response, every redirect hop included, on HTTP/1.1 and HTTP/2; past it the request fails. | — |
| Compressed responses (gzip, deflate, br) | Decoded, with maxBodySize applied to the decoded bytes too, so a decompression bomb stops at the limit. At most 2 content-encodings. br only on a Node release with the Brotli fix (see Security). | decompress: false |
| Total time (DNS + all hops) | 30 s | timeoutMs (0 = none) |
| Idle connection | 10 s without bytes | socketTimeoutMs (0 = none) |
| TLS verification | Always on. It cannot be disabled. | tls: { ca, cert, key } for private CAs and mTLS |
| Header injection (CRLF/NUL), pseudo-headers | Rejected before connecting. | — |
| JSON prototype pollution | __proto__, constructor and prototype keys are stripped by json(). | — |
| Unknown options or wrong option types | Throw a TypeError, with a hint for common mistakes. | — |
| Truncated response (content-length mismatch, GOAWAY or reset mid-body) | Rejected. It never resolves as a short body. | — |
| Retries | Off. When on: idempotent methods only, Retry-After waited (or the response returned when it asks for too long), and policy, memory and redirect errors never retried. Every attempt is validated again. | retry (0–10, or { limit, methods, statusCodes, maxDelayMs, onRetry }) |
| Proxies | Not used, and never read from HTTP_PROXY/HTTPS_PROXY. When set, the proxy gets the validated IP (CONNECT or SOCKS5), never a name, and the target's certificate is still checked. | proxy (client option) |
Why was this blocked?
client.explain(url) says what a request would do before connecting, without connecting: the URL as it would be sent (baseUrl applied, normalized), the addresses the host resolves to and how the policy judges each one, and the verdict with the code the request would reject with. The same functions decide here and in a request.
const client = new UltraTonClient({ permitReservedIps: ['10.0.0.0/8'] });
const why = await client.explain('https://cache.internal/');
// { href: 'https://cache.internal/', verdict: 'blocked', code: 'ERR_ULTRATON_RESERVED_IP',
// reason: 'UltraTon: Target resolves to a prohibited internal/reserved network IP. ...',
// addresses: [{ address: '192.168.1.20', family: 4, allowed: false }] }It covers the first hop (not redirects), and with resolve: 'proxy' nothing is resolved locally. The result names resolved addresses: it is for the operator configuring the policy, never for whoever supplied the URL.
Choosing a client
UltraTon does not try to be the fastest HTTP client: Node's own http.request is about 1.3× faster, and it should be, with no checks to run. UltraTon is the client for code that sends requests to URLs it does not fully control, and for teams that want safe defaults without writing them by hand.
- The URL is yours and you want maximum throughput: use
fetch. - The URL comes from outside (webhooks, link previews, integrations, user input), or you want safe defaults: use UltraTon. It is also 1.4–1.9× faster than axios, got and node-fetch in our benchmarks, and over HTTP/1.1 ahead of
fetchwith 64 KiB bodies (fetchleads with 1 KiB ones). - You already use axios, got or node-fetch: keep them and add
createSecureAgents(). It costs nothing measurable.
How they compare, with each library's defaults (UltraTon 2.x, Node 24's fetch, axios 1.20, got 16.1, node-fetch 3.3):
| | UltraTon | fetch (undici) | axios | got | node-fetch |
|---|---|---|---|---|---|
| Blocks SSRF (reserved IPs, DNS rebinding, redirects inside) | Yes, all 32 corpus cases | No | No (yes with createSecureAgents) | No (yes with createSecureAgents) | No (yes with createSecureAgents) |
| Redirects | off by default | followed (up to 20) | followed (up to 21) | followed (up to 10) | followed (up to 20) |
| Response size limit | 2 MB, also on decoded bytes | none | none | none | none |
| Total timeout | 30 s | none (300 s idle) | none | none | none |
| Retries | opt-in, idempotent only | — | — (plugin) | on (2), idempotent | — |
| Proxies | CONNECT and SOCKS5, opt-in, target pinned | NODE_USE_ENV_PROXY (recent Node) | from HTTP_PROXY by default | through an agent | through an agent |
| HTTP/2 | http2: true | not by default | httpVersion: 2 | http2: true | — |
| Hooks / interceptors | diagnostics_channel (read-only) | — | interceptors | hooks | — |
| Pagination | paginate(), same origin only | — | — | Yes | — |
| Cookies, HTTP cache | — | — | — | Yes | — |
| Upload streaming | Yes, with an optional size limit | Yes | Yes | Yes | Yes |
| Runs in browsers, Bun, Deno | — (Node only) | Yes | Yes | — | — |
| Runtime dependencies | 0 | built in | 4 | 10 | 3 |
| Maturity | new (2.x in 2026), one maintainer | Node core team | many years, large community | many years | many years |
Where UltraTon is not the right choice:
- Raw throughput against an API you trust: Node's own
http.requestis about 1.3× faster.fetchis up to 1.4× faster than UltraTon over HTTP/1.1 with small bodies, and needs nothing installed. - Other runtimes: browsers, Bun or Deno.
- Features it does not have: request-rewriting interceptors or hooks, a cookie jar or HTTP caching. Anything that could rewrite a request after validation is left out on purpose.
Options
Client options go to new UltraTonClient(options). All of them are optional:
| Option | Type | Notes |
|---|---|---|
| baseUrl | string | Relative request paths resolve under it and can never escape it (see baseUrl). |
| http2 | boolean | Use HTTP/2 (ALPN h2, or h2c to loopback). Default false. |
| allowPlainHttp | boolean | Plain http: to any host, not only loopback: for an internal service without TLS (http://api.staging.internal). Every address is still judged by permitReservedIps, an https→http redirect is still refused, and nothing is encrypted. Cannot be combined with proxy. Default false. |
| tls | { ca?, cert?, key? } | PEM strings or Buffers: a private CA (or a dev server's self-signed certificate, see Recipes), or a client certificate for mTLS. ca replaces the system roots. |
| userAgent | string | Default ultraton/<version>. |
| proxy | string or { url, auth, ca, resolve } | Outbound HTTP CONNECT or SOCKS5 proxy for https: targets, tunnelled to the validated IP (see Proxy). |
| headers | plain object, or (ctx) => headers | Sent with every request; a request header with the same name (any case) wins. A function is called once per call (not per retry or redirect hop) with { method, url }, for values that change per request: headers: () => ({ traceparent: currentTraceparent() }). What it returns is validated as static headers are, and it can only set headers. |
| maxConcurrency | integer from 1 | At most this many calls in flight at once per origin; the rest wait in order, and the wait counts against timeoutMs. Unset: no limit. See Limits and things to know. |
| timeoutMs, socketTimeoutMs, maxBodySize, maxRequestBodySize, maxRedirects, redirectHopHeaders, permitReservedIps, retry, timings | | Defaults for every request. A request that sets the same option wins; retry is merged key by key. |
Request options are the second argument of every method:
| Option | Type | Default |
|---|---|---|
| json | unknown | Serialized, and sent with content-type: application/json unless you set one. |
| form | plain object or URLSearchParams | Sent urlencoded, with the same rules as query. |
| body | string \| Buffer \| Uint8Array \| Blob \| FormData \| URLSearchParams, or a stream (Readable \| ReadableStream \| AsyncIterable) | A FormData goes as multipart with its boundary, a Blob with its type. A stream is sent as it is read (see Upload a stream). Only one of json, form and body. GET and HEAD cannot have a body. |
| headers | plain object | — |
| query | plain object or URLSearchParams | Appended to the URL's own query. Arrays repeat the key, undefined is skipped, null and nested objects throw. |
| auth | 'user:password' | Sent as Basic auth. Dropped on a cross-origin redirect. |
| signal | AbortSignal | Aborts in any phase. The request rejects with signal.reason. |
| timeoutMs | integer ms | 30000 |
| socketTimeoutMs | integer ms | 10000 |
| maxBodySize | integer bytes | 2097152 (2 MB) |
| maxRequestBodySize | integer bytes | None. A larger buffered body throws before connecting; a stream is aborted as it goes past it. |
| decompress | boolean | true: sends accept-encoding (unless you set one) and decodes the body. false leaves data as received. |
| maxRedirects | integer 0–20 | 0 |
| redirectHeaders | 'all' \| 'safe' | 'all': on a redirect to another origin, every header but the credential ones follows. 'safe': only accept, accept-encoding, accept-language, user-agent, content-type and content-length do, so an x-api-key stays behind. A same-origin redirect keeps every header. |
| redirectHopHeaders | boolean | false. true keeps each followed redirect's response headers on its entry of redirects: to audit a chain, or to read a hop's set-cookie when you keep cookies yourself (UltraTon keeps none). Bounded as a final response's headers are. |
| discardBody | boolean | false. true reads the body to the end and keeps none of it: data is empty and bodySize says how many bytes came, decoded. For health checks and probes that need only the status and the size. maxBodySize, the timeouts and the check of a body cut short still apply. Not a client default; stream() and paginate() refuse it. |
| permitReservedIps | boolean \| string[] | false |
| retry | integer 0–10 or { limit, methods, statusCodes, maxDelayMs, onRetry } | 0 (off). See Retries. |
| timings | boolean | false. true puts timings on the response: where the time of that call went (see Observability). |
Response:
| Field | Type | Notes |
|---|---|---|
| statusCode | number | — |
| ok | boolean | true for 200–299. |
| headers | IncomingHttpHeaders | Never contains HTTP/2 pseudo-headers. |
| url | string | The final URL, after any redirect that was followed. |
| redirected | boolean | true when at least one redirect was followed. |
| redirects | readonly { url, statusCode, headers? }[] | The redirects followed, in order: the URL that answered each one and its status (where it led is the next entry's url, or the response's). Empty when none was followed. headers, that hop's response headers, only with redirectHopHeaders. |
| data | Buffer | The raw body. It is never parsed automatically. |
| bodySize | number \| undefined | With discardBody: the body's size in bytes, decoded (data is then empty). |
| timings | UltraTonTimings \| undefined | With the timings option: DNS, connect, TLS, first byte, queue and total of this call, in ms (see Observability). totalMs runs until the body was read (on stream(), until the headers). |
| json<T>() | T | Parses on each call, whatever the content-type. An empty body gives {}. A leading UTF-8 BOM is skipped. |
| text() | string | Decodes the body as UTF-8. A leading BOM is skipped, as with fetch; data keeps it. |
| assertOk() | the response | Returns the response when ok, otherwise throws UltraTonHttpError (statusCode, response). On stream() it cancels the body first. A redirect that was not followed is not ok. |
Errors
Every error the library raises is a SecureHttpError, apart from the TypeErrors for invalid input listed below and an abort, which rejects with your signal.reason. Policy errors carry a stable code.
| Error | When | code |
|---|---|---|
| TypeError | Unknown options or wrong option types, a body on GET/HEAD, and with baseUrl a request path that is absolute, malformed or escapes it. Thrown before any connection. | — |
| TypeError | A number option that is not an integer within its range (timeoutMs above 2147483647, maxRedirects: 21, retry.limit: 1.5). Thrown before any connection, with the option's name and bounds as err.option, err.min and err.max. | ERR_ULTRATON_OPTION_RANGE |
| SecureHttpError | A malformed URL, a scheme other than http(s), or credentials in the URL (request URL, baseUrl or redirect target) | — |
| SecureHttpError | A reserved IP not allowed by permitReservedIps | ERR_ULTRATON_RESERVED_IP |
| SecureHttpError | Plain http: to a host or address that is not loopback, without allowPlainHttp | ERR_ULTRATON_PLAIN_HTTP |
| SecureHttpError | A redirect from https to http | ERR_ULTRATON_DOWNGRADE |
| SecureHttpError | createSecureAgents(): a library passed a host that is an IPv4 in a non-canonical form (127.1, 0x7f.0.0.1, 2130706433) | ERR_ULTRATON_NON_CANONICAL_IP |
| SecureHttpError | createSecureAgents(), on a Node.js release without the keep-alive fix (see SECURITY.md): keepAlive, maxSockets or maxTotalSockets set on an agent. Thrown by the assignment. | ERR_ULTRATON_AGENT_KEEP_ALIVE |
| SecureHttpError | A request on a closed client | — |
| SecureHttpError | events(): the response is a 2xx but not a 200 with content-type: text/event-stream (a status that is not 2xx is an UltraTonHttpError) | ERR_ULTRATON_EVENT_STREAM |
| UltraTonMemoryError | events(): a line, or one event's data, went past maxEventSize | ERR_ULTRATON_EVENT_TOO_LARGE |
| SecureHttpError | websocket(): the server answered the upgrade with a 2xx, or with a 101 that is not a valid WebSocket handshake (a status that is neither is an UltraTonHttpError) | ERR_ULTRATON_WS_HANDSHAKE |
| SecureHttpError | websocket(): the server broke RFC 6455 (a masked frame, a reserved bit or opcode, invalid UTF-8, an invalid close code, ...). The connection is failed with close code 1002 or 1007. | ERR_ULTRATON_WS_PROTOCOL |
| UltraTonMemoryError | websocket(): a received message (or one frame of it) went past maxMessageSize. The connection is failed with close code 1009. | ERR_ULTRATON_WS_MESSAGE_TOO_LARGE |
| UltraTonMemoryError | websocket(): send() while more than 1 MiB is still waiting for the server to read it | ERR_ULTRATON_WS_SEND_BUFFER |
| SecureHttpError | websocket(): send() on a connection that is closing or closed | ERR_ULTRATON_WS_CLOSED |
| UltraTonNetworkTimeoutError | timeoutMs expired (the whole budget, retries included), or no data arrived for socketTimeoutMs (the server or the network stalled) | ERR_ULTRATON_TIMEOUT, ERR_ULTRATON_SOCKET_TIMEOUT |
| UltraTonNetworkError | DNS, connect, TLS, reset or truncated response, or a compressed body that is corrupt or cut short | Node's or zlib's code (ECONNREFUSED, ENOTFOUND, ECONNRESET, Z_DATA_ERROR, …) |
| UltraTonNetworkError | HTTP/2: the server never processed the request (a REFUSED_STREAM reset, or above a GOAWAY's last stream). retry retries it. | NGHTTP2_REFUSED_STREAM |
| UltraTonNetworkError | The proxy refused the tunnel (CONNECT status or SOCKS5 reason), or answered outside its protocol | ERR_ULTRATON_PROXY |
| SecureHttpError | More than 2 content-encodings on a response | ERR_ULTRATON_CONTENT_ENCODING |
| UltraTonMemoryError | The body exceeded maxBodySize | — |
| UltraTonMemoryError | HTTP/2: the response headers (a followed redirect's too) exceeded 16 KiB. On HTTP/1.1 the same overflow is an UltraTonNetworkError with HPE_HEADER_OVERFLOW. | — |
| UltraTonMemoryError | The request body exceeded maxRequestBodySize | ERR_ULTRATON_REQUEST_BODY_TOO_LARGE |
| SecureHttpError | A streamed request body yielded more or fewer bytes than its content-length | ERR_ULTRATON_REQUEST_BODY_LENGTH |
| UltraTonRedirectError | More redirects than maxRedirects | — |
| UltraTonHttpError | assertOk() on a non-2xx response. statusCode and response are on the error; the message carries the status only. | ERR_ULTRATON_HTTP_STATUS |
| SecureHttpError | paginate(): a next page on another origin, or outside baseUrl | ERR_ULTRATON_PAGE_OUT_OF_SCOPE |
| SecureHttpError | paginate(): a next page is left after maxPages | ERR_ULTRATON_MAX_PAGES |
| UltraTonParseError | json() on an invalid body. The SyntaxError is in cause. | — |
isUltraTonError(err, code?) tells UltraTon's errors apart by shape (name and code), so it also recognizes them when two copies of the package are installed, and code autocompletes as UltraTonErrorCode (a misspelled code is a compile error). ERR_ULTRATON_OPTION_RANGE is the exception: it comes on a TypeError, so test err.code for it:
import { isUltraTonError, UltraTonNetworkTimeoutError, UltraTonNetworkError } from 'ultraton';
try {
await api.get('reports/slow');
} catch (err) {
if (isUltraTonError(err, 'ERR_ULTRATON_RESERVED_IP')) { /* blocked target */ }
else if (err instanceof UltraTonNetworkTimeoutError) { /* retry later */ }
else if (err instanceof UltraTonNetworkError) { /* connection problem, err.code */ }
else throw err;
}UltraTonNetworkTimeoutError extends UltraTonNetworkError, so test for the timeout first when using instanceof.
Error messages never contain the IP address or hostname, so they are safe to show to the user who supplied the URL. The original Node error is in err.cause, and it does contain them, so do not send cause to untrusted sinks.
Troubleshooting by error code
Every code UltraTon raises, what happened, and what to change. A test keeps this table complete.
| Code | What happened | What to change |
|---|---|---|
| ERR_ULTRATON_RESERVED_IP | The host is, or resolves to, a private, loopback, link-local or otherwise reserved address that permitReservedIps does not allow. | For a local or internal target, allow it in permitReservedIps (an IP, a CIDR or the exact hostname). client.explain(url) shows the addresses. |
| ERR_ULTRATON_PLAIN_HTTP | Plain http: to something that is not loopback. | Use https:. For an internal service without TLS that you trust, allowPlainHttp: true on the client (plus permitReservedIps for its private address). |
| ERR_ULTRATON_DOWNGRADE | A redirect from https to http. | Nothing on your side: the server tried to move you onto plaintext. |
| ERR_ULTRATON_OPTION_RANGE | A number option is not an integer within its range: on a TypeError, with err.option ('timeoutMs', 'retry.limit', ...), err.min and err.max. The millisecond options and maxBodySize stop at 2147483647. | Bring the value within err.min..err.max; timeoutMs: 0 and socketTimeoutMs: 0 mean no limit. For a download beyond 2 GiB use stream(), whose maxBodySize goes to Number.MAX_SAFE_INTEGER. |
| ERR_ULTRATON_TIMEOUT | timeoutMs ran out: the whole budget, retries included. | Raise timeoutMs for that request, or 0 for none. |
| ERR_ULTRATON_SOCKET_TIMEOUT | No bytes arrived for socketTimeoutMs: the server or the network stalled. On events(), no complete line (an event or a keep-alive comment) for socketTimeoutMs; on websocket(), no frame for socketTimeoutMs while a message is awaited (with pingIntervalMs, after a ping), or a send() that made no progress. | Raise socketTimeoutMs, or retry it (it is retried by default when retries are on). On events() and websocket() the connection had already started, so nothing retries it: open a new one (pingIntervalMs keeps a quiet WebSocket alive). |
| ERR_ULTRATON_EVENT_STREAM | events(): the response is not a 200 with content-type: text/event-stream. | Check the URL and the accept the endpoint wants; stream() reads any response. |
| ERR_ULTRATON_EVENT_TOO_LARGE | events(): one event (or one line) went past maxEventSize. | Raise maxEventSize for that call. |
| ERR_ULTRATON_WS_HANDSHAKE | websocket(): the server answered the upgrade without upgrading (a 2xx), or its 101 is not a valid handshake: a wrong Sec-WebSocket-Accept, an extension, a subprotocol you did not offer. | Check the URL is a WebSocket endpoint, and the protocols it expects. UltraTon offers no extension (no compression). |
| ERR_ULTRATON_WS_PROTOCOL | websocket(): the server sent a frame or a message RFC 6455 does not allow. | Nothing on your side: the server is broken or hostile. |
| ERR_ULTRATON_WS_MESSAGE_TOO_LARGE | websocket(): a message went past maxMessageSize. | Raise maxMessageSize for that connection. |
| ERR_ULTRATON_WS_SEND_BUFFER | websocket(): more than 1 MiB was still queued for a server that is not reading. | Await send() before sending more. |
| ERR_ULTRATON_WS_CLOSED | websocket(): send() after the connection started closing. | Open a new connection. |
| ERR_ULTRATON_HTTP_STATUS | assertOk() on a response that is not 2xx. | Read err.statusCode and err.response; or check res.ok instead of asserting. |
| ERR_ULTRATON_REQUEST_BODY_TOO_LARGE | The request body went past maxRequestBodySize. | Raise maxRequestBodySize, or send less. |
| ERR_ULTRATON_REQUEST_BODY_LENGTH | A streamed request body sent more or fewer bytes than its content-length. | Fix the content-length you set, or leave it out and let the body go chunked. |
| ERR_ULTRATON_CONTENT_ENCODING | The response stacked more than two content-encodings. | Nothing on your side; decompress: false returns the body as received. |
| ERR_ULTRATON_PAGE_OUT_OF_SCOPE | paginate(): a next page on another origin, or outside baseUrl. | Pages must stay on the first page's origin (and inside baseUrl); fetch the other origin with its own call. |
| ERR_ULTRATON_MAX_PAGES | paginate(): a next page was left after maxPages. | Raise maxPages, or stop iterating earlier. |
| ERR_ULTRATON_PROXY | The proxy refused the tunnel (CONNECT status or SOCKS5 reason) or answered outside its protocol. | Check proxy.url, proxy.auth and that the proxy allows CONNECT to that IP and port (or set resolve: 'proxy' for proxies that filter by hostname). |
| ERR_ULTRATON_AGENT_PROXY | createSecureAgents(): the library tried to send a forward-proxy request (an absolute URL in the request line). | With axios, set proxy: false; proxies go in UltraTon's own client. |
| ERR_ULTRATON_AGENT_KEEP_ALIVE | createSecureAgents(), on a Node release without the keep-alive fix: keepAlive, maxSockets or maxTotalSockets set on an agent. | Upgrade Node (see SECURITY.md); the agents cannot pool sockets safely before that. |
| ERR_ULTRATON_NON_CANONICAL_IP | createSecureAgents(): a host such as 127.1 or 0x7f.0.0.1. | Write the IP in its canonical dotted form. |
| ECONNREFUSED, ECONNRESET, ENOTFOUND | Node's own network codes: nothing listens there, the connection dropped, the name does not resolve. | The server or the network. retry covers the transient ones. |
| ERR_TLS_CERT_ALTNAME_INVALID and other TLS codes | The certificate does not match the host, or is not trusted (DEPTH_ZERO_SELF_SIGNED_CERT, SELF_SIGNED_CERT_IN_CHAIN: a self-signed one). | A private CA, or a dev server's self-signed certificate, goes in tls: { ca } (see Recipes); verification cannot be turned off. |
Recipes
Fetch a URL a user gave you (webhooks, link previews). The defaults are already right. Keep the redirect count small; every hop is still checked against the SSRF policy. With redirectHeaders: 'safe', a server that redirects cannot carry your custom headers (an x-api-key) to another origin:
const client = new UltraTonClient({ maxRedirects: 3, redirectHeaders: 'safe', maxBodySize: 512 * 1024, timeoutMs: 5000 });
const res = await client.get(untrustedUrl);Deliver a webhook. POST it, retry only what is safe to retry, never follow redirects, keep the reply small. The code below runs as a test in __tests__/recipes.test.ts:
import { UltraTonClient } from 'ultraton';
/** Deliver a webhook: POST it, retry what is safe to retry, never follow redirects, keep the reply small. */
async function deliver(client: UltraTonClient, url: string, event: unknown): Promise<void> {
const res = await client.post(url, { json: event, retry: { limit: 2, methods: ['POST'] }, maxBodySize: 64 * 1024, timeoutMs: 10_000 });
res.assertOk(); // a 4xx/5xx after the retries throws UltraTonHttpError
}methods: ['POST'], because POST is not retried by default: list it only if your receivers are idempotent (webhook receivers are expected to be).maxBodySize: 64 * 1024: a webhook reply has no business being large, and a hostile receiver must not make you buffer one.- Redirects stay off (the default): a receiver that redirects your event elsewhere is not a receiver you want to follow.
- A URL that points inside your network rejects with
ERR_ULTRATON_RESERVED_IPbefore connecting, whatever the customer typed.
Preview a link. Read at most 64 KiB, stop at </head>, and cancel the rest so the connection is released:
import { UltraTonClient, UltraTonMemoryError } from 'ultraton';
/** The <title> of a page, reading at most 64 KiB of it. */
async function previewTitle(client: UltraTonClient, url: string): Promise<string | undefined> {
const res = await client.stream(url, { maxBodySize: 64 * 1024, timeoutMs: 5_000, maxRedirects: 3, redirectHeaders: 'safe' });
if (!res.ok) { await res.body.cancel(); return undefined; }
const reader = res.body.getReader();
const decoder = new TextDecoder();
let html = '';
try {
while (!html.includes('</head>')) {
const { done, value } = await reader.read();
if (done) break;
html += decoder.decode(value, { stream: true });
}
} catch (err) {
if (!(err instanceof UltraTonMemoryError)) throw err; // past 64 KiB without a </head>: no title, not an error
} finally {
await reader.cancel().catch(() => {});
}
return /<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1].trim();
}stream()withmaxBodySize: 64 * 1024: the limit applies as the body is read, so a page without a</head>ends withUltraTonMemoryErrorat 64 KiB, never buffered beyond that.reader.cancel()infinally: a body nobody reads to the end holds its connection and keeps the process alive.maxRedirects: 3withredirectHeaders: 'safe': previews follow a few redirects, and your own headers never follow them to another origin.
Call an internal service. Allow only the address or ranges you need, never true, and keep redirects off so the service cannot send you to another internal host:
/** The client for one internal service: its address only, no redirects, short timeout. */
function internalClient(baseUrl: string, address: string): UltraTonClient {
return new UltraTonClient({ baseUrl, permitReservedIps: [address], maxRedirects: 0, timeoutMs: 2_000 });
}
const billing = internalClient('https://billing.internal/', '10.20.3.7'); // or a range: permitReservedIps: ['10.20.0.0/16']permitReservedIps: [address]: only that address resolves; another private address, even from a redirect, rejects withERR_ULTRATON_RESERVED_IP.baseUrl: a request path cannot escape it, sobilling.get('invoices/7')can never become a call on another host.- Add
tls: { ca: fs.readFileSync('internal-ca.pem') }for a private CA; verification itself cannot be turned off.
Variants of a client. extend(options) returns a new client with the same options and options on top: headers merge (the variant wins), retry merges field by field, and a userAgent here replaces the base's. Variants share the base's connection pools unless they set http2, tls or proxy; pools stay apart per permitReservedIps policy either way. Closing one client leaves the others working; the pools close with the last one:
const api = new UltraTonClient({ baseUrl: 'https://api.example.com/v1/', timeoutMs: 5000 });
const admin = api.extend({ headers: { authorization: `Bearer ${adminToken}` } }); // same pools, one more header
const slow = api.extend({ timeoutMs: 60_000, maxBodySize: 50 * 1024 * 1024 }); // for the export endpointLocal development against http://localhost. localhost usually resolves to both ::1 and 127.0.0.1, and every resolved address must be allowed:
const dev = new UltraTonClient({ baseUrl: 'http://localhost:3000/', permitReservedIps: ['localhost'] });
// or: permitReservedIps: ['127.0.0.0/8', '::1']HTTP/2:
const h2 = new UltraTonClient({ http2: true, baseUrl: 'https://api.example.com/' });Mutual TLS:
const mtls = new UltraTonClient({ tls: { cert: fs.readFileSync('client.pem'), key: fs.readFileSync('client.key') } });A development server with a self-signed certificate. Verification cannot be turned off, but it can trust that one certificate: pass it (or the CA that signed it, as with mkcert) in tls.ca. This works for a self-signed CA certificate and for a self-signed leaf (CA:FALSE, as Vite's and ASP.NET's dev certificates are), on both engines. ca replaces the system roots, so add them back if the same client also calls public hosts. A variant with its own tls gets pools of its own:
import tls from 'node:tls';
const api = new UltraTonClient({ permitReservedIps: ['localhost'] });
const dev = api.extend({ tls: { ca: [...tls.rootCertificates, fs.readFileSync('dev-server.pem')] } });
await dev.get('https://localhost:8443/health');The certificate must still name the host you call (localhost, 127.0.0.1, ...): a name mismatch is ERR_TLS_CERT_ALTNAME_INVALID whatever ca says.
Cancel a request:
const controller = new AbortController();
setTimeout(() => controller.abort(), 1000);
await api.get('search?q=x', { signal: controller.signal }); // rejects with controller.signal.reasonSend a form or upload a file. form sends urlencoded fields (OAuth token endpoints, older APIs). A FormData body goes as multipart/form-data, with the boundary set for you:
const token = await client.post(tokenUrl, { form: { grant_type: 'client_credentials', scope: 'read' } });
const fd = new FormData();
fd.append('title', 'Q3 report');
fd.append('file', await fs.openAsBlob('report.pdf', { type: 'application/pdf' }), 'report.pdf');
await client.post(uploadUrl, { body: fd });Your own content-type header is kept for form, URLSearchParams and Blob. With a FormData it throws, because the header has to carry the boundary of the body. A content-type in the client's default headers is replaced by the one the body implies: a form, a FormData or a Blob with a type (for json, or a Blob without a type, it is kept). These bodies are read into memory before sending: for a large file, stream it instead.
Upload a stream. A Readable, a ReadableStream or any async iterable of bytes (or strings) as body is sent as it is read, never held in memory. Set maxRequestBodySize to stop a stream that would never end:
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
const { size } = await stat('backup.tar');
await client.put(uploadUrl, {
body: createReadStream('backup.tar'),
headers: { 'content-type': 'application/x-tar', 'content-length': String(size) },
maxRequestBodySize: 10 * 1024 ** 3,
});- Framing: with your
content-length, it is sent as is, and the stream must yield exactly that many bytes (ERR_ULTRATON_REQUEST_BODY_LENGTHotherwise, never a truncated body). Some servers, such as presigned S3 URLs, require it. Without it, HTTP/1.1 sends the body chunked. - Sent once: a stream can only be read once, so a request with one is never retried, whatever
retrysays, and a 307 or 308 redirect resolves as the 3xx response instead of being followed. 301, 302 and 303 are followed as a GET without a body, as usual. - Ending early: if the request fails, before or during the upload (invalid input, a blocked address, an abort, a timeout,
close()), the stream is destroyed (aReadableStreamis cancelled). So is the rest of it when the server answers before reading it all. An error from your stream rejects the request as is, even if the server already started answering.socketTimeoutMsalso runs while your stream produces no data.
Use it as fetch, or hand it to a library. client.fetch takes the same arguments as fetch and returns a standard Response. Every request still goes through the client's SSRF policy and limits. It is bound to the client, so libraries that accept a custom fetch get the protection without code changes:
const client = new UltraTonClient({ maxRedirects: 3 });
const res = await client.fetch(webhookUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(event) });
const sdk = new SomeSdk({ fetch: client.fetch });Differences from the platform's fetch:
- Redirects follow the client's
maxRedirects, which defaults to 0. With the default, a 3xx comes back as theResponse;redirect: 'manual'and'error'work as usual. - Failures: network and policy failures reject as
TypeError('fetch failed')with the UltraTon error ascause, as undici does. An abort rejects with the signal's reason. json()strips__proto__,constructorandprototypekeys.- Request bodies: a
ReadableStreamor async iterable body is streamed, and so is the body of aRequestinput, with the rules of Upload a stream. With a streamed body, a 307 or 308 comes back as theResponse(the platform'sfetchwould reject). baseUrlapplies to string paths. ARequestorURLinput is absolute, so on a client withbaseUrlit is rejected as not relative: use a client withoutbaseUrlfor libraries.- Options: browser-only options (
mode,credentials,cache, …) are ignored. Options that would change the connection (dispatcher,agent) throw: configure the client instead.
Stream a large download. stream() resolves on the headers and hands you the body as a ReadableStream. Its limits still apply as it is read, and maxBodySize may go up to Number.MAX_SAFE_INTEGER here:
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
const res = await client.stream('https://files.example.com/big.csv', { maxBodySize: 500 * 1024 * 1024 });
if (!res.ok) await res.body.cancel();
else await pipeline(Readable.fromWeb(res.body), fs.createWriteStream('big.csv'));stream(url, { method: 'POST', json, … }) takes the method as an option, any method in uppercase as for request(). The body fails, never ends early, if it is cut short, too large, corrupt, aborted or the client is closed. socketTimeoutMs only counts while you are waiting for data, so a slow consumer never times out. timeoutMs ends when the headers arrive: a body that keeps moving may take longer than the budget, and only the idle timer guards it. Read the body to the end or cancel() it: until then it holds its connection and keeps the process alive.
Walk a paginated API. paginate() yields one complete response per page, each a full get() with every check. By default the next page is the rel="next" link of the Link header (RFC 8288), as GitHub and many other APIs send it; next(page) reads it from somewhere else:
for await (const page of client.paginate('https://api.example.com/items', { query: { per_page: 100 } })) {
if (!page.ok) throw new Error(`page failed: ${page.statusCode}`);
for (const item of page.json<Item[]>()) handle(item);
}
// A cursor in the body: return the next URL, or undefined on the last page.
const pages = api.paginate<{ items: Item[]; cursor?: string }>('items', {
next: page => { const { cursor } = page.json(); return cursor ? `items?cursor=${encodeURIComponent(cursor)}` : undefined; },
});- Scope: the next page must be on the first page's origin and, with
baseUrl, inside it (its path checks apply). A server cannot steer the walk to another host: that rejects withERR_ULTRATON_PAGE_OUT_OF_SCOPE, and so does anext()result. So does a page that a followed redirect served from outside that scope (it is yielded, then the walk rejects before its link is read): otherwise another server would choose the next request on your origin, with your credentials. - Bounded:
maxPages(default 100). A next page left after it rejects withERR_ULTRATON_MAX_PAGES, after the pages already yielded: a collection is never cut short in silence. - Errors: a non-2xx page is yielded and ends the walk. A relative
Linkresolves against the page's finalurl, as RFC 8288 says. A path fromnext()reads like aget()path: underbaseUrl,'/items?cursor=x'and'items?cursor=x'are both from the base. An absolute URL fromnext()(anext_urlfield in the body, say) works too, as long as it stays in scope. - Options:
queryapplies to the first page only, since a next link carries its own; every other option (headers,signal,retry, …) to every page. There is no body: every page is a GET.
Server-Sent Events
client.events(url, options) opens a Server-Sent Events stream (notifications, progress, model output) with a GET request and resolves once the headers arrive; read the events with for await. It goes the way of stream(): every check (SSRF, TLS, redirects only with maxRedirects, maxConcurrency) applies, on both engines.
const stream = await client.events('https://api.example.com/v1/updates', {
headers: { authorization: `Bearer ${token}` },
maxBodySize: 64 * 1024 * 1024, // the whole stream; raise it for a long one
signal: AbortSignal.timeout(10 * 60_000), // how long it may last is yours to bound
});
for await (const { event, data, id } of stream) {
if (event === 'done') break; // leaving the loop releases the connection
handle(data); // a string, never parsed
}- Events are
{ event, data, id, retry }:eventis'message'unless the server names one,dataits lines joined with\n,idthe last event ID the server set (''until it sets one),retrythe server's last reconnection hint in ms. Parsing follows the WHATWG standard: comments are skipped, a leading BOM dropped, CR, LF and CRLF all end a line, an event the stream ends in the middle of is dropped. - Limits:
maxBodySizebounds the whole stream (the client's default is 2 MiB, so a long stream needs more),maxEventSize(default 64 KiB) one line or one event'sdata, andsocketTimeoutMsthe time without a complete line: a keep-alive comment keeps a quiet stream open, bytes that never end a line do not. There is no limit on how long a stream may last unless you set one withsignal. - Responses: a status that is not 2xx rejects with
UltraTonHttpError; any other response that is not a 200 withcontent-type: text/event-streamwithERR_ULTRATON_EVENT_STREAM. Either way the connection is released. - No automatic reconnection, on purpose: a server that ends the stream, or answers
retry: 0, cannot make the client loop. To resume, open a new stream with the lastidand a wait of your own:
let lastId = '';
for (let attempt = 0; attempt < 5; attempt++) {
try {
const stream = await client.events(url, { headers: lastId ? { 'last-event-id': lastId } : {} });
for await (const event of stream) {
// The id is the server's: send back only one a header can carry, or the next events() call throws.
if (/^[\x20-\x7e]{0,256}$/.test(event.id)) lastId = event.id;
handle(event.data);
}
} catch (err) {
if (!isUltraTonError(err)) throw err;
}
await new Promise(r => setTimeout(r, 1000 * 2 ** attempt)); // your floor and ceiling, not the server's
}A last-event-id you send is server data going back into a header. UltraTon refuses CR, LF and NUL in any header, so a hostile id cannot inject one; but an id with other characters a header cannot carry (control characters, non-ASCII) would make the next call throw a TypeError, which is why the loop keeps only printable ASCII ids, and only short ones.
WebSocket
client.websocket(url, options) opens a WebSocket (RFC 6455) to an endpoint someone else chose (an integration, a URL from a user) and resolves once the server accepted the upgrade. The upgrade is an HTTP/1.1 request with every check of the other methods: SSRF and pinned DNS, TLS, the proxy, maxConcurrency (the connection holds its slot until it closes), over HTTP/1.1 even on an http2 client. Node's global WebSocket has no lookup hook to pin the addresses it connects to, which is why UltraTon has its own.
const ws = await client.websocket('wss://api.example.com/v1/live', {
headers: { authorization: `Bearer ${token}` }, // any header, on the upgrade
protocols: ['v2.json'], // the server's choice is ws.protocol
pingIntervalMs: 15_000, // keep a quiet connection alive
});
await ws.send(JSON.stringify({ subscribe: 'orders' }));
for await (const message of ws) {
if (typeof message !== 'string') continue; // binary messages are Buffers
if (handle(message) === 'done') break; // leaving the loop closes with 1000
}
console.log(ws.closeCode, ws.closeReason); // set when the server closed- URLs:
ws:orwss:, withws:only to loopback unlessallowPlainHttp, ashttp:; withbaseUrl, a relative path, which connects aswss:(ws:for anhttp:base). Anhttp(s):URL throws. - The handshake: no redirect is followed and no extension offered (no compression:
permessage-deflateis where decompression bombs live). A status that is not 101 or 2xx rejects withUltraTonHttpError, its body unread; a 2xx, or a 101 that is not a valid handshake, withERR_ULTRATON_WS_HANDSHAKE. NoOriginis sent unless you set one. The headers the handshake owns (upgrade,sec-websocket-*) cannot be set. - Limits:
maxMessageSize(default 1 MiB) bounds one message and so one frame, checked from the frame's header before anything is buffered.socketTimeoutMsbounds the time without a frame while you wait for a message; withpingIntervalMs, the client pings a quiet server first andsocketTimeoutMsis then the deadline for an answer. The close handshake takes 5 s at most. There is no limit on how long a connection may last, or on the bytes it carries in all, unless you set one withsignal: memory is bounded per message. - Reading: the socket is read only while you wait for a message, so a server that sends faster than you read is held back by TCP, not buffered. Pings are answered while you read: keep a loop reading. The loop ends without error when the server closes (
closeCode,closeReason), and rejects when the connection fails or ends without a close frame. - Sending:
send(string)sends text,send(bytes)binary, and resolves once the frame is on the socket. Await it: with more than 1 MiB still queued for a server that is not reading, it rejects withERR_ULTRATON_WS_SEND_BUFFER, and a write that makes no progress forsocketTimeoutMsfails the connection. - Closing:
close(code?, reason?)(1000, or 3000 to 4999),break,await using,signal(at once, without the close handshake) andclient.close()all end it. It never reconnects: open a new connection, with a wait of your own.
Retries
Off by default. retry: n retries up to n times (at most 10) with these rules:
- Methods: GET, PUT, DELETE, HEAD and OPTIONS. POST, PATCH and methods of your own (
request('PURGE', …)) are never retried unless you list them inmethods, because the server may have already acted on them. - Statuses: 408, 429, 502, 503 and 504.
statusCodesreplaces the list; 500 is not in it by default. When the retries run out, you get the last response, not an error. - Network failures:
ECONNRESET,ECONNREFUSED,ETIMEDOUT,EAI_AGAIN,EPIPEand the idle timeout (ERR_ULTRATON_SOCKET_TIMEOUT). Policy errors,maxBodySize,maxRedirects, invalid input, thetimeoutMstimeout and an abort are never retried. - Waits: exponential backoff with full jitter (300 ms base, at most
maxDelayMs, default 30 s). ARetry-After(seconds or an HTTP date) is waited exactly; if it asks for more thanmaxDelayMsor than what is left oftimeoutMs, that response is returned instead. - Budget:
timeoutMscovers every attempt and wait together, andsignalalso cancels a wait. - HTTP/2: a stream the server never processed fails with
NGHTTP2_REFUSED_STREAMand is retried, on a new connection after a GOAWAY. That covers aREFUSED_STREAMreset and a stream above the last one a GOAWAY processed. Any other reset is not retried.
const api = new UltraTonClient({
baseUrl: 'https://api.example.com/',
// error.code, not error: its cause is Node's own error, whose message contains the IP address.
retry: { limit: 3, onRetry: ({ attempt, delayMs, statusCode, error }) => log.warn({ attempt, delayMs, statusCode, code: (error as Error & { code?: string })?.code }) },
});
await api.get('reports'); // up to 3 retries
await api.post('jobs', { json: job, retry: { methods: ['POST'] } }); // opt in for an idempotent POST
await api.get('health', { retry: 0 }); // off for this requestEvery attempt is a full request: a new connection resolves and validates DNS again, so a rebinding between attempts is blocked. A request's retry merges over the client's field by field. stream() retries until the headers arrive. fetch() never retries, because libraries that take a fetch bring their own retries. If onRetry throws, the request ends with that error.
Observability
For a quick look without code, run with NODE_DEBUG=ultraton: one line on stderr per response, retry and error, with the method, the origin, the status or error code, the duration and, in brackets, where it went (the timings below; ttfb is the time to the first byte). Query strings, credentials, headers, bodies and resolved IPs are never written. Paths are off by default, because APIs put secrets in them: ULTRATON_DEBUG_PATHS=1 adds them (NODE_DEBUG=* does not).
ULTRATON 4242: GET https://api.example.com -> 200 (84 ms) [dns 3 ms, connect 12 ms, tls 28 ms, ttfb 40 ms]
ULTRATON 4242: GET https://api.example.com -> 200 (10 ms) [reused, ttfb 9 ms]
ULTRATON 4242: GET https://api.example.com -> 200 (131 ms) [queued 120 ms, reused, ttfb 9 ms]
ULTRATON 4242: POST https://hooks.example.net retry 1 in 300 ms (503)
ULTRATON 4242: GET https://api.example.com failed: UltraTonNetworkTimeoutError ERR_ULTRATON_TIMEOUT (5001 ms) [reused]UltraTon publishes diagnostics_channel events for every client call (get, post, …, stream(), fetch()), so tracing and metrics tools can see requests without wrapping the client. The debug lines above are a subscriber of these events. Publishing costs nothing when nobody subscribes.
| Channel | When | Message |
|---|---|---|
| ultraton:request:start | The options are valid and the request starts | { request } |
| ultraton:request:retry | Before each wait of retry | { request, attempt, delayMs, statusCode?, error? } |
| ultraton:request:response | A response arrived (for stream(): its headers) | { request, statusCode, durationMs, timings } |
| ultraton:request:error | The call failed (policy, network, timeout, abort, …) | { request, error, durationMs, timings } |
request is { method, origin, path }, frozen, and the same object on every event of one call, so it can key a span or a timer. error is { name, code }. timings (UltraTonTimings) says where the time of a slow call went, in milliseconds:
queuedMs: the wait for a free slot under the client'smaxConcurrency, before the first request went out;undefinedwhen the call did not wait (or the client has no limit).dnsMs: name resolution, when a new connection was opened (none for an IP literal).connectMs: the TCP connect. On HTTP/2 it includes the TLS handshake, which Node reports together with it; through a proxy it includes the tunnel.tlsMs: the TLS handshake, on HTTP/1.1 only.firstByteMs: from the request being sent (its body included) to the response headers: mostly the server's own time.totalMs: the whole call, every redirect and retry included; the same asdurationMs.reusedConnection: whether the request went over a pooled socket or HTTP/2 session, which had no DNS, connect or TLS of its own. HTTP/1.1 sockets are pooled only on a Node with its keep-alive fix (see SECURITY.md).
The phases are those of the last hop: after a redirect, the last request; after a retry, the attempt that produced the response or the final error. A phase the request never went through, or never finished (a refused connection has no connectMs), is undefined. They are measured only while someone subscribes to an UltraTon channel or the call asked for them, and hold numbers and one boolean, nothing else.
On the response, per call. The events are global to the process, so with several calls in flight a subscriber cannot tell which result a measurement belongs to. timings: true (on the client or on one request) puts the same object on the response itself, so each call reads its own. It costs a few microseconds per call, which is why it is off by default; the event of that call carries the very same object.
const client = new UltraTonClient({ timings: true });
const [a, b] = await Promise.all([client.get(urlA), client.get(urlB)]);
console.log(a.timings?.firstByteMs, b.timings?.firstByteMs); // each one its own
``` The events never carry headers, bodies, `auth`, URL credentials, the query string, `;params` (session ids), `error.cause` or resolved IP addresses, because tracing tools ship them off the host.
> **Warning:** **Path segments are published as they are.** If an API puts a secret in the path (a bot token like `/bot<TOKEN>/…`, a webhook URL, a reset token), redact it in your subscriber before it reaches your tracing backend, as the recipe below does.
A call that fails before it starts publishes nothing: options that throw a `TypeError`, a URL that does not parse, or a closed client. A retry event is published once a retry is decided, after your `onRetry` ran; if `onRetry` throws, there is no retry and no retry event, just the error.
An OpenTelemetry span per request, with the `@opentelemetry/api` you already use:
```ts
import diagnosticsChannel from 'node:diagnostics_channel';
import { trace, SpanKind, SpanStatusCode, type Span } from '@opentelemetry/api';
import type { UltraTonDiagnosticsRequest, UltraTonRequestStartMessage, UltraTonRequestResponseMessage, UltraTonRequestErrorMessage } from 'ultraton';
const tracer = trace.getTracer('ultraton');
const spans = new WeakMap<UltraTonDiagnosticsRequest, Span>();
// Your own rule for secrets in paths, for example: /bot123:ABC/sendMessage → /bot{token}/sendMessage
const redact = (path: string) => path.replace(/^\/bot[^/]+/, '/bot{token}');
diagnosticsChannel.subscribe('ultraton:request:start', (message) => {
const { request } = message as UltraTonRequestStartMessage;
const url = new URL(request.origin);
spans.set(request, tracer.startSpan(request.method, {
kind: SpanKind.CLIENT,
attributes: {
'http.request.method': request.method,
'url.full': request.origin + redact(request.path),
'server.address': url.hostname,
'server.port': Number(url.port) || (url.protocol === 'https:' ? 443 : 80),
},
}));
});
diagnosticsChannel.subscribe('ultraton:request:response', (message) => {
const { request, statusCode } = message as UltraTonRequestResponseMessage;
spans.get(request)?.setAttribute('http.response.status_code', statusCode).end();
});
diagnosticsChannel.subscribe('ultraton:request:error', (message) => {
const { request, error } = message as UltraTonRequestErrorMessage;
spans.get(request)
?.setAttribute('error.type', error.code ?? error.name)
.setStatus({ code: SpanStatusCode.ERROR, message: error.code ?? error.name })
.end();
});Subscribers run synchronously inside the request. As with any diagnostics_channel subscriber, one that throws raises an uncaught exception (the request itself is unaffected), so keep them simple. Subscribers cannot change the request: to add headers (trace propagation such as traceparent), pass them in headers on the request, or on the client, where a function (headers: () => ({ traceparent: currentTraceparent() })) gives each call its own value. The message types are exported: UltraTonRequestStartMessage, UltraTonRequestRetryMessage, UltraTonRequestResponseMessage, UltraTonRequestErrorMessage, UltraTonDiagnosticsRequest, UltraTonDiagnosticsError and UltraTonTimings.
Proxy
proxy sends every https: request through an HTTP CONNECT or a SOCKS5 proxy, on both engines. The SSRF checks run before the proxy is ever contacted, and the proxy cannot point the connection anywhere else:
const client = new UltraTonClient({
proxy: { url: 'http://egress.corp:3128', auth: process.env.PROXY_AUTH }, // auth is 'user:password'
});- Pinning through the proxy. UltraTon resolves the name and validates every address, then sends
CONNECT <ip>:<port>: the proxy gets an IP, never a name it could resolve to something else. TLS runs to the target through the tunnel, and its certificate is checked against the hostname as on a direct connection. If the proxy cannot reach one address, the next validated one is tried. - Credentials in
authgo on the CONNECT only, asproxy-authorization, and never reach a target. Credentials in the URL are rejected. - An
https:proxy (TLS to the proxy itself) is checked against the system CAs, or againstproxy.ca. Thetls.cayou set for targets is never trusted for the proxy, andproxy.cais never trusted for targets. - Refusals (407, 403, 502, …) reject with
UltraTonNetworkErrorand codeERR_ULTRATON_PROXY. The message carries the status and never the proxy address.retrydoes not retry them; a connection to the proxy that drops (ECONNRESET,ETIMEDOUT) is retried like any other. A proxy that cannot be reached at all fails at once instead of being tried for every address. - Plain
http:targets (loopback only) never use the proxy, which is whyallowPlainHttpcannot be combined withproxy. Proxies are never read fromHTTP_PROXY/HTTPS_PROXY. For internal hosts that must not go through the proxy, use a second client without it.
Proxies that filter by hostname. Squid ACLs or Smokescreen allowlists reject a CONNECT by IP. resolve: 'proxy' sends the name instead:
new UltraTonClient({ proxy: { url: 'http://smokescreen:4750', resolve: 'proxy' } });Warning: With
resolve: 'proxy'the proxy resolves the name, so SSRF protection for hostnames depends on the proxy. UltraTon still rejects reserved IP literals, bad schemes and credentials in URLs, but it cannot pin DNS. Use it only with a proxy that enforces its own egress policy.
SOCKS5. A socks5: URL (default port 1080) tunnels the same way: UltraTon resolves and validates the name and sends the IP as the SOCKS5 address (IPv4 or IPv6), never the name. auth is SOCKS5 username/password (RFC 1929, each part 1 to 255 bytes). socks5h: is the SOCKS5 spelling of resolve: 'proxy': the name is sent and the proxy resolves it, with the same warning as above. proxy.ca does not apply, since SOCKS5 has no TLS of its own; TLS to the target runs through the tunnel as with CONNECT.
new UltraTonClient({ proxy: { url: 'socks5://bastion.corp:1080', auth: 'svc:s3cret' } });A SOCKS5 refusal rejects with ERR_ULTRATON_PROXY and the reason (connection refused, host unreachable, authentication failed, …). These are final: a refused login, no acceptable authentication method, the proxy's ruleset and "command not supported". Any other reply (unreachable, refused, general failure, …) moves on to the next validated address. SOCKS4, BIND and UDP ASSOCIATE are not supported.
Warning: SOCKS5 has no encryption of its own: the username and password in
authcross the network to the proxy in cleartext. The request itself is safe (TLS runs end to end through the tunnel), but only use SOCKS5 credentials over a network you trust, or reach the proxy through an SSH tunnel.
Protect the client you already use
Not ready to switch clients? createSecureAgents() gives you an http.Agent and an https.Agent that apply UltraTon's SSRF policy to any client that takes an agent:
import { createSecureAgents } from 'ultraton';
const agents = createSecureAgents({ permitReservedIps: ['10.20.0.0/16'] }); // same options as the client: permitReservedIps, tls
// axios
const api = axios.create({ httpAgent: agents.http, httpsAgent: agents.https, proxy: false }); // proxy: false, see below
// got
const gotApi = got.extend({ agent: { http: agents.http, https: agents.https } });
// node-fetch
await fetch(url, { agent: (u: URL) => (u.protocol === 'http:' ? agents.http : agents.https) });
// Node's own http.request / https.request
https.get(url, { agent: agents.https }, onResponse);
// when done (or `using agents = createSecureAgents(...)` on Node 24+ / TypeScript 5.2+, and they are destroyed when the block ends)
agents.http.destroy();
agents.https.destroy();Every new socket resolves its host and validates every address, then connects only to those addresses: a DNS rebinding between connections is blocked. Redirects the library follows open their own sockets through the agent, so each hop is checked. IP literals are judged in every spelling, TLS is always verified (a library's rejectUnauthorized: false is ignored), and plain http: only reaches loopback unless you pass allowPlainHttp: true, which lifts the scheme rule but never the address policy. A blocked connection fails with the usual SecureHttpError (ERR_ULTRATON_RESERVED_IP, ERR_ULTRATON_PLAIN_HTTP). axios, got and node-fetch all expose its code as err.code; axios and got also keep the original error as err.cause. A failure to reach an allowed address is Node's own error, and its message may name that address.
Warning: With axios,
proxy: falseis mandatory, andhttpVersion: 2is not allowed. axios readsHTTP_PROXY/HTTPS_PROXYby default. For anhttps:URL it then replaces yourhttpsAgentwith its own tunnelling agent, which sends the proxy the hostname: the secure agents never see the request, and no check runs. For anhttp:URL it sends the proxy an absolute URL through your agent, which the agents refuse withERR_ULTRATON_AGENT_PROXY. Always create the instance withproxy: false, and never passproxyon a single request (it overrides the instance's). axios withhttpVersion: 2opens its own HTTP/2 connection without any agent, so never use it with the secure agents. If you need a proxy or HTTP/2, useUltraTonClient, whoseproxyandhttp2keep the target pinned. got and node-fetch do not read proxy variables; got withhttp2: trueuses the agent, but not when you also passcreateConnectionorh2session. The compat tests in this repository check all three, including this axios behaviour.
The agents protect the connection. The rest stays with the library you plug them into:
| Protection | Secure agents | UltraTonClient |
|---|---|---|
| Reserved IPs bl
