Мини-CMS на Fastify: Pug, htmx и MVC
Кому это: ты прошёл Fastify-трек раздела 08 (как минимум урок про основы и про плагины), умеешь поднять сервер, повесить роут и зарегистрировать плагин. Теперь хочешь собрать не JSON-API, а полноценное приложение, которое отдаёт человеку готовый HTML. Без React, без отдельной сборки фронтенда, без SPA.
Зачем это: в разделе 08 мы сознательно держали Fastify как систему плагинов над JSON-API. Но большая часть веба, это server-rendered страницы: формы, сессии, редиректы, flash-сообщения. Это другой жанр и другая дисциплина, чем API. Здесь мы соберём мини-CMS: список статей, страница статьи, создание и редактирование через формы, вход в админку. А чтобы получить живой интерфейс без целого SPA, добавим htmx: частичные обновления страницы прямо из атрибутов разметки.
Что соберём в итоге: рабочую CMS на пять экранов. Список статей с живым поиском, просмотр, форму создания и редактирования, вход. Под капотом будет MVC-раскладка по модулям, сессии, защита от CSRF и данные через типобезопасный SQL.
Как устроено: пять модулей, каждый самодостаточен. Можно остановиться на любом, и то, что собрано, уже работает.
- Фундамент веб-архитектуры. Чем server-rendered отличается от API, как Fastify раздаёт HTML, динамические маршруты.
- HTML-слой. Pug и лейауты через
@fastify/view, поисковые формы (GET), модифицирующие формы (POST), валидация. - Сессии и безопасность. Сессия в зашифрованной cookie, flash-сообщения, CSRF, экранирование, заголовки безопасности.
- CRUD и MVC. Именованные маршруты, разделение модели, вида и контроллера, данные через Kysely, awilix DI.
- Инфраструктура и htmx. Хуки и декораторы, частичные обновления через
hx-*, fragment-рендеринг Pug-партиалов, деплой.
Стек: Fastify 5, @fastify/view плюс Pug, htmx, @fastify/secure-session, @fastify/csrf-protection, @fastify/helmet, @fastify/awilix плюс awilix, Kysely поверх Postgres. Чтобы запускаться без отдельного сервера БД, в дев-режиме и тестах используем pglite.
Модуль 1 · Фундамент веб-архитектуры
Прежде чем писать шаблоны, договоримся, чем серверный рендеринг отличается от API, который мы строили в разделе 08.
В API обработчик возвращает данные:
app.get('/api/articles', async () => {
return articlesService.list(); // Fastify сам сериализует это в JSON
});
В server-rendered приложении обработчик возвращает HTML. Тот же роут, но на выходе строка разметки, которую браузер покажет как страницу:
app.get('/articles', async (request, reply) => {
const articles = await articlesService.list();
return reply.view('articles/index', { articles }); // рендер Pug-шаблона
});
Разница не только в формате ответа. Меняется вся модель взаимодействия. В API клиент сам решает, что делать с данными. В server-rendered мы сами решаем всё: какие ссылки показать, куда отправит форма, какой будет редирект после сохранения. Состояние между запросами держим в сессии, а не в localStorage браузера.
Базовый сервер мини-CMS выглядит как обычный Fastify, к которому мы будем по очереди прикручивать слои. Точка входа:
// src/server.ts
import { buildApp } from '#app.ts';
import { loadConfig } from '#config.ts';
const config = loadConfig();
const app = await buildApp({ config });
await app.listen({ port: config.port, host: config.host });
Вся сборка живёт в buildApp. Это корень композиции: создаём подключение к БД, регистрируем плагины в правильном порядке, вешаем роуты. К нему мы будем возвращаться в каждом модуле. Пока запомни главное: порядок регистрации важен. Сессия должна подняться раньше CSRF (он хранит секрет в сессии), вид раньше роутов (роуты зовут рендер).
Динамические маршруты
CMS живёт на slug’ах: /articles/moya-statya. Это динамический маршрут: имя статьи подставляется в путь, Fastify кладёт его в request.params.
app.get('/articles/:slug', async (request, reply) => {
const article = await articlesService.findBySlug(request.params.slug);
if (article === undefined) return reply.callNotFound();
return reply.view('articles/show', { article });
});
Slug мы генерируем из заголовка. Маленькая чистая функция, которую легко протестировать:
// src/shared/slugify.ts (фрагмент)
export const slugify = (input: string): string =>
[...input.toLowerCase()]
.map((char) => translit[char] ?? char) // кириллица -> латиница
.join('')
.replaceAll(/[^a-z0-9]+/g, '-') // всё лишнее в дефис
.replaceAll(/^-+|-+$/g, ''); // дефисы по краям убираем
Мост: базовый Fastify-сервер, радиксный роутер и обработчики мы подробно разбирали в уроке про основы Fastify. Здесь это фундамент, дальше строим вверх.
Модуль 2 · HTML-слой: Pug, лейауты и формы
Отдавать HTML руками из строк быстро превращается в ад экранирования. Поэтому берём шаблонизатор. Мы выбрали Pug: лаконичный, с наследованием лейаутов и партиалами.
Подключается он плагином @fastify/view. Заводим его как отдельный плагин инфраструктуры:
// src/server/plugins/view.ts
import { fileURLToPath } from 'node:url';
import fastifyStatic from '@fastify/static';
import view from '@fastify/view';
import fp from 'fastify-plugin';
import pug from 'pug';
const viewsRoot = fileURLToPath(new URL('../../views', import.meta.url));
const publicRoot = fileURLToPath(new URL('../../../public', import.meta.url));
const viewPlugin = fp(async (app) => {
await app.register(view, { engine: { pug }, root: viewsRoot, viewExt: 'pug' });
await app.register(fastifyStatic, { root: publicRoot, prefix: '/public/' });
});
export default viewPlugin;
Оборачиваем в fastify-plugin, потому что декоратор reply.view должен быть доступен всем роутам, а не только этому плагину. Про инкапсуляцию плагинов и про то, зачем её ломать через fp, был отдельный разговор в уроке про плагины и инкапсуляцию.
Лейаут и наследование
В Pug есть наследование: общий каркас в layout.pug, страницы его расширяют и заполняют блок content.
//- src/views/layout.pug
doctype html
html(lang="ru")
head
meta(charset="utf-8")
title= title ? title + ' · mini-CMS' : 'mini-CMS'
link(rel="stylesheet" href="/public/style.css")
script(src="https://unpkg.com/htmx.org@2.0.4" crossorigin="anonymous")
body
header.site-header
a.brand(href="/articles") mini-CMS
nav.site-nav
a(href="/articles") Статьи
if currentUser
a(href="/articles/new") Новая
form.inline(method="post" action="/logout")
input(type="hidden" name="_csrf" value=csrfToken)
button.linklike(type="submit")= 'Выйти (' + currentUser + ')'
else
a(href="/login") Войти
main.site-main
include partials/flash
block content
footer.site-footer
p Воркшоп fastify-mvc-cms · Fastify + Pug + htmx
Заметь две вещи, к которым вернёмся позже: в навигации мы проверяем currentUser (это из сессии), а в форме выхода лежит скрытое поле _csrf (это защита от подделки запросов). Сейчас просто держим их в голове.
Страница списка наследует лейаут и заполняет content:
//- src/views/articles/index.pug
extends ../layout
block content
h1 Статьи
form.search(role="search")
input(
type="search"
name="q"
value=q
placeholder="Поиск по заголовку"
hx-get="/articles"
hx-trigger="input changed delay:300ms, search"
hx-target="#article-list"
hx-swap="outerHTML"
)
include _list
Поисковая форма (GET)
Поиск, это читающая операция, поэтому форма с методом GET. Параметр уходит в query-строку (/articles?q=...), страницу можно сохранить в закладки и переслать. Контроллер читает request.query.q и фильтрует список:
app.get('/articles', async (request, reply) => {
const articles = await articlesService.list(request.query.q);
return reply.render('articles/index', { articles, q: request.query.q ?? '' });
});
Сам список выносим в партиал _list.pug. Префикс с подчёркиванием, это наша конвенция: партиал, не самостоятельная страница. Скоро именно его htmx будет подменять при живом поиске.
//- src/views/articles/_list.pug
#article-list.article-list
if !articles.length
p.empty Ничего не найдено.
each article in articles
article.card(id="article-" + article.slug)
h2
a(href="/articles/" + article.slug)= article.title
p.meta
span.status(class="status-" + article.status)= article.status
span.date= article.created_at
Обрати внимание на = article.title: знак равенства, это экранированный вывод. Если в заголовке окажется <script>, Pug превратит его в безопасный текст. К безопасности вернёмся в модуле 3, но привычку выводить через = закладываем сразу.
Модифицирующая форма (POST) и валидация
Создание статьи меняет состояние, значит метод POST. Форму одну переиспользуем и для создания, и для редактирования, разница только в action:
//- src/views/articles/form.pug
extends ../layout
block content
- var values = article || {}
h1= article && article.slug ? 'Редактировать статью' : 'Новая статья'
form.form(method="post" action=action)
input(type="hidden" name="_csrf" value=csrfToken)
label
span Заголовок
input(type="text" name="title" value=values.title || '' required)
if errors.title
p.field-error= errors.title
label
span Текст
textarea(name="body" rows="10")= values.body || ''
if errors.body
p.field-error= errors.body
.form-actions
button.btn(type="submit") Сохранить
a.btn.btn-ghost(href="/articles") Отмена
Тело формы приходит обычной application/x-www-form-urlencoded строкой. Чтобы Fastify распарсил её в объект, регистрируем @fastify/formbody. А вот доверять этому объекту нельзя: пользователь пришлёт что угодно. Поэтому валидируем на входе через zod. Fastify умеет валидировать и встроенным JSON Schema, это разбирали в уроке про валидацию и сериализацию; здесь для форм берём zod, чтобы тот же тип переиспользовать в сервисе:
// src/modules/articles/articles.schemas.ts
import { z } from 'zod';
export const articleInputSchema = z.object({
title: z.string().trim().min(1, 'Заголовок обязателен').max(160, 'Слишком длинный заголовок'),
body: z.string().trim().min(1, 'Текст обязателен'),
status: z.enum(['draft', 'published']).default('draft'),
});
export const parseArticleInput = (raw: unknown) => {
const parsed = articleInputSchema.safeParse(raw);
if (parsed.success) return { ok: true as const, value: parsed.data };
const errors: Record<string, string> = {};
for (const issue of parsed.error.issues) {
const field = issue.path[0];
if (typeof field === 'string' && errors[field] === undefined) {
errors[field] = issue.message;
}
}
return { ok: false as const, errors };
};
Контроллер на невалидный ввод не падает, а возвращает ту же форму с ошибками и статусом 422. Пользователь видит, что именно поправить, и не теряет введённое:
app.post('/articles', mutating, async (request, reply) => {
const result = parseArticleInput(request.body);
if (!result.ok) {
return reply.code(422).render('articles/form', {
article: request.body, // вернём введённые значения
errors: result.errors,
action: '/articles',
});
}
const article = await articlesService.create(result.value);
return reply.flash('success', 'Статья создана').redirect(`/articles/${article.slug}`);
});
После успешного сохранения, редирект, а не рендер. Это паттерн Post/Redirect/Get: браузер после POST уходит на GET-страницу, и обновление страницы не отправит форму повторно. Что такое reply.flash и mutating, разберём в следующем модуле.
урок для менти
Продолжение только для менти Vassiliy: этот урок идёт вместе с разбором у ментора.