Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Это вторая часть статьи о применении комбинации технологий nest.js и NEXT.js. В первой части был создан и настроен проект, а также выбран способ отправки данных для SSR, в результате чего проект уже удовлетворял большинство потребностей при разработке простого сайта. В этой статье можно узнать о том, как выжать максимум пользы из nest-next: HMR, CDN, удобный SSR и разворачивание "за слешом".
Оглавление
Вступление
Разворачивание на CDN
HMR и кэширование инстанса сервера NEXT.js
Подключение HMR в nest
Кэширование инстанса сервера NEXT.js
Передача данных клиенту при SSR
Создание конфига
AOP для GSSP
Доступ к контексту приложения
Доступ к частям контекста
Работа клиентских переходов
Разворачивание "за слешом"
Создание proxy для разработки
Добавление basePath в конфигурацию
Proxy и basePath
Обертка над fetch
Заключение
Вступление
В данной статье можно узнать про продвинутые механики в nest-next и про то, как их можно применять с пользой. Напомню, что в первой части статьи уже был создан и настроен проект, поверх которого будут производиться изменения здесь. Готовое приложение доступно в репозитории на GitHub - https://github.com/yakovlev-alexey/nest-next-example - последовательность коммитов в целом совпадает с ходом статьи.
Необходимая для начала этой статьи ревизия доступна по ссылке.
Разворачивание на CDN
Начнем с простого. NEXT.js "из коробки" поддерживает разворачивание на CDN. Для этого достаточно добавить assetPrefix
в конфигурацию next.config.js
при сборке. После ее завершения нужно загрузить содержимое ./.next/static
на CDN - сервер NEXT.js автоматически будет подключать нужные ресурсы оттуда. Важным моментом будет то, что assetPrefix
должен присутствовать в конфигурации и при запуске продакшен сервера nest.
HMR и кэширование инстанса сервера NEXT.js
В текущий момент приложение в режиме разработки работает быстро, перекомпиляция скорее всего занимает не более 3 секунд, а страницы грузятся почти мгновенно. Здорово, если сборка будет оставаться такой же быстрой по мере развития проекта, но это маловероятно. Особенно неприятной проблемой может стать раздутый клиент: при каждом перезапуске сервера nest (при изменении любого файла на сервере) создается новый сервер NEXT.js, который заново собирает клиентский бандл. Решение простое: нужно подключить HMR в nest и кэшировать инстанс сервера NEXT.js. Начнем по порядку.
Подключение HMR в nest
Проследуем официальному рецепту по добавлению Hot Reload в nest из документации. Я не буду копировать в статью все содержимое рецепта, но обращу внимание на некоторые детали.
Так, нам не сгодится имеющаяся команда запуска сервера, так как она будет пытаться использовать базовый tsconfig.json
, в то время как мы хотим использовать tsconfig.server.json
. Отразим это в команде запуска:
// ./package.json
"start:dev": "nest build --webpack --webpackPath webpack-hmr.config.js --path tsconfig.server.json --watch"
Следует обратить внимание на то, что название передается в сыром виде:
ts-loader
в таком случае будет искать этот файл рекурсивно в родительских папках. Если бы мы передали относительный путь, то пришлось бы указывать его относительно исполняемого файла сервера.
Далее взглянем на подключение Hot Reload в main.ts
.
// ./src/server/main.ts
import { NestFactory } from '@nestjs/core';
import { PORT } from 'src/shared/constants/env';
import { AppModule } from './app.module';
declare const module: any;
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(PORT);
if (module.hot) {
module.hot.accept();
module.hot.dispose(() => app.close());
}
}
bootstrap();
Здесь все в порядке, но обратим внимание на то, как мы обрабатываем очистку при завершении работы старого модуля: мы вызываем метод app.close()
- тем самым полностью закрываем все приложения и все сервисы.
Проверим работу сервера после подключения HMR. Запустим сервер и попробуем изменить что-то в app.controller.ts
. Важным моментом является то, что сервер не перезапустится, если в результате компиляции не было обнаружено изменений. Если же изменениия были, то время запуска сервера должно упасть до ~1-2 секунд в маленьких проектах и не более 4-5 секунд в больших - существенная разница, которая сильно улучшает качество жизни разработчикам.
Если какие-то модули на сервере nest использовали динамический
import/require
, то велика вероятность, что после подключения Webpack HMR этот код перестанет работать из-за бандлинга. В таком случае, есть вариант запускать компилятор TypeScript параллельно с работой Webpack nest. Результаты компиляции лучше класть в отдельную папку (например,build
) и указать при запуске сервера переменную окруженияNODE_PATH=./build
, чтобы динамический импорт модулей искал их в нужном месте.
Кэширование инстанса сервера NEXT.js
Тем не менее, у нас остается большой ботлнек при перезапуске в виде пересоздания сервера NEXT.js. В разросшихся проектах это может занимать несколько секунд при инициализации сервера и десятки секунд при первой загрузке страницы - не хочется ждать такое время просто из-за изменения символа в каком-то контроллере на сервере nest.
Чтобы побороть эту проблему, будем кэшировать RenderModule
из nest-next между перезагрузками модуля app.module.ts
. Для этого сменим инициализацию AppModule
на динамическую.
// ./src/server/app.module.ts
import { DynamicModule, Module } from '@nestjs/common';
import Next from 'next';
import { RenderModule } from 'nest-next';
import { NODE_ENV } from 'src/shared/constants/env';
import { AppController } from './app.controller';
import { AppService } from './app.service';
declare const module: any;
@Module({})
export class AppModule {
public static initialize(): DynamicModule {
/* При инициализации модуля попробуем извлечь инстанс RenderModule
из персистентных данных между перезагрузками модуля */
const renderModule =
module.hot?.data?.renderModule ??
RenderModule.forRootAsync(Next({ dev: NODE_ENV === 'development' }), {
viewsDir: null,
});
if (module.hot) {
/* При завершении работы старого модуля
будем кэшировать инстанс RenderModule */
module.hot.dispose((data: any) => {
data.renderModule = renderModule;
});
}
return {
module: AppModule,
imports: [renderModule],
controllers: [AppController],
providers: [AppService],
};
}
}
Обновим main.ts
: теперь для подключения AppModule
нам нужно вызывать метод initialize()
.
// ./src/server/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
const app = await NestFactory.create(AppModule.initialize());
Чтобы убедиться в корректной работе наших улучшений, последим за выводом в терминал при перезагрузке сервера nest. В случае успеха мы больше не увидим надпись compiling...
при перезагрузках - это значит, что кэш сборки NEXT.js не сбрасывается и мы выиграли еще больше времени для разработки без ожидания сборки.
Передача данных клиенту при SSR
Изначально перед нами стоял вопрос: как с наименьшими потерями избавиться от необходимости поднимать два сервиса для работы nest и NEXT.js - кажется, удалось избавиться от всех неудобств, которые могла принести провязка nest-next. Теперь же хочется получить реальные преимущества от такого взаимодействия.
Ранее мы отказались от варианта передачи данных, необходимых для страницы, на клиент с помощью возвращаемого значения функции-контроллера. Мы также обнаружили, что с помощью интерсепторов мы можем удобным образом подкладывать данные во все запросы на страницы в nest, не ломая клиентские переходы. В голову приходит мысль: а почему бы не использовать интерсепторы, чтобы помещать на клиент какие-то общие данные, необходимые для всех страниц? Это могут быть различные конфигурации, флаги, переводы и так далее.
Действительно, давайте этим займемся.
Создание конфига
Для начала создадим простенький конфиг - не будем разрабатывать отдельные модуль или сервис nest, а обойдемся простым файлом. В конфиг поместим фичефлаги, которые будем обрабатывать на клиенте. В качестве первого фичефлага возьмем blog_link
- будем менять отображение ссылки на блог на главной странице в зависимости от состояния этого флага.
// ./src/server/config.ts
const CONFIG = {
features: {
blog_link: true,
},
};
export { CONFIG };
Создадим интерсептор ConfigInterceptor
для подкладывания конфига в возвращаемое значение контроллеров и подключим его.
// ./src/server/config.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { CONFIG } from './config';
@Injectable()
export class ConfigInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(
map((data) => ({
...data,
config: CONFIG,
})),
);
}
}
// ./src/server/app.controller.ts
import { ConfigInterceptor } from './config.interceptor';
// ...
@Get('/')
@Render('index')
@UseInterceptors(ParamsInterceptor, ConfigInterceptor)
public home() {
return {};
}
@Get(':id')
@Render('[id]')
@UseInterceptors(ParamsInterceptor, ConfigInterceptor)
public blogPost() {
return {};
}
Для проверки работы интерсептора можем добавить console.log(ctx.query)
в GSSP.
Не следует помещать в возвращаемое значение скрытую информацию вроде токенов, адресов и так далее -
ctx.query
так же сериализуется и при отправке на клиент. Следовательно, не рекомендуется отправлять черезctx.query
в GSSP NEXT.js в том числе и объемные данные - для этого можно использовать объект запросаreq
из Express.
AOP для GSSP
Теперь необходимо добавить одну и ту же обработку для всех методов GSSP - конфиг нужно поместить в возвращаемое значение этого метода. Создадим обертку buildServerSideProps
для этого.
// ./src/client/ssr/buildServerSideProps.ts
import { ParsedUrlQuery } from 'querystring';
import { Config } from 'src/shared/types/config';
import {
GetServerSideProps,
GetServerSidePropsContext,
} from 'src/shared/types/next';
type StaticProps = {
features: Config['features'];
};
type StaticQuery = {
config: Config;
};
const buildServerSideProps = <P, Q extends ParsedUrlQuery = ParsedUrlQuery>(
getServerSideProps: (ctx: GetServerSidePropsContext<Q>) => Promise<P>,
): GetServerSideProps<Partial<StaticProps> & P, Partial<StaticQuery> & Q> => {
return async (ctx) => {
const { features } = ctx.query.config || {};
const props = await getServerSideProps(ctx);
return {
props: {
...props,
features,
},
};
};
};
export { buildServerSideProps };
Как можно заметить, для удобства мы получаем в параметры не полноценный метод GSSP, а его упрощенную версию, которая возвращает сразу поле
props
, не вложенное в объект. Это лишает нас возможности возвращать полеredirect
. Если возникает такая необходимость, поменяйте методbuildServerSideProps
соответствующим образом.
Для корректной типизации нам потребовались тайпинги для нашего конфига, добавим их.
// ./src/shared/types/config.ts
export type Config = {
features: Record<string, boolean>;
};
// ./src/server/config.ts
import type { Config } from 'src/shared/types/config';
const CONFIG: Config = {
features: {
blog_link: true,
},
};
Также нас не устраивает встроенный в NEXT.js тип GetServerSideProps
- нельзя перезаписать тип Query
, он всегда должен удовлетворять требованию ParsedUrlQuery
. Добавим собственные тайпинги.
// ./src/shared/types/next.ts
import {
GetServerSidePropsResult,
GetServerSidePropsContext as GetServerSidePropsContextBase,
} from 'next';
import { ParsedUrlQuery } from 'querystring';
export type GetServerSidePropsContext<Q = ParsedUrlQuery> = Omit<
GetServerSidePropsContextBase,
'query'
> & { query: Q };
export type GetServerSideProps<P, Q = ParsedUrlQuery> = (
ctx: GetServerSidePropsContext<Q>,
) => Promise<GetServerSidePropsResult<P>>;
Обновим наши страницы, чтобы они использовали эту обертку.
// ./src/pages/index.tsx
export const getServerSideProps = buildServerSideProps<THomeProps>(async () => {
const blogPosts = await fetch('/api/blog-posts');
return { blogPosts };
});
// ./src/pages/[id].tsx
export const getServerSideProps = buildServerSideProps<TBlogProps, TBlogQuery>(
async (ctx) => {
const id = ctx.query.id;
const post = await fetch(`/api/blog-posts/${id}`);
return { post };
},
);
Важно понимать, что не следует отправлять на клиент всю конфигурацию сервера. Там может содержаться скрытая информация (токены, адреса, креды), а даже если такой информации нет, то размер загружаемой страницы будет увеличиваться от наличия неиспользуемых данных.
Доступ к контексту приложения
Уже сейчас мы можем получить доступ к features
из наших страниц. Но это может быть неудобно - чтобы получить доступ к данным о фиче-флаге или другой конфигурации в каком-то компоненте, нам придется применить prop-drilling. Чтобы этого избежать, создадим контекст для данных приложения.
// ./src/shared/types/app-data.ts
import { Config } from './config';
export type AppData = {
features: Config['features'];
};
// ./src/client/ssr/appData.ts
import { createContext } from 'react';
import { AppData } from 'src/shared/types/app-data';
const AppDataContext = createContext<AppData>({} as AppData);
export { AppDataContext };
Чтобы не повторять подключение контекста в каждом экране, вынесем эту логику в компонент _app.tsx
. Для удобства я реализую этот компонент через класс, но это необязательно.
// ./src/pages/_app.tsx
import NextApp, { AppProps } from 'next/app';
import { AppDataContext } from 'src/client/ssr/appData';
import { AppData } from 'src/shared/types/app-data';
class App extends NextApp<AppProps> {
appData: AppData;
constructor(props: AppProps) {
super(props);
this.appData = props.pageProps.appData || {};
}
render() {
const { Component, pageProps } = this.props;
return (
<AppDataContext.Provider value={this.appData}>
<Component {...pageProps} />
</AppDataContext.Provider>
);
}
}
export default App;
Немного поменяем buildServerSideProps
:
// ./src/client/ssr/buildServerSideProps.ts
import { AppData } from 'src/shared/types/app-data';
// ...
type StaticProps = {
appData: Partial<AppData>;
};
// ...
return {
props: {
...props,
appData: {
features,
},
},
};
И вынесем доступ к AppDataContext в отдельный хук.
// ./src/client/ssr/useAppData.ts
import { useContext } from 'react';
import { AppDataContext } from './appData';
const useAppData = () => {
return useContext(AppDataContext);
};
export { useAppData };
Доступ к частям контекста
Наконец, реализуем хук useFeature
и применим его на Home странице.
// ./src/client/hooks/useFeature.ts
import { useAppData } from 'src/client/ssr/useAppData';
const useFeature = (feature: string, defaultValue = false) => {
return useAppData().features[feature] || defaultValue;
};
export { useFeature };
// ./src/pages/index.tsx
const Home: FC<THomeProps> = ({ blogPosts }) => {
const linkFeature = useFeature('blog_link');
return (
<div>
<h1>Home</h1>
{blogPosts.map(({ title, id }) => (
<div key={id}>
{linkFeature ? (
<>
{title}
<Link href={`/${id}`}> Link</Link>
</>
) : (
<Link href={`/${id}`}>{title}</Link>
)}
</div>
))}
</div>
);
};
Проверим в браузере: теперь по адресу localhost:3000
мы должны увидеть измененный формат ссылки - надпись Link рядом будет ссылкой, в то время как название просто текстом. Изменим значение в конфиге, дождемся перезапуска сервера и повторим эксперимент - теперь мы видим старый формат списка.
Работа клиентских переходов
Внимательный разработчик после добавления такой возможности попробует осуществить клиентский переход: ведь мы сильно поменяли метод GSSP, нужно проверить его работу. Опасения подтвердятся - браузер перезагружает страницу при переходе, а в терминале сервера ошибка: невозможно сериализовать поле appData.features
- оно ожидаемо приходит undefined
при клиентском переходе. Связано это с тем, что контроллер nest не вызывается, соответственно, интерсептор не подкладывает значение конфига в GSSP.
Применяйте такое прокидывание данных в GSSP только для SSR, чтобы разово разместить данные на клиенте, а в методах GSSP не закладывайтесь на их присутствие в запросе. Такими данными могут быть фиче-флаги, переводы, прочие конфиги.
Чтобы избавиться от такой ошибки, обернем возвращаемое значение buildServerSideProps
в рекурсивный метод очистки несериализуемых значений.
// ./src/client/ssr/filterUnserializable.ts
const filterUnserializable = (
obj: Record<string, unknown>,
filteredValues: unknown[] = [undefined],
): Record<string, unknown> => {
return Object.keys(obj).reduce<Record<string, unknown>>((ret, key) => {
if (typeof obj[key] === 'object' && obj[key] !== null) {
return {
...ret,
[key]: filterUnserializable(obj[key] as Record<string, unknown>),
};
} else if (!filteredValues.includes(obj[key])) {
return { ...ret, [key]: obj[key] };
}
return ret;
}, {});
};
export { filterUnserializable };
// ./src/client/ssr/buildServerSideProps
import { filterUnserializable } from './filterUnserializable';
// ...
return {
props: {
...(await getServerSideProps(ctx)),
appData: filterUnserializable({ features }) as StaticProps['appData'],
},
};
Убедимся в работе переходов - действительно, теперь все в порядке.
Возможность легко передавать на клиент арбитрарные данные для работы приложения является одним из самых сильных преимуществ при работе с nest-next. При этом источником этих данных может служить что угодно, в том числе значения, которые express-middleware подкладывает в
req
, что может быть удобно при разработке в энтерпрайзе с уже имеющимися решениями. Изменения в фиче-флагах/переводах/конфигурации, вносимые в CMS, могут быть отображены на клиенте почти мгновенно.
Разворачивание "за слешом"
Предположим, что разрабатываемый сервис в продакшене разворачивается "за слешом". Например, мы делаем документацию для какого-то проекта и сервис будет находится за прокси с адресом /docs
. Или, перекладывая на текущее приложение - отдел с блогом - нашим префиксом будет /blog
.
Что нужно сделать, чтобы поддержать такую возможность? Необходимо добавить префикс во все переходы (ссылки), а также к запросам к серверу, но только с клиента (не в GSSP). Статика будет лежать на CDN, для нас не будет это проблемой. Кажется, все это в наших силах, у нас даже есть механизм подкладывания данных на клиент при SSR.
Но тут мы вспоминаем, что NEXT.js при переходах делает запрос во внутренний эндпоинт, который исполняет GSSP на сервере и возвращает сериализованные данные для следующей страницы. На этом наши полномочия заканчиваются, клиентские переходы будут неизбежно сломаны. А если мы не будем пользоваться CDN, то сломается и вся статика - это совершенно не годится.
В документации NEXT.js мы обнаружим параметр basePath
, который "из коробки" добавляет всем клиентским переходам необходимый префикс, а также добавляет его во все запросы внутренним эндпоинтам сервера NEXT.js. Отлично, план есть, приступаем.
Создание proxy для разработки
Прежде чем мы начнем добавлять поддержку basePath
в проект, нам следует написать простенький proxy-сервер, чтобы проверить работу нашего сайта. Воспользуемся Docker и nginx. Создадим нужный конфиг для nginx.
# ./nginx.conf
server {
listen 8080;
location /blog/ {
proxy_pass http://localnode:3000/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
}
Важно понимать, что proxy будет запускаться в Docker-контейнере с собственной сетью, поэтому для проксирования запроса к нашему серверу нам потребуется адрес хост-машины: будем помещать его в контейнер через имя
localnode
.
В скрипты в package.json
добавим скрипт start:proxy
для запуска контейнера со следующей командой:
docker run --name nest-next-example-proxy \
-v $(pwd)/nginx.conf:/etc/nginx/conf.d/default.conf:ro \
--add-host localnode:$(ifconfig en0 | grep inet | grep -v inet6 | awk '{print $2}') \
-p 8080:8080 \
-d nginx
Добавление basePath в конфигурацию
Добавим поддержку basePath
в конфигурацию сервера. Обновим типы и будем брать значение из переменной окружения BASE_PATH
.
// ./src/shared/types/config.ts
export type Config = {
features: Record<string, boolean>;
basePath: string;
};
// ./src/server/config.ts
import { Config } from 'src/shared/types/config';
const CONFIG: Config = {
features: {
blog_link: true,
},
basePath: process.env.BASE_PATH || '',
};
export { CONFIG };
Создадим next.config.js
- файл конфигурации NEXT.js. Поместим туда такое же поле.
// ./next.config.js
module.exports = {
basePath: process.env.BASE_PATH,
};
Proxy и basePath
Проверим работу сервиса. Перезапустим сервер разработки и вновь зайдем на localhost:8080/blog
. Снова запрос доходит до сервера, но не удается запросить статику NEXT.js. Сервер NEXT.js ожидает, что запрос придет с соответствующим basePath
в начале req.url
. Мы же при проксировании отрезаем эту часть запроса. Добавим отдельное правило для проксирования запросов на /blog/_next
без перезаписи адреса запроса.
server {
listen 8080;
location /blog/_next/ {
proxy_pass http://localnode:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
location /blog/ {
proxy_pass http://localnode:3000/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
}
Перезапускаем контейнер Docker и снова проверяем сервис в браузере. К большому сожалению, опять не работает. Здесь мы встречаем ограничение реализации модуля nest-next.
Проблема связана с тем, что nest-next устанавливает фильтр nest, который перенаправляет необработанные контроллерами запросы обработчикам NEXT.js. В исходном коде фильтра вызывается обработчик ошибки во всех запросах, которые не начинаются на
/_next
. Получается, сервер NEXT.js ожидает запрос, начинающийся наbasePath
, а nest-next проксирует только запросы, начинающиеся на/_next
.
Мною был открыт PR на добавление поддержки basePath
в nest-next. Он был вмерджен автором пакета, но новая версия не была собрана. Пока нет версии, можно загрузить собранную версию с GitHub следующим способом.
yarn upgrade nest-next@https://github.com/yakovlev-alexey/nest-next/tarball/base-path-dist
Этот тег
base-path-dist
содержит в себе собранный пакет только с необходимыми файлами.
После обновления версии пакета проверим в браузере вебсайт: наконец-то мы видим знакомую страницу Home уже по адресу localhost:8080/blog
. Проверим переход - тоже работает!
Обертка над fetch
Остается только добавлять basePath
к запросам из fetch
. Сейчас может показаться, что в этом нет необходимости, можно просто использовать уже имеющуюся обертку над fetch
в GSSP, а в остальных случаях обычный fetch
. Но что, если логика запросов находится в стейт-менеджере и может вызываться как на сервере, так и на клиенте? В таком случае нам действительно нужно в зависимости от среды исполнения выполнять дополнительные проверки на адрес запроса.
Первым делом, немного отрефакторим buildServerSideProps
. Обновим тайпинги для AppData
и вынесем метод по извлечению appData
из ctx.query
в отдельный метод.
// ./src/shared/types/app-data.ts
import { Config } from './config';
export type AppData = Pick<Config, 'basePath' | 'features'>;
// ./src/client/ssr/extractAppData.ts
import { GetServerSidePropsContext } from 'src/shared/types/next';
import { AppData } from 'src/shared/types/app-data';
import { filterUnserializable } from './filterUnserializable';
import { StaticQuery } from './buildServerSideProps';
const extractAppData = (
ctx: GetServerSidePropsContext<Partial<StaticQuery>>,
) => {
const { features, basePath } = ctx.query.config || {};
return filterUnserializable({ features, basePath }) as Partial<AppData>;
};
export { extractAppData };
Подключим новый хелпер в buildServerSideProps
.
// ./src/client/ssr/buildServerSideProps.ts
import { extractAppData } from './extractAppData';
// ...
const buildServerSideProps = <P, Q extends ParsedUrlQuery = ParsedUrlQuery>(
getServerSideProps: (ctx: GetServerSidePropsContext<Q>) => Promise<P>,
): GetServerSideProps<Partial<StaticProps> & P, Partial<StaticQuery> & Q> => {
return async (ctx) => {
const props = await getServerSideProps(ctx);
return {
props: {
...props,
appData: extractAppData(ctx),
},
};
};
};
export { buildServerSideProps };
Наконец, у нас есть доступ к basePath
на клиенте. Осталось добавить поддержку этого значения в fetch
. Я не буду заморачиваться с хитрым решением, а просто превращу наш envAwareFetch
из чистой функции в функцию с побочными эффектами. Отобразим изменения в fetch.ts
.
// ./src/shared/utils/fetch.ts
import { isServer, PORT } from '../constants/env';
type FetchContext = {
basePath: string;
};
const context: FetchContext = {
basePath: '',
};
const initializeFetch = (basePath: string) => {
context.basePath = basePath;
};
const getFetchUrl = (url: string) => {
if (isServer) {
// на сервере не нужно добавлять basePath - запрос делается не через proxy
return url.startsWith('/') ? `http://localhost:${PORT}${url}` : url;
}
return url.startsWith('/') ? context.basePath + url : url;
};
const envAwareFetch = (url: string, options?: Partial<RequestInit>) => {
const fetchUrl = getFetchUrl(url);
return fetch(fetchUrl, options).then((res) => res.json());
};
export { envAwareFetch as fetch, initializeFetch };
Остается только инициализировать fetch с помощью initializeFetch
. Кажется, что это можно было бы сделать в GSSP, но этот метод исполняется только на сервере, а нам необходимо добавлять basePath
как раз на клиенте, поэтому в качестве места вызова метода я выбрал конструктор _app.tsx
.
// ./src/pages/_app.tsx
constructor(props: AppProps) {
super(props);
this.appData = props.pageProps.appData || {};
initializeFetch(this.appData.basePath);
}
Чтобы проверить, можем добавить запрос в эффект на клиенте в index.tsx
.
Таким образом можно задеплоить nest-next приложение "за слешом", не теряя преимуществ ни одного из фреймворков.
Заключение
Работая в симбиозе, nest и NEXT.js являются очень сильным инструментом разработки веб-приложений и сервисов. Надеюсь, что данная статья поможет желающим попробовать эти фреймворки вместе войти в разработку с максимальной эффективностью несмотря на скудность официальной документации nest-next. Также надеюсь, что и опытные пользователи этой комбинации инструментов нашли для себя в статье что-то новое.