baton/docs/tech_report.md
Gros Frumos 3456c90e9e docs: rename ADR-002-offline-pattern → ADR-007-offline-queue-v2, update all refs
- 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>
2026-03-20 21:00:51 +02:00

20 KiB
Raw Blame History

Tech Report: Baton PWA

Дата: 2026-03-20 Версия: 2.0 (пересмотр по директорскому фидбеку v1) Источник: исследование марта 2026 + директорский пересмотр требований


Executive Summary

Baton — PWA экстренного сигнала. Одна кнопка → HTTPS POST → FastAPI backend → sendMessage в Telegram-группу.

Параметры нагрузки: 300400 зарегистрированных пользователей, одновременно нажимает максимум 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/signalsendMessage (прямой) 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 в приватном режиме. Стратегия:

  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)

# 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