Next.js изнутри. Часть 6. Кастомный сервер.

Next.js изнутри. Часть 6. Кастомный сервер.


Введение

В прошлой части мы разобрали серверный слой Next.js и познакомились с router-server, который занимается маршрутизацией, и render-server, который отвечает за рендеринг.

Стандартно Next.js сам поднимает сервер. Однако фреймворк позволяет написать кастомный сервер и самому решать, что делать с входящими запросами. В этой части мы разберём такой вариант подробнее.

Зачем может понадобиться кастомный сервер

Кастомный сервер — это когда мы поднимаем HTTP-сервер в том же процессе, что и Next.js, и сами передаём ему запросы. Это долго живущий Node.js-процесс, который мы держим самостоятельно. На платформах, где Next.js разворачивается как набор serverless-функций с маршрутизацией на стороне инфраструктуры (например, Vercel), такой процесс не вписывается в модель, и мы рискуем потерять часть платформенных оптимизаций.

Тем не менее остаются задачи, ради которых это может быть оправдано. Например, когда часть маршрутов в том же процессе обслуживает не Next.js, а отдельное Express- или Fastify-приложение, GraphQL-эндпоинт или вебхук. Сюда же относится WebSocket-сервер, которому по тем или иным причинам нужно жить в одном процессе и на одном порту с Next.js.

Всё это — про сценарии, когда нам нужно держать Next.js и другую серверную логику в одном процессе на одном хосте. Такое может потребоваться для serverful-приложения или если мы ограничены со стороны инфраструктуры. И всё же зачастую современный Next.js предлагает решения, не требующие собственного сервера.

Минимальный сервер

Канонический кастомный сервер выглядит так:

import { createServer } from 'http'
import next from 'next'

const port = parseInt(process.env.PORT || '3000', 10)
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()

app.prepare().then(() => {
  createServer((req, res) => {
    handle(req, res)
  }).listen(port)
})

Здесь работают три вызова, и у каждого своя роль.

next(options) создаёт экземпляр приложения. В объект опций можно передать почти то же, что живёт в next.config.js, а также dev, dir (расположение проекта), hostname, port, httpServer и выбор бандлера (turbopack либо webpack). Функция ничего не запускает — она только конструирует объект.

app.prepare() инициализирует приложение и возвращает промис.

app.getRequestHandler() возвращает функцию-обработчик с сигнатурой (req, res, parsedUrl?). Именно её мы навешиваем на свой HTTP-сервер. Всё, что Next.js дальше делает с запросом, спрятано за этим handle.

Сам файл server.js не проходит через компилятор и бандлер Next.js, в отличие от страниц и компонентов.

Путь запроса в handle

Что делает handle, когда мы вызываем handle(req, res)?

Дело в том, что handle — это обработчик запросов router-server. Поэтому запрос попадает не сразу в рендеринг, а в начало той же лестницы, которую мы обсуждали в предыдущей части: заголовки из конфига, редиректы, middleware, rewrites, проверка файловой системы, отдача статики, оптимизация картинок — и только затем, если нужно, рендеринг через render-server.

Поскольку handle делегирует запрос в router-server, а middleware (он же proxy) — это один из шагов лестницы router-server, то proxy также отрабатывает, если путь подходит под его матчер. Однако здесь важно помнить, что он срабатывает только для того, что дошло до handle. Если наш кастомный сервер перехватил маршрут раньше и обработал его сам, ни разу не позвав handle, — Next.js этого запроса просто не видит, и его proxy для такого маршрута не запустится.

Кастомный сервер, таким образом, не заменяет сервер Next.js, а оборачивает его. Мы владеем сокетом и самым внешним (req, res), но всё, что происходит с запросом дальше, — это по-прежнему двухслойный сервер Next.js. В итоге весь процесс можно записать одной строкой: наш HTTP-сервер → handle → router-server → render-server.

Кастомная маршрутизация

Раз handle прогоняет запрос через весь роутинг Next.js, то где же тогда учитывается наша собственная маршрутизация? Для этого существует третий аргумент handleparsedUrl.

