Перейти к содержимому

Ленивое тело запроса и лимит его размера

Рецепт о том, когда сервер @aom-js/api на самом деле читает тело запроса и как ограничить его размер — глобально и на отдельном маршруте.

Справочник по всем сигнатурам и internals — @aom-js/api §«Ленивое тело запроса и лимит размера» и @aom-js/router §«Ленивые значения контекста…». Здесь — только то, что нужно, чтобы написать прикладной код и не наступить на грабли.


Тело запроса читается не при приходе запроса, а при первом обращении к @Body в цепочке маршрута. Сервер кладёт в контекст не готовое значение, а lazy(() => parseJsonBody(req, { limit })); разворачивает обёртку RxContext.value("body") не раньше, чем до неё дойдёт какой-то @Body.

Отсюда три следствия:

  1. Маршруту, которому тело не нужно, поток не трогается вовсе. Ни на байт.
  2. Отказ в доступе выше по цепочке срабатывает, не дожидаясь тела — даже если клиент заявил Content-Length, но ничего не прислал.
  3. Значение вычисляется максимум один раз за запрос. Два @Body() на одном эндпоинте, @Body() в middleware и в самом эндпоинте — все получают один и тот же промис, а не второй проход по потоку. Отказ (400 на битом JSON, 413 на превышении лимита) тоже запоминается и отдаётся повторно, фабрика не перезапускается.
@Controller()
class Guard {
@Middleware()
static async check(@Body() body: unknown) {
/* один и тот же body, что и в handler ниже */
}
}
@Controller()
@Before(Guard.check)
class App {
@Get("/body")
static async handler(@Body() first: unknown, @Body() second: unknown) {
// first === second, фабрика разбора вызвана один раз на весь запрос
}
}

Поведение по умолчанию не требует никакой настройки.

@Controller()
class Uploads {
@Post("/echo")
static async echo(@Body() body: any) {
return new DataResponse("echo", body);
}
}

Лимит по умолчанию — DEFAULT_BODY_LIMIT = 1 МиБ. Если заявленный Content-Length уже больше лимита, клиент получает 413 до того, как сервер тронул поток; если превышение вскрывается по ходу чтения — читайте ниже про Connection: close. Тело клиента:

{ "message": "AOM_API:ERROR_PAYLOAD_TOO_LARGE", "status": 413 }

Рецепт 2: глобальный лимит через настройку сервера

Заголовок раздела «Рецепт 2: глобальный лимит через настройку сервера»

AomServerSettings.bodyLimit — общий лимит для всех маршрутов, если конкретный маршрут не переопределил его через @BodyOptions.

const settings: AomServerSettings = {
port: 3000,
url: "http://localhost",
bodyLimit: 5 * 1024 * 1024, // 5 МиБ вместо дефолтного 1 МиБ
};
new Server(settings, router);

bodyLimit: 0 снимает ограничение полностью:

const settings: AomServerSettings = { port: 3000, url: "http://localhost", bodyLimit: 0 };

Снимать лимит опасно и оправдано только за инфраструктурой, у которой лимит уже есть (обратный прокси, балансировщик). Без него неаутентифицированный клиент может отправить сколь угодно большое тело: процесс будет копить его в памяти целиком (Buffer.concat в parseJsonBody ждёт конца потока), и это открытый путь к отказу в обслуживании. 0 — это осознанный шаг «лимит есть, но не здесь», а не «лимита нет вообще».


Рецепт 3: лимит на отдельном маршруте через @BodyOptions

Заголовок раздела «Рецепт 3: лимит на отдельном маршруте через @BodyOptions»
@Post("/import")
@BodyOptions({ limit: 10 * 1024 * 1024 }) // этому маршруту нужно больше дефолта
static async import(@Body() body: ImportPayload) { /* ... */ }

@BodyOptions — маркер-декоратор, вешается и на middleware, и на эндпоинт:

@Middleware()
@BodyOptions({ limit: 4096 })
static async Mw() {}
@Get("/override-down")
@Before(App.Mw)
@BodyOptions({ limit: 1024 })
static async endpoint() {}
// действует limit: 1024 - эндпоинт перекрывает middleware, в любую сторону (и вверх, и вниз)

Маркеры применяются в порядке курсоров маршрута, а курсор эндпоинта — последний, поэтому его значение побеждает вне зависимости от того, больше оно или меньше значения middleware.

Почему лимит задаётся на маршруте, а не в самом @Body. Тело запроса у маршрута одно (один разбор, один lazy), а обращений @Body в цепочке может быть несколько — билдер тела в middleware плюс ещё один @Body() в самом эндпоинте, как в примере из «Главного правила». Если бы лимит задавался в аргументе @Body(...), при разных лимитах у разных @Body было бы неясно, какой из них действует, — а лимит должен быть один на разбор. Поэтому он живёт на маркере маршрута, который читает сервер до вызова parseJsonBody, а не на конкретном вызове декоратора.


Выигрыш «неавторизованный запрос не заставляет читать тело» держится только пока проверки доступа идут в цепочке курсоров раньше @Body и сами @Body не трогают. Курсоры маршрута исполняются строго последовательно (concatMap), рецепт не содержит скрытой параллельности — порядок в @Before это и есть порядок исполнения.

