Каркас целиком: контейнер, маршрутизатор и конвейер отвечают 200 и 404

каркас≈ 19 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Узлы главы лежат на столе по отдельности. Декораторы помечают классы и методы, реестр на WeakMap хранит пометки, контейнер создаёт объекты вглубь, маршрутизатор выбирает обработчик по адресу, конвейер проводит запрос через звенья. Каждый узел разобран и работает, а приложения из них всё ещё нет: между ними нет шва. Напишем этот шов — семьдесят шесть строк, которые превращают набор узлов в каркас: @Controller и @Get над методами, сборка, которая обходит модули и заводит маршруты, ловец, который переводит ошибку в ответ. В конце каркас отвечает 200 на заметку, 404 на ту, которой нет, и 500 на испорченное тело — сначала в песочнице, потом на настоящем сокете.

Разделы темы:·

Начнём с того, что каркас ещё не нужен. Приложение из готовых узлов собирается и руками: контейнер с границами модулей отдаёт сервис, маршрутизатор запоминает путь /notes/:id, конвейер доводит запрос до отправки. Все три узла лежат в подложке, в примере — только их типы: так видно, чем именно приложение пользуется.

Прогноз: что ответит этот сервер на /notes/99, которой в хранилище нет, и откуда возьмётся ответ на /health?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три запроса — три ответа, и только два из них честные. Заметка нашлась. Несуществующий адрес /health получил 404 от маршрутизатора: такого маршрута в таблице нет вовсе. А /notes/99 получила 200 со строкой «нет такой заметки» — и это ложь на проводе. Маршрут-то есть, маршрутизатор отработал верно и отдал управление обработчику, а обработчику нечем сказать «не нашлось»: он возвращает строку, строка становится телом, а код ответа ставит не он. Клиент, который смотрит на код, а не читает тело глазами, запишет несуществующую заметку как существующую.

Беда не одна. Путь живёт отдельно от метода. Строка "/notes/:id" написана в одном месте, код, который на неё отвечает, — в другом: переименуйте метод — и router.add об этом не узнает. Экземпляры собираются руками. Сервис достали из контейнера в переменную, и обработчик замкнулся на неё; десяток обработчиков — десяток переменных. Склейка повторяется. Строки от new App() до runPipeline будут одинаковыми в любом приложении, и переписывать их заново в каждом — ровно та работа, ради которой берут фреймворк.

Каркас и есть этот шов. Работ у него три: собрать записи о маршрутах с классов и методов, создать контроллеры через контейнер и связать маршрутизатор с конвейером. Ни одного нового узла — только то, что соединяет готовые. Начнём с записей.

Приложение из узлов собирается и без каркаса. Каркас нужен не для того, чтобы стало возможно, а чтобы одну и ту же склейку не писали заново в каждом проекте.

Запись, которая нужна каркасу, — «GET /notes/:id → NotesController.byId». Одним декоратором её не собрать: декоратор метода не знает класса, в котором метод лежит, а декоратор класса ничего не знает о методах. Поэтому записей две, и лежат они в реестре на WeakMap: у класса — префикс пути, у функции-обработчика — способ запроса и путь. Сложит их сборка, когда обе окажутся у неё на руках.

@Get, @Post и @Delete отличаются одним словом, поэтому тело у них общее. mapping("GET") возвращает фабрику декоратора, фабрика — сам декоратор: три вложенных функции, из которых каждая запоминает своё. А @Controller делает сразу две записи: путь и список зависимостей. Вторая — та самая, которую раньше писал @Injectable: его тоже создаёт контейнер, и список зависимостей нужен ему такой же.

Прогноз: сколько строк напечатает таблица маршрутов, попадёт ли в неё format и какой путь достанется create, у которого в @Post() пути нет вовсе?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три маршрута из четырёх методов. format никто не помечал, реестр о нём молчит, и маршрутом он не станет — в контроллере спокойно живут обычные методы. create получил путь самого контроллера: @Post() без аргумента даёт пустую строку, и от склейки остаётся один префикс.

Последняя строка — список зависимостей контроллера, и записал его @Controller. В Nest над контроллером тоже не пишут @Injectable: @Controller и есть пометка, от которой компилятор выпускает типы параметров конструктора. Два декоратора над одним классом не нужны ни там, ни здесь.

Путь метода и префикс контроллера складываются не в декораторе, а при сборке: раньше складывать нечего — класса у декоратора метода ещё нет.

