Отдаем корректный код 404 в связке VUE SPA + SSR

    Есть у меня один сайт, как сейчас говорят, пет-проект. Был написан в далеком 2013 году, что называется "на коленке" без использования каких-то фреймворков. Только php, только хардкор. Но тем не менее, функции свои выполнял, даже обрел некую популярность в узких кругах и был неплохо проиндексирован.

    Недавно было решено начисто переписать его на современном стеке. Выбор пал на Laravel и Vue с серверным рендером. Сказано — сделано. Сайт переписан, развернут на vps, работает. Но есть одно но. В яндекс-метрике остались тысячи ссылок, которые на текущий момент не актуальны, но эти адреса возвращают код 200 и поисковый бот снова и снова их проверяет. Не хорошо.

    Итак, проблема обозначена. Посмотрим на используемый стек технологий, чтобы понять что к чему.

    Подготовка

    Laravel используется исключительно в качестве API, на сервере висит на localhost:81, а nginx проксирует к нему маршруты /api . Здесь ничего не сделать.

    Фронтэнд написан с использованием фреймворка quasar. Это невероятно крутая вещь, которая может собрать вам сайт или приложение под несколько платформ. Я использую платформу SSR. В этом случае квазар собирает весь фронт, плюс генерирует nodejs-сервер на базе express. Этот сервер у меня запущен на localhost:3000 и опять же nginx проксирует к нему все остальные запросы (кроме API).

    Чтобы говорить более предметно, давайте создадим простенький проект. Будем считать, что с установкой quasar/cli вы справитесь сами/

    quasar create q404

    В папке q404 будет создана стартовая заготовка проекта. Можно перейти в нее и запустить сервер разработки.

    cd q404
    quasar dev -m ssr

    Не заморачиваясь сильно на этом тестовом проекте, добавим вторую страницу AboutMe:

    pages/AboutMe.vue
    <script>
    export default {
      name: 'AboutMe',
    };
    </script>
    <template>
      <q-page padding>
        <h1>About me</h1>
      </q-page>
    </template>
    

    Соответствующий роут

    router/routes.js
    const routes = [
      {
        path     : '/',
        component: () => import('layouts/MainLayout'),
        children : [
          { path: '', component: () => import('pages/Index') },
          // Added:
          { path: 'about-me', component: () => import('pages/AboutMe') },
        ],
      },
    

    И заменим главное меню

    layouts/MainLayout.vue
    const linksData = [
      {
        title: 'Homepage',
        icon : 'code',
        link : { path: '/' },
      },
      {
        title: 'About Me',
        icon : 'code',
        link : { path: '/about-me' },
      },
      {
        title: '404 test',
        icon : 'code',
        link : { path: '/404' },
      },
    ];
    

    Для правильной работы следует еще поменять компонент EssentialLink.vue

    EssentialLink.vue
    <script>
       ...
       link: {
          type   : Object,
          default: null,
       },
       ...
    </script>
    <template>
      <q-item
          clickable
          :to="link"
      >
      ...
      </q-item>
    </template>

    Теперь все готово. Если сейчас мы запустим dev-сервер и откроем сайт, то увидим, что все работает, а заглянув в исходный код страницы убедимся, что и серверный рендер отрабатывает.

    Кроме одной проблемы — страница 404 возвращает нам код ответа 200.

    Поиск решения

    Поиск информации в интернете готовых к использованию решений не дал. В официальном репозитории квазара есть ишью где рекомендуют создать отдельный роут для 404 страницы и редиректить на нее. Это не всегда подходит, мне, например, хотелось бы, чтобы пользователь оставался на той же странице, которую запросил, но с отображением плашки "404 not found", т.е. чтобы url в адресной строке не менялся.

    Другой совет заключается в том, чтобы не использовать в приложении роут "*". Да. В этом случае при запросе несуществующей страницы сервер ответит кодом 404, но при навигации внутри приложения у нас теперь не будет красивой плашки 404. Не подходит.

    На тостере предлагалось в сервере express делать дополнительные запросы и отправлять при необходимости 404-й код. Но сами понимаете, такое себе решение. В топку.

    Встречались и еще советы разной степени полезности, но все они были отброшены как не подходящие по тем или иным причинам.

    Но как я люблю отвечать заказчикам на их хотелки — "для программиста нет ничего невозможного, чего бы он не мог сделать с кодом". Это наша вселенная, мы здесь боги.

    Решение

    Давайте еще раз сформулируем ТЗ. Мы хотим

    • отдавать 404 по несуществующим адресам (тем, что явно не прописаны в нашем роутере)

    • отдавать 404 по несуществующим эндпойнтам API

    • отдавать 404 при отсутствии запрошенной информации. Т.е. эндпойнт верный, но объекта в базе данных нет.

    • также не хотим отказываться от использования роута "*" на стороне клиента

    Решение на самом деле находится на поверхности.

    Посмотрим на на код сервера, который нам предлагает квазар:

    src-ssr/index.js
    ssr.renderToString({ req, res }, (err, html) => {
        if (err) {
          if (err.url) {
            res.redirect(err.url)
          }
          else if (err.code === 404) {
            // Should reach here only if no "catch-all" route
            // is defined in /src/routes
            res.status(404).send('404 | Page Not Found')
          }
          else {
            // Render Error Page or
            // create a route (/src/routes) for an error page and redirect to it
            res.status(500).send('500 | Internal Server Error')
            if (ssr.settings.debug) {
              console.error(`500 on ${req.url}`)
              console.error(err)
              console.error(err.stack)
            }
          }
        }
        else {
          res.send(html)
        }
    })
    

    Комментарий в ветке условия 404 предупреждает нас, что сюда мы попадем, только если не будем использовать роут "*". А также мы можем понять, что если фреймворк не выбрасывает нас сюда, то мы сами можем бросить ошибку с телом {code:404}.

    Квазар предлагает нам дополнительный хук — preFetch. Мы можем им воспользоваться, чтобы на стороне сервера выбросить нужную ошибку.

    Для задействования данной фичи, нужно раскомментировать в файле quasar.conf.js строку

    preFetch: true,

    В компоненте Error404.vue добавим код

    export default {
      name: 'Error404',
      preFetch({ ssrContext }) {
        if (ssrContext) {
          return Promise.reject({ code: 404 });
        }
      },
    };

    Теперь при отображении данного компонента будет выбрасываться ошибка и express сервер сможет поймать её и ответить кодом 404. Причем, ошибка будет выбрасываться только в контексте серверного рендера, на клиенте же, перейдя на не существующий адрес, мы увидим красивую заглушку NotFound.

    Первый и четвертый пункты требований мы выполнили.

    Теперь займемся обработкой api-вызовов. Подготовим Axios. Создадим инстанс, настроим его и привяжем Vue.

    boot/axios.js
    import Axios from 'axios';
    
    export default ({ Vue, ssrContext, store }) => {
    
      let axiosInstance = Axios.create({
        baseURL         : '/api',
        timeout         : 0,
        responseType    : 'json',
        responseEncoding: 'utf8',
        headers         : {
          'X-Requested-With': 'XMLHttpRequest',
          'Accept'          : 'application/json',
        },
    
        // Reject only if the status code is greater than or equal to specify here
        validateStatus: status => status < 500,
      });
      
      // ...
    
      Vue.axios = axiosInstance;
    }
    

    Здесь все стандартно — обозначаем базовый урл, типы ответов, кодировку, заголовки. Функция validateStatus определяет ответы с какими кодами считать ошибкой. Мы будем считать ошибками все коды 5xx. В этом случае сайт будет возвращать код 500 и соответствующее сообщение.

    Чтобы централизованно обрабатывать запросы к несуществующим эндпойнтам, добавим в эту конфигурацию перехватчик (interceptor в axios):

    //...
    axiosInstance.interceptors.response.use(response => {
      if (response.status >= 400) {
        if (ssrContext) {
          return Promise.reject({ code: response.status });
        } else {
          // store.commit('showErrorPage', response.status);
        }
      }
      return response.data;
    });

    К закомментированной строке вернемся позднее. Теперь Axios будет отклонять промис при ответах сервера с ошибками 4xx, и мы будем попадать в соответствующую ветку условия в сервере express чтобы вернуть правильный статус-код.

    Для примера модифицируем компонент AboutMe.vue, добавив в него запрос к нашему API. Так как апишки у нас сейчас нет, запрос вернет 404 ошибку.

    preFetch() {
      return Vue.axios.get('/test.json')
        .then(response => {
          console.log(response);
        });
    },

    Здесь два важных момента. Мы должны обязательно вернуть промис и мы не должны перехватывать ошибку, оставив это на откуп библиотеке Axios. Если нам нужно выполнить для данной страницы несколько запросов, можно обернуть их в Promise.all.

    Теперь, если мы перейдем на адрес /about-me, и обновим страницу, то увидим в панели разработчика браузера, что запрос страницы возвращает ответ с кодом 404. То что нужно поисковым системам! Пункт два выполнен.

    Однако при внутреннем переходе на данную страницу пользователь никак не информируется о проблеме. Тут можно применить разные решения для отображения плашки 404. Я использовал следующее.

    Добавил в стор флаг

    showErrorPage: false,

    Мутацию

    export const showErrorPage = (state, show) => state.showErrorPage = show;

    И условие в компонент основной раскладки

    <q-page-container>
      <Error404 v-if="$store.state.example.showErrorPage"/>
      <router-view v-else/>
    </q-page-container>

    И возвращаясь к загрузчику Axios, раскомментируем там строку

    store.commit('showErrorPage', response.status);

    Еще в роутере придется добавить хук beforeEach для сброса этого флага (но только при работе в браузере)

    router/index.js
    export default function ({ store, ssrContext }) {
      const Router = new VueRouter({
        scrollBehavior: () => ({ x: 0, y: 0 }),
        routes,
        mode: process.env.VUE_ROUTER_MODE,
        base: process.env.VUE_ROUTER_BASE,
      });
    
      if (!ssrContext) {
        Router.beforeEach((to, from, next) => {
          store.commit('showErrorPage', false);
          next();
        });
      }
    
      return Router;
    }
    

    На данный момент мы реализовали 3 из 4-х пунктов технического задания.
    Что касается третьего пункта

    отдавать 404 при отсутствии запрошенной информации. Т.е. эндпойнт верный, но объекта в базе данных нет.

    то тут возможны варианты. Если вы делаете свой API, как положено, RESTful, то такой запрос обязан вернуть статус-код 404, что уже вписывается в построенную систему. Если же вы по каким-то причинам возвращаете объекты типа

    {
      "status": false,
      "message": "Object not found"
    }

    то можно добавить дополнительные проверки в перехватчик Axios.

    Еще кое-что

    Внимательный читатель заметил, что мы в перехватчике отклоняем промис таким образом:

    Promise.reject({ code: response.status });

    А значит должны немного доработать express-сервер

    else if (err.code >=400 && err.code < 500) {
      res.status(err.code).send(`${err.code} | ${getStatusMessage(err.code)}`);
    }

    Реализацию функции getStatusMessageрассматривать не будем =)
    Таким образом мы получили также возможность корректной обработки любых 4хх кодов ответа от API и в первую очередь нам конечно интересны 401 и 403.

    Заключение

    Вот, пожалуй, и все, что я хотел написать.

    Исходники тестового проекта закинул на гитхаб

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 2

      0

      Как то слишком много движений для простого проекта использовать кучу телодвижений и технологий...

        0
        Может я слишком подробно все описал )
        В сухой выжимке нужно отклонить промис в роутере на стороне сервера и поймать исключение в express сервере. Это безотносительно технологий. Все остальное — рюшечки, чтобы было красиво и удобно.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое