# Маршрутизация

> Перевод страницы с английского оригинала. При расхождениях приоритет имеет [английская версия](/docs/routing).

DaloyJS использует префиксное дерево (trie) по сегментам с быстрым путём для статических маршрутов. После проверок безопасности пути точные статические маршруты разрешаются одним вызовом `Map.get`. Динамический поиск сначала предпочитает литеральные сегменты, затем параметры, затем wildcard-сегменты. Поиск без возвратов линеен по длине пути; пересекающиеся маршруты могут потребовать обхода дополнительных ветвей дерева.

Совпадением считаются только полные маршруты. Например, регистрация `/users/me/details/:field` не мешает `/users/:id` совпасть с `/users/me`. Внутри динамического дерева полный путь с более высоким приоритетом побеждает, даже если у него нет обработчика для запрошенного метода. Точный статический маршрут побеждает для своих зарегистрированных методов; остальные методы всё ещё могут совпасть в динамическом дереве.

## Сокращённые формы

Сокращённые методы убирают лишнюю церемонию, не отказываясь от контракта. DaloyJS выводит стабильный идентификатор операции из метода и пути, например `getRoot` или `postBookItemsByItemId`. Явно заданный `operationId` по-прежнему имеет приоритет.

```ts
const app = new App()
  .get(
    "/",
    { responses: { 200: { body: z.object({ hello: z.string() }) } } },
    () => ({ status: 200, body: { hello: "world" } }),
  )
  .post(
    "/books",
    {
      request: { body: z.object({ title: z.string().min(1) }) },
      responses: {
        201: { body: z.object({ id: z.string() }) },
      },
    },
    ({ body }) => ({ status: 201, body: { id: "book_1" } }),
  );
```

Сокращённые формы сохраняют ту же валидацию, OpenAPI, безопасность и поведение типизированного клиента, что и `route()`. Формы с двумя аргументами, которая молча пропускала бы контракт ответа, нет. Для намеренно непрозрачного тела явно передайте `acknowledgeNoResponseBodySchema: true`. Успешные «сырые» ответы, возвращаемые из `preBody` или `beforeHandle`, требуют того же подтверждения. Обычные отказы хуков с кодами `4xx`/`5xx` его не требуют. Описания ответов остаются необязательными. Пропущенные значения превращаются в OpenAPI в стабильное описание `HTTP <status> response`.

## Объявление маршрутов

Объявление маршрута является единым источником истины для сопоставления, валидации запроса, валидации ответа, вывода OpenAPI и типизированных клиентов. Указывайте `operationId`, когда используете `route()` для публичного маршрута, который должен попасть в типизированный клиент или сгенерированный SDK. Сокращённые HTTP-методы выводят его автоматически. DaloyJS отклоняет повторяющиеся значения `operationId` при регистрации.

```ts
import { App } from "@daloyjs/core";
import { z } from "zod";

const UserSchema = z.object({
  id: z.uuid(),
  email: z.email(),
  tenantId: z.string(),
  included: z.enum(["profile", "settings"]).optional(),
});

async function loadUser(input: {
  id: string;
  tenantId: string;
  include?: "profile" | "settings";
}) {
  return {
    id: input.id,
    email: "dev@example.com",
    tenantId: input.tenantId,
    included: input.include,
  };
}

export const app = new App().route({
  method: "GET",
  path: "/users/:id",
  operationId: "getUser",
  tags: ["Users"],
  summary: "Get a user by id",
  request: {
    params: z.object({ id: z.uuid() }),
    query: z
      .object({ include: z.enum(["profile", "settings"]).optional() })
      .optional(),
    headers: z.object({ "x-tenant": z.string() }),
  },
  responses: {
    200: { description: "Found", body: UserSchema },
    404: { description: "Not found" },
  },
  handler: async ({ params, query, headers }) => {
    // params, query и headers выводятся из схем выше.
    return {
      status: 200,
      body: await loadUser({
        id: params.id,
        tenantId: headers["x-tenant"],
        include: query?.include,
      }),
    };
  },
});
```

## Вывод типов в нескольких файлах