В таблице маршрутов у каркаса лежат имена методов, а маршрутизатору нужна функция, которую можно позвать. Взять её по имени — дело одной строки, но строк этих две разные: cls.prototype[name] и Reflect.get(instance, name). Кажется, что это одно и то же, и почти всегда так и есть. Разница видна, когда над методом стоял декоратор с addInitializer: его инициализатор кладёт в экземпляр собственное свойство с привязанной копией метода, и оно заслоняет метод прототипа.

Ниже над byId стоят два декоратора: @Get записывает путь, bound привязывает метод к экземпляру. Прогноз: что напечатают два обработчика — взятый у прототипа и взятый у экземпляра?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Путь нашёлся по функции из прототипа. bound ничего не возвращает, метод в прототипе остаётся прежним, и ловушки с обёрткой здесь нет. У экземпляра при этом есть собственное byId — его положил инициализатор, и getOwnPropertyNames это показывает. Дальше пути расходятся. Функция из прототипа ни к чему не привязана: handler("1") зовёт её без this, и обращение к приватному полю даёт TypeError. Функция из экземпляра привязана — и она отвечает.

Отсюда правило каркаса: запись ищут у прототипа, обработчик берут у экземпляра. И зовёт его каркас через handler.call(instance, …) — на случай, когда привязки не было вовсе: оторванный метод теряет this, а контроллеры обычно пишут без bound. Лишним call не будет: у привязанной функции this уже не поменять.

Разница не в поведении, а в типах. Контейнер возвращает object — про его поля компилятор не знает ничего, — а имя приходит из перебора и имеет тип string. Прогноз: сколько диагностик даст этот пример и выполнится ли он?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика одна, на строке с квадратными скобками: TS7053, «Элемент неявно имеет тип "any", так как выражение типа "string" не может использоваться для индексации типа "{}"». Это та самая ошибка: тип без индексной сигнатуры не обещает значения для любой строки. А у Reflect.get объявление другое: get<T extends object, P extends PropertyKey>(target: T, propertyKey: P, receiver?: unknown): P extends keyof T ? T[P] : any. Известен ключ типу — вернётся его тип; не известен — а у object ключей нет вовсе — остаётся any, и он обещан объявлением, а не получен по недосмотру. Выполняются обе строки одинаково, но только вторая переживёт включённый noImplicitAny.

Реестр спрашивают о функции из прототипа, а зовут функцию из экземпляра. Это разные функции, если над методом стоял декоратор с инициализатором.

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

Модули приложения — это корень и всё, что он ввозит, вглубь: у ввезённого модуля свои imports. modulesOf перебирает массив, в который сам же дописывает: перебор массива через for…of видит элементы, добавленные во время перебора, поэтому отдельная рекурсия не нужна. Проверка includes держит круг ввоза: два модуля, ввозящие друг друга, добавятся по одному разу.

Прогноз: в каком порядке попадут в таблицу три контроллера и найдёт ли NotesService часы, записанные в соседнем модуле?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Порядок — от корня вглубь: сперва контроллер самого AppModule, потом ввезённого NotesModule и только потом ClockModule — его ввозит уже не корень, а NotesModule. Третья строка и доказывает, что обход идёт вглубь: до ClockModule от корня два шага. Часы сервис получил: NotesModule ввозит ClockModule, тот их вывозит. Заметьте, от чьего имени сборка просит зависимости контроллера — от его собственного модуля. Спрашивать от имени корня было бы неверно: модуль видит вывоз только тех, кого ввёз сам, и контроллер получил бы то, чего его модулю не видно.

И ещё одно, что легко проглядеть: контроллера в контейнере нет. Его создаёт та же пара строк — список зависимостей из реестра, каждая через app.get, — но в таблицу экземпляров он не ложится, потому что его никто не внедряет. В Nest так же: контроллеры перечисляются отдельным списком, а не в providers. Отсюда и проверка перед созданием: без записи в реестре сборка отказывает, а не создаёт объект без аргументов — то же правило, что у провайдера без пометки.

Всё это — сборка, а не запрос: контроллеры создаются один раз, при старте, и вместе с ними всё дерево их зависимостей. Поэтому забытый ввоз и непомеченный класс вылезают при запуске, а не на первом запросе: сервер либо поднялся целиком, либо не поднялся вовсе.

Контроллер создаётся один раз, при сборке. Значит, его поля живут столько же, сколько приложение, и класть в них что-то про текущий запрос нельзя.

Остался долг из первого раздела: обработчику нечем сказать «нет такой заметки». Возвращаемая строка для этого не годится — она становится телом ответа с кодом 200. Заведём ошибку, которую каркас понимает: HttpError с кодом и сообщением, — и звено-ловец, который переводит её в ответ. Всё, чего ловец не узнал, становится пятисоткой: ошибка, о которой каркас ничего не знает, — это его собственная ошибка.

