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 пользователя сервер берёт из подписанной куки, клиент сам ничего не присылает.

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