Экспортируйте каждый маршрут через `defineRoute()`, затем соберите импортированные контракты через `registerRoutes()`. Литеральный кортеж переживает границы файлов и модулей, поэтому клиент без кодогенерации сохраняет все идентификаторы операций и схемы.

```ts
// routes/list-books.ts
export const listBooksRoute = defineRoute({
  method: "GET",
  path: "/books",
  operationId: "listBooks",
  responses: {
    200: { description: "Books", body: z.array(z.object({ id: z.string() })) },
  },
  handler: async () => ({ status: 200, body: [{ id: "1" }] }),
});

// app.ts
import { listBooksRoute } from "./routes/list-books.js";
import { createBookRoute } from "./routes/create-book.js";

export const app = new App().registerRoutes([
  listBooksRoute,
  createBookRoute,
] as const);
```

Цепочка вызовов `route()` тоже накапливает маршруты корректно. Не ставьте голую аннотацию `const app: App` на итоговое собранное приложение: она явно расширяет тип и стирает кортеж маршрутов.

`group()` с колбэком и `register()` плагинов по-прежнему дают изоляцию во время выполнения, но TypeScript не может расширить тип родительской переменной изнутри их колбэков. Чтобы получить полностью типизированный клиент без кодогенерации в больших модулях, экспортируйте литеральные кортежи маршрутов и делайте `registerRoutes()` последней границей композиции.

## HTTP-методы

Поддерживаются методы `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD` и `OPTIONS`. Нестандартные методы, такие как `TRACE`, `CONNECT` и глаголы WebDAV, отклоняются при регистрации.

`HEAD` откатывается к соответствующему маршруту `GET`, если явного маршрута `HEAD` нет, и возвращает те же заголовки с пустым телом. `OPTIONS` возвращает preflight-ответ 204 с заголовком `Allow`, если путь существует, но явный маршрут `OPTIONS` не зарегистрирован.

Сокращённые методы есть для `GET`, `POST`, `PUT`, `PATCH`, `DELETE` и `HEAD`. Явный маршрут `OPTIONS` регистрируйте через `route()`. В противном случае DaloyJS сам обработает preflight.

## Параметры пути

```ts
app.route({
  method: "GET",
  path: "/orgs/:org/repos/:repo",
  operationId: "getRepo",
  request: {
    params: z.object({ org: z.string(), repo: z.string() }),
  },
  responses: {
    200: {
      description: "Repository",
      body: z.object({ org: z.string(), repo: z.string() }),
    },
  },
  handler: async ({ params }) => ({ status: 200, body: params }),
});
```

Значения пути декодируются до валидации. Если схема `request.params` не указана, `ctx.params` выводится из пути как набор «сырых» строк. Конфликтующие имена параметров в одной и той же позиции дерева, например `/a/:x` и `/a/:y`, вызывают исключение при регистрации.

Имена захватов должны быть непустыми и уникальными в пределах маршрута. Имена `__proto__`, `constructor` и `prototype` отклоняются, чтобы объекты параметров оставались безопасными для дальнейшего использования. Отклонённая регистрация не резервирует свой `operationId`.

### Wildcard-захваты

Завершающий сегмент `*name` захватывает остаток пути в одну декодированную строку и требует хотя бы одного оставшегося сегмента. Голый `*` использует имя `wildcard`. Wildcard-сегмент должен быть последним: `/assets/*path/private` вызывает исключение при регистрации, а не молча совпадает с более широким путём. Несколько методов могут разделять один wildcard-маршрут, если имя захвата совпадает. Повторяющиеся методы и конфликтующие имена wildcard вызывают исключение.

```ts
app.route({
  method: "GET",
  path: "/assets/*path",
  operationId: "getAsset",
  request: { params: z.object({ path: z.string() }) },
  responses: {
    200: { description: "Asset", body: z.object({ path: z.string() }) },
  },
  handler: async ({ params }) => ({ status: 200, body: params }),
});

// GET /assets/css/app.css -> params.path === "css/app.css"
```

