Перейти к содержимому
Документация на русском

Перевод страницы с английского оригинала. При расхождениях приоритет имеет английская версия.

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

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 (на английском).

Группы

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 (на английском).

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

  • 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: последний наблюдатель. Используйте его для логирования и метрик. Не изменяйте здесь ответ.
Жизненный цикл запроса
  1. 01самый раннийonRequestдо разбора запроса
  2. 02самый дешёвый отказpreBodyмаршрут найден · тело не тронуто
  3. 03фреймворквалидацияparams · query · body · headers
  4. 04beforeHandleверните Response, чтобы прервать обработку
  5. 05обработчиклогика вашего маршрута
  6. 06afterHandleобернуть / преобразовать результат
  7. 07onSendизменить или заменить Response
  8. 08всегда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 не добавляет записей.

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

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

Последнее обновление: