PWA-трекер питания на DynapiCMS: план и архитектура

PWA-трекер питания на DynapiCMS: план и архитектура

Мы хотели дневник калорий, который ставится на телефон с сайта и работает офлайн. Серверного кода в планах не было. Устанавливается он с calories.dynapi.ru: пользователь открывает ссылку, добавляет сайт на домашний экран и сразу пользуется. Магазины приложений и «скачайте APK» при этом не нужны.

Это первая часть цикла из семи статей. Мы строим приложение на DynapiCMS — платформе, где данные описываются как типы контента, а GraphQL API генерируется из этой схемы. Проблема в том, что дневнику нужен свой бэкенд, которого в CMS нет: пользователи с паролями, приватные данные, синхронизация между устройствами и HTTP API для мобильного клиента. Интересно было проверить, справится ли платформа с этим без единой строки серверного кода.

Справилась: серверного кода в проекте ноль.

Архитектура одним экраном

Проект состоит из трёх частей.

Схема. Семь типов данных в CMS:

Тип Что хранит
member пользователь: email, пароль (bcrypt), нормы КБЖУ
food продукт: КБЖУ на 100 г; без владельца — публичный каталог
entry запись дневника: дата, приём пищи, граммы, КБЖУ
weight-log взвешивания
landing-section секции публичного лендинга
nav-item пункты меню
site-meta title и description для SEO

Первые четыре — данные приложения, последние три — контент сайта. Все они в одной схеме и правятся из одной админки.

Бэкенд. Его роль играют 26 флоу платформы. Флоу — это граф из узлов: condition, graphql-запрос, отправка письма, установка куки, ответ. Из них 12 закрывают авторизацию (login, register, forgot, reset, verify, logout, смена пароля плюс страницы форм), 9 образуют JSON API приложения, а оставшиеся 5 — страницы и сервисы (лендинг, оболочка приложения, service worker, манифест, 404).

Фронтенд. Vanilla JS PWA без сборки. Вся статика лежит в проекте: пять JS-файлов, один CSS и иконки. Исключение — манифест и тело service worker’а, у них файлов на сервере нет, оба отдаёт флоу. Как именно они устроены, расскажем в пятой части.

Хостится всё на субдомене платформы: calories.dynapi.ru отдаёт лендинг, приложение и API одновременно. Рядом работает preview-calories.dynapi.ru, который исполняет черновики флоу и статики. Всю разработку мы вели на preview и публиковали live, только когда всё было проверено.

Лендинг приложения на мобильном: светлая и тёмная темы

Главные решения

Флоу вместо своего сервера. Поначалу мы всерьёз рассматривали отдельный бэкенд — FastAPI или Hono рядом с CMS. Пришлось бы держать второй деплой, пробрасывать сессию, настраивать CORS и отдельный CI. Флоу избавили от всей этой обвязки: они исполняются внутри платформы, graphql-узлы обращаются к тем же данным, сессия читается из платформенной куки, а публикация эндпоинта сводится к команде CLI. Самое тонкое место здесь — как флоу узнаёт, от какого пользователя пришёл запрос. Это тема следующей статьи.

IndexedDB-first клиент. Офлайн был требованием с первого дня: запись о завтраке должна сохраняться при обрыве сети. Каждая мутация сначала пишется в локальный IndexedDB с флагом dirty, затем попадает в очередь отправки. Очередь лежит в той же базе и сохраняется после перезагрузки. Когда сеть появляется, клиент отправляет накопившиеся задачи. Сервер идемпотентен по client_uuid, так что повтор не создаёт дубль — подробный разбор синхронизации будет в четвёртой части цикла.

Каталог продуктов — тип контента в CMS. 131 продукт (крупы, мясо и рыба, молочка, овощи, фрукты, ягоды, орехи и масла, хлеб, сладости, напитки) — обычные записи типа food с пустым полем owner. Пустой владелец означает «публичный продукт». Если пользователь заводит свой продукт, создаётся та же запись, только с заполненным owner. Поиск при этом один: запрос с OR покрывает и публичный каталог, и свои продукты.

Мы выиграли и в другом: каталог правится прямо из админки. Если в КБЖУ творога ошибка, правка в таблице записей занимает минуту и не требует релиза.

Чего мы делать не стали

Без фреймворков и сборки. Мы не взяли ни React, ни Vite, ни node_modules. Приложение — четыре экрана и форма добавления еды, и для такого объёма бандлер добавил бы сложности, а не скорости. Обновление выглядит просто: правится app.js, публикуется статика, и пользователи получают новую версию при следующей загрузке. За сброс кэша отвечает параметр в шаблоне оболочки: статика подключается как /static/app.js?v={{ site.version }}. Service worker держит офлайн оболочку приложения. Запросы /api/* он не перехватывает вовсе: кэшировать приватные данные на слое SW мы сознательно не стали, для этого есть IndexedDB.

Без своего логина. В платформе есть готовые пресеты member-авторизации: регистрация с подтверждением почты, вход, восстановление пароля, выход. Мы применили их пакетами одной командой и дописали сверху только валидацию полей, которой в пресетах нет. Изоляция данных у пресетов тоже своя — id пользователя сервер берёт из подписанной куки, клиент сам ничего не присылает.

Оболочка приложения на мобильном: светлая и тёмная темы

План серии

  1. План и архитектура — эта статья.
  2. Логин без бэкенда — тип member, bcrypt, сессионная кука на 30 дней и изоляция данных между пользователями на серверных переменных.
  3. JSON API из флоу — девять эндпоинтов, идемпотентный апсет по client_uuid и грабли Liquid-шаблонов.
  4. Офлайн и синхронизация — IndexedDB, очередь отправки, слияние по правилу LWW и два неприятных бага синхронизации.
  5. Service worker — манифест и /sw.js, которые отдают флоу, стратегии кэша и проблема кэша модулей при релизе.
  6. Подводные камни Liquid и динамической схемы — движок без привычек Shopify, асинхронный schema rebuild, ограничение на limit и библиотека запросов, которая не принимала Liquid (починили по ходу цикла).
  7. Калькулятор нормы, темы и полировка — Mifflin-St Jeor, кольцо прогресса на pathLength, парные тёмные токены.

Что дальше

Следующая статья — «Логин без бэкенда»: как устроена member-авторизация платформы, почему пароль не видит никто, кроме логин-флоу, и что случилось, когда второй тестовый пользователь увидел первый завтрак первого.


Все записи