Package Exports
- @toxicoder/nestjs-undici
- @toxicoder/nestjs-undici/dist/index.js
This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (@toxicoder/nestjs-undici) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.
Readme
Undici (NestJS HTTP client wrapper)
A lightweight NestJS library built on top of the undici HTTP client. It provides:
- A DI-friendly UndiciService with typed helpers (get/post/put/patch/delete/request)
- Config via module options or environment variables (through UndiciBaseConfig)
- Automatic JSON serialization for request bodies and JSON/text/ArrayBuffer parsing for responses
- Request/Response interceptors pipeline
- Error handling strategies: throw | pass | intercept
- Connection pooling per origin, optional retry, and TLS configuration
- Graceful shutdown: closes dispatcher and connection pools on module destroy
- When pooling is disabled and no custom dispatcher is provided, per-request Agents are created and automatically closed after the request finishes
Table of contents
- Quick overview
- Installation and compatibility
- Getting started
- Configuration
- Type safety
- API reference
- Testing
- Troubleshooting and best practices
- License
Quick overview
This library wraps undici to offer a clean NestJS integration. You can:
- Configure globally via module options (forRoot/forRootAsync) or rely on env variables via UndiciBaseConfig (default provider).
- Send HTTP requests with helpers and a single request() method for full control.
- Choose between automatic response parsing or streaming large payloads.
- Attach request/response interceptors for cross-cutting concerns (auth, logging, error shaping, etc.).
- Control connection pooling and retries using undici primitives (Pool, Agent, RetryAgent).
Installation and compatibility
- Runtime: Node.js compatible with undici ^7 (see package.json for the exact version used by the repo)
- Framework: NestJS ^11
- Install:
pnpm add @toxicoder/nestjs-undici
(or npm/yarn). The undici runtime is included as a dependency of this package; NestJS is a peer dependency.
Getting started
Registering the module
You can use environment variables out of the box by importing the module directly:
// app.module.ts
import { Module } from '@nestjs/common';
import { UndiciModule } from '@toxicoder/nestjs-undici';
@Module({
imports: [UndiciModule],
})
export class AppModule {}
Or provide options explicitly:
import { Module } from '@nestjs/common';
import { UndiciModule } from '@toxicoder/nestjs-undici';
@Module({
imports: [
UndiciModule.forRoot({
baseURL: 'https://api.example.com/',
timeout: 5000,
parse: true,
rawBody: false,
pool: true, // enable per-origin pooling
retry: { retries: 2 }, // wrap dispatcher with RetryAgent
}),
],
})
export class AppModule {}
Async options are supported as well (factory, class, existing):
UndiciModule.forRootAsync({
global: true,
useFactory: async () => ({
baseURL: process.env.API_BASE_URL,
timeout: 3000,
parse: true,
}),
});
Using the service
Inject UndiciService and call helpers:
import { Injectable } from '@nestjs/common';
import { UndiciService } from '@toxicoder/nestjs-undici';
@Injectable()
export class FooService {
constructor(private readonly http: UndiciService) {}
async getUser(id: string) {
const res = await this.http.get<{ id: string; name: string }>(
`/users/${id}`,
);
if (res.statusCode >= 400) {
// With default error strategy ('throw'), you would not see this branch
}
return res.body; // typed body or null
}
async createUser(dto: { name: string }) {
// JSON is auto-serialized when content-type is JSON or not set
const res = await this.http.post<{ id: string }>(`/users`, dto);
return res.body;
}
}
To stream large payloads set parse to false (globally or per-request) and use the raw readable body:
const res = await this.http.get(`/large-file`, {
/* parse is global; override via service config if needed */
});
// With parse=false at config level → res.body is BodyReadable
// Pipe it to a stream if necessary
Multipart and FormData
- Use only the FormData provided by this library: import it from
@toxicoder/nestjs-undici
. This re-exports undici's FormData implementation and ensures correct behavior with undici. - Do NOT set
Content-Type
(orContent-Length
) headers manually when sending FormData. When you pass undici's FormData as the request body, undici will automatically set the correctmultipart/form-data; boundary=...
header and compute lengths when possible.
Example:
import { Injectable } from '@nestjs/common';
import { FormData, UndiciService } from '@toxicoder/nestjs-undici';
@Injectable()
export class UploadService {
constructor(private readonly http: UndiciService) {}
async uploadAvatar(userId: string, bytes: Buffer) {
const fd = new FormData();
fd.append('userId', userId);
// If Blob is available (Node >= 18), wrap the Buffer and optionally set type
const file = new Blob([bytes], { type: 'image/png' });
fd.append('file', file, 'avatar.png');
// Do not set Content-Type manually — undici handles it for FormData
const res = await this.http.post<{ ok: boolean }>(`/upload`, fd);
return res.body;
}
}
Configuration
Configuration can be provided in three ways:
- Via UndiciModule.forRoot(options)
- Via UndiciModule.forRootAsync(options)
- Via environment variables using the built-in UndiciBaseConfig (default provider when importing UndiciModule without forRoot/Async)
The full set of options is described by the UndiciConfig interface (see Types and interfaces).
Environment variables
If you rely on UndiciBaseConfig, the following env variables are read:
Variable | Type | Default | Description |
---|---|---|---|
UNDICI_BASE_URL | string | none | Base URL used to resolve relative request paths. If not set, relative paths are not allowed. |
UNDICI_TIMEOUT | number (ms) | none | Default request timeout. Can be overridden per-request via timeout . |
UNDICI_RAW_BODY | boolean | false | When true, include rawBody in the response. |
UNDICI_RAW_PARSE | boolean | true | When false, do not consume/parse the response (enables streaming). |
UNDICI_RAW_RETRY | boolean | false | When true, enables wrapping dispatcher with RetryAgent using default options. |
UNDICI_ERROR_STRATEGY | enum ('throw' | 'pass' | 'intercept') | 'throw' unless response interceptors are present (then 'intercept') | Controls error handling (see Error strategies). |
Notes:
- UndiciBaseConfig (extends BaseConfig) maps string envs to typed values using
asString
,asNumber
,asBoolean
,asEnum
. - When an env var is missing, the respective option is undefined and normal defaults apply (e.g.,
parse
defaults to true at runtime unless overridden). - If pooling is enabled without explicit options (either via env or code), the pool uses
{ connections: 10 }
by default. - If retry is enabled without explicit options (either via env or code), it uses
{ throwOnError: false }
by default. SetthrowOnError: true
to force throwing regardless oferrorStrategy
.
Pooling and dispatcher
Dispatcher selection logic inside the service:
- If a custom
dispatcher
is provided in the service config (module options), it is always used and will be wrapped byRetryAgent
whenretry
is enabled. - If a custom
dispatcher
is provided per request (via request options), it is used as-is and is not wrapped. You are responsible for its lifecycle. - If
pool
isfalse
, a newAgent
is created per request (no connection reuse). This is useful when connecting to the same origin with different TLS configs or when avoiding persistent connections. These transient Agents are automatically closed after the request completes. - If
pool
istrue
, aPool
is created and cached per origin. The TLS config used for the first request to an origin is reused for subsequent requests to the same origin. When pooling is enabled but you don't provide explicit pool options, the default is{ connections: 10 }
. - On module destroy, the service-level dispatcher and all created pools are closed. Per-request custom dispatchers are not closed by the service.
Retry
Set retry
to:
true
to enable undici'sRetryAgent
with default retry options- a concrete
UndiciRetryOptions
object to configure retries false
(default) to disable retry wrapping
Defaults and behavior:
- When
retry
is enabled without explicit options, the default is{ throwOnError: false }
. - When
throwOnError
isfalse
, whether an error is thrown or handled depends on yourerrorStrategy
(see below). - When
throwOnError
istrue
, the retry agent will throw on request/connection failure regardless of the configurederrorStrategy
. - Providing a custom dispatcher per request bypasses retry wrapping for that request.
TLS
You can provide TLS options (ca, key, cert). They are applied to the Agent/Pool creation when no custom dispatcher is supplied:
import { readFileSync } from 'node:fs';
UndiciModule.forRoot({
baseURL: 'https://secure.example.com',
tls: {
ca: readFileSync('ca.pem'),
cert: readFileSync('cert.pem'),
key: readFileSync('key.pem'),
},
});
Parsing and streaming
parse: true
(default): consumes and parses the response body according to thecontent-type
header.text/*
→ body and rawBody are stringsapplication/json
and*+json
→ body is JSON (parsed object), rawBody is the original string- Unknown/binary content-types → body and rawBody are
ArrayBuffer
parse: false
: the service returns the undiciBodyReadable
without consumption/parsing (streaming mode). Interceptors receive the raw stream.
You can control parse
globally or per request.
Important: parse=false and body consumption
Undici in Node.js does not aggressively or deterministically GC unused response bodies. If you leave the body unconsumed, you can leak connections, reduce connection reuse, and even stall when running out of connections.
Therefore, when you use parse: false
in this library, you (the caller) are responsible for consuming or canceling the response body yourself — either inside a response interceptor or in your application code.
Do:
const { body, headers } = await undiciService.get(url, { parse: false });
for await (const _ of body as any) {
// Consume the stream to release the connection
}
Do not:
const { headers } = await undiciService.get(url, { parse: false });
// WRONG: body is left unconsumed → can leak connections
If you need only headers, call the dedicated method:
const headers = await undiciService.headers(url);
Error strategies
throw
(default unless response interceptors exist): throwsResponseStatusCodeError
for HTTP status >= 400. The error body is consumed if parsing would not happen later.pass
: returns the response with anerror
property (an instance ofResponseStatusCodeError
);error.body
andbody
are parsed according to the parse rules.intercept
: calls response interceptors with the parsed body and the error; if there are no interceptors, the error is thrown.
You can set the error strategy globally via module config or per-request via errorStrategy
option.
Interceptors
You can register interceptors globally (via module config) and manage them at runtime, or provide per-request interceptors in request options.
- Request interceptors:
UndiciRequestInterceptor
receive a shallow copy of the request config (headers, query, and plain-object body are shallow-cloned per interceptor) allowing safe mutation without leaking changes across interceptors. Non-plain bodies (Buffer, ArrayBuffer, TypedArray, URLSearchParams, FormData, stream-like) are kept by reference. - Response interceptors:
UndiciResponseInterceptor
run in order and can transform the response and/or inspect the error. They receive the parsed body whenparse: true
is enabled, otherwise the raw stream. - You can add and remove interceptors at runtime via the
UndiciService.interceptors
API. - Per-request interceptors replace the global pipeline for that request.
Global (module-level) configuration example:
UndiciModule.forRoot({
baseURL: 'https://api.example.com',
requestInterceptors: [
async (cfg) => ({
...cfg,
headers: { ...(cfg.headers ?? {}), Authorization: `Bearer ${token}` },
}),
],
responseInterceptors: [
async (resp, err) => {
if (err) {
// wrap error details
return { ...resp, body: { error: true, data: resp.body } };
}
return resp;
},
],
});
Per-request override (replaces the global interceptors for this call):
await http.get('/users', {
requestInterceptors: [
async (cfg) => ({
...cfg,
headers: { ...(cfg.headers ?? {}), 'x-req-id': crypto.randomUUID() },
}),
],
responseInterceptors: [
async (resp) => ({ ...resp, headers: { ...resp.headers, 'x-doc': 'ok' } }),
],
errorStrategy: 'intercept',
rawBody: true,
parse: true,
});
Managing interceptors at runtime:
import { UndiciService } from '@toxicoder/nestjs-undici';
@Injectable()
class ApiService {
constructor(private readonly http: UndiciService) {
// Add a request interceptor dynamically
const rq = this.http.interceptors.request.add(async (cfg) => {
return { ...cfg, headers: { ...(cfg.headers ?? {}), 'x-runtime': '1' } };
});
// Add a response interceptor dynamically
const rs = this.http.interceptors.response.add(async (res, err) => {
if (err) {
return res; // inspect error if needed
}
return { ...res, headers: { ...res.headers, 'x-seen': 'true' } };
});
// You can later remove them when no longer needed
this.http.interceptors.request.remove(rq);
this.http.interceptors.response.remove(rs);
}
}
Notes
- The default
errorStrategy
becomesintercept
when response interceptors are present. You can always seterrorStrategy
explicitly per request.
Per-request overrides
Most configuration options can be overridden per request by passing an options
object to the helper methods or to request()
:
- headers, timeout, signal
- dispatcher (used as-is and not auto-wrapped by RetryAgent)
- rawBody (include rawBody in responses)
- parse (enable/disable auto-parsing)
- tls (per-request TLS options used when creating transient Agent/Pool)
- errorStrategy ('throw' | 'pass' | 'intercept')
- requestInterceptors / responseInterceptors (replace the global interceptors for that request)
Note: retry
is configured at the service level. If you supply a custom dispatcher per request, retry wrapping is bypassed.
Type safety
UndiciService supports compile-time type-safety for response bodies with a flexible model using the TypeSafety
enum.
- Default mode: GUARDED
- Import:
import { UndiciService, TypeSafety } from '@toxicoder/nestjs-undici'
Concepts:
- Each request returns
UndiciResponse<TBody, TRaw, TSafety>
where:TBody
— the expected body type after parsing (e.g., a DTO or interface)TRaw
— the raw body type whenrawBody: true
(defaults tostring | Buffer | ArrayBuffer
)TSafety
— affects whether the return type is a union with a potential error
Modes:
- TypeSafety.GUARDED (default)
- Success shape:
{ body: TBody | null; rawBody: TRaw | null }
(rawBody present only whenrawBody: true
) - Error shape: union with
{ error: Error; body: TBody | BodyReadable | null; rawBody: TRaw | BodyReadable | null }
- You must handle the union when your
errorStrategy
is not 'throw'.
- Success shape:
- TypeSafety.UNSAFE
- Return type is forced to the success shape only, i.e., no error union in the type system. This removes compile-time safeguards and can lead to runtime errors if misused (see warnings below).
Using GUARDED (default):
@Injectable()
export class ApiService {
constructor(private readonly http: UndiciService) {}
async getUser(id: string): Promise<{ id: string; name: string } | null> {
const res = await this.http.get<{ id: string; name: string }>(
`/users/${id}`,
);
// res: UndiciResponse<{id: string; name: string}, string|Buffer|ArrayBuffer, TypeSafety.GUARDED>
if ('error' in res) {
// handle error; res.body may be parsed JSON, text, ArrayBuffer or null depending on config
throw res.error;
}
return res.body; // typed as {id: string; name: string} | null
}
}
Specifying the return type for request():
// TRaw defaults to string | Buffer | ArrayBuffer when omitted
const res = await http.request<{ ok: boolean }>({
path: '/ping',
method: 'GET',
});
Opting into UNSAFE at the service type level:
@Injectable()
export class UnsafeApiService {
// Note the generic TypeSafety.UNSAFE on the service type annotation
constructor(private readonly http: UndiciService<TypeSafety.UNSAFE>) {}
async create(dto: any) {
const res = await this.http.post<{ id: string }>(`/items`, dto);
// res.body is typed as { id: string } (no union), which is convenient but potentially unsafe
return res.body;
}
}
Important: When to use TypeSafety.UNSAFE
- Justified ONLY when:
- errorStrategy is 'throw' (errors are thrown, and you never see error unions in the response), OR
- you use response interceptors with errorStrategy 'intercept' and validate both possible error and the success body rigorously inside the interceptors.
Strict warning about UNSAFE with 'pass'
- Using TypeSafety.UNSAFE together with
errorStrategy = 'pass'
is extremely dangerous: the function will appear to return a successful shape at compile time while, at runtime, the response may actually contain an error. This very likely leads to runtime crashes or invalid assumptions in your code. Avoid this combination.
Cheat sheet examples:
// Default GUARDED mode
const a = await http.get<{ ok: true }>(`/ok`);
// a is union (success | error) unless errorStrategy='throw'
// Explicit UNSAFE service annotation
const httpUnsafe: UndiciService<TypeSafety.UNSAFE> = http;
const b = await httpUnsafe.get<{ ok: true }>(`/ok`);
// b is success-only in types, i.e. { ok: true }
API reference
UndiciModule
UndiciModule
(default provider usesUndiciBaseConfig
):forRoot(options: UndiciConfig): DynamicModule
forRootAsync(options: UndiciAsyncOptions): DynamicModule
UndiciService
Properties
interceptors: { request: RequestInterceptors; response: ResponseInterceptors }
— allows runtime management of interceptors (add
/remove
).
Methods
get<TBody, TRaw = string | Buffer | ArrayBuffer>(url, options?)
post<TBody, TRaw = string | Buffer | ArrayBuffer>(url, body, options?)
put<TBody, TRaw = string | Buffer | ArrayBuffer>(url, body, options?)
patch<TBody, TRaw = string | Buffer | ArrayBuffer>(url, body, options?)
delete<TBody, TRaw = string | Buffer | ArrayBuffer>(url, options?)
headers(input: RequestInfo, options?: Omit<RequestInit, 'method'>): Promise<Headers>
— performs a HEAD request using undici.fetch and returns response headers. Use this when you need only headers without consuming the body.request<TBody, TRaw>(options: UndiciRequestOptions)
— low-level method used by helpers. Providepath
(absolute URL or relative whenbaseURL
is set) and any per-request overrides:headers
,timeout
,signal
,dispatcher
,rawBody
,parse
,tls
,errorStrategy
,requestInterceptors
,responseInterceptors
.
Behavioral notes:
- If
headers['content-type']
is JSON or missing and body is a plain object, the body is auto-serialized to JSON and the header is set toapplication/json
. - If body is a plain object and content-type is non-JSON (e.g., text/plain), an error is thrown to prevent accidental serialization.
- Relative
url
requiresbaseURL
to be set; absolute URLs are used as-is.
Types and interfaces
UndiciConfig
: configuration (baseURL, timeout, interceptors, dispatcher, rawBody, parse, tls, pool, retry, errorStrategy)UndiciRequestOptions
: per-request options (path, headers, body, timeout, signal, dispatcher, rawBody, parse, tls, errorStrategy, request/response interceptors). Note:retry
is configured at service level; providing a per-request dispatcher bypasses retry wrapping.UndiciRequestConfig
: internal request configuration (url, headers, body, timeout, tls, signal, and the same override options)UndiciResponse<TBody, TRaw>
: response union with typedbody
and optionalrawBody
anderror
UndiciRequestInterceptor
,UndiciResponseInterceptor
RequestInterceptors
,ResponseInterceptors
,Interceptors
(runtime classes to manage and apply interceptors)UndiciTlsOptions
,UndiciRetryOptions
UndiciAsyncOptions
andUndiciConfigFactory
Testing
What is covered by unit tests
Unit tests (see tests/*.spec.ts) cover:
- JSON body serialization and header handling
- URL building with absolute/relative paths and error without baseURL
- Parsing matrix: empty body, text, application/json, *+json, application/octet-stream, and 204/205 statuses
- Malformed JSON behavior (throws and logs debug)
- Error strategies: throw, pass (with/without rawBody), intercept (with/without interceptors)
- Body consumption semantics: body is consumed in all cases except
parse: false
, including error cases UndiciService.headers()
HEAD helper- Request interceptors cloning and modification
- Case-insensitive header lookup
- isPlainObject exclusions (Buffer, ArrayBuffer views, URLSearchParams, streams, FormData)
- AbortSignal creation (timeout only, user only, both, none)
- Dispatcher selection: Agent per request when pooling disabled; per-origin Pool caching; custom dispatcher wrapped by RetryAgent; convenience HTTP methods set method
- No double-wrapping when the dispatcher is already a RetryAgent
How to run tests
From the repository root:
- Run unit tests:
- Using pnpm:
pnpm exec jest -c jest.config.ts
- Using npx:
npx jest -c jest.config.ts
- Using pnpm:
Jest config: jest.config.ts
(rootDir='./', testMatch='
Troubleshooting and best practices
- Relative path error: Provide
baseURL
or use absolute URLs. - Multipart uploads: Use the FormData exported by this library (
import { FormData } from '@toxicoder/nestjs-undici'
). Do not setContent-Type
orContent-Length
manually; undici sets the appropriatemultipart/form-data
boundary and lengths automatically when FormData is used. - Large responses: Set
parse=false
to stream the response instead of buffering it into memory. - Content-type and body: Avoid passing plain objects with non-JSON content-type; either set JSON content-type or convert to string/Buffer.
- Pooling and TLS: Remember that the first Pool created per origin determines TLS
connect
options used for subsequent requests to that origin. - Error strategy
intercept
: Ensure at least one response interceptor is configured; otherwise the error will be thrown. - Retries: When enabling
retry
, configure it appropriately to avoid retry storms. - Shutdown: If you create long-lived pools/dispatchers, ensure
onModuleDestroy
is called (Nest will do this on application shutdown) to close connections.
License
This project is licensed under the ISC License (see package.json).