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