Правильная модель кастомного роутинга выглядит так: мы сами разбираем req.url, при необходимости меняем pathname или query и передаём изменённый объект в handle третьим аргументом. Дальше, если parsedUrl передан, обработчик пересобирает req.url из него и только потом отдаёт запрос в router-server. По факту мы правим то, что router-server увидит как входной URL, а всю остальную работу он делает сам.

createServer((req, res) => {
  const parsedUrl = parse(req.url, true)
  const { pathname } = parsedUrl

  if (pathname === '/legacy') {
    // отдать запрос так, будто пришли на /modern —
    // но пройдя через весь роутинг Next.js
    handle(req, res, { ...parsedUrl, pathname: '/modern' })
    return
  }

  handle(req, res, parsedUrl)
}).listen(port)

Pages Router и App Router

Зная о двух роутерах Next.js, мы вправе ожидать, что для App Router кастомный сервер настраивается как-то иначе. Однако нет. Кастомный сервер работает над router-server, а разрешение того, к какому роутеру относится маршрут, происходит гораздо глубже, уже внутри render-server. Поэтому слой, которым мы управляем через handle, вообще не знает и не должен знать, Pages это или App Router. Он оперирует сырым запросом и разрешённым маршрутом, а различие между роутерами для него не существует. Один и тот же handle обслуживает обе архитектуры, в том числе если они сосуществуют в одном приложении.

Устаревшие способы

Исторически у кастомного сервера был другой инструмент — метод app.render(req, res, pathname, query). Он позволял отрендерить конкретную страницу напрямую. Аналогично работали методы renderToHTML(), renderError(), render404(). В свежих версиях Next.js все эти методы помечены как устаревшие.

Причина деприкейта — раньше app.render() шёл в рендеринг напрямую, минуя router-server, а значит, минуя middleware, rewrites, редиректы и решения о кешировании. Для простого случая это работало, но означало, что часть поведения Next.js, которое разработчик видел при обычном запуске, при такой реализации пропускалась.

Однако в актуальных версиях этой разницы в поведении уже нет. Если заглянуть в текущую реализацию render() внутри NextCustomServer, окажется, что она больше ничего не рендерит напрямую, а только нормализует pathname, пересобирает из него, query и parsedUrl новый req.url и вызывает ровно тот же this.requestHandler, что и getRequestHandler(), то есть тот же самый обработчик запросов router-server. Это значит, что сейчас app.render() проходит через ту же лестницу, что и обычный путь.

Команда Next.js сводит все варианты к одному getRequestHandler(), чтобы свободно менять внутренности, а метод render() и аналогичные ему оставляет тонкими обёртками с предупреждением о деприкейте.

На одном из моих рабочих проектов кастомный сервер устроен именно на основе app.render(). Приложение не запускается через next start. Ответственным за процесс выступает NestJS на Express-адаптере: он владеет HTTP-сервером, роутингом, guard'ами и BFF, а Next.js вызывают только чтобы отрендерить страницу или отдать /_next/* и статику. Маршрутизацию страниц описывает контроллер NestJS: у каждого роута свой набор @UseGuards (авторизация, фичафлаги), которые отрабатывают на сервере до рендера, а @Render('some-page') под капотом превращается в вызов app.render(req, res, '/some-page') из Next.js.

Исторически такую связку нам давал пакет nest-next, но он перестал поддерживаться, и на очередном апгрейде Next мы переписали мост сами — на тот же публичный API кастомного сервера (next(), prepare() и т.д.). Для рендера страниц мы пока что оперлись на app.render(req, res, view), а не на getRequestHandler(), по двум причинам. Во-первых, так сохранялся прежний контракт: @Render('view') в контроллере продолжал один в один отображаться на app.render(req, res, '/view'), и переписать нужно было только прослойку, а не сами контроллеры. Во-вторых, сигнатура render(req, res, view) буквально выражает нашу модель — «контроллер уже выбрал страницу и пропустил её через guard'ы, теперь отрендери ровно эту view». getRequestHandler() устроен иначе: он берёт страницу из req.url. Там, где путь запроса и есть путь страницы, его можно отдать как есть. Но в случае с @Get('*') @Render('404') он должен отрендерить /404 независимо от того, какой прилетел URL, поэтому render(req, res, view) в данном случае подходит больше. Однако целевое решение, скорее всего, будет заключаться в переезде на getRequestHandler().

