@uzum-tech/node-http-client
v1.1.0
Published
Framework-agnostic HTTP client for Node: undici pools, middleware, pluggable auth, content-type handlers and typed errors
Maintainers
Readme
@uzum-tech/node-http-client
HTTP-клиент для Node.js без привязки к фреймворку. Пул соединений
undici, middleware, подключаемая авторизация, сериализация тела по content-type и типизированные ошибки — в одном инстансе.
Содержание
- Зачем
- Установка
- Быстрый старт
- Конфигурация клиента
- API инстанса
- Middleware
- Авторизация
- Тело запроса и content-type
- Ответы
- Ошибки
- TLS и mTLS
- Пул соединений
- Что экспортирует пакет
- Рецепты
- FAQ
Зачем
В каждом сервисе повторялся один и тот же код: собрать URL с query,
проставить заголовки, сериализовать тело под нужный content-type,
подмешать авторизацию, разобрать ответ и превратить не-2xx в понятную
ошибку. Пакет собирает это в один объект поверх undici.
Что внутри:
- пул соединений — по одному
undici.Poolна хост, переиспользуется между запросами; - авторизация — подключаемые стратегии: заголовок, query, тело, Basic, Bearer, Keycloak;
- middleware — последовательное изменение пути, метода, тела и конфигурации перед отправкой запроса;
- content-type — JSON,
x-www-form-urlencodedиmultipart/form-dataс файлами из коробки, свои типы регистрируются; - ответы —
json,textилиbufferпо выбору вызывающего; - ошибки — один класс
HttpClientErrorс кодом, статусом и телом ответа; - TLS — свой сертификат, ключ и CA, в том числе mTLS.
Пакет не зависит ни от NestJS, ни от Fastify, ни от Express: это обычный класс, который можно создать где угодно.
Установка
pnpm add @uzum-tech/node-http-clientТребуется Node.js >= 20.18.1.
Пакет собран в двух форматах, поэтому работает и в CommonJS-проектах
(NestJS с "module": "commonjs"), и в ESM:
// ESM
import { HttpClient, HttpClientTypes } from '@uzum-tech/node-http-client';
// CommonJS
const { HttpClient } = require('@uzum-tech/node-http-client');Клиент доступен и как дефолтный экспорт:
import HttpClient from '@uzum-tech/node-http-client';Быстрый старт
import { HttpClient } from '@uzum-tech/node-http-client';
export const billingClient = new HttpClient({
baseUrl: process.env.API_BILLING_URL!,
timeout: 10_000,
headers: {
'x-service': 'my-service'
}
});Дальше — обычные HTTP-методы, типизированные ответом:
interface Invoice {
id: number
amount: number
}
// GET /invoices/42
const invoice = await billingClient.get<Invoice>('/invoices/42');
// POST /invoices { amount: 1000 }
const created = await billingClient.post<Invoice>('/invoices', {
amount: 1000
});Инстанс создаётся один раз на интеграцию и переиспользуется — именно он хранит пул соединений.
Конфигурация клиента
Конструктор принимает HttpClientTypes.ClientOptions:
| Поле | Тип | Обязательное | По умолчанию | Описание |
|---|---|---|---|---|
| baseUrl | string | да | — | Базовый URL. К нему приклеивается path запроса |
| timeout | number | нет | 25000 | Таймаут заголовков и тела ответа в мс |
| maxParallelRequests | number | нет | 100 | Размер пула соединений на хост |
| headers | HttpClientTypes.Headers | нет | {} | Заголовки по умолчанию для всех запросов |
| protocol | HttpClientTypes.Protocol | нет | из baseUrl | Протокол пула: HTTP или HTTPS |
| tls | HttpClientTypes.TlsOptions | нет | — | Сертификаты для HTTPS, см. TLS и mTLS |
| auth | HttpClientTypes.AuthStrategy | нет | — | Стратегия авторизации, см. Авторизация |
| middlewares | HttpClientTypes.Middleware[] | нет | [] | Middleware, выполняемые последовательно перед каждым запросом, см. Middleware |
Протокол определяется по baseUrl: адрес на https:// даёт
Protocol.HTTPS, всё остальное — Protocol.HTTP. Явно передавать
protocol нужно только в редком случае, когда пул должен ходить не по
тому протоколу, что записан в baseUrl.
API инстанса
HTTP-методы
get<T, U = {}>(path, config?): Promise<T>
post<T, U = {}>(path, data?, config?): Promise<T>
put<T, U = {}>(path, data?, config?): Promise<T>
patch<T, U = {}>(path, data?, config?): Promise<T>
delete<T, U = {}>(path, config?): Promise<T>T— тип разобранного ответа, его возвращает метод;U— тип объекта query-параметров (config.params);path— путь относительноbaseUrl(ведущий/необязателен) либо абсолютный URL: адрес наhttp://илиhttps://уходит как есть, мимоbaseUrl.
const list = await client.get<Invoice[], InvoiceFilter>('/invoices', {
params: {
page: 1,
status: 'PAID'
}
});null и undefined в params в URL не попадают, остальные значения
приводятся к строке.
Конфиг запроса
Последний аргумент любого метода — HttpClientTypes.RequestConfig<U>:
| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
| params | U | — | Query-параметры |
| headers | HttpClientTypes.Headers | — | Заголовки поверх дефолтных |
| timeout | number | таймаут клиента | Таймаут конкретного запроса, мс |
| contentType | HttpClientTypes.ContentType | JSON | Как сериализовать тело |
| responseType | 'json' \| 'text' \| 'buffer' | 'json' | Как разобрать ответ |
Управление клиентом
| Метод | Что делает |
|---|---|
| setBaseUrl(baseUrl) | Меняет базовый URL |
| getBaseUrl() | Текущий базовый URL |
| setDefaultHeaders(headers) | Заменяет заголовки по умолчанию целиком |
| addDefaultHeader(name, value) | Добавляет один заголовок по умолчанию |
| setAuth(auth?) | Меняет стратегию авторизации, undefined — снимает её |
| setMiddlewares(middlewares) | Заменяет список middleware целиком |
| setProtocol(protocol) | Меняет протокол пула |
| setTlsOptions(options) | Меняет TLS-настройки и пересоздаёт пулы |
| close() | Закрывает все соединения инстанса |
close() стоит вызывать при graceful shutdown приложения и в afterAll
тестов, иначе открытые сокеты не дадут процессу завершиться.
Middleware
Middleware выполняются по порядку перед отправкой запроса. Они могут синхронно или асинхронно менять путь, метод, тело и конфиг запроса.
export default class AuthMiddleware
implements HttpClientTypes.Middleware {
async handle(
options: HttpClientTypes.RequestOptions<unknown>
) {
options.config.headers = {
...options.config.headers,
authorization: `Bearer ${await getAccessToken()}`
};
}
}
const authMiddleware = new AuthMiddleware();
const client = new HttpClient({
baseUrl: process.env.API_URL!,
middlewares: [ authMiddleware ]
});Каждый middleware видит изменения предыдущего. Конфиг запроса
копируется, а тело сохраняет исходный тип и ссылку, чтобы не ломать
Buffer, FormData и потоки. Для преобразования тела присвойте новое
значение options.data.
Цепочку можно заменить у готового клиента:
client.setMiddlewares([ authMiddleware ]);Авторизация
Авторизация — это объект-стратегия, который клиент спрашивает перед каждым запросом. У стратегии три точки входа, и каждая может ничего не возвращать:
interface AuthStrategy {
getAuthHeaders(): Promise<Headers> | void
getAuthUrlQuery(): Promise<QueryParams> | void
getAuthBody(): Promise<UnknownRow> | void
}Стратегия передаётся в конструктор — дальше о ней можно забыть:
import {
BearerAuthStrategy, HttpClient
} from '@uzum-tech/node-http-client';
const client = new HttpClient({
baseUrl: process.env.API_URL!,
auth: new BearerAuthStrategy(() => tokenProvider.getAccessToken())
});Стратегии из коробки
| Стратегия | Аргумент конструктора | Что делает |
|---|---|---|
| BearerAuthStrategy | string или () => Promise<string> \| string | Authorization: Bearer <token>, функция вызывается на каждый запрос |
| BasicAuthStrategy | username, password | Authorization: Basic <base64> |
| HeaderAuthStrategy | Record<string, string> или функция | Произвольные заголовки, например x-api-key |
| UrlQueryAuthStrategy | HttpClientTypes.QueryParams | Дописывает параметры в query каждого запроса |
| BodyAuthStrategy | HttpClientTypes.UnknownRow | Подмешивает поля в тело запроса |
| KeycloakAuthStrategy | объект с getAuthHeader() | Готовый заголовок из Keycloak |
BodyAuthStrategy сливает свои поля с JSON-телом запроса; если тела нет
вовсе, оно будет собрано из одних учётных данных. Для остальных
content-type тело запроса остаётся нетронутым.
Связка с node-keycloak
KeycloakAuthStrategy не тянет за собой зависимость: ей нужен любой
объект с методом getAuthHeader(). Инстанс
@uzum-tech/node-keycloak
подходит как есть — со всем кешем и авторефрешем токена внутри:
import NodeKeycloak from '@uzum-tech/node-keycloak';
import {
HttpClient, KeycloakAuthStrategy
} from '@uzum-tech/node-http-client';
const keycloak = new NodeKeycloak({
auth: {
url: process.env.API_ESB_KEYCLOAK_URL!,
username: process.env.API_ESB_AUTH_LOGIN!,
password: process.env.API_ESB_AUTH_PASSWORD!,
clientId: process.env.API_ESB_AUTH_CLIENT!,
clientSecret: process.env.API_ESB_AUTH_SECRET!
}
});
export const esbClient = new HttpClient({
baseUrl: process.env.API_ESB_AUTH_URL!,
auth: new KeycloakAuthStrategy(keycloak)
});Своя стратегия
Наследуйтесь от BaseAuthStrategy и переопределите только нужный метод —
остальные по умолчанию ничего не делают:
import {
BaseAuthStrategy, HttpClientTypes
} from '@uzum-tech/node-http-client';
class SignatureAuthStrategy extends BaseAuthStrategy {
constructor(private secret: string) {
super();
}
async getAuthHeaders(): Promise<HttpClientTypes.Headers> {
return {
'x-signature': sign(this.secret)
};
}
}Тело запроса и content-type
Тело сериализует обработчик, выбранный по config.contentType. Он же
проставляет нужный заголовок.
JSON
Режим по умолчанию: тело уходит через JSON.stringify, заголовок
content-type: application/json ставится автоматически.
await client.post('/invoices', {
amount: 1000
});form-urlencoded
Вложенные объекты и массивы разворачиваются в скобочную нотацию,
null и undefined выбрасываются:
await client.post('/login', {
user: {
name: 'john'
},
tags: [ 'a', 'b' ]
}, {
contentType: HttpClientTypes.ContentType.FORM_URLENCODED
});
// user[name]=john&tags[0]=a&tags[1]=bform-data и файлы
Файл — это объект { buffer, name? }; всё остальное приводится к строке.
Заголовок content-type обработчик снимает намеренно: undici
проставит его сам вместе с boundary.
await client.post('/mass-mailing/upload', {
title: 'Рассылка',
file: {
buffer: fileBuffer,
name: 'clients.xlsx'
}
}, {
contentType: HttpClientTypes.ContentType.FORM_DATA,
responseType: 'text'
});Массив файлов уходит полями file[0], file[1] и так далее.
Свой обработчик
Реестр обработчиков — синглтон на процесс. Зарегистрируйте свой один раз при старте приложения, и он станет доступен всем клиентам:
import {
BaseContentTypeHandler,
ContentTypeRegistry,
HttpClientTypes
} from '@uzum-tech/node-http-client';
class XmlHandler extends BaseContentTypeHandler {
contentType = HttpClientTypes.ContentType.HTML;
prepareBody(data: unknown): string {
return toXml(data);
}
prepareHeaders(
headers: HttpClientTypes.Headers
): HttpClientTypes.Headers {
return {
...headers,
[HttpClientTypes.Header.CONTENT_TYPE]: 'application/xml'
};
}
}
ContentTypeRegistry.getInstance().registerHandler(new XmlHandler());Запрос с content-type, для которого обработчика нет, упадёт с кодом
CONTENT_TYPE_UNSUPPORTED ещё до похода в сеть.
Ответы
config.responseType решает, что вернёт метод:
| Значение | Что вернётся |
|---|---|
| 'json' (по умолчанию) | Разобранный JSON, приведённый к T |
| 'text' | Строка как есть |
| 'buffer' | Buffer — для файлов и бинарных ответов |
Пустое тело (204 No Content и такие же пустые 200) в режиме json
превращается в null, а не в ошибку парсинга.
Ошибки
Все ошибки пакета — экземпляры HttpClientError с полем code:
import {
ErrorTypes, HttpClientError
} from '@uzum-tech/node-http-client';
try {
await client.get('/invoices/42');
} catch (error) {
if (HttpClientError.is(error, ErrorTypes.Code.REQUEST_FAILED)) {
error.statusCode; // 404
error.response; // { message: 'Invoice not found' }
error.headers; // заголовки ответа
}
}| Код | Когда возникает |
|---|---|
| REQUEST_FAILED | Ответ пришёл, но статус вне диапазона 2xx |
| TIMEOUT | Сервер не ответил за отведённое время |
| NETWORK_ERROR | Соединение не установлено или оборвалось |
| RESPONSE_PARSE_FAILED | Ответ в режиме json не разбирается как JSON |
| CONTENT_TYPE_UNSUPPORTED | Для запрошенного content-type нет обработчика |
Поля ошибки:
| Поле | Тип | Описание |
|---|---|---|
| code | ErrorTypes.Code | Код из таблицы выше |
| statusCode | number | Статус ответа, 0 если ответа не было |
| response | unknown | Тело ответа; для REQUEST_FAILED — разобранный JSON, а если он не разбирается, текст как есть |
| headers | HttpClientTypes.Headers | Заголовки ответа |
| cause | unknown | Исходная ошибка транспорта |
HttpClientError.is(error) без второго аргумента проверяет только тип.
Оба варианта сужают тип для TypeScript, так что после проверки
error.statusCode доступен без приведения. toJSON() отдаёт
{ code, statusCode, response, headers } — удобно логировать.
TLS и mTLS
TLS-опции применяются к пулам с https://:
const client = new HttpClient({
baseUrl: 'https://esb.example.com',
tls: {
cert: readFileSync('./certs/client.crt'),
key: readFileSync('./certs/client.key'),
ca: readFileSync('./certs/ca.crt'),
passphrase: process.env.CERT_PASSPHRASE,
rejectUnauthorized: true
}
});Сертификаты можно заменить на лету — setTlsOptions() закроет текущие
пулы, и следующий запрос поднимет соединения с новыми настройками.
Пул соединений
На каждый хост клиент держит один undici.Pool на
maxParallelRequests соединений. Ключ пула — протокол и хост, поэтому
запросы к разным путям одного сервиса переиспользуют соединения, а
абсолютный URL на другой хост заведёт отдельный пул внутри того же
инстанса.
Отсюда два практических следствия:
- инстанс на интеграцию, а не на запрос — новый клиент на каждый запрос означает новый пул и новый TCP-хендшейк;
close()при остановке — пулы держат сокеты открытыми.
Что экспортирует пакет
| Экспорт | Зачем нужен |
|---|---|
| HttpClient | Сам клиент, он же дефолтный экспорт |
| HttpClientTypes | Неймспейс типов: опции, конфиг запроса, Method, Protocol, ContentType, Header |
| ErrorTypes | Неймспейс типов ошибок: Code, Options, Data |
| HttpClientError | Класс ошибки с is() и toJSON() |
| BaseAuthStrategy | База для своей стратегии авторизации |
| BasicAuthStrategy, BearerAuthStrategy, HeaderAuthStrategy, UrlQueryAuthStrategy, BodyAuthStrategy, KeycloakAuthStrategy | Готовые стратегии |
| ContentTypeRegistry | Реестр обработчиков тела |
| BaseContentTypeHandler | База для своего обработчика |
| JsonHandler, FormDataHandler, FormUrlHandler | Обработчики по умолчанию |
| HttpClientUrlBuilder, HttpClientHeaderBuilder, HttpClientBodyBuilder | Сборка URL, заголовков и тела по отдельности |
| HttpClientResponseHandler | Разбор ответа undici в json/text/buffer |
| HttpClientPoolManager | Пулы соединений, если нужен свой транспорт |
| httpClientConstants | Значения по умолчанию: timeoutInMs, maxParallelRequests, pipelining, contentType, responseType |
Слои можно использовать по отдельности: если фасад чем-то не подходит, соберите свой клиент из тех же деталей.
Рецепты
Базовый клиент и доменные методы
Клиент — один на интеграцию, поверх него тонкие классы с методами:
// clients/esb-base.client.ts
export const esbBaseClient = new HttpClient({
baseUrl: process.env.API_ESB_AUTH_URL!,
auth: new KeycloakAuthStrategy(keycloak)
});
// clients/notifications.client.ts
export default class NotificationsClient {
static async sendTemplate(payload: SendTemplatePayload): Promise<null> {
return esbBaseClient.post('/notification/api/sms/send-async/template', {
...payload,
priority: Priority.HIGH
});
}
}NestJS: провайдер
import { Global, Module } from '@nestjs/common';
import { HttpClient } from '@uzum-tech/node-http-client';
export const BILLING_CLIENT = Symbol('BILLING_CLIENT');
@Global()
@Module({
providers: [
{
provide: BILLING_CLIENT,
useFactory: () => new HttpClient({
baseUrl: process.env.API_BILLING_URL!,
timeout: 10_000
})
}
],
exports: [BILLING_CLIENT]
})
export default class BillingModule {}Прокидывание сквозных заголовков
const response = await client.get<Invoice>('/invoices/42', {
headers: {
'x-request-id': request.id,
'accept-language': request.headers['accept-language']
}
});Скачивание файла
const file = await client.get<Buffer>('/reports/42/export', {
responseType: 'buffer'
});Долгий запрос с отдельным таймаутом
const report = await client.post<Report>('/reports/build', payload, {
timeout: 120_000
});Graceful shutdown
process.on('SIGTERM', async () => {
await Promise.all([ esbClient.close(), billingClient.close() ]);
await app.close();
});FAQ
Инстанс создавать один на интеграцию или на запрос? Один на интеграцию. Внутри инстанса живут пулы соединений — новый клиент на каждый запрос сводит их пользу к нулю.
Как сходить на другой хост тем же клиентом?
Передайте абсолютный URL первым аргументом — baseUrl будет проигнорирован,
а для нового хоста поднимется отдельный пул.
Почему ответ 204 не падает?
Пустое тело в режиме json отдаётся как null. Если ждёте пустой ответ,
объявляйте тип как null.
Как получить тело ошибки от сервиса?
Оно в error.response у HttpClientError с кодом REQUEST_FAILED.
Если сервис вернул не JSON, там будет текст ответа.
Почему form-data уходит без content-type?
Заголовок ставит undici вместе с boundary — руками его проставлять не
нужно и нельзя.
Как отправить XML или свой формат?
Зарегистрируйте обработчик в ContentTypeRegistry — см.
Свой обработчик.
Процесс не завершается после тестов
Не закрыты соединения. Вызовите await client.close().
Нужен свой транспорт или ретраи
Из корня пакета экспортируются HttpClientUrlBuilder,
HttpClientHeaderBuilder, HttpClientBodyBuilder,
HttpClientResponseHandler и HttpClientPoolManager — соберите свой
клиент из тех же деталей.
