Ленивое тело запроса и лимит его размера
This content is not available in your language yet.
Рецепт о том, когда сервер @aom-js/api на самом деле читает тело запроса и как ограничить его размер — глобально и на отдельном маршруте.
Справочник по всем сигнатурам и internals — @aom-js/api §«Ленивое тело запроса и лимит размера» и @aom-js/router §«Ленивые значения контекста…». Здесь — только то, что нужно, чтобы написать прикладной код и не наступить на грабли.
Главное правило
Заголовок раздела «Главное правило»Тело запроса читается не при приходе запроса, а при первом обращении к @Body в цепочке маршрута. Сервер кладёт в контекст не готовое значение, а lazy(() => parseJsonBody(req, { limit })); разворачивает обёртку RxContext.value("body") не раньше, чем до неё дойдёт какой-то @Body.
Отсюда три следствия:
- Маршруту, которому тело не нужно, поток не трогается вовсе. Ни на байт.
- Отказ в доступе выше по цепочке срабатывает, не дожидаясь тела — даже если клиент заявил
Content-Length, но ничего не прислал. - Значение вычисляется максимум один раз за запрос. Два
@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, фабрика разбора вызвана один раз на весь запрос }}Рецепт 1: ничего не делать
Заголовок раздела «Рецепт 1: ничего не делать»Поведение по умолчанию не требует никакой настройки.
@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 успевает прочитать (и уже потратить лимит, время, память на) тело до того, как выяснится, что читать его было не нужно. С точки зрения защиты от больших тел от неавторизованных клиентов это то же самое, что не иметь лимита порядка вовсе.
413 и соединение
Заголовок раздела «413 и соединение»Два разных момента отказа — два разных состояния соединения после ответа.
Отказ по заявленному 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});Не-JSON тело не трогается
Заголовок раздела «Не-JSON тело не трогается»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; }}Рецепт для чужого HTTP-фреймворка
Заголовок раздела «Рецепт для чужого HTTP-фреймворка»Отдельного интерфейса адаптера нет: контракт целиком описывается полем 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