npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

Readme

@uzum-tech/node-http-client

HTTP-клиент для Node.js без привязки к фреймворку. Пул соединений undici, middleware, подключаемая авторизация, сериализация тела по content-type и типизированные ошибки — в одном инстансе.


Содержание


Зачем

В каждом сервисе повторялся один и тот же код: собрать 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]=b

form-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 — соберите свой клиент из тех же деталей.