Раздел 18 · Воркшопы и проекты

Мини-CMS на Fastify: Pug, htmx и MVC

middle~70 мин

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

Мини-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.

Как устроено: пять модулей, каждый самодостаточен. Можно остановиться на любом, и то, что собрано, уже работает.

  1. Фундамент веб-архитектуры. Чем server-rendered отличается от API, как Fastify раздаёт HTML, динамические маршруты.
  2. HTML-слой. Pug и лейауты через @fastify/view, поисковые формы (GET), модифицирующие формы (POST), валидация.
  3. Сессии и безопасность. Сессия в зашифрованной cookie, flash-сообщения, CSRF, экранирование, заголовки безопасности.
  4. CRUD и MVC. Именованные маршруты, разделение модели, вида и контроллера, данные через Kysely, awilix DI.
  5. Инфраструктура и 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: этот урок идёт вместе с разбором у ментора.