«Сырые» сегменты обхода пути (`..`), пустые сегменты `//` и некорректные percent-последовательности в захватах просто не совпадают. Определение методов применяет те же проверки, поэтому отклонённые пути не объявляют заголовок `Allow`. Начиная с 1.3.7 сегмент параметра или wildcard никогда не связывает `.` или `..`, включая закодированные варианты `%2e` и `%2e%2e`. URL-парсер разрешил бы их как шаги по каталогам, и если бы они связывались, обработчик получил бы не тот путь, который видел middleware. Захват также отклоняет декодированное значение с компонентом `.` или `..` за закодированным разделителем (`..%2F..%2Fsecret`, `..%5C`) или с любым управляющим символом (`%00`, `%0A`, `%09`, `%7F`). Такие запросы получают `404`.

Захват, который декодирует закодированный `/` или `\` (`%2F`, `%5C`), по умолчанию тоже отклоняется с `404`, потому что одно захваченное значение тогда охватывало бы несколько сегментов пути для любого последующего потребителя: файловой системы, URL или прокси. Если маршрут действительно использует идентификаторы со слешем, включите это для отдельного маршрута:

```ts
app.route({
  method: "GET",
  path: "/books/:id",
  allowEncodedSlash: true, // GET /books/book%2F1 -> ctx.params.id === "book/1"
  responses: { 200: { description: "Book" } },
  handler: (ctx) => ({ status: 200, body: { id: ctx.params.id } }),
});
```

Эта опция никогда не допускает обратно компоненты `..` или управляющие символы. Декодированные параметры остаются недоверенными данными и не являются очищенными путями файловой системы, поэтому доступ к файловой системе проверяйте отдельно.

Адаптеры также держат роутер и middleware на одном представлении пути (начиная с 1.3.7). Адаптер Node канонизирует request-target, содержащий `\`, точечные сегменты или их формы `%2e`, так же, как это сделал бы `new URL()`, ещё до маршрутизации. Адаптеры Node и AWS Lambda отклоняют с `400` заголовок `Host`, который не является простым `host[:port]` (например, содержит `\ / ? # @ %` или пробельные символы). Поэтому защиты на основе пути, такие как `except()`, совпадают ровно с тем маршрутом, который выполняется. Подробности смотрите в описании [адаптера Node](/docs/adapters/node) (на английском).

## Группы

```ts
app.group("/api/v1", { tags: ["v1"] }, (v1) => {
  v1.route({
    method: "GET",
    path: "/health",
    operationId: "health",
    responses: {
      200: { description: "ok", body: z.object({ ok: z.boolean() }) },
    },
    handler: async () => ({ status: 200, body: { ok: true } }),
  });
});
// итоговый путь: /api/v1/health
```

Группы объединяют префиксы, теги, хуки и настройки аутентификации по умолчанию с маршрутами, зарегистрированными внутри колбэка. Дочернее приложение изолировано: middleware, добавленный внутри группы, не утекает в маршруты вне этой группы. Маршруты в группах видны маршрутизации во время выполнения и OpenAPI. О параллельных контрактах `/api/v1` и `/api/v2`, политике миграции и отдельно генерируемых SDK читайте в [руководстве по версионированию API](/docs/api-versioning) (на английском).

## Опции маршрута

- `request`: схемы для `params`, `query`, `headers` и `body`.
- `responses`: объявленные коды статуса и необязательные схемы тела и заголовков ответа.
- `accepts`: список разрешённых `Content-Type` для маршрута со схемой тела запроса.
- `auth`: требование безопасности OpenAPI для маршрута. Используйте вместе с хуком аутентификации, например `bearerAuth()`.
- `internal`: скрывает маршрут от публичных адаптеров, но разрешает вызовы `app.inject()` внутри процесса.
- `deprecated` и `sunset`: помечают эндпоинт как устаревший и выставляют соответствующие заголовки ответа.
- `callbacks` и `meta`: добавляют колбэки OpenAPI, примеры и метаданные маршрута, удобные для ИИ.

## Хуки

Хуки подключают поведение в фиксированных точках жизненного цикла:

