- git mv docs/adr/ADR-002-offline-pattern.md docs/adr/ADR-007-offline-queue-v2.md - Update title inside file: ADR-002 → ADR-007 - Update reference in docs/tech_report.md:410 - grep -r 'ADR-002-offline-pattern' returns no matches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
20 KiB
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) + <link rel="apple-touch-icon"> |
✅ 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
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
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:
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)
// В app.js
button.addEventListener('click', async () => {
if (!navigator.onLine) {
showError('Нет подключения. Проверьте сеть и попробуйте снова.');
return;
}
await sendSignal();
});
Нет очереди, нет retry — это v2.0 функционал. В v1 просто показываем ошибку.
PWA Installability
Web App Manifest — минимальный набор
{
"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 для иконки недостаточен):
<link rel="apple-touch-icon" sizes="180x180" href="/icon-180.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
Ограничения 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
Реализация
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 в приватном режиме. Стратегия:
try/catchвокруг localStorage операций- Fallback 1:
sessionStorage— данные живут до закрытия вкладки - 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 используется двунаправленно:
- Исходящий: backend вызывает
sendMessage→ сообщение в группу - Входящий: Telegram шлёт обновления (например
/start) → backend регистрирует пользователя
POST /setWebhook
{
"url": "https://yourdomain.com/api/webhook/telegram",
"secret_token": "WEBHOOK_SECRET"
}
Валидация входящих запросов (#1010)
# 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 │
│ → Группа оповещения │
└──────────────────────┘
Открытые вопросы
- Иконки: нужны реальные файлы
icon-180.png,icon-192.png,icon-512.png+ maskable вариант - WEBHOOK_URL: должен быть публичным HTTPS URL — dev-окружение требует ngrok или tunnel
- Geolocation permission UX: когда запрашивать разрешение — при загрузке или при первом нажатии?
- Код агрегатора в codebase:
telegram.py:51-121иmain.py:24,36-44содержатSignalAggregator— по решению директора не нужен в v1, рекомендуется убрать или отключить во избежание confusion - 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