Место у ловца при этом другое, чем было. В главе про конвейер он стоял первым звеном, потому что весь конвейер писали вы. Здесь конвейер собирает каркас, и ловец у него свой: он стоит перед отправкой, а ваши звенья остаются снаружи. Поэтому журнал, стоящий раньше, видит уже готовый ответ, а не вылетающее исключение, — и печатает строку даже про пятисотку. Цена — ошибка в вашем собственном звене до ловца не долетит. В Nest граница проходит там же: фильтр обнимает обработчик с его стражами и трубами, а прослойка работает снаружи — её ошибки ловит своя такая же обёртка.

Ниже каркас целиком: реестр, декораторы, ошибка, обход модулей и сборка — и приложение, которое на нём стоит: хранилище, сервис, контроллер и модуль. Прогноз: какой из шести запросов получит 500 и что ответит DELETE /notes/1?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Шесть запросов, четыре разных кода, и все ответы честные. GET /notes/1 — 200 и текст заметки. GET /notes/99 — 404, и пришёл он из сервиса: HttpError поднялся сквозь await next() до ловца, тот поставил код и сообщение. POST /notes с телом создал заметку. DELETE /notes/1 — 405 и Allow: GET: путь такой есть, способ не тот. GET /health — 404 от маршрутизатора, маршрута нет вовсе. Последний POST с телом «не json» — 500: JSON.parse бросил SyntaxError, и каркас не сделал вид, что понял её.

Журнал при этом напечатал все шесть строк, включая 404 и 500: звено стоит снаружи ловца, исключение до него не долетает, и код в его строке — тот же, что увидит клиент. А теперь посчитайте: своих строк в примере семьдесят шесть — от prefixes до закрывающей скобки createApp, не считая пустых строк и комментариев. Остальное — объявления типов подложки и ваше приложение.

И это не фокус песочницы. Тот же каркас, слово в слово, работает за настоящим node:http: нужен только адаптер — полтора десятка строк, которые превращают байты сокета в объект запроса, а ответ — обратно в байты. Запускает его tsx, а не node: стирание типов декораторы не переписывает. Прогноз: совпадут ли ответы с песочницей и что напечатается раньше — строка журнала или строка клиента?

прогон node 26.5.0 · 22 сентября 2026
Тот же каркас за настоящим node:http: запросы приходят из сокета, а не из inject
// То же приложение, что в песочнице, но запросы приходят по-настоящему — из сокета.
import { createServer } from "node:http";
import { Controller, createApp, Get, HttpError, Injectable, Module, Post } from "./mini-nest.js";
import type { Call, Link } from "./mini-nest.js";

@Injectable()
class NotesRepo {
  #rows = new Map([["1", "купить хлеб"]]);

  byId(id: string) {
    return this.#rows.get(id);
  }

  add(text: string) {
    const id = String(this.#rows.size + 1);
    this.#rows.set(id, text);
    return id;
  }
}

@Injectable({ deps: [NotesRepo] })
class NotesService {
  constructor(private readonly repo: NotesRepo) {}

  find(id: string) {
    const text = this.repo.byId(id);
    if (text === undefined) throw new HttpError(404, "нет такой заметки");
    return text;
  }

  create(title: string) {
    return this.repo.add(title);
  }
}

@Controller("/notes", { deps: [NotesService] })
class NotesController {
  constructor(private readonly notes: NotesService) {}

  @Get("/:id")
  byId(call: Call) {
    return this.notes.find(call.params.id);
  }