- `onRequest`: самый ранний, до разбора запроса.
- `preBody`: после сопоставления маршрута, до валидации схем и чтения тела. Здесь выполняется встроенная аутентификация по заголовку, JWT, basic и mTLS.
- `beforeHandle`: после валидации, перед вашим обработчиком. Верните `Response`, чтобы прервать обработку.
- `afterHandle`: оборачивает или преобразует результат обработчика до сериализации ответа.
- `onError`: наблюдает за ответом с ошибкой или заменяет его.
- `onSend`: изменяет исходящие заголовки на месте или возвращает новый `Response`. Выполняется на путях успеха, ошибки и preflight-запроса `OPTIONS`.
- `onResponse`: последний наблюдатель. Используйте его для логирования и метрик. Не изменяйте здесь ответ.

**Diagram: Жизненный цикл запроса**

1. **onRequest** (самый ранний) - до разбора запроса
2. **preBody** (самый дешёвый отказ) - маршрут найден · тело не тронуто
3. **валидация** (фреймворк) - params · query · body · headers
4. **beforeHandle** - верните Response, чтобы прервать обработку
5. **обработчик** - логика вашего маршрута
6. **afterHandle** - обернуть / преобразовать результат
7. **onSend** - изменить или заменить Response
8. **onResponse** (всегда) - только наблюдение

Хуки срабатывают в фиксированных точках вокруг обработчика. Дешёвая аутентификация может отклонить запрос в preBody ещё до чтения тела. Middleware, которому нужно тело, оставляет beforeHandle после валидации. Если что-то выбрасывает исключение, управление переходит в onError, а затем onSend и onResponse всё равно выполняются.

```ts
app.route({
  method: "POST",
  path: "/admin/purge",
  operationId: "adminPurge",
  hooks: bearerAuth({ validate: t => timingSafeEqual(t, process.env.ADMIN_TOKEN!) }),
  responses: {
    200: { description: "ok", body: z.object({ purged: z.boolean() }) },
    401: { description: "denied" },
  },
  handler: async () => ({ status: 200, body: { purged: true } }),
});
```

## Преобразование ответов через `onSend`

Используйте `onSend`, когда нужно переписать исходящий ответ: например, добавить заголовок, убрать внутренний заголовок или полностью заменить ответ. Возврат `void` сохраняет текущий ответ. Несколько хуков `onSend` выстраиваются в конвейер (глобальные -> группы -> маршрута).

```ts
const app = new App({
  hooks: {
    onSend(res) {
      // Всегда сообщаем версию API в каждом исходящем ответе,
      // включая ответы с ошибками и preflight-запросы OPTIONS.
      res.headers.set("x-api-version", "2026-05-15");
    },
  },
});

app.route({
  method: "GET",
  path: "/users/me",
  operationId: "me",
  hooks: {
    onSend(res) {
      // Убираем внутреннюю деталь реализации, пока ответ не ушёл.
      res.headers.delete("x-internal-cache-key");
    },
  },
  responses: {
    200: { description: "ok", body: z.object({ id: z.string() }) },
  },
  handler: async ({ set }) => {
    set.headers.set("x-internal-cache-key", "shard-a");
    return { status: 200, body: { id: "u_1" } };
  },
});
```

`onSend` выполняется *после* валидации ответа и после слияния заголовков уровня запроса, включая `x-request-id`. Он выполняется *до* `onResponse`, который остаётся правильным местом для логирования и метрик.

## 405 Method Not Allowed

Если путь зарегистрирован для одного метода, а вызван другим, роутер возвращает **405** с корректным заголовком `Allow`, а не вводящий в заблуждение 404. Маршруты с `internal: true` исключаются из публичных ответов 405 и заголовка `Allow`, чтобы скрытые административные или cron-эндпоинты не раскрывались через перебор методов.

Определение методов учитывает и точный статический маршрут, и выбранный динамический маршрут. При `GET /users/me` и `POST /users/:id` путь `/users/me` объявляет оба зарегистрированных метода. Заголовок перечисляет явные регистрации; синтезированная обработка HEAD и OPTIONS не добавляет записей.

## Производительность

```
static route lookup         25,810,420 ops/sec
dynamic 4-segment lookup     2,105,121 ops/sec
miss                         7,742,635 ops/sec
```

Последнее обновление: 30 октября 2026 г.

---

Source: https://daloyjs.dev/ru/docs/routing