Ворклог по задаче "Добавить мультиязычность"
Next.js i18n domain routing в локальной разработке: почему пришлось запускать dev-сервер на 80-м порту
При настройке локального окружения для Next.js я столкнулся с довольно неприятным ограничением встроенного domain-based i18n routing.
Задача на первый взгляд простая: приложение использует разные домены для разных локалей, и локально хочется воспроизвести ту же схему. Например:
const nextConfig: NextConfig = {
i18n: {
locales: LOCALE_CODES,
defaultLocale: 'en',
localeDetection: false,
domains: [
{
domain: 'vietnamguru-v3.localhost',
defaultLocale: 'ru',
locales: ['ru'],
http: true,
},
],
},
}
Next.js официально поддерживает такую конфигурацию. Поле http: true существует в том числе для локального тестирования locale domains по HTTP вместо HTTPS.
Проблема начинается с порта.
В i18n.domains нельзя нормально указать dev-порт
Обычный Next.js dev server работает на 3000:
http://vietnamguru-v3.localhost:3000
Логично было бы написать:
domains: [
{
domain: 'vietnamguru-v3.localhost:3000',
defaultLocale: 'ru',
locales: ['ru'],
http: true,
},
]
Но i18n.domains в Next.js рассчитан именно на домены, а не на произвольные origins.
Актуальный тип DomainLocale выглядит так:
export interface DomainLocale {
defaultLocale: string
domain: string
http?: true
locales?: readonly string[]
}
У него нет ни port, ни origin, ни какого-либо devPort.
Это само по себе было бы не так страшно, если бы Next использовал домен только для определения locale. Но domain routing влияет ещё и на генерацию ссылок.
<Link href="/place"> неожиданно становится абсолютной ссылкой
В приложении совершенно обычная ссылка:
<Link href="/place">
Что посетить
</Link>
Без domain-based i18n от неё ожидаешь HTML примерно такого вида:
<a href="/place">Что посетить</a>
И браузер естественным образом открывает:
http://vietnamguru-v3.localhost:3000/place
То есть текущий origin, включая порт, сохраняется автоматически.
Но при включённом domain routing Next.js знает, что конкретная locale относится к конкретному домену. Поэтому он может сформировать для Link абсолютный locale-domain URL.
В результате получается:
<a href="http://vietnamguru-v3.localhost/place">
Что посетить
</a>
И вот здесь :3000 уже потерян.
Это особенно неприятно потому, что в исходном JSX никакого абсолютного URL нет:
<Link href="/place">
Абсолютным его делает сам routing layer Next.js.
Получается парадоксальная ситуация:
Текущая страница:
http://vietnamguru-v3.localhost:3000/foo
JSX:
<Link href="/place">
Сгенерированный href:
http://vietnamguru-v3.localhost/place
Браузер совершенно справедливо воспринимает последний URL как HTTP на стандартном порту 80.
Нельзя просто сказать Next.js: «оставь внутренние ссылки относительными»
Вот это, пожалуй, главное ограничение.
В конфигурации domain i18n нет настройки в духе:
relativeLinks: true
или:
absoluteLocaleLinks: false
Нет возможности задать:
port: 3000
и нет отдельного dev-origin:
origin: 'http://vietnamguru-v3.localhost:3000'
То есть модель конфигурации фактически предполагает, что locale domain доступен на стандартном порту соответствующего протокола.
Для production это совершенно нормально:
https://example.com
https://example.fr
Для локальной разработки:
http://example.localhost:3000
— уже проблема.
Почему http: true проблему не решает
Название поля сначала даёт надежду:
{
domain: 'vietnamguru-v3.localhost',
http: true,
}
Но оно отвечает только за выбор схемы:
https://
или:
http://
То есть Next получает достаточно информации, чтобы построить:
http://vietnamguru-v3.localhost/place
Но информации о том, что локальный сервер находится на 3000, в этой модели просто нет.
Вариант с reverse proxy
Архитектурно наиболее чистое решение — поставить перед Next локальный reverse proxy:
http://vietnamguru-v3.localhost
|
v
localhost:3000
Например, через nginx, Caddy или другой локальный proxy.
Тогда Next продолжает генерировать:
http://vietnamguru-v3.localhost/place
и этот URL действительно работает.
Но для моего текущего dev environment это лишняя инфраструктура ради обхода ограничения framework.
Поэтому временное решение оказалось проще: запускать сам Next.js непосредственно на 80-м порту.
Запускаем Next.js на 80-м порту
Сам запуск элементарный:
PORT=80 npm run dev
После этого:
http://vietnamguru-v3.localhost
действительно является адресом dev server, и абсолютные ссылки, генерируемые Next.js, начинают работать правильно.
Но появляется следующая проблема.
Обычный пользователь не может слушать порт 80
На Linux порты ниже 1024 традиционно являются privileged ports.
Поэтому обычный:
PORT=80 npm run dev
может закончиться ошибкой доступа при попытке Node.js забиндиться на порт 80.
Первая очевидная мысль:
sudo npm run dev
Но это плохой вариант сам по себе, а в моём случае ещё и практически нерабочий: Node/npm установлены не глобально в системное окружение.
Например, если Node управляется пользовательским version manager'ом, npm, который видит текущий shell, вовсе не обязательно существует в окружении sudo.
Получается классическая ситуация:
npm run dev
работает, а:
sudo npm run dev
— уже нет или запускает вообще другое Node-окружение.
И запускать весь dev server от root только ради возможности открыть один порт всё равно не хочется.
CAP_NET_BIND_SERVICE вместо запуска Node от root
В Linux для этого существует capability CAP_NET_BIND_SERVICE.
Она позволяет конкретному executable открывать privileged network ports без запуска всего процесса от root.
Для текущего Node executable:
which node
можно выдать capability:
sudo setcap 'cap_net_bind_service=+ep' $(which node)
После этого Node можно продолжать запускать от обычного пользователя:
PORT=80 npm run dev
и процесс сможет слушать порт 80.
В результате локальная схема становится такой:
vietnamguru-v3.localhost
|
| :80
v
Next.js dev
а Next.js domain routing генерирует:
http://vietnamguru-v3.localhost/place
что теперь соответствует реальному адресу приложения.
Почему это всё-таки workaround
Выдача CAP_NET_BIND_SERVICE самому node — не идеальное универсальное решение.
Capability назначается executable Node.js, а не конкретному Next.js-проекту. Следовательно, любой процесс, запущенный через этот конкретный Node binary от данного окружения, получает возможность bind'иться к privileged ports.
Кроме того, если Node установлен через version manager и версия Node переключается или переустанавливается, путь к executable может измениться. Capability тогда придётся назначать новому бинарнику заново.
Проверить текущие capabilities можно, например, так:
getcap $(which node)
Ожидаемый результат:
/path/to/node cap_net_bind_service=ep
При необходимости capability можно удалить:
sudo setcap -r $(which node)
Поэтому это именно удобный локальный workaround, а не настройка, которую стоит бездумно распространять на все development environments.
Что в итоге
Проблема оказалась не в DNS, /etc/hosts, React или поведении браузера.
Она возникает из сочетания нескольких особенностей Next.js domain-based i18n:
i18n.domainsописывает hostname, но не предоставляет отдельной настройки порта.http: trueпозволяет выбрать HTTP вместо HTTPS, но не задаёт dev-port.- При domain routing Next.js может преобразовывать обычный
<Link href="/...">в абсолютную ссылку на locale domain. - В конфигурации нет отдельного переключателя, позволяющего сохранить такие внутренние ссылки относительными.
- Поэтому стандартный Next dev port
3000плохо сочетается с локальной эмуляцией domain-based locale routing.
В моём случае временное решение получилось таким:
sudo setcap 'cap_net_bind_service=+ep' $(which node)
PORT=80 npm run dev
После этого локальный hostname можно использовать без явного порта:
http://vietnamguru-v3.localhost
и абсолютные locale-domain ссылки Next.js совпадают с фактическим origin.
Работает. Но довольно много инфраструктурных последствий для вещи, которая на уровне приложения выглядит всего лишь как:
<Link href="/place">
Источники
Актуальная документация Next.js по Pages Router подтверждает встроенную поддержку domain routing, структуру domains и назначение http: true именно для локального HTTP-тестирования.
Текущий тип DomainLocale в vercel/next.js содержит domain, defaultLocale, locales и http, но отдельной настройки порта в нём нет.
В документации по Link обычные внутренние переходы по-прежнему задаются относительными pathname (/, /about, /blog/...), то есть абсолютный locale-domain URL не требуется писать в JSX приложения.