@smounters/kit
v2.22.1
Published
Batteries for Imperium services: structured logging, Redis module with a distributed lock, SSRF-safe fetch, decimal money, request context, env schemas
Downloads
4,503
Maintainers
Readme
@smounters/kit
Батарейки к @smounters/core: структурный журнал,
Redis с распределённым локом, защита от SSRF, деньги на decimal, сквозной id запроса, схемы для env.
npm i @smounters/kit @smounters/coreКаждая часть — отдельный подпуть, а тяжёлые зависимости объявлены необязательными peer-зависимостями:
ставится только то, чем пользуетесь. Взяли kit/money — ioredis и fastify не нужны.
| подпуть | что внутри | нужны |
|---|---|---|
| kit/log | транспорты для configureLogger() + автономный логгер: одна строка JSON вне local, цветная строка локально | @smounters/core |
| kit/redis | RedisService: подключение, pub/sub, кеш «чтение сквозь», распределённый лок | @smounters/core, ioredis |
| kit/http | реальный IP клиента, сквозной id запроса, точные байты тела, лимит частоты | fastify |
| kit/net | isPrivateIp, assertPublicUrl, safeFetch | — |
| kit/config | zod-препроцессоры для разбора env | zod |
| kit/money | decimal-арифметика, масштабы, округление, проверка сбалансированности проводки | — |
| kit/rpc | ProtoValidateInterceptor — правила buf.validate из контракта, enforced транспортом | @smounters/core, @connectrpc/connect, @bufbuild/* |
| kit/util | ulid, redactSecrets | — |
Что здесь НЕ лежит и почему
Механизм — здесь, политика — в приложении. Это правило видно в подписях: лимитер частоты не знает
ваших адресов, он получает bucketFor(req); RedisService не знает, откуда взялась строка подключения,
она приходит значением-провайдером; журнал не знает имени сервиса, оно передаётся.
Поэтому сюда сознательно не попали: словарь ваших HTTP-заголовков (это словарь продукта, не инфраструктура), план счетов и любые доменные ключи Redis, матрицы прав.
Журнал
import { createTransports, createLogger } from "@smounters/kit/log";
app.configureLogger({ transports: createTransports({ service: "api" }) });
const logger = createLogger({ service: "api" }); // для кода вне DI: старт процесса, скриптыВне local каждая запись — один объект JSON в строку, поэтому сборщик логов достаёт поля без разбора
регулярками. Структурный первый аргумент раскладывается в поля верхнего уровня:
this.logger.info({ type: "payment", event: "credited", amount, currency });Если в приложении зарегистрирован requestContextHook, к КАЖДОЙ строке сам подмешивается reqId —
вызывающий код о нём не знает.
Redis
Пакет не решает, откуда берётся конфигурация: подключение приходит значением-провайдером.
import { REDIS_CONNECTION, RedisService } from "@smounters/kit/redis";
@Module({
providers: [{ provide: REDIS_CONNECTION, useValue: { url: appConfig.REDIS_URL } }, RedisService],
exports: [RedisService],
global: true,
})
export class RedisModule {}withLock(key, ttlSec, fn) — для периодических задач при нескольких репликах: работа делается один
раз на кластер, а не по разу на процесс. Освобождение — сравнение-и-удаление по случайному токену,
поэтому лок, истёкший на середине и перехваченный другим процессом, не будет снят предыдущим владельцем.
ttlSec обязан превышать худшее время работы fn.
Сквозной id запроса
import { clientIpNormalizeHook, requestContextHook } from "@smounters/kit/http";
app.addHook("onRequest", clientIpNormalizeHook()); // ПЕРВЫМ
app.addHook("onRequest", requestContextHook({ headerName: "X-Request-Id" }));Порядок обязателен: нормализация IP должна быть первой (на req.ip опираются журнал и лимитер), а
контекст — до всего остального, потому что он продолжает цепочку внутри AsyncLocalStorage и его видят
только зарегистрированные ПОЗЖЕ хуки.
Зачем вообще: связать «пришёл вебхук → поставлена задача → воркер сделал внешний вызов» иначе нечем, кроме метки времени, — процессы разные, а логи в одном потоке. Протаскивать id параметром через все слои значило бы править сигнатуру каждой функции ради поля, которое нужно только журналу.
Деньги
Ни одна функция не принимает и не возвращает JS-число: double не представляет 0.1 точно, а книга,
которая не может представить свои же суммы, перестаёт сходиться в первый же день. Суммы ходят
десятичными строками.
import { isBalanced, quantizeToDecimals, ROUND_UP, toMoneyAmount } from "@smounters/kit/money";
toMoneyAmount("10.00001"); // null — точнее, чем хранит колонка: отклонить, а не округлить
isBalanced(lines); // ≥2 строки, без нулевых, сумма знаковых РОВНО 0
quantizeToDecimals(due, 6, ROUND_UP); // сумма, достижимая у токена с 6 знакамиquantizeToDecimals нужен, когда сумма должна быть достижима на другой стороне: токен с шестью
знаками не переведёт значение с восемью, и «ожидаемая» сумма, скруглённая до масштаба книги, окажется
неоплатной точно — а строгое сравнение назовёт это недоплатой, которой плательщик не мог избежать.
Защита от SSRF
import { safeFetch, SsrfBlockedError } from "@smounters/kit/net";assertPublicUrl в одиночку проверяет только ПЕРВЫЙ адрес, а fetch по умолчанию идёт по
перенаправлениям — публичный хост мог ответить 302 на приватный адрес, и проверка обходилась.
safeFetch идёт по перенаправлениям вручную и проверяет каждый шаг. SsrfBlockedError отделён от
сетевых ошибок намеренно: это ПОСТОЯННАЯ ошибка настройки, её нельзя перевыкладывать в очередь на
повтор.
Известный предел: гонку DNS-rebinding между проверкой и подключением так не закрыть — для этого нужна фиксация адреса на момент соединения. Отсекаются реальные случаи: ошибка настройки и попытка достать адрес метаданных облака.