  @Post()
  create(call: Call) {
    return `создана ${this.notes.create(JSON.parse(call.body).title)}`;
  }
}

@Module({ providers: [NotesRepo, NotesService], controllers: [NotesController] })
class AppModule {}

const journal: Link = async (request, response, next) => {
  await next();
  console.log(`журнал: ${request.method} ${request.target} → ${response.status}`);
};

const app = createApp(AppModule, [journal]);

// Адаптер: байты из сокета в объект запроса и обратно. Всё, что каркас знает о node:http
const server = createServer((req, res) => {
  const chunks: Buffer[] = [];
  req.on("data", (chunk: Buffer) => chunks.push(chunk));
  req.on("end", () => {
    // заголовки у node:http бывают и массивом: каркасу нужен простой словарь строк
    const headers: Record<string, string> = {};
    for (const [name, value] of Object.entries(req.headers)) headers[name] = String(value);
    void app
      .inject({ method: req.method ?? "GET", target: req.url ?? "/", headers, body: Buffer.concat(chunks).toString("utf8") })
      .then((response) => {
        res.writeHead(response.status, { "content-type": "text/plain; charset=utf-8" });
        res.end(response.body);
      });
  });
});

await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const base = `http://127.0.0.1:${typeof address === "object" && address !== null ? address.port : 0}`;
console.log(`каркас слушает ${base}`);

for (const call of [
  { method: "GET", path: "/notes/1" },
  { method: "GET", path: "/notes/99" },
  { method: "POST", path: "/notes", body: '{"title":"каркас готов"}' },
  { method: "DELETE", path: "/notes/1" },
  { method: "GET", path: "/health" },
  { method: "POST", path: "/notes", body: "не json" },
]) {
  const response = await fetch(`${base}${call.path}`, { method: call.method, body: call.body });
  console.log(`клиент: ${call.method} ${call.path} → ${response.status} ${await response.text()}`);
}

server.closeIdleConnections();
server.close();
tsx main.ts

Ответы совпали до буквы, включая 405 с Allow и 500 на испорченном теле. Журнал печатается раньше клиента: ответ сначала складывается на сервере, потом уезжает в сокет. А всё, что каркас знает про node:http, — строки адаптера в main.ts: createServer, сбор кусков тела, словарь заголовков и res.end. В самом каркасе сервер не упомянут ни разу, и поменять его на другой — работа для адаптера, а не для контроллеров.

Дальше каркас живёт в подложке: в примерах и задачах на экране остаётся только новое. Это не приём учебника, а то же самое, что делает всякий фреймворк, — его код перестаёт быть виден и становится средой.

Ошибка, о которой каркас ничего не знает, — это 500. Переводить «нет такой заметки» в 404 должен тот, кто знает, что такое заметка.

Теперь сравнение имеет смысл. Совпало не название, а устройство: ваш каркас и Nest 12 делают одно и то же и в одном и том же порядке, а расходятся там, где у настоящего фреймворка за спиной годы чужого опыта.

Узел каркасаКак это зовётся в NestЧем настоящий Nest отличается
@Controller("/notes") и @Get("/:id")та же пара, только путь принято писать без ведущей косой чертыдекораторы старой системы: у стандартных нет декораторов параметров, поэтому @Body() в методе даёт TS1206
createApp обходит модули и заводит маршрутыNestFactory.createмаршруты Nest отдаёт роутеру платформы: путь и обработчик получает Express или Fastify, и синтаксис путей у них свой
App.get с границами модулейконтейнер внутри NestFactory.createсписок зависимостей не пишут руками: типы параметров конструктора компилятор кладёт в метаданные сам
звенья, которые вы передали в createAppпрослойка, стражи, перехватчики и трубывместо одного вида звена четыре, у каждого свои права и своё место в порядке
ловец перед отправкойфильтр исключенийфильтров несколько: глобальный, у класса и у метода, и приоритет у самого близкого к обработчику
throw new HttpError(404, …)throw new NotFoundException(…)набор готовых исключений на каждый код и тело ответа объектом, а не строкой

Чего в каркасе нет вовсе — тоже полезный список. Заголовков ответа: Allow он пишет в тело, потому что ответ у него — это { status, body }. Разбора тела: JSON.parse зовёт сам обработчик, и ошибка от него становится пятисоткой вместо честного 400. Параметров метода: обработчик получает один объект вместо @Body() и @Param(). Областей: контроллер и его зависимости создаются при сборке по разу на всё приложение. Ничего из этого не потребовало бы нового узла — только новых строк в уже написанных.

Строчкам третьей колонки в части посвящены отдельные статьи. Страж и труба разделят одно звено на два с разными правами, перехватчик и фильтр займутся формой ответа и порядком, в котором ловцы сменяют друг друга, доменные коды заменят голый HttpError одним форматом ответа об ошибке. А первая статья про настоящий Nest поставит ваш каркас и Nest 12 рядом и покажет, где в нём что лежит; адаптер из прогона этой статьи вырастет там в отдельную тему.

Фреймворк — не магия и не абстракция. Это шов, который вы только что написали, плюс годы чужих граблей поверх него.

  • Каркас — это шов между готовыми узлами, а не новый узел. Семьдесят шесть строк соединяют реестр метаданных, контейнер, маршрутизатор и конвейер в приложение, у которого маршрут написан над методом, а склейка не повторяется в каждом проекте.
  • Записей о маршруте две: у класса префикс, у функции-обработчика способ и путь. Сложить их в одну декоратор не может — у декоратора метода нет класса, — поэтому складывает сборка.
  • Запись о маршруте ищут у функции из прототипа, а обработчик берут у экземпляра через Reflect.get: декоратор с инициализатором кладёт в экземпляр собственное свойство, и оно заслоняет метод прототипа. Квадратные скобки на объекте типа object дали бы TS7053.
  • Сборка обходит корень и всё, что он ввозит, вглубь, создаёт контроллеры через контейнер от имени их собственных модулей и записывает маршруты. Контроллер в контейнере не лежит — его никто не внедряет, — но без пометки сборка его не создаёт.
  • Код ответа ставит не обработчик. «Нет такой заметки» — это HttpError, которую ловец переводит в 404; всё, чего каркас не узнал, становится 500. Ловец каркас ставит перед отправкой, а ваши звенья остаются снаружи и видят уже собранный ответ.
  • Ваш каркас и Nest 12 устроены одинаково: декораторы над методами, контейнер с границами модулей, конвейер вокруг обработчика, фильтр-ловец. Расходятся они в подробностях — старые декораторы ради параметров, роутер платформы, четыре вида звеньев вместо одного и готовые исключения на каждый код.

1/4Каркас ответил 404 на /notes/99, которой нет в хранилище, и 404 на /health, которого нет в таблице маршрутов. Чем эти два ответа отличаются внутри?

2/4Почему сборка берёт обработчик через Reflect.get(instance, name), а не через cls.prototype[name]?

3/4В приложении AppModule ввозит NotesModule, в котором лежит NotesController, а нужный контроллеру NotesService записан в providers корневого AppModule. Что произойдёт?

4/4Обработчик бросил HttpError(404, "нет такой заметки"). Звено-журнал стоит в цепочке, которую вы передали в createApp, и печатает строку после await next(). Что оно напечатает?

Часы, которых не видно

Приложение из трёх модулей: часы в ClockModule, заметки в NotesModule, корень ввозит заметки. Сейчас оно не поднимается вовсе — сборка отказывает. Почините границы модулей, не трогая ни классы, ни каркас: нужен ответ 200 заметка 7 от 22.09. Подсказка в самом сообщении — оно называет модуль, в котором искали.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Сообщение называет NotesModule: именно в нём сборка искала часы и не нашла. Починить нужно две вещи, и обе — в модулях. ClockModule держит часы в providers, но наружу их не вывозит, а NotesModule его не ввозит.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Обеих правок мало поодиночке: без exports ввоз ничего не даст, без imports вывоз некому принять. Соблазн дописать Clock в providers самого NotesModule стоит пережить: так часы завелись бы во второй раз, и два модуля жили бы с разными объектами. И заметьте, когда приходит отказ, — при createApp, до первого запроса.

Страж на входе

В цепочку, которую получает createApp, передано звено guard, и сейчас оно пропускает всех. Напишите его: GET проходит кому угодно, остальные способы — только с заголовком authorization. Гостю нужен ответ 401 нужен токен, и обработчик при этом выполниться не должен — строки «обработчик: создана заметка» в выводе для него быть не может.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Звено, которое отвечает само, просто не зовёт next() — и обязательно возвращается, иначе выполнение пойдёт дальше по телу и гость всё-таки проедет внутрь.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В выводе остаются четыре строки: 401 без токена, строка обработчика и 200 с токеном, 200 на чтение. Обработчик про стража не знает ничего — и это главное: отказ в доступе не размазан по контроллерам, а лежит одним звеном, которое каркас зовёт раньше отправки. В Nest такое звено и называется стражем, только отвечает оно не само, а возвращает «пускать» или «нет».

Маршрут, который не завёлся

В контроллере два метода, оба помечены. GET работает, а DELETE /notes/7 отвечает 405 Allow: GET, будто такого способа у адреса нет. Ошибки не видно ни одной: ни в консоли, ни при сборке. Найдите причину и добейтесь ответа 200 удалена 7 вместе со строкой журнала.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Декораторы применяются снизу вверх. Над remove первым сработал @Delete и записал маршрут на исходную функцию, а затем logged вернул обёртку, и в прототип встала она. Сборка перебирает прототип, спрашивает реестр об обёртке — и ничего не находит: маршрута нет, остаётся 405 от маршрута GET. Достаточно поменять декораторы местами, чтобы @Delete получил уже обёртку.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Порядок — лечение хрупкое: следующий декоратор с обёрткой снова всё сломает. Надёжнее, чтобы обёртка сама переносила на себя записи исходной функции, — этим и занимается первая задача статьи про реестр метаданных. Nest переносит их так же и по той же причине.