// так: Guard первым, тело не тронуто до отказа
@Post("/guarded")
@Before(Guard.check)
static async guarded(@Body() body: any) { ... }
// ❌ антипример: билдер тела стоит в @Before раньше авторизации
@Middleware()
static async ReadsBodyFirst(@Body() _body: any) {} // например, BodyBuilder-миддлвара
@Post("/wrong-order")
@Before(ReadsBodyFirst, Guard.check) // тело читается ПЕРВЫМ, до проверки доступа
static async wrongOrder(@Body() body: any) { ... }

Во втором варианте Guard.check всё равно в итоге отказывает — но ReadsBodyFirst успевает прочитать (и уже потратить лимит, время, память на) тело до того, как выяснится, что читать его было не нужно. С точки зрения защиты от больших тел от неавторизованных клиентов это то же самое, что не иметь лимита порядка вовсе.


Два разных момента отказа — два разных состояния соединения после ответа.

Отказ по заявленному Content-Length — до первого "data" на потоке. Рантайм сам сольёт недочитанное тело, соединение остаётся рабочим, Connection: close не нужен и не выставляется.

Отказ по ходу чтения (счётчик байт превысил лимит уже после того, как что-то прочитано) обязан закрыть соединение: недочитанный остаток тела иначе останется в TCP-буфере и на keep-alive соединении будет разобран как начало следующего запроса. Такое поведение достижимо только там, где длина заранее неизвестна — при Transfer-Encoding: chunked. Если Content-Length заявлен и меньше лимита, Node сам обрежет тело по заявленной длине раньше, чем сработает счётчик parseJsonBody.

// chunked - длина неизвестна заранее, превышение обнаруживается по ходу чтения
fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
duplex: "half",
body: new ReadableStream({/* ... */}), // без Content-Length
});

content-type, отличный от application/json, даёт {}, и поток при этом не трогается вовсе — ни один слушатель на нём не вешается. На этом держится загрузка файлов через formidable и любой другой парсер multipart в приложении: он должен получить нетронутый поток. Распознавать multipart внутри parseJsonBody не нужно.


Битый JSON на маршруте, который тело не запрашивает, не даёт 400. Если в цепочке маршрута нет ни одного @Body, поток никто не читает — а значит, и разобрать его как невалидный JSON некому.

@Post("/no-body")
static async noBody() {
return new MessageResponse("no body needed");
} // POST сюда с "{не json" в теле -> 200, а не 400

Ошибки разбора тела возникают внутри цепочки маршрута, а не до неё. 400 на битом JSON и 413 на превышении лимита — обычные отказы курсора, как и любой другой брошенный @Body/@Middleware. Значит, их видит @Run-перехватчик и любая middleware приложения, стоящая раньше по цепочке, — и может залогировать, подменить или (по ошибке) молча проглотить.

@Middleware()
static async Intercept(@Run() next: (...handlers: HandlerFunction[]) => Promise<unknown>) {
try {
return await next(ReadBody); // ReadBody содержит @Body() - именно здесь может прилететь 413/400
} catch (err: any) {
logPayloadError(err.status); // приложение видит эту ошибку и может её обработать
throw err;
}
}

Отдельного интерфейса адаптера нет: контракт целиком описывается полем HttpContextValues (в body кладётся либо готовое значение, либо lazy(...)) и функцией parseJsonBody, которая экспортируется как самостоятельный кирпич и от Server пакета не зависит.

Фреймворк уже разобрал тело сам (например, Express с body-parser) — кладём готовое значение, lazy не нужен:

import { lazy, type HttpContextValues } from "@aom-js/router";
app.post("*", async (req, res) => {
const values: HttpContextValues = {
ctx: { req, res },
req,
res,
body: req.body, // body-parser уже прочитал и разобрал JSON - здесь готовое значение
query: req.query,
params: req.params,
cookies: req.cookies,
headers: req.headers,
session: {},
files: {},
};
const result = await route.apply(values);
res.json(result);
});

Фреймворк отдаёт сырой поток — используем lazy(...) и готовый parseJsonBody, тем же способом, каким это делает сам @aom-js/api:

import { lazy, type HttpContextValues } from "@aom-js/router";
import { parseJsonBody } from "@aom-js/api";
const values: HttpContextValues = {
ctx: { req, res },
req,
res,
body: lazy(() => parseJsonBody(req, { limit: 1_048_576 })), // разбор откладывается до первого @Body
// остальные поля - как выше
};

Код адаптера под конкретный сторонний фреймворк тестами ядра не проверяется: проверены примитивы lazy/parseJsonBody, которые он использует напрямую.


Тот же приём — не lazy только для тела. HttpContextValues допускает lazy(...) и для query, params, cookies, headers, session (поля типизированы как Lazy<T> = T | LazyValue<T>), а RxContext.value(key) разворачивает любой из них одинаково — по ключу, а не только по имени body. Встроенный Server из @aom-js/api использует lazy только для тела: остальные поля он и так собирает дёшево (URLSearchParams, заголовки, cookie-строка) и заворачивать их в lazy там особо не за что. Если у вашего адаптера дорого получить query/cookies/files — тот же приём подойдёт без каких-либо изменений в самом @aom-js/router.


  • Справочник по Server, parseJsonBody, @BodyOptions: @aom-js/api §«Ленивое тело запроса и лимит размера»
  • Справочник по lazy, LazyValue, RxContext.value: @aom-js/router §«Ленивые значения контекста…»
  • Порядок исполнения курсоров маршрута целиком: Архитектура ядра
  • Тесты, исполняющие этот рецепт: packages/api/test/server.test.ts, packages/api/test/units.test.ts, packages/api/test/body-options.test.ts, packages/router/test/lazy-values.test.ts