Ограничения

У кастомного сервера есть несколько несовместимостей и ограничений.

Он несовместим с режимом output: 'standalone'. Этот режим нужен, чтобы получить маленький самодостаточный артефакт для деплоя. Обычно, чтобы запустить собранное приложение через next start, на сервере должны лежать папка .next, весь node_modules и package.json — и основной вес приходится на node_modules, куда попадают в том числе зависимости сборки и разработки. Standalone решает это трассировкой: на next build Next статически анализирует серверный код, определяет, какие файлы реально нужны в рантайме, и складывает в папку .next/standalone только их — скомпилированный сервер, нужные куски node_modules и свою точку входа server.js, которую запускают через node server.js вместо next start. Получается папка, которой для старта достаточно установленного Node.js. Конфликт с кастомным сервером вытекает прямо из этого устройства. Наш server.js в эту трассировку не попадает, поэтому при попытке запустить собственный сервер он упадёт в рантайме, так как не найдёт нужные модули из node_modules.

Кастомный сервер также несовместим с output: 'export'. Экспорт превращает приложение в набор статических файлов в out/, и всё, что требует живого сервера, в нём запрещено: getServerSideProps, API-роуты, middleware/proxy, rewrites, redirects, headers, ISR, дефолтная оптимизация картинок. Если что-то из этого используется, падает уже сборка next build. Если же приложение полностью статическое и собирается, кастомный сервер в том понимании, в котором мы рассматривали его здесь, как правило не нужен.

Отдельно стоит упомянуть опцию useFileSystemPublicRoutes. По умолчанию Next.js обслуживает каждую страницу из pages/ по пути, совпадающему с её именем: файл pages/product.tsx доступен по адресу /product сам по себе, без дополнительной нашей маршрутизации. Когда роутинг строит кастомный сервер, это мешает: одну и ту же страницу мы отдаём по своему адресу (например, рендерим product на /products/:id), но она параллельно остаётся доступна и по «файловому» /product. Получается один контент по двум URL — дубли для поисковиков и открытые наружу пути, которых мы не планировали.

useFileSystemPublicRoutes: false выключает эту автоматическую файловую маршрутизацию: на сервере рендерятся только те пути, которые наш сервер обрабатывает явно, а прямой заход на файловый путь отдаёт 404. Важная оговорка: выключение работает только на стороне сервера. Клиентский роутинг (переходы по next/link, кнопка «назад») всё ещё может открыть такую страницу — её бандл лежит на клиенте, и клиентская навигация не проходит через наш сервер. Так что полностью закрыть путь одним флагом не выйдет: клиентские переходы придётся запрещать отдельно.

Что касается dev-режима — за кастомным сервером он продолжает работать, поднимая бандлер через тот же router-server, но у этого достаточно нюансов, чтобы разобрать их отдельно. Здесь достаточно помнить, что server.js живёт вне компилятора, поэтому про его перезапуск при изменениях стоит подумать отдельно.

Итого

Кастомный сервер — это не замена сервера Next.js, а обёртка над ним. Мы берём под контроль самые внешние (req, res), но всё, что происходит с запросом дальше, остаётся двухслойной машиной из прошлой части.

Большинство причин, по которым раньше писали кастомный сервер — редиректы, rewrites, заголовки, backend-for-frontend логика, — сегодня закрываются конфигом и proxy, без собственного процесса и без отказа от платформенных оптимизаций. Кастомный сервер по-прежнему существует и по-прежнему работает, но современный Next.js устроен так, чтобы к нему приходилось прибегать всё реже.

Report Page