NEST-NEXT: Best Practices — Часть 2

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру 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. Также надеюсь, что и опытные пользователи этой комбинации инструментов нашли для себя в статье что-то новое.

Источник: https://habr.com/ru/post/571032/


Интересные статьи

Интересные статьи

В прошлой заметке я вспомнил несколько популярных игр, в которые мы самозабвенно рубились на «Поисках» и «Искрах» на заре 90-х. Но время шло, и постепенно парк доступной нам техники обн...
CosmicWatch — это проект Массачусетского технологического института из США и Национального центра ядерных исследований Польши. Он позволяет всем желающим с базовыми навыками в электрон...
В принципе, я согласен с комментариями, что данная тема излишняя, так как существуют автоматические инструменты форматирования кодаИ к тому же у каждого своё мнение о кра...
Итак, это третья часть моей попытки переосмыслить привычный поиск по картам. Первая часть тут, а вторая тут — они более технические, но пробежать глазами для лучшего понимания можно. Вкратце это ...
Сегодня мы представляем вам первую часть перевода этого большого материала. Он посвящён детальному разбору новых возможностей React, которые появились в этой библиотеке начиная с версии 16. Автор...