Express или Fastify под Nest: что такое адаптер
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Адаптер вы уже написали — полтора десятка строк в конце главы про каркас: они складывали из кусков сокета объект запроса, звали inject и писали ответ обратно. Сам каркас о node:http не знал ничего. У Nest устройство ровно то же, и потому Nest — не веб-сервер: сервером работает Express или Fastify, а Nest разговаривает с ними через объект-переводчик. Сегодня поменяем платформу под одним и тем же контроллером и посмотрим, что при этом меняется в коде. Окажется, что почти ничего — пока в обработчике не появится @Res(), а в app.use не приедет прослойка, написанная на сахаре Express.
Глава 5 кончилась приложением, которое отвечает 200, 404 и 500, — и в нём не было ни строки про сервер. Каркас принимал объект { method, target, body } и возвращал объект { status, body }; сокет появился только в прогоне на настоящем node:http, и жил он отдельно — в createServer рядом с приложением. Эти полтора десятка строк и есть адаптер: они спрашивают у node:http способ и цель запроса, зовут inject и пишут ответ обратно в res.
Договор у адаптера короткий: спросить у платформы о запросе — каким способом пришёл, по какому адресу, что в теле — и отправить ей готовый ответ: вот код, вот текст. Всё остальное каркас делает сам: ищет маршрут, создаёт контроллер, проводит запрос через звенья. Значит, платформу можно заменить, не трогая ни контроллера, ни каркаса, — если, конечно, договор соблюдён обеими сторонами.
Проверим это на двух выдуманных платформах. У первой способ лежит в method, адрес — в originalUrl, а ответ отправляют парой status().send(). У второй способ спрятан в raw, адрес — в url, ответ отправляют парой code().send(). Контроллер один и тот же, каркас один и тот же, разные только два объекта-переводчика. Тела в примере не будет вовсе — запросы только GET, — и адаптеру хватает двух вопросов из трёх.
Прогноз: разойдутся ли ответы двух платформ хоть в одном из трёх запросов — и какой код придёт на /health, которого в контроллере нет?
Ни одного расхождения: три запроса, три пары одинаковых строк. Каркас каждый раз вернул одно и то же { status, body }, а разными были только методы, которыми это { status, body } ушло в платформу: status(404) у первой, code(404) у второй. /notes/99 — 404 от HttpError, который бросил обработчик; /health — 404 от маршрутизатора, который не нашёл адреса. Обе четырёхсотчетвёрки пришли из каркаса, и адаптер о разнице между ними ничего не знает: его дело — передать число.
Адаптер не «абстракция над сервером». Это перевод: слева объект платформы, справа объект каркаса, между ними — три вопроса и один ответ.
Раз договор держится на трёх вопросах, платформа может быть какой угодно — даже не передавать объектов вовсе. Возьмём третью: запрос приезжает стартовой строкой, как она идёт по проводу, а ответ надо сложить строками. Адаптер к ней — четыре строки, и две из них просто режут строку по пробелу: никакого чужого API здесь нет вовсе.
Прогноз: что из трёх запросов дойдёт до обработчика и что окажется в теле у DELETE /notes/1?
Контроллер не тронут ни одним символом, и он по-прежнему не знает, кто его позвал. DELETE /notes/1 получил 405 и строку Allow: GET в теле: заголовков ответа ваш каркас не умеет вовсе, и список разрешённых способов он пишет туда, куда умеет. Это, кстати, первая подсказка, что трёх вопросов мало: как только ответу понадобятся заголовки, в договоре появится четвёртый.
У Nest договор тот же, только длиннее. Платформу описывает класс, наследник AbstractHttpAdapter<TServer, TRequest, TResponse> из @nestjs/core, и в нём двадцать три обязательных метода (посчитано по http-adapter.d.ts в @nestjs/core 12.0.3). Ваши три вопроса и один ответ лежат среди них почти дословно.
| Что каркасу нужно от платформы | У вас | В Nest 12 |
|---|---|---|
| способ запроса | adapter.method(request) | getRequestMethod(request) |
| адрес запроса | adapter.target(request) | getRequestUrl(request) |
| отправить ответ | adapter.reply(response, status, body) | status(), reply(), end() — три метода вместо одного |
| заголовки ответа | нет вовсе | setHeader(), getHeader(), appendHeader(), isHeadersSent() |
| завести маршрут | router.add() внутри каркаса | get(), post() и родня — базовый класс передаёт их экземпляру платформы |
| общая прослойка | звено в списке chain | createMiddlewareFactory() |
| разбор тела | звено-разборщик | registerParserMiddleware() |
| чужой домен, редирект, шаблоны, статика | нет вовсе | enableCors(), redirect(), render(), useStaticAssets() |
| имя платформы | поле name в примере | getType() — по нему код и узнаёт, на чём он живёт |
Списком методов дело не кончается. Документация AbstractHttpAdapter честно предупреждает: ядро Nest, кроме адаптера, читает и пишет свойства самого объекта запроса — body, params, query, headers. То есть платформа обязана не только отвечать на вопросы, но и выглядеть определённым образом. Это та часть договора, которая нигде не проверяется типами и о которой узнают, когда что-нибудь отваливается.
Отсюда же вырастет главная ловушка статьи. Прослойка, которой отдали «объект запроса», работает не с адаптером, а прямо с этим объектом — и всё, чего у него на новой платформе не окажется, отвалится молча.
Договор с платформой — не интерфейс из учебника по ООП, а список работ. Он растёт ровно настолько, насколько каркас хочет уметь: заголовки, статика, шаблоны, чужой домен.
Теперь настоящий Nest. NestFactory.create(AppModule) без второго аргумента поднимает приложение на Express — это платформа по умолчанию, и её пакет @nestjs/platform-express стоит в стартере (проверено запуском 23.09.2026: getType() у такого приложения — express). Чтобы вместо Express работал Fastify, вторым аргументом передают new FastifyAdapter() из @nestjs/platform-fastify. Всё.
Ниже одно приложение поднимается дважды, и разница между заходами — один объект в массиве. Контроллер заметок тот же, что в предыдущих статьях главы: @Get(":id") с ParseIntPipe, @Post() с телом, сервис из контейнера, NotFoundException на пропаже.
Прогноз: в скольких из пяти ответов Express и Fastify разойдутся — и чем при этом будут отличаться заголовки, которых никто не просил?
import 'reflect-metadata'; import { Module } from '@nestjs/common'; import { AbstractHttpAdapter, NestFactory } from '@nestjs/core'; import { ExpressAdapter } from '@nestjs/platform-express'; import { FastifyAdapter } from '@nestjs/platform-fastify'; import { NotesController } from './notes.controller.js'; import { NotesService } from './notes.service.js'; @Module({ controllers: [NotesController], providers: [NotesService] }) class AppModule {} const requests = [ { method: 'GET', path: '/notes/1' }, { method: 'GET', path: '/notes/99' }, { method: 'GET', path: '/notes/abc' }, { method: 'DELETE', path: '/notes/1' }, ]; // Одно и то же приложение поднимается дважды; меняется только объект платформы const platforms: AbstractHttpAdapter[] = [new ExpressAdapter(), new FastifyAdapter()]; for (const platform of platforms) { const app = await NestFactory.create(AppModule, platform, { logger: false }); // порт 0 — настоящий сокет на свободном порту, а не inject мимо сети await app.listen(0, '127.0.0.1'); const base = await app.getUrl(); console.log(`${platform.constructor.name}: getType() = ${app.getHttpAdapter().getType()}, слушает ${base}`); for (const { method, path } of requests) { const res = await fetch(`${base}${path}`, { method }); console.log(` ${method} ${path} → ${res.status} ${await res.text()}`); } const created = await fetch(`${base}/notes`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: 'молоко' }), }); console.log(` POST /notes → ${created.status} ${await created.text()}`); // заголовки, которых никто не просил: их дописывает сама платформа const head = created.headers; console.log( ` свои заголовки: x-powered-by=${head.get('x-powered-by') ?? 'нет'}` + ` etag=${head.has('etag') ? 'да' : 'нет'} keep-alive=${head.get('keep-alive') ?? 'нет'}`, ); await app.close(); }
ExpressAdapter: getType() = express, слушает http://127.0.0.1:<порт>
GET /notes/1 → 200 {"id":1,"title":"купить хлеб"}
GET /notes/99 → 404 {"message":"заметки 99 нет","error":"Not Found","statusCode":404}
GET /notes/abc → 400 {"message":"Validation failed (numeric string is expected)","error":"Bad Request","statusCode":400}
DELETE /notes/1 → 404 {"message":"Cannot DELETE /notes/1","error":"Not Found","statusCode":404}
POST /notes → 201 {"id":2,"title":"молоко"}
свои заголовки: x-powered-by=Express etag=да keep-alive=timeout=5
FastifyAdapter: getType() = fastify, слушает http://127.0.0.1:<порт>
GET /notes/1 → 200 {"id":1,"title":"купить хлеб"}
GET /notes/99 → 404 {"message":"заметки 99 нет","error":"Not Found","statusCode":404}
GET /notes/abc → 400 {"message":"Validation failed (numeric string is expected)","error":"Bad Request","statusCode":400}
DELETE /notes/1 → 404 {"message":"Cannot DELETE /notes/1","error":"Not Found","statusCode":404}
POST /notes → 201 {"id":2,"title":"молоко"}
свои заголовки: x-powered-by=нет etag=нет keep-alive=timeout=72Пять ответов из пяти совпали посимвольно, включая те, которые пишет не ваш код: 404 Cannot DELETE /notes/1 на маршрут, которого нет, 400 с текстом отказавшей трубы, 201 у создания. Совпали они не потому, что Express и Fastify похожи, а потому, что тело ответа складывает Nest: у обеих платформ он спрашивает одно и то же и отдаёт им уже готовый объект. Даже эта 404 — не ответ сервера: не найдя маршрута, платформа отдаёт запрос ловушке, которую Nest вешает на неё сам (setNotFoundHandler), а текст Cannot DELETE /notes/1 складывает сама ловушка и передаёт его NotFoundException (router/routes-resolver.js в @nestjs/core 12.0.3).
А вот последняя строка каждого захода показывает, что сервер и правда разный. Express дописывает в ответ x-powered-by: Express и etag, Fastify не дописывает ни того, ни другого; keep-alive у Express — timeout=5, у Fastify — timeout=72. Ни одну из этих строк не заказывал ни контроллер, ни Nest: это поведение самой платформы, и оно переезжает вместе с ней.
Версии подтянуты жёстко: @nestjs/platform-express 12.0.3 тянет express 5.2.1, cors 2.8.6 и multer 2.4.0; @nestjs/platform-fastify 12.0.3 — fastify 5.12.4, @fastify/middie 9.3.4, @fastify/cors 11.3.0, @fastify/formbody 9.0.0 и light-my-request 6.6.0. Обновить Fastify отдельно от Nest не выйдет: в зависимостях пакета версия стоит точным числом, а не диапазоном, и @nestjs/platform-fastify принесёт с собой свою.
Из этого списка сразу видно и приятное различие. light-my-request — это app.inject: запрос, который доходит до обработчиков, не выходя в сеть. У приложения на Fastify этот метод есть (NestFastifyApplication.inject), у приложения на Express — нет, и тесты придётся писать через настоящий сокет.
Различается и умолчание у listen: про адреса и порты — отдельный разговор в конце главы.
Одинаковый ответ на двух серверах — не совпадение и не «совместимость». Это значит, что ответ складывал не сервер.
Пока обработчик возвращает объект, платформы не видно: отправкой занимается адаптер. Но у Nest есть декоратор @Res(), который кладёт в параметр сам объект ответа платформы — тот, что за адаптером. Берут его ради того, чего в каркасе нет: поставить куку, отдать поток, дописать заголовок. Берут — и код перестаёт быть переносимым, причём не сразу, а на первом же вызове чужого метода.
Ниже четыре обработчика. Первый написан под Express: res.status(200).json({…}). Второй под Fastify: reply.code(200).send({…}), и тип параметра выписан честно — FastifyReply. Третий берёт @Req() и печатает, какие свойства у запроса нашлись. Четвёртый не написан ни под кого: он просто возвращает объект. Оба приложения, как и раньше, поднимаются из одного файла.
Прогноз: сколько из восьми ответов будут не 200 — и что напечатает строка про объект из @Res() на каждой платформе?
import { Controller, Get, Req, Res } from '@nestjs/common'; import type { FastifyReply } from 'fastify'; @Controller('notes') export class NotesController { // написано под Express: сначала status(), потом json(). Типа под рукой нет — @Res() отдаёт что угодно @Get('express-style') expressStyle(@Res() res: any) { const has = (name: string) => `${name}:${typeof res[name]}`; console.log(` что умеет объект из @Res(): ${['status', 'code', 'json', 'send'].map(has).join(' ')}`); res.status(200).json({ ok: 'express-style' }); } // написано под Fastify, и тип платформы здесь выписан честно @Get('fastify-style') fastifyStyle(@Res() reply: FastifyReply) { reply.code(200).send({ ok: 'fastify-style' }); } // @Req() — такая же дверь на платформу, только в другую сторону @Get('request') request(@Req() request: any) { const names = ['method', 'url', 'originalUrl', 'query', 'headers', 'ip', 'hostname', 'params']; return { есть: names.filter((name) => request[name] !== undefined).join(' ') }; } // написано ни под кого: объект возвращён каркасу, отправляет его адаптер @Get('portable') portable() { return { ok: 'portable' }; } }
платформа express, слушает http://127.0.0.1:<порт>
что умеет объект из @Res(): status:function code:undefined json:function send:function
GET /notes/express-style → 200 {"ok":"express-style"}
GET /notes/fastify-style → 500 сорвалось — TypeError: reply.code is not a function
GET /notes/request → 200 {"есть":"method url originalUrl query headers ip hostname params"}
GET /notes/portable → 200 {"ok":"portable"}
платформа fastify, слушает http://127.0.0.1:<порт>
что умеет объект из @Res(): status:function code:function json:undefined send:function
GET /notes/express-style → 500 сорвалось — TypeError: res.status(...).json is not a function
GET /notes/fastify-style → 200 {"ok":"fastify-style"}
GET /notes/request → 200 {"есть":"method url originalUrl query headers ip hostname params"}
GET /notes/portable → 200 {"ok":"portable"}Строка про методы объясняет всё остальное. У ответа Express есть status, json и send, но нет code. У Reply Fastify есть status — давний псевдоним к code, — и send, но нет json. Пересечение — status и send, и ровно на нём код и выглядит переносимым, пока не позовут json.
Падение при этом самое обычное: TypeError: res.status(...).json is not a function под Fastify и TypeError: reply.code is not a function под Express. Не «несовместимая платформа», не предупреждение при старте, а обращение к несуществующему методу в середине обработчика — то есть 500 на одной ручке из четырёх, который увидят не все и не сразу.
Компилятор здесь не помощник, и это видно прямо в прогоне: он снят командой nest start, а это tsc, а потом node dist. Значит, tsc 6.0.3 пропустил обработчик, у которого параметр объявлен как FastifyReply, в приложении на Express — и промолчал. Проверять платформу ему нечем: @Res() — декоратор параметра, тип параметра он не ограничивает, а какой адаптер приедет в NestFactory.create, решается в другом файле.
С @Req() история другая и оттого более коварная. Строка про /notes/request печатает, какие свойства нашлись у объекта запроса, и списки совпали: method url originalUrl query headers ip hostname params — и у Express, и у Fastify. Сошлись именно имена, а не типы: объекты за ними разные. Код на @Req() переезжает молча — ровно до первого свойства, которого на новой платформе не окажется.
Отсюда правило: @Res() и @Req() — последнее средство, а не удобный доступ к запросу. Почти всё, ради чего их берут, у Nest есть отдельными декораторами: @Body(), @Query(), @Param(), @Headers(), @Ip(). А если объект платформы всё-таки нужен ради одного заголовка, есть @Res({ passthrough: true }): отправку он оставляет каркасу, а объект отдаёт в руки — то есть снимает половину беды, но не ту, о которой эта статья. Всё, что вы на этом объекте позовёте, по-прежнему платформенное.
Вызов чужого метода не проверяется ничем: ни типом, ни стартом приложения, ни тестом соседней ручки. Проверяется он запросом — тем самым, который придёт в бою.
Вторая дверь на платформу — app.use. Это тот самый конвейер, только звенья в нём пишутся по-экспрессовски: функция трёх аргументов (req, res, next). Под Fastify такие звенья тоже работают: Nest 12 сам подключает @fastify/middie — FastifyAdapter.init() зовёт registerMiddie(), а звенья, объявленные до старта, ждут в очереди и регистрируются после (исходник fastify-adapter.js в @nestjs/platform-fastify 12.0.3). Руками регистрировать плагин не нужно.
Работает вызов — это ещё не значит, что работает звено. Прежде чем смотреть на настоящий Nest, повторим ловушку в миниатюре: звену дают ответ платформы, и оно ставит на него отметку.
Прогноз: что случится со вторым заходом — и дойдёт ли запрос до обработчика?
Первая платформа отдала setHeader, звено поставило отметку, запрос дошёл до обработчика. У второй такого метода нет, и звено упало на TypeError ещё до маршрутизатора — не 500 с телом, а исключение наружу: ловец каркаса стоит за вашими звеньями, а не перед ними. Отметок при этом ноль: звено успело сломаться раньше, чем что-нибудь сделало.
Теперь то же самое на настоящих платформах. Прослоек три. Первая служебная: печатает, что вообще приехало в прослойку на этой платформе. Вторая написана на голом node:http — res.setHeader есть у любого ServerResponse. Третья написана так, как пишут для Express: req.query на входе и res.status(401).json({…}) на отказе.
Прогноз: какая из двух прослоек переживёт переезд на Fastify — и что придёт гостю без токена?
// Прослойки в том виде, в каком их пишут для Express: три аргумента и next type Any = any; // Первая написана на голом node:http: setHeader есть у любого ServerResponse export const stamp = (req: Any, res: Any, next: Any) => { res.setHeader('x-tag', 'seen'); next(); }; // Вторая написана на сахаре Express: req.query и res.status().json() export const needsToken = (req: Any, res: Any, next: Any) => { if (!req.query?.token) return res.status(401).json({ message: 'нет токена' }); next(); }; // Что именно приехало в прослойку — печатается один раз на платформу export const peek = { shown: false }; export const look = (req: Any, res: Any, next: Any) => { if (!peek.shown) { peek.shown = true; const have = (o: Any, names: string[]) => names.map((n) => `${n}:${typeof o[n]}`).join(' '); console.log(` запрос: ${have(req, ['query', 'params', 'get', 'originalUrl'])}`); console.log(` ответ: ${have(res, ['status', 'code', 'json', 'send', 'setHeader', 'end'])}`); } next(); };
платформа express, слушает http://127.0.0.1:<порт>
запрос: query:object params:object get:function originalUrl:string
ответ: status:function code:undefined json:function send:function setHeader:function end:function
GET /notes/1?token=x → 200 x-tag=seen {"id":1,"title":"купить хлеб"}
GET /notes/1 → 401 x-tag=seen {"message":"нет токена"}
платформа fastify, слушает http://127.0.0.1:<порт>
запрос: query:object params:undefined get:undefined originalUrl:string
ответ: status:undefined code:undefined json:undefined send:undefined setHeader:function end:function
GET /notes/1?token=x → 200 x-tag=seen {"id":1,"title":"купить хлеб"}
GET /notes/1 → 500 x-tag=seen сорвалось — TypeError: res.status is not a functionПрослойка на node:http отработала везде: x-tag=seen стоит во всех четырёх ответах, включая тот, что кончился 500. Прослойка на сахаре Express отработала только под Express. Под Fastify она упала на TypeError: res.status is not a function — и заметьте, упала не на всяком запросе, а только на том, где дело дошло до отказа. Запрос с токеном прошёл мимо ветки с res.status и ответил 200: прослойка, сломанная переездом, полдня может выглядеть исправной.
Строки про запрос и ответ объясняют, почему так. Ответ, который middie отдаёт прослойке, — это голый ServerResponse из node:http: у него есть setHeader и end и нет ни status, ни json, ни send. А запрос — голый IncomingMessage, которому middie перед вызовом прослоек переписал то, что успел разобрать сам Fastify: originalUrl, query, ip, hostname, protocol, номер запроса и журнал, а тело — только если его уже разобрали (в @fastify/middie 9.3.4 это десять строк подряд в index.js). Больше он не дописывает ничего: остальное у прослойки ровно то, что есть у всякого IncomingMessage. Поэтому req.query и переехало, а req.params и req.get — нет. С params, впрочем, разница меньше, чем кажется: прослойка стоит до маршрутизатора, и переменные сегменты заполнять ещё некому — у Express это пустой объект, у Fastify его просто нет. В обработчике req.params заполнен на обеих платформах: там их уже разобрал маршрутизатор.
| Что берёт прослойка | Express 5.2.1 | Fastify 5.12.4 + middie 9.3.4 |
|---|---|---|
req.query | есть | есть — его дописывает middie |
req.originalUrl | есть | есть — его дописывает middie |
req.params | есть | нет |
req.get(имя) | есть | нет — заголовки только через req.headers |
res.setHeader, res.end, res.statusCode | есть | есть — это node:http |
res.status, res.json, res.send | есть | нет |
Правило из этой таблицы простое: прослойка, написанная на node:http, переезжает; прослойка, написанная на сахаре Express, — нет. Этим же меряются и чужие пакеты. helmet и cors ставят заголовки через setHeader — переедут. Всё, что отвечает само через res.json или res.send, — не переедет. А у Fastify для тех же работ есть свои плагины, и Nest даёт до них дотянуться: app.getHttpAdapter().getInstance() возвращает сам экземпляр Fastify, у которого есть register.
Звено, сломанное переездом, падает не на каждом запросе, а только на своей ветке. Отказ гостю — как раз та ветка, которую в тестах проверяют последней.
Сведём различия, которые вы сегодня увидели своими глазами, и добавим те, что уже разбирались раньше в части.
| Что сравниваем | Express 5.2.1 | Fastify 5.12.4 |
|---|---|---|
| как включить | ничего не делать: платформа по умолчанию | NestFactory.create(AppModule, new FastifyAdapter()) |
| ответы контроллера | одинаковые | одинаковые |
| лишние заголовки | x-powered-by и etag | нет |
| запрос мимо сети | нет | app.inject из light-my-request |
объект в @Res() | status, json, send | status, code, send |
прослойки app.use | родные | через middie: голые req и res |
| хвостовой слэш в адресе | не значащий | значащий: /notes/ попадёт в /notes/:id с пустым номером |
| экосистема | прослойки Express | плагины Fastify через getInstance().register() |
Выбор между ними — не предмет этой статьи: Fastify берут ради скорости и схем ответа, Express — ради готовых прослоек, которых для него написано больше, и мерить это здесь мы не станем. Предмет статьи другой: цена решения. Если в приложении нет ни @Res(), ни @Req(), ни прослоек на сахаре платформы, переезд стоит одну строку в main.ts и одну правку типа приложения. Если они есть, переезд стоит ровно столько, сколько таких мест, — и найти их придётся не компилятором, а поиском по слову.
Отсюда же привычка, которая ничего не стоит сегодня и много стоит потом: держать платформенные места на счётном числе файлов. Нужны куки — свой перехватчик, а не @Res() в каждом обработчике. Нужен заголовок на каждый ответ — перехватчик, а не прослойка. Нужен поток из файла — один обработчик с @Res(), про который все знают.
Дальше по главе платформа никуда не денется. Порядок звеньев вокруг запроса у Nest один и тот же на обеих, а настройка старта — наоборот, место, где платформа видна: адрес listen и CORS у Express и Fastify настраиваются по-разному, хотя вызовы Nest одинаковые. А следующая глава поставит рядом с REST второй транспорт — и он приедет на экземпляр Fastify через тот же getInstance().
Платформа под Nest меняется одной строкой. Дорого обходится не смена платформы, а места, где вы к ней обратились напрямую.
- Nest — не веб-сервер. Сервером работает Express или Fastify, а между ними и ядром стоит адаптер: объект, который отвечает на вопросы о запросе и отправляет готовый ответ методами платформы. Тот же шов вы написали сами в конце главы про каркас.
- Договор адаптера — это список работ, а не абстракция: у вашего каркаса в нём три вопроса и один ответ, у
AbstractHttpAdapterиз @nestjs/core 12.0.3 — двадцать три обязательных метода, отgetRequestMethodдоenableCors. - Один и тот же контроллер на Express 5.2.1 и на Fastify 5.12.4 отвечает посимвольно одинаково, включая 404 на несуществующий маршрут и 400 от трубы. Разными остаются заголовки, которых никто не просил:
x-powered-byиetagесть только у Express. @Res()отдаёт объект платформы, и с этого места код переносимым быть перестаёт: у ответа Express естьjson, уReplyFastify — нет, а падение выходит обычнымTypeErrorв середине обработчика. Компилятор молчит: тип параметра@Res()ничем не связан с выбранным адаптером.- Прослойки
app.useпод Fastify работают — Nest 12 подключает@fastify/middieсам, — но получают голыеreqиresизnode:http. Переезжает то, что написано наsetHeaderиend; не переезжает то, что написано наres.status().json()иreq.params. - Цена переезда равна числу мест, где код обратился к платформе напрямую. Нет
@Res(),@Req()и прослоек на сахаре Express — переезд стоит одну строку вmain.ts.
1/4Контроллер, не тронутый ни одной строкой, ответил на Express и на Fastify одинаково. Что при этом делает адаптер?
2/4В обработчике под Fastify стоит res.status(200).json({ ok: true }). Почему это 500 во время запроса, а не ошибка компиляции?
3/4Прослойка app.use((req, res, next) => { res.setHeader("x-tag", "seen"); next(); }) написана для Express. Переедет ли она на Fastify?
4/4Приложение переезжает с Express на Fastify. Что из перечисленного придётся переписать?
Страж, который переживёт переезд
Звено-страж отвечает гостю без токена — и отвечает объектом платформы. На первой платформе это выглядит работающим, на второй роняет запрос. Перепишите строку так, чтобы страж не знал о платформе вовсе: у звена есть второй аргумент — ответ каркаса. Смотрите на обе половины строки вывода: что получил каркас и что услышала платформа.
Звено отвечает каркасу, а не платформе:
После правки обе платформы печатают одно и то же: с токеном — «каркас 200 «заметка 1», платформе ничего», без токена — «каркас 401 «нет токена», платформе ничего». Платформе не сказали ничего ни разу — и это правильно: отправлять ответ будет адаптер, когда inject вернётся.
Обратите внимание и на исходный вывод. На первой платформе страж «сработал»: status(401) send(нет токена) платформа услышала. А каркас при этом вернул 404 со своим телом — он ведь о платформенном ответе не знает. Так и выглядит двойной ответ в бою: звено уже написало в сокет, а каркас пишет туда же второй раз.
Адаптер теряет строку запроса
Две платформы, один контроллер. Первая отдаёт цель одной строкой, вторая держит путь и строку запроса врозь — и её адаптер собран второпях. Маршрут находится, ответ приходит с кодом 200, а вот значение draft до обработчика не доезжает. Почините адаптер второй платформы, не трогая ни контроллера, ни первого адаптера.
Каркасу нужна цель целиком — с той же строкой запроса, которую прислал клиент:
После правки обе строки вывода одинаковые: «200 заметка 1, черновик да». Ошибка эта не выдуманная: у Nest тот же вопрос называется getRequestUrl, и в прослойках Fastify его ответ берут из req.originalUrl — поля, которое middie дописывает как раз потому, что req.url к моменту прослойки может быть уже не тем, что пришло по проводу.
Стоит заметить и то, чем такая поломка опасна: она не даёт ни ошибки, ни 404. Маршрут найден, обработчик выполнен, код ответа 200 — просто строка запроса всегда пустая. Тест на «ручка отвечает» её не поймает.
Сколько мест держат вас на Express
Перед вами контроллер из проекта на Express. Его собираются перевести на Fastify. Назовите строки, которые этого не переживут, и скажите, чем их заменить. Подсказка: одна из них выглядит платформенной, но переезжает без правок, а одна выглядит безобидной, но ломается молча.
Мест, привязанных к Express, три — и только первое из них сломается заметно.
@Res() res: Responseиres.status(200).json({ id }): уReplyнетjson, и обработчик даст 500. Лечится удалением@Res()—byIdдолжен просто вернуть{ id }.import type { Request, Response } from "express": типы остаются от прежней платформы, иtscо подмене не скажет ни слова — он и так молчал.req.params.pageвcreate: имена свойств у@Req()на обеих платформах совпадают, ноparamsзаполняет маршрутизатор по шаблону маршрута, а уPOST /notesпеременных сегментов нет — значение и на Express былоundefined. Это та самая безобидная с виду строка: она молчала до переезда и будет молчать после.
Переезжают без правок: @Query("draft"), @Ip(), @Param("id"), а также req.hostname и req.query — имена у Fastify те же (проверено запуском 23.09.2026 на обеих платформах). И всё-таки @Req() здесь лишний: draft уже приехал отдельным параметром, а за именем узла есть @Headers().