# Tech Report: Baton PWA **Дата:** 2026-03-20 **Версия:** 2.0 (пересмотр по директорскому фидбеку v1) **Источник:** исследование марта 2026 + директорский пересмотр требований --- ## Executive Summary Baton — PWA экстренного сигнала. Одна кнопка → HTTPS POST → FastAPI backend → sendMessage в Telegram-группу. **Параметры нагрузки:** 300–400 зарегистрированных пользователей, одновременно нажимает максимум 1 человек, реалистичная частота ~1 нажатие/неделю. **Ключевые решения по пересмотру v1 (директор):** - **Офлайн-режим НЕ нужен** — только cache-first SW для мгновенного открытия с главного экрана. Offline queue → v2.0 - **Тротлинг Telegram неактуален** — прямой sendMessage без агрегатора. Агрегатор → v2.0 если потребуется - **Система должна висеть в фоне бесконечно** — PWA на главном экране, SW зарегистрирован **Граница v1/v2:** | Фича | v1 | v2 | |---|---|---| | Cache-first SW (мгновенное открытие) | ✅ | — | | Offline queue (IndexedDB) | ❌ | ✅ | | Background Sync | ❌ | ✅ | | Прямой sendMessage | ✅ | — | | Агрегатор сигналов | ❌ | ✅ (если нагрузка вырастет) | --- ## Матрица покрытия требований (#1007) | # | Требование | Технология/решение | Статус | Риски | |---|-----------|-------------------|--------|-------| | R1 | PWA на главный экран iOS/Android | `manifest.json` (name, start_url, icons 192+512, display:standalone) + `` | ✅ COVERED | iOS: ручная установка, нет `beforeinstallprompt` | | R2 | Мгновенное открытие с главного экрана | SW cache-first: `cache.addAll()` при install + skipWaiting + clientsClaim | ✅ COVERED | iOS: 7-дневная очистка кеша при неактивности | | R3 | Нажатие кнопки → сообщение в Telegram | FastAPI `POST /api/signal` → `sendMessage` (прямой) | ✅ COVERED | При нажатии без сети — показать ошибку (нет retry в v1) | | R4 | Stateless UUID auth | `crypto.randomUUID()` → `localStorage` | ✅ COVERED | iOS Safari приватный режим: SecurityError → `sessionStorage` fallback (#1015) | | R5 | Telegram /start регистрация | `setWebhook` + `/api/webhook/telegram` endpoint | ✅ COVERED | HTTPS обязателен (#1011), валидация secret token (#1010) | | R6 | Геолокация (optional v1) | `navigator.geolocation.getCurrentPosition()` | ✅ COVERED | HTTPS обязателен (#999), cold start GPS до 60 сек | | R7 | Висеть в фоне бесконечно | PWA на главном экране, SW registration сохраняется | ✅ COVERED | iOS очищает кеш через 7 дней; SW re-registers при открытии | --- ## Service Worker — cache-first (без offline queue) ### Что кешировать при install ```javascript const CACHE_NAME = 'baton-v1'; const PRECACHE_ASSETS = [ '/', '/index.html', '/app.js', '/style.css', '/manifest.json', '/sw.js', '/icon-180.png', '/icon-192.png', '/icon-512.png', ]; self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_ASSETS)) ); self.skipWaiting(); }); ``` **Логика выбора:** кешируем только app shell — статику, необходимую для рендера UI. API-запросы (`/api/signal`, `/api/register`) не кешируются — они должны идти в сеть. ### Стратегия fetch ```javascript self.addEventListener('fetch', event => { // Кешируем только GET-запросы к статике if (event.request.method !== 'GET') return; const url = new URL(event.request.url); // API-запросы не перехватываем — только сеть if (url.pathname.startsWith('/api/')) return; event.respondWith( caches.match(event.request).then(cached => cached || fetch(event.request)) ); }); ``` ### Обновление кеша: skipWaiting + clientsClaim | Механизм | Этап | Эффект | |---|---|---| | `self.skipWaiting()` | `install` | Новый SW активируется немедленно, не ждёт закрытия вкладок | | `self.clients.claim()` | `activate` | Новый SW берёт контроль над всеми открытыми страницами сразу | Вместе обеспечивают бесшовное обновление: пользователь не замечает смены версии SW. **Очистка старых кешей при activate:** ```javascript self.addEventListener('activate', event => { event.waitUntil( Promise.all([ caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) ), self.clients.claim(), ]) ); }); ``` ### Обработка нажатия без сети (v1 — простой fallback) ```javascript // В app.js button.addEventListener('click', async () => { if (!navigator.onLine) { showError('Нет подключения. Проверьте сеть и попробуйте снова.'); return; } await sendSignal(); }); ``` **Нет очереди, нет retry** — это v2.0 функционал. В v1 просто показываем ошибку. --- ## PWA Installability ### Web App Manifest — минимальный набор ```json { "name": "Baton", "short_name": "Baton", "start_url": "/", "display": "standalone", "background_color": "#000000", "theme_color": "#ff0000", "icons": [ { "src": "/icon-192.png", "type": "image/png", "sizes": "192x192" }, { "src": "/icon-512.png", "type": "image/png", "sizes": "512x512" }, { "src": "/icon-512.png", "type": "image/png", "sizes": "512x512", "purpose": "maskable" } ] } ``` **Критичные поля:** `name`, `start_url`, `display: "standalone"`, иконки 192px + 512px. ### iOS Safari — особенности и ограничения **Установка:** только через Safari → Поделиться → «На экран Домой». `beforeinstallprompt` event отсутствует. **Обязательный HTML-тег (manifest.json для иконки недостаточен):** ```html ``` **Ограничения iOS PWA:** | Ограничение | Детали | |---|---| | Storage quota | ~50 МБ кеш (Chrome: сотни МБ) | | Cache expiry | 7 дней неиспользования → кеш удаляется | | Background Sync | Не поддерживается (только Chromium) | | Push notifications | iOS 16.4+, НЕ работает в EU | | beforeinstallprompt | Отсутствует — только ручная установка | **iOS 17.4 EU standalone (#1016):** Apple анонсировала удаление standalone режима в EU (DMA) → отменила решение 2 марта 2024 до релиза. Standalone работает. Push уведомления в EU по-прежнему недоступны. ### Android Chrome - `beforeinstallprompt` срабатывает автоматически при соответствии критериям - Полный Background Sync (Chrome 49+) - Кеш без срока действия - Splash screen генерируется из `background_color` + иконок ### Разница iOS vs Android | | Android Chrome | iOS Safari | |---|---|---| | Install prompt | Автоматический | Ручной (Share menu) | | Storage | Сотни МБ | ~50 МБ | | Cache TTL | Без ограничений | 7 дней без открытия | | Background Sync | ✅ | ❌ | | beforeinstallprompt | ✅ | ❌ | | Push (EU) | ✅ | ❌ | --- ## Auth — UUID + localStorage ### Реализация ```javascript let _sessionUserId = null; function getOrCreateUserId() { try { let id = localStorage.getItem('baton_user_id'); if (!id) { id = crypto.randomUUID(); localStorage.setItem('baton_user_id', id); } return id; } catch (e) { // iOS Safari приватный режим: SecurityError (#1015) if (!_sessionUserId) { _sessionUserId = crypto.randomUUID(); } return _sessionUserId; } } ``` ### crypto.randomUUID() — поддержка Требует HTTPS или localhost. Chrome 92+, Firefox 95+, Safari 15.4+. Охват 97%+. ### Обработка iOS Safari private mode (#1015) `localStorage.setItem()` бросает `SecurityError` в приватном режиме. Стратегия: 1. `try/catch` вокруг localStorage операций 2. Fallback 1: `sessionStorage` — данные живут до закрытия вкладки 3. Fallback 2: in-memory переменная `_sessionUserId` — до перезагрузки страницы В private mode UUID не сохраняется между сессиями — это ожидаемое поведение. ### Поведение localStorage | Сценарий | Результат | |---|---| | Нормальный режим | UUID хранится бессрочно | | iOS private mode | SecurityError → sessionStorage fallback | | Clear browsing data | UUID удалён → новый UUID | | iOS 7-дневная автоочистка | UUID удалён → новый UUID | | Другое устройство | Новый UUID (stateless — переноса нет) | --- ## Backend — стек и endpoints ### Выбор стека (→ ADR-001) | Компонент | Технология | Обоснование | |---|---|---| | Framework | FastAPI (Python 3.11+) | Async, Pydantic, знакомость команды | | БД | SQLite WAL | Один writer, достаточно для ~1 запроса/неделю | | HTTP client | httpx async | Нативный async, нет блокировки event loop | | HTTPS | Nginx reverse proxy | TLS termination перед uvicorn | ### Endpoints | Метод | Путь | Описание | |---|---|---| | `POST` | `/api/register` | Регистрация пользователя: `{uuid, name}` → `{user_id, uuid}` | | `POST` | `/api/signal` | Сигнал: `{user_id, timestamp, geo?}` → `{status, signal_id}` | | `POST` | `/api/webhook/telegram` | Входящие обновления от Telegram (для /start) | ### SQLite WAL конфигурация (#1005) `busy_timeout=5000` + `synchronous=NORMAL` — обязательны вместе. Обеспечивают: - Конкурентный доступ нескольких readers - Retry при contention без ошибки для клиента --- ## Telegram — прямая отправка (v1) ### Архитектура (v1 — без агрегатора) ``` [PWA] POST /api/signal → [Backend] INSERT в SQLite → [Backend] POST api.telegram.org/sendMessage → [Telegram] → Группа оповещения ``` **Обоснование отказа от агрегатора в v1:** нагрузка ~1 нажатие/неделю, лимит 20 msg/min группы неактуален. Прямая отправка проще и надёжнее для данной нагрузки. ### setWebhook (входящий канал для /start) Telegram webhook используется **двунаправленно:** 1. **Исходящий:** backend вызывает `sendMessage` → сообщение в группу 2. **Входящий:** Telegram шлёт обновления (например `/start`) → backend регистрирует пользователя ``` POST /setWebhook { "url": "https://yourdomain.com/api/webhook/telegram", "secret_token": "WEBHOOK_SECRET" } ``` ### Валидация входящих запросов (#1010) ```python # middleware.py async def verify_webhook_secret( x_telegram_bot_api_secret_token: str = Header(default=""), ) -> None: if x_telegram_bot_api_secret_token != config.WEBHOOK_SECRET: raise HTTPException(status_code=403, detail="Forbidden") ``` Заголовок `X-Telegram-Bot-Api-Secret-Token` присылается Telegram с каждым webhook-запросом. ### HTTPS требования (#1011) - Telegram принимает webhook только на HTTPS - Поддерживаемые порты: **443, 80, 88, 8443** (только эти четыре) - TLS 1.2 минимум (1.0/1.1 отклоняются) - CA-signed сертификат достаточен; self-signed — загрузить PEM через `certificate` параметр ### Rate limits Telegram | Ограничение | Значение | |---|---| | В один чат (любой тип) | ~1 msg/сек | | В группу | 20 msg/минута | | Глобально (бесплатно) | ~30 msg/сек | **При превышении:** HTTP 429 с `parameters.retry_after` (секунды). Код (`telegram.py:22-25`) уже обрабатывает это корректно. --- ## Схема взаимодействия v1 ``` ┌──────────────────────────────────────────────────────────┐ │ PWA (Браузер) │ │ │ │ ┌──────────┐ нажатие ┌──────────────────────┐ │ │ │ index │ ────────────> │ app.js │ │ │ │ .html │ │ getOrCreateUserId() │ │ │ └──────────┘ │ getGeolocation() │ │ │ │ navigator.onLine? │ │ │ ┌──────────┐ │ fetch('/api/signal')│ │ │ │ manifest │ └──────────┬───────────┘ │ │ │ .json │ │ HTTPS │ │ └──────────┘ если offline│ → showError() │ │ │ │ │ ┌──────────┐ │ │ │ │ sw.js │ cache-first static only │ │ │ │ precache │ (не перехватывает /api/) │ │ │ └──────────┘ │ │ └───────────────────────────────────────┼─────────────────┘ │ POST /api/signal │ {user_id, timestamp, geo} ▼ ┌──────────────────────────────────────────────────────────┐ │ Backend (FastAPI) │ │ │ │ POST /api/signal │ │ ├── валидация (Pydantic) │ │ ├── INSERT в SQLite (WAL) │ │ └── POST sendMessage (прямой) │ │ │ │ POST /api/webhook/telegram ←── Telegram (setWebhook) │ │ └── /start → register_user() │ └──────────────────────────────┬───────────────────────────┘ │ POST sendMessage ▼ ┌──────────────────────┐ │ Telegram Bot API │ │ api.telegram.org │ │ → Группа оповещения │ └──────────────────────┘ ``` --- ## Открытые вопросы 1. **Иконки:** нужны реальные файлы `icon-180.png`, `icon-192.png`, `icon-512.png` + maskable вариант 2. **WEBHOOK_URL:** должен быть публичным HTTPS URL — dev-окружение требует ngrok или tunnel 3. **Geolocation permission UX:** когда запрашивать разрешение — при загрузке или при первом нажатии? 4. **Код агрегатора в codebase:** `telegram.py:51-121` и `main.py:24,36-44` содержат `SignalAggregator` — по решению директора не нужен в v1, рекомендуется убрать или отключить во избежание confusion 5. **Background lifetime PWA:** на iOS SW не работает в фоне без push-события — если пользователь не открывал приложение 7 дней, кеш очищается и при следующем открытии потребуется сетевой запрос --- ## Файловая структура проекта (v1) ``` baton/ ├── frontend/ │ ├── index.html # Точка входа PWA, meta теги, apple-touch-icon │ ├── app.js # UUID, геолокация, fetch, offline error handler │ ├── style.css # Стили │ ├── sw.js # SW: cache-first precache, skipWaiting+clientsClaim │ ├── manifest.json # PWA manifest │ ├── icon-180.png # iOS apple-touch-icon │ ├── icon-192.png # Android manifest (обязателен) │ └── icon-512.png # Android splash (обязателен + maskable) ├── backend/ │ ├── main.py # FastAPI app, /api/signal, /api/register, /api/webhook/telegram │ ├── db.py # SQLite WAL init, CRUD │ ├── models.py # Pydantic схемы │ ├── telegram.py # sendMessage + setWebhook (SignalAggregator — не используется в v1) │ ├── middleware.py # verify_webhook_secret │ └── config.py # Env vars: BOT_TOKEN, CHAT_ID, WEBHOOK_URL, WEBHOOK_SECRET ├── docs/ │ ├── tech_report.md # Этот файл │ └── adr/ │ ├── ADR-001-backend-stack.md │ └── ADR-007-offline-queue-v2.md (описывает offline queue) ├── .env.example ├── requirements.txt └── .gitignore ```