Испытание Astro SSR и Astro 1.0 Hackaton

Ух, как же давно я не писал в блог.

Пожалуйста, проголосуйте за этот проект в хакатоне Astro 1.0: https://forms.gle/1ATrxGcRbFxJpjr2A Выберите passle-courses в категории «Лучшее использование SSR».

Я немного поиграл с Astro в самом начале его существования, но, к сожалению, я так и не смог найти оправдание, чтобы построить что-нибудь на его основе. До недавнего времени, когда команда Astro выпустила свой новый режим SSR. Это казалось хорошим временем, чтобы попробовать Astro еще раз, и не успел я оглянуться, как создал половину сайта по продаже курсов.

В этом блоге я расскажу:

  • краткое введение в Astro SSR
  • мой личный опыт создания реального приложения с помощью Astro SSR
  • некоторые проблемы, с которыми я столкнулся, в надежде обеспечить обратную связь для официального релиза
  • демонстрация приложения, которое я создал.

Начало работы

Я начал свой проект с запуска npm init astro@latest. Будучи поклонником минимального инструментария сборки, сложных настроек проекта и большого количества конфигурационных файлов, я был рад получить в качестве одного из вариантов стартовый проект Minimal:

? Which app template would you like to use? › - Use arrow-keys. Return to submit.
    Starter Kit (Generic)
    Blog
    Documentation
    Portfolio
❯   Minimal

Войти в полноэкранный режим Выйти из полноэкранного режима

Да, черт возьми.

Как и было обещано, стартовый проект Minimal предоставил мне очень хорошую и чистую структуру проекта для начала работы. Хорошо, теперь перейдем к… Что именно? Имея небольшой опыт работы с Astro, и не совсем понимая, как делается SSR, я решил посмотреть документацию. На момент релиза было доступно не так много документации. Был блог анонса, очень краткая страница документации, и блог Netlify. Скудный выбор, поэтому я решил, что буду вести блог Netlify, потому что он казался достаточно простым, и я с удовольствием использовал Netlify в прошлом.

Согласно инструкциям, я установил необходимые зависимости:

npm i -S @astrojs/netlify
Войти в полноэкранный режим Выйти из полноэкранного режима

Обновил свой конфиг:

import { defineConfig } from 'astro/config';
+ import netlify from '@astrojs/netlify/functions';

export default defineConfig({
+  adapter: netlify()
});

Войти в полноэкранный режим Выйти из полноэкранного режима

Запустил npm start и…

error Invalid URL
Вход в полноэкранный режим Выйти из полноэкранного режима

Столкнулся с ошибкой.

Боль от кровоточащего края

Ах, как больно быть на краю пропасти. При тестировании бета-версий проектов вполне естественно сталкиваться с некоторыми проблемами то тут, то там; в этом, собственно, и заключается весь смысл. Проекты могут собирать ценную обратную связь от сообщества и выявлять проблемы на ранней стадии, а для пользователей это отличный способ узнать о новых функциях. В ходе работы над этим проектом я столкнулся с несколькими проблемами, которые, надеюсь, внесут конструктивный вклад в официальный стабильный релиз.

В случае с Invalid URL; блог Netlify не упомянул об этом, но, очевидно, вы должны настроить свойство site в astro.config.mjs, например:

import { defineConfig } from 'astro/config';
import netlify from '@astrojs/netlify/functions';

export default defineConfig({
+ site: 'https://example.com',
  adapter: netlify()
});
Войти в полноэкранный режим Выйти из полноэкранного режима

Для меня это было не совсем понятно, потому что на момент создания проекта у меня еще не было сайта; я едва начал его создавать! К счастью, с тех пор сообщение об ошибке стало более удобным для пользователя.

Слишком много куки вредно для вас

Еще одна небольшая проблема, с которой я столкнулся, пытаясь установить несколько файлов cookie, заключалась в том, что только один из них по какой-то причине попадал в браузер.

К счастью, в Astro discord очень живое сообщество, где даже члены основной команды готовы помочь, поэтому я создал проблему на Github, и она была очень быстро решена и выпущена. (Спасибо, Мэтью!)

Пользовательские элементы всегда отображаются неопределенными

Позже в проекте я обнаружил еще одну ошибку при попытке отрисовки пользовательского элемента, например: <my-el></my-el> в браузере всегда выводился <my-el>undefined</my-el>, независимо от того, был ли пользовательский элемент обновлен или нет. Я создал проблему для этой проблемы здесь. К счастью, я смог обойти эту проблему с помощью супер хакерского решения 😄.

connectedCallback() {
  this.innerHTML = '';
}
Войдите в полноэкранный режим Выйти из полноэкранного режима

Тело запроса недоступно после развертывания

Другая, немного более болезненная проблема, с которой я столкнулся, заключалась в том, что я обнаружил, что мои тела запросов недоступны только после сборки и развертывания в Netlify. Это проблема, поскольку моя аутентификация и другие маршруты зависят от URI перенаправления, способных обрабатывать данные в телах запросов. Я снова создал еще один вопрос на Github, с небольшим воспроизведением.

SSR это

Хорошо, давайте погрузимся в некоторые из приятных возможностей, которыми обладает Astro SSR. Есть два способа ответить на запросы:

  • index.astro
  • index.js

Используя файл .astro, вы можете использовать ваш frontmatter для выполнения кода на сервере, а затем вернуть HTML в ваш шаблон:

[pokemon].astro:

---
// Code in the frontmatter gets executed on the server
// Note how we have access to the `fetch` api, as well as top level await here

const pokemon = await fetch(`https://pokeapi.co/api/v2/pokemon/${Astro.params.pokemon}`).then(r => r.json());
---
<html>
  <body>
    <h1>{pokemon.name}</h1>
  </body>
</html>
Вход в полноэкранный режим Выход из полноэкранного режима

Или, используя файл .js, мы можем создать обработчик маршрута, например:

[pokemon].js:

export async function get({pokemon}, request) {
  const pokemon = await fetch(`https://pokeapi.co/api/v2/pokemon/${pokemon}`).then(r => r.json());

  return new Response(null, {
    status: 200,
    body: JSON.stringify({ pokemon })
  });
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Обратите внимание, что первым аргументом здесь является параметр route, который берется из имени файла: [pokemon].js. Если вы используете «обычное» имя файла, например: pokemon.js, это будет неопределено. Второй аргумент — это обычный объект Request, к которому вы можете использовать все ваши привычные методы, например: await request.json(), например.

Обработчики маршрутов также полезны для обработки перенаправлений, настроек cookies и т.д:

export function get() {
  const headers = new Headers();

  headers.append('Set-Cookie', 'foo="bar";');
  headers.append('Location', '/');

  return new Response(null, {
    status: 302,
    headers,
  });
}
Вход в полноэкранный режим Выход из полноэкранного режима

Обратная связь

Неиспользуемые параметры маршрута

Как упоминалось выше, первый аргумент, который передается вашим обработчикам маршрутов — это параметры маршрута. Однако, если вы не используете параметры маршрута, они будут неопределены. В моем проекте мне пришлось создать довольно много обработчиков маршрутов, и я обнаружил, что использую параметры маршрута очень редко, что приводит к неудобной сигнатуре функции:

// Always have to ignore the first arg :(
export function get(_, request) {}
Вход в полноэкранный режим Выйти из полноэкранного режима

Переменные окружения

Еще один момент: Если в вашем проекте есть файл .env, вы можете получить доступ к ним в ваших астро-файлах, используя import.meta.env. Однако, похоже, это работает только для кода, который выполняется на сервере. Это не работает для кода, который выполняется в браузере, например, было бы неплохо использовать эти переменные env для инициализации кнопки Sign In With Google:

---
// some frontmatter etc
---
<html>
  <body>
    <script>
      google.accounts.id.initialize({ 
        // wont work ☹️
        client_id: import.meta.env.GOOGLE_CLIENT_ID, 
        login_uri: `${import.meta.env.APP_URL}/auth/success`
        ux_mode: "redirect", 
      });
    </script>
  </body>
</html> 
Вход в полноэкранный режим Выход из полноэкранного режима

Обновление: До меня дошло, что переменные окружения с префиксом PUBLIC_ открыты для клиента. В качестве альтернативы я мог бы использовать директиву шаблона define:vars.

Перенаправление с сообщением об ошибке

В некоторых обработчиках маршрутов мне приходится обрабатывать довольно много ошибок. Инстинктивно я пытался сделать что-то вроде:

export function get() {
  try {
    // error prone code 
  } catch {
    return new Response(JSON.stringify({message: 'something went wrong'}), {
      status: 302,
      headers: {'Location': '/error'}
    });
  }

  // etc
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Не по вине Astro, а скорее по вине спецификации HTTP, это не работает. После обсуждения того, как изящно справиться с этой ситуацией на Astro discord, Мэтью предложил, возможно, ввести Astro.flash:

На данный момент я реализовал перенаправление ошибок следующим образом:

return new Response('', {
  status: 302,
  headers: {'Location': '/error?code=SOME_ERR_ENUM'}
});
Войти в полноэкранный режим Выход из полноэкранного режима

Где, во frontmatter’е error.astro, я отображаю SOME_ERR_ENUM в полезное сообщение об ошибке для пользователя.

Это также подводит меня к другому вопросу: во frontmatter файлов .astro есть глобальный Astro, который можно использовать, например, для перенаправления: return Astro.redirect('/');. Насколько я могу судить, глобал Astro недоступен в обработчиках маршрутов. Было бы очень хорошо иметь доступ к Astro.flash или Astro.redirect в обработчиках маршрутов. Например:

route.js:

export function get() {
  try {
    // error prone code
  } catch {
    return Astro.flash('/error', 'error message');
  }

  return Astro.redirect('/success');
}
Войти в полноэкранный режим Выход из полноэкранного режима

Middleware

Самой большой недостающей частью головоломки является промежуточное ПО. Несколько раз я обнаруживал необходимость в промежуточном ПО, но не знал, как добиться чего-то подобного. Например, были определенные активы, которые я хотел бы обслуживать только тогда, когда пользователь аутентифицирован, и я надеялся, что смогу сделать что-то вроде:

/protected/[...assets]/index.js:

import { isLoggedIn } from '../../utils/auth.js';

export async function get(_, request) {
  const { authed } = await isLoggedIn(request);

  const url = new URL(request.url);
  const protectedRoutes = new URLPattern({pathname: '/protected/:image'});
  const match = protectedRoutes.exec(url);

  // We have a match, this is a 'protected' asset
  if(match) {
    if(authed) {
      // If user is authenticated, pass the request along
      return fetch(request);
    } else {
      // If user is not authenticated, forbidden
      return new Response(null, {status: 403});
    }
  }

  // request didnt match any protected assets, pass it on as normal
  return fetch(request);
}
Войти в полноэкранный режим Выход из полноэкранного режима

Или, возможно, что-то вроде:

export const get = [
  authMiddleware,
  async (_, request) => {
    // route handler etc
  }
];
Войти в полноэкранный режим Выйти из полноэкранного режима

Но это оказалось пока невозможным. Надеюсь, что-то подобное будет реализовано в будущем, я создал RFC здесь.

А пока (и в основном просто для развлечения) я создал небольшой пакет: https://github.com/thepassle/astro-router.

/sales/[...all]/index.js:

import { router } from 'astro-router';
import { auth, logger } from './middleware.js';
import { User, Order } from './db.js';

export const get = router({
  routes: [
    {
      path: '/sales/:user/:order',
      middleware: [logger, auth],
      response({params}) {
        const user = await User.findOne({id: params.user});
        const order = await Order.findOne({id: params.order});

        return new Response(null, {status: 200});
      }
    }
  ]
})
Вход в полноэкранный режим Выход из полноэкранного режима

Витрина приложений: Сайт продажи курсов

Ладно, хватит технических подробностей, давайте посмотрим на сайт по продаже курсов, который я создал с помощью Astro SSR. Я давно хотел найти хороший способ создать и продать несколько онлайн курсов, и я тихонько искал способ начать это делать. Это было что-то, что было на заднем плане моего сознания уже некоторое время, и Astro SSR показался мне хорошим поводом для того, чтобы потратить некоторое время на погружение в это.

Для того чтобы создать этот сайт по продаже курсов, я использовал следующие технологии:

  • Вход в систему с помощью Google/JWT для аутентификации, используя google-auth-library и jsonwebtoken.
  • Бесплатный уровень MongoDB на CleverCloud, который я взаимодействую с mongoose.
  • Mollie Payments для работы с подписками и (повторяющимися) платежами
  • Astro SSR, конечно же
  • Lit в качестве хоста компонентов, monaco-editor для интерактивного редактора упражнений во фронтенде, и typescript для синтаксического анализа
  • Netlify для хостинга/развертывания

В то же время, это был бы классный проект для демонстрации возможностей Astro SSR, таких как:

  • Динамические маршруты
  • Аутентификация
  • Обработка маршрутов
  • Перенаправления

Домашняя страница

Аутентификация

Для аутентификации я использовал библиотеку google-auth-library, работа с которой, поиск информации о ней и ее использование были сплошным мучением. Однако, когда я наконец-то настроил аутентификацию, я смог добавить несколько полезных функций для моих защищенных страниц. Например, я хочу, чтобы только аутентифицированные пользователи и пользователи с активной подпиской могли получить доступ к материалам курса.

chapter-1.astro:

---
import { isLoggedIn } from '../pages/utils/auth.js';
const { authed, active } = await isLoggedIn(Astro.request);

if(!authed && !active) {
  return Astro.redirect('/');
}
---
<html>
  <!-- course content -->
</html>
Вход в полноэкранный режим Выход из полноэкранного режима

Подписка

Для подписки я использовал Mollie в качестве платежного процессора. Прежде всего, нужно сказать, что документация по API Mollie до смешного хороша. Я использовал API Mollie для создания платежа, а затем использовал веб-крючки Mollie для обработки обновлений статуса платежа. Я также использовал веб-крючки для обработки повторяющихся платежей. Их очень понятная документация позволила реализовать все это с легкостью.

API Mollie

Mollie поставляет свой собственный клиент Node API, но я использовал API напрямую.

Чтобы создать подписку для пользователя с повторяющимися платежами, я делаю следующее:

  • Получите текущего пользователя из базы данных и посмотрите, есть ли у него уже mollieId.
    • Если у них нет mollieId, я должен создать Mollie Customer, что даст мне mollieId, который я затем сохраню для пользователя из базы данных.
  • Затем я создаю платеж Mollie, используя mollieId.
  • Затем я также создаю специальный ActivationToken, и сохраняю его в своей базе данных, который автоматически истекает со временем. Причина в том, что платеж Mollie будет вести на URI перенаправления, где я активирую учетную запись подписки пользователя. Если кто-то узнает URI перенаправления, он может просто перейти по этому url и получить бесплатную подписку. Однако проверка наличия ActivationToken предотвращает это.

Это имитация данных

Динамическая маршрутизация

Здесь я также хочу рассказать о замечательной функции Astro SSR: параметрах маршрута. Перед совершением платежа я создаю уникальный ActivationToken, который я использую как часть моего Mollie redirectUrl, например, ${import.meta.env.APP_URL}/mollie/${token}/cb.

Теперь я могу использовать параметры маршрута Astro для обработки этого:

/mollie/[token]/cb.js:

export async function get({token}, req) {
  const activationToken = await ActivationToken.findOne({token});

  if(!activationToken) {
    return new Response(null, {
      status: 302, 
      headers: {
        'Location': '/error?code=INVALID_ACTIVATION_TOKEN'
        }
      });
  }

  // Token is valid, we can activate the user
}
Войти в полноэкранный режим Выход из полноэкранного режима

Если ActivationToken действителен, я могу теперь активировать подписку на объект пользователя в базе данных, что дает ему доступ к защищенным маршрутам моего приложения, содержащим содержимое курса.

Тестирование веб-крючков

В качестве небольшого забавного дополнения, сервер Mollie’s, естественно, не может отправлять запросы моему обработчику webhook, когда я запускаю свое приложение локально. Чтобы обойти это, я создал небольшую имитацию API-страницы, которая посылает сообщения моему обработчику webhook, которые я могу использовать для имитации любых запросов и перезаписи транзакции:

webhook-test.astro:

---
if(import.meta.env.ENV !== 'dev') {
  return Astro.redirect('/');
}
---
<html>
  <!-- buttons etc -->
</html>
Войти в полноэкранный режим Выход из полноэкранного режима

И в моем обработчике вебхука:

if (import.meta.env.ENV === 'dev' && body?.mock) {
  transaction = {
    ...transaction,
    ...body.mock
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Отписаться от рассылки

Содержание курса

Содержание курса состоит из двух частей: теория и интерактивные упражнения. Для интерактивных упражнений я использовал Lit, monaco-editor и typescript. Возможно, для этой части мне и не нужен был Lit, но я продуктивно работаю с ним, так что это был легкий выбор.

То, как я загружаю содержимое курса, отлично использует динамическую маршрутизацию Astro SSR, и я даже был удивлен, узнав, что в Astro у нас есть доступ к таким новым функциям, как URLPattern! Используя следующую структуру: /sw/[...i]/index.astro (обратите внимание на ...), по сути, будет действовать как универсальный, и будет соответствовать любому запросу под /sw/, поэтому /sw/foo, но также и /sw/foo/bar.

Затем я могу использовать URLPattern для извлечения главы и урока:

/sw/[...i]/index.astro:

const urlPattern = new URLPattern({pathname: '/sw/chapter/:chapter/lesson/:lesson'});
const match = urlPattern.exec(new URL(Astro.request.url));
const { chapter, lesson } = match.pathname.groups;

const currentLesson = courseIndex[chapter].lessons[lesson];
Вход в полноэкранный режим Выход из полноэкранного режима

Я также убеждаюсь, что пользователь аутентифицирован и имеет активную подписку, а затем создаю соответствующую страницу содержимого: либо <Theory/>, содержащую некоторые разметки, либо <InteractiveExercise/>. Я также передаю некоторую дополнительную информацию о текущем уроке, например, title, а также информацию о следующем уроке.

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

function isServiceWorkerRegisterCall(ts, node) {
  if (
    ts.isCallExpression(node) &&
    ts.isPropertyAccessExpression(node.expression) &&
    node?.expression?.expression?.expression?.getText?.() === 'navigator' && 
    node?.expression?.expression?.name?.getText?.() === 'serviceWorker' &&
    node?.expression?.name?.getText?.() === 'register'
  ) {
    return true;
  }
  return false;
}

export const validators = [
  {
    title: 'Register a service worker',
    validate: ({ ts, node, context }) => isServiceWorkerRegisterCall(ts, node)
  },
  {
    title: 'Register "./sw.js"',
    validate: ({ts, node}) => {
      if(isServiceWorkerRegisterCall(ts, node)) {
        if (node?.arguments?.[0]?.text === './sw.js') {
          return true;
        }
      }
      return false;
    }
  }
]
Войти в полноэкранный режим Выйти из полноэкранного режима

Поскольку monaco-editor и typescript (даже в комплекте) представляют собой довольно большие файлы, я также создал простой рабочий сервис для кэширования этих больших файлов и обеспечения хорошей производительности:

// etc

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHENAME).then((cache) => {
      return cache.addAll([
        './monaco-editor.js',
        './ts.worker.js',
        './typescript.js',
      ]);
    })
  );
});
Вход в полноэкранный режим Выход из полноэкранного режима

Серверный рендеринг веб-компонентов?!

Я также добавил некоторую интерактивность в виде викторин, используя серверные веб-компоненты. Использование интеграции SSR от Astro для Lit позволило сделать это очень просто:

import lit from '@astrojs/lit';

export default {
  // ...
  integrations: [lit()],
}
Войти в полноэкранный режим Выход из полноэкранного режима

Панель администратора

Наконец, я создал панель администратора, чтобы можно было легко увидеть количество активных подписок, а также получить статус и информацию о подписках и платежах пользователя.

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


Заключение

Работа с Astro SSR была очень увлекательной. Будучи в основном фронтенд разработчиком, было приятно немного выйти из своей зоны комфорта и сделать больше работы на стороне сервера. В качестве дополнительного приятного результата, я обнаружил, что я почти не использовал JS на стороне клиента для большей части сайта, а только HTML и CSS. Очевидно, что интерактивный редактор использует немного JS на стороне клиента, но это только небольшая часть приложения. Кроме того, Astro SSR был очень прост в освоении, использовании и продуктивной работе. Прежде чем я осознал это, у меня уже была собрана половина курса по продаже сайта, и я очень рад, что сделал это.

В настоящее время проект является минимальным жизнеспособным продуктом, и все функции (такие как повторяющиеся платежи и т.д.) действительно функциональны, а не «придуманные», но я буду работать над ним с течением времени, чтобы отполировать его, улучшить стиль и добавить больше функций. Испытание Astro SSR было именно тем толчком, который мне был необходим, чтобы начать его создание.

Хакатон Astro 1.0

О, и еще кое-что…

Это также моя заявка на участие в Astro Hackathon! Я буду дорабатывать некоторые вещи, улучшать стилистику и т.д., а также чистить код.

Вы можете найти исходный код на github, а приложение здесь

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *