baton/docs/tech_research_raw.md
2026-03-20 20:44:00 +02:00

575 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Tech Research Raw: Baton PWA
**Дата:** 2026-03-20
**Статус:** Полное исследование — все 6 требований покрыты
**Источники:** web.dev, MDN, caniuse.com, core.telegram.org, sqlite.org, caniuse.com
---
## ТРЕБОВАНИЕ 1: PWA на главный экран iOS/Android
### manifest.json — обязательные поля
Минимум для Android Chrome (Add to Home Screen / A2HS):
- `name` или `short_name` — строка, отображается под иконкой
- `start_url` — относительный путь к начальной странице
- `icons` — массив, минимум одна запись с размером 192×192
- `display` — одно из: `standalone`, `fullscreen`, `minimal-ui`
Без `display: standalone` — не устанавливается как PWA (остаётся закладкой).
Дополнительно рекомендованы:
- `background_color` — цвет сплэш-экрана при запуске
- `theme_color` — цвет браузерного хромирования
- `description` — для магазинов и SEO
### Иконки: обязательные размеры
**Android (Chrome, Samsung Internet):**
- `192×192` px PNG — минимум для установки
- `512×512` px PNG — для экрана загрузки (splash screen)
- Маскируемая иконка: `"purpose": "any maskable"` — без неё ОС обрезает иконку в круг
- Отдельный файл с отступом ~10% с каждой стороны
**iOS (Safari, Chrome на iOS):**
- manifest.json НЕ используется для иконки на главном экране iOS
- Нужен HTML-тег: `<link rel="apple-touch-icon" sizes="180x180" href="/icon-180.png">`
- Стандарт 2025: 180×180 px — покрывает все современные iPhone и iPad
- Без apple-touch-icon iOS берёт скриншот страницы как иконку
**Рекомендованный набор файлов (покрывает все платформы):**
- `icon-180.png` — iOS/Safari
- `icon-192.png` — Android/Chrome (manifest, обязателен)
- `icon-512.png` — Android/Chrome splash (manifest, обязателен)
- `icon-384.png` — дополнительно
- `icon-1024.png` — дополнительно для высокого разрешения
### HTTPS — подтверждение
PWA installability требует HTTPS — подтверждено MDN, web.dev, Apple Developer Docs. HTTP-страница не может быть установлена как PWA ни на iOS, ни на Android. Исключение: `localhost` для разработки.
### Ограничения iOS
**iOS 16.4+ (2023):**
- Push-уведомления для PWA: добавлены в iOS 16.4 через Web Push API
- Только если PWA **добавлена на главный экран** → только тогда можно запросить permission
- Нет тихих уведомлений (silent push) для PWA на iOS
- Только текст и иконки в уведомлениях (без rich media)
**iOS 17.4 (EU-регион):**
- Standalone PWA в EU — открывается в Safari Tab, без push support
- Причина: Digital Markets Act (DMA), Apple удалила standalone режим
- Статус: под расследованием EU регуляторов
**Хранилище на iOS:**
- Квота кэша: ~50 МБ
- Хранилище очищается автоматически через несколько недель при неиспользовании
- 7-дневный лимит на script-writable storage (IndexedDB, localStorage)
**Background execution на iOS:**
- Фоновое выполнение скриптов: не поддерживается
- Service Worker работает только когда PWA активна в foreground или при push-событии
### Как установить на iOS
- Пользователь открывает Safari → Share → «На экран Домой»
- Начиная с iOS 16.4 также работает из Chrome, Edge, Firefox на iOS
- Нет автоматического install prompt (как на Android) — только ручная установка
---
## ТРЕБОВАНИЕ 2: Офлайн через Service Worker + precache
### Background Sync API — реальная поддержка (2026)
Источник: caniuse.com, данные на март 2026:
**Глобальный охват: 78.75%**
Поддерживают:
- Chrome 49+ (десктоп и Android)
- Edge 79+
- Opera 42+
- Samsung Internet 5+
- UC Browser для Android 15.5+
- Android Browser 97+
**НЕ поддерживают:**
- Safari (все версии, десктоп и iOS)
- Firefox (все версии, включая Android)
- Opera Mini
- IE
Итого: ~21.25% пользователей без поддержки Background Sync API.
> Решение #1001 указывало ~15% без поддержки. Актуальные данные caniuse (март 2026): ~21% без поддержки. Разница значительна — ручной fallback обязателен.
### Workbox vs ручной Service Worker
**Workbox:**
- Используется на 54% мобильных сайтов (web.dev 2025)
- Модульная архитектура (import только нужное)
- Встроенные стратегии: CacheFirst, NetworkFirst, StaleWhileRevalidate
- Встроенный BackgroundSync модуль: автоматическая очередь + повтор
- Размер: базовый модуль ~6-10 KB gzip
- Плюсы: готовые паттерны, меньше ошибок, активная поддержка
- Минусы: зависимость, для 3-5 файлов «тяжеловато» (но размер приемлем)
**Ручной SW:**
- Полный контроль
- Для 3-5 файлов: ~30-50 строк кода
- Минусы: нужно вручную реализовать cache versioning, cleanup, retry логику
- Ошибки сложнее отловить (SW обновляется с задержкой)
**Для минимального приложения (5 файлов):**
- Ручной SW достаточен для кэширования статики
- Для BackgroundSync + outbox лучше Workbox (workbox-background-sync)
### Cache-first стратегия для статики
```javascript
// Ручной SW — cache-first для статических файлов
const CACHE_NAME = 'baton-v1';
const PRECACHE_URLS = ['/', '/index.html', '/app.js', '/style.css', '/manifest.json'];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_URLS))
);
self.skipWaiting();
});
self.addEventListener('fetch', event => {
if (PRECACHE_URLS.some(url => event.request.url.endsWith(url))) {
event.respondWith(
caches.match(event.request).then(cached => cached || fetch(event.request))
);
}
});
```
### IndexedDB Outbox + Dual Triggers (решение #1006)
**Trigger 1: online event**
```javascript
// В main thread (app.js)
window.addEventListener('online', () => flushOutbox());
async function flushOutbox() {
const items = await getAllFromOutbox(); // читаем из IndexedDB
for (const item of items) {
try {
await fetch('/signal', { method: 'POST', body: JSON.stringify(item) });
await removeFromOutbox(item.id);
} catch (e) { /* останется в очереди */ }
}
}
```
**Trigger 2: Background Sync (SW)**
```javascript
// В service worker
self.addEventListener('sync', event => {
if (event.tag === 'flush-outbox') {
event.waitUntil(flushOutboxFromSW());
}
});
```
**Регистрация синка:**
```javascript
// Когда добавляем в outbox:
if ('serviceWorker' in navigator && 'sync' in registration) {
await registration.sync.register('flush-outbox');
} else {
// Fallback: пробуем сразу если online
if (navigator.onLine) flushOutbox();
}
```
**Edge cases:**
- SW lifecycle: при обновлении SW (новая версия) старый SW остаётся активным до закрытия всех вкладок
- Если SW обновляется во время flush — запрос может быть потерян → нужен idempotent ключ (timestamp + random)
- IndexedDB: доступна как из main thread, так и из SW → разделяемое хранилище
- iOS: Background Sync не работает → только Trigger 1 (online event) и ручная кнопка retry
### Ручной Fallback (обязателен для iOS и Firefox)
При отправке сигнала:
1. Попробовать fetch()
2. При ошибке → сохранить в IndexedDB
3. На каждый `window.addEventListener('online')` → попытка flush
4. Кнопка в UI «Повторить отправку» → явный flush
---
## ТРЕБОВАНИЕ 3: POST → Сервер → Telegram
### Telegram Bot API — sendMessage
**Эндпоинт:**
```
POST https://api.telegram.org/bot{TOKEN}/sendMessage
```
**Обязательные параметры:**
| Параметр | Тип | Описание |
|----------|-----|----------|
| `chat_id` | Integer или String | ID группы (отрицательное число) или @username |
| `text` | String | Текст сообщения, 1-4096 символов |
**Опциональные параметры:**
| Параметр | Тип | Описание |
|----------|-----|----------|
| `parse_mode` | String | `Markdown`, `MarkdownV2`, или `HTML` |
| `disable_notification` | Boolean | Без звукового уведомления |
| `reply_to_message_id` | Integer | Ответить на сообщение |
| `message_thread_id` | Integer | Тред в супергруппе |
**Пример payload:**
```json
{
"chat_id": -1001234567890,
"text": "🚨 Экстренный сигнал от пользователя abc123\nВремя: 2026-03-20T10:30:00Z\nГеолокация: 55.7558, 37.6173",
"parse_mode": "HTML"
}
```
**Ответ при успехе:**
```json
{
"ok": true,
"result": { "message_id": 42, "chat": {...}, "text": "..." }
}
```
**При ошибке:** `{ "ok": false, "error_code": 429, "description": "Too Many Requests", "parameters": { "retry_after": 30 } }`
### Rate Limits Telegram Bot API (официальные, 2026)
| Сценарий | Лимит |
|----------|-------|
| Один чат (любой) | 1 сообщение/секунду |
| Группа | 20 сообщений/минуту |
| Глобально (все чаты) | 30 сообщений/секунду |
| С платными broadcast | до 1000 сообщений/секунду |
**Критично для Baton:**
- 400 пользователей нажали кнопку одновременно = 400 `sendMessage` запросов
- В одну группу: лимит 20/минуту
- **Проблема:** 400 сообщений в одну группу за 1 минуту превышает лимит в 20 раз
- Решение: не отправлять по одному сообщению на пользователя — агрегировать сигналы
### setWebhook vs getUpdates — уточнение для Baton
**Ключевое:** Baton ОТПРАВЛЯЕТ сообщения в Telegram, не принимает. Поэтому:
- `setWebhook`НЕ нужен. Webhook нужен только если бот принимает команды от пользователей.
- `getUpdates`НЕ нужен. Polling нужен только для получения обновлений.
- Для отправки: просто `POST /sendMessage` с токеном бота в URL.
- Токен бота = Bearer-авторизация через URL: `api.telegram.org/bot{TOKEN}/sendMessage`
**Если в будущем понадобится принимать ответы** → setWebhook и getUpdates взаимоисключающие (решение #1009). Выбрать что-то одно.
### X-Telegram-Bot-Api-Secret-Token (решение #1010)
Актуально **только** если мы принимаем webhook-запросы от Telegram на наш сервер.
Для исходящих sendMessage — не применимо.
### HTTPS для эндпоинта (решение #1011)
Актуально только для webhook-эндпоинта (если бот принимает входящие). Для исходящих вызовов Bot API — Telegram API сам является HTTPS, TLS на стороне клиента.
---
## ТРЕБОВАНИЕ 4: Stateless авторизация UUID v4
### Паттерн реализации (решение #1013)
```javascript
// Инициализация при первом запуске
function getOrCreateUserId() {
let userId = localStorage.getItem('baton_user_id');
if (!userId) {
userId = crypto.randomUUID(); // UUID v4, встроен в браузер (ES2022+)
localStorage.setItem('baton_user_id', userId);
}
return userId;
}
// Отправка сигнала
async function sendSignal(geo) {
const userId = getOrCreateUserId();
return fetch('/signal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, geo, timestamp: Date.now() })
});
}
```
**`crypto.randomUUID()`:** Доступен в современных браузерах (Chrome 92+, Firefox 95+, Safari 15.4+). Требует HTTPS или localhost.
### UUID v4 — энтропия и безопасность
- UUID v4: 122 бита случайности (6 бит зарезервированы для версии/варианта)
- `crypto.randomUUID()` использует CSPRNG (криптографически безопасный ГПСЧ)
- Вероятность коллизии при 400 пользователях: практически нулевая
**Это НЕ секретный токен в классическом смысле, а постоянный идентификатор устройства/пользователя.** Безопасность: UUID не может быть угадан, но может быть украден через XSS.
### Риски localStorage
| Риск | Описание | Вероятность |
|------|----------|-------------|
| XSS-кража | Скрипт на странице читает `localStorage.getItem('baton_user_id')` | Средняя (если есть XSS) |
| Clear browsing data | Пользователь сбросил браузер → UUID потерян | Средняя |
| Приватный режим iOS Safari | `localStorage.setItem()` выбрасывает исключение (блокирует запись) | Высокая на iOS |
| Приватный режим Chrome/Firefox | Работает в сессии, очищается при закрытии вкладки | Средняя |
| Другое устройство | Новое устройство → новый UUID, идентификатор не переносится | Ожидаемо |
| Замена UUID | Пользователь вручную заменил значение в DevTools | Низкая (намеренное действие) |
**Ограничение приватного режима iOS Safari:**
- `localStorage` в приватном режиме Safari бросает `SecurityError` при попытке записи
- Нужен try/catch и fallback на `sessionStorage` или переменную в памяти (UUID не сохраняется между сессиями)
**Это — ЖЁСТКОЕ требование проекта** (решение #1003: не понижать до nice-to-have).
### Поведение при очистке хранилища
- `Clear browsing data` / «Очистить данные сайта» → UUID удаляется → следующий визит генерирует новый
- iOS: автоматическая очистка после нескольких недель неиспользования
---
## ТРЕБОВАНИЕ 5: 300-400 пользователей, разные страны
### SQLite WAL — анализ нагрузки
**WAL Mode характеристики:**
- Чтение: одновременные read-транзакции, без блокировок
- Запись: **один writer в любой момент времени** — все write-операции сериализуются
- `busy_timeout=5000`: ожидать до 5 секунд перед возвратом SQLITE_BUSY
- `synchronous=NORMAL`: батчинг fsync вместо вызова после каждой транзакции (вместе с WAL — обязательно, решение #1005)
**Расчёт worst case — 400 одновременных нажатий:**
- 400 POST /signal за 1 секунду
- Каждый запрос = 1 INSERT в БД
- SQLite WAL: серилизует все 400 INSERT — они встанут в очередь
- Каждый INSERT (простой, без joins): ~0.1-1 мс в WAL+NORMAL режиме
- 400 INSERT × 1 мс = ~400 мс до завершения последней транзакции
- С `busy_timeout=5000`: все 400 запросов получат ответ в течение 5 секунд (не сразу упадут с ошибкой)
- Telegram rate limit (20 сообщений/минуту в группу) — бутылочное горлышко раньше SQLite
**Бенчмарки SQLite WAL (из исследований):**
- 150,000 rows/second с 100 INSERT/транзакцию (full synchronous mode)
- 400 одиночных INSERT: ~0.4 секунды суммарно при busy_timeout=5000
**Вывод:** SQLite WAL с busy_timeout=5000 + synchronous=NORMAL справится с 400 одновременными записями без потери данных. Задержка ответа может вырасти до 500 мс для последних в очереди.
### Telegram Rate Limits при массовой отправке
| Сценарий | Лимит | При 400 юзерах |
|----------|-------|----------------|
| Сообщения в 1 группу | 20/минуту | 400 > 20 = превышение в 20 раз |
| Глобально | 30/секунду | 400 > 30 = нужна очередь |
**Критическая проблема:** отправка 400 отдельных сообщений в одну группу невозможна без throttling.
**Возможные решения (факты, не рекомендации):**
1. Агрегация: собирать сигналы за 1 минуту → одно сообщение «N сигналов получено»
2. Throttling: очередь отправки с задержкой 3 секунды между сообщениями
3. Несколько групп: распределить оповещения по разным чатам
### CDN / Геораспределение
Для 400 пользователей из разных стран:
- Статика (HTML/JS/CSS): CDN снизит latency для первого посещения
- API запросы: без CDN (CDN для API требует сложной настройки Edge Functions)
- Без CDN: для 400 юзеров — один сервер в центральной локации достаточен
- Latency без CDN: Европа→Европа ~20-50 мс, США→Европа ~100-150 мс, Азия→Европа ~200-300 мс
---
## ТРЕБОВАНИЕ 6: Геолокация (опциональна в v1)
### Geolocation API — базовые факты
**HTTPS:** Обязателен. Chrome 50+, Firefox 55+, Safari 10.1+ заблокировали Geolocation на HTTP. Исключение: `localhost`.
**User Permission:** Обязателен. Нет способа получить координаты без явного разрешения пользователя.
**API:**
```javascript
// Получить текущую позицию
navigator.geolocation.getCurrentPosition(
(pos) => {
const lat = pos.coords.latitude; // Float
const lon = pos.coords.longitude; // Float
const acc = pos.coords.accuracy; // метры
},
(err) => { /* PERMISSION_DENIED, POSITION_UNAVAILABLE, TIMEOUT */ },
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
);
```
### Точность по методу
| Метод | Точность | Время получения |
|-------|----------|-----------------|
| GPS (enableHighAccuracy: true) | 3-5 м (95% случаев) | 5-60 секунд (cold start) |
| WiFi positioning | 10-20 м | 1-3 секунды |
| Cell towers triangulation | 300-3000 м | 1-2 секунды |
| IP-based | 5-50 км | Мгновенно |
Браузер выбирает метод автоматически исходя из `enableHighAccuracy`. На мобильных: при `true` → GPS; при `false` → WiFi/Cell.
### Формат передачи в POST body
```json
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": 1742471400000,
"geo": {
"lat": 55.7558,
"lon": 37.6173,
"accuracy": 5.0
}
}
```
Когда геолокация недоступна или пользователь отказал: `"geo": null`
### Поддержка браузерами
Geolocation API: 98%+ глобальная поддержка (caniuse). Доступен во всех современных браузерах при HTTPS.
---
## СРАВНЕНИЕ БЭКЕНД-СТЕКОВ
### FastAPI (Python)
| Параметр | Значение |
|----------|----------|
| Runtime | Python 3.11+, требует установки |
| Deployment | venv/virtualenv или Docker образ ~200-400 MB |
| SQLite binding | `aiosqlite` (async) или `sqlite3` (sync через executor) |
| Async | asyncio, нативно |
| bcrypt | Нужен `run_in_executor` (#1004) |
| Знакомость команде | Да (используется в Kin) |
| Производительность (vs Go) | В 2-3x медленнее Go по RPS |
| SQLite + Python | sqlite3 (stdlib), aiosqlite для async |
### Express/Fastify (Node.js)
| Параметр | Значение |
|----------|----------|
| Runtime | Node.js 20+, требует установки |
| Deployment | node_modules + ~200-300 MB Docker |
| SQLite binding | `better-sqlite3` (sync, самый быстрый) или `node-sqlite3` |
| Async | Eventloop, I/O async |
| bcrypt | `bcryptjs` или `bcrypt` (нативный) — без проблем с eventloop |
| Единый язык с фронтом | Да (vanilla JS → Node.js) |
| Производительность | Fastify ~24% быстрее FastAPI по RPS в тестах |
**Примечание:** `better-sqlite3` работает синхронно, но это преимущество для SQLite (не блокирует eventloop нестандартно, быстрее async биндингов).
### Go (net/http)
| Параметр | Значение |
|----------|----------|
| Runtime | Нет. Компилируется в статический бинарь |
| Deployment | Один файл ~8-15 MB |
| SQLite binding | `modernc.org/sqlite` (CGO-free) или `mattn/go-sqlite3` (CGO) |
| Async | Горутины, нативный concurrency |
| bcrypt | `golang.org/x/crypto/bcrypt` — не блокирует горутины |
| Знакомость команде | Нет |
| Производительность | В 2-3x быстрее Python, ~2x быстрее Node.js |
| Cross-compile | `GOARCH=amd64 GOOS=linux go build` |
**modernc.org/sqlite (CGO-free):** В 10-100% медленнее нативного SQLite (CGO), но кросс-компиляция без C toolchain.
### Сравнительная таблица
| Критерий | FastAPI | Express/Fastify | Go |
|----------|---------|-----------------|-----|
| Знакомость | ✅ | ⚠️ (JS фронт) | ❌ |
| Размер деплоя | ~300 MB | ~200 MB | ~10 MB |
| Производительность | Базовая | +24% vs FastAPI | +200% vs FastAPI |
| SQLite-интеграция | Хорошая | Отличная (better-sqlite3) | Хорошая (modernc) |
| Сложность деплоя | Средняя | Средняя | Низкая (единый бинарь) |
| 400 конк. запросов | Справится | Справится | Справится |
---
## СРАВНЕНИЕ ОФЛАЙН-ПАТТЕРНОВ
### Вариант 1: IndexedDB outbox + Background Sync + manual fallback (решение #1006)
**Схема:**
1. Пользователь нажал кнопку → сохраняем в IndexedDB
2. Если online → немедленная попытка отправки
3. Если offline → регистрируем BackgroundSync tag
4. При появлении сети: SW получает `sync` event → flush очереди
5. Fallback: `window.addEventListener('online', flush)` + кнопка retry
**Плюсы:**
- Персистентность: IndexedDB не очищается при закрытии вкладки
- BackgroundSync: браузер сам управляет повтором (даже без открытой вкладки — в Chrome)
- Работает в SW контексте (shared между main thread и SW)
- Размер: IndexedDB не имеет лимита по умолчанию (квота браузера, обычно GB)
**Минусы:**
- IndexedDB API — громоздкий (нужна обёртка или `idb` библиотека ~1.9 KB)
- BackgroundSync: не работает в Safari/Firefox (~21% юзеров) → ручной fallback обязателен
- Сложнее отлаживать
### Вариант 2: localStorage queue + online event listener
**Схема:**
1. Сохраняем в `localStorage` как JSON-массив
2. `window.addEventListener('online', flush)` → при появлении сети отправляем всё
**Плюсы:**
- Простая синхронная реализация (~20 строк)
- Нет зависимостей
- Работает в iOS Safari (при HTTPS, в non-private mode)
**Минусы:**
- Лимит localStorage: 5 МБ (достаточно для outbox, но риск при больших данных)
- Недоступна в SW контексте — нет синхронизации между main thread и SW
- В iOS приватном режиме: бросает исключение при записи → нужен try/catch + memory fallback
- Не персистентна между сессиями в приватном режиме Chrome
### Вариант 3: Cache API + Request replay
**Схема:**
- Перехватываем failed запросы в SW → сохраняем в Cache API
- При появлении сети → повторяем запросы
**Плюсы:**
- Нативная интеграция с SW fetch event
- Не требует отдельного хранилища
**Минусы:**
- Cache API спроектирован для Response (кэш ответов), не для Request replay
- Нет гарантий персистентности очереди (Cache может быть очищен браузером)
- Workbox Background Sync использует IndexedDB внутри, не Cache API
- Нестандартный подход, мало документации для outbox паттерна
---
## ПОКРЫТИЕ ТРЕБОВАНИЙ — СВОДНАЯ ТАБЛИЦА
| # | Требование | Статус | Ключевые факты |
|---|-----------|--------|----------------|
| 1 | PWA на главный экран | RESEARCHED | manifest: name, start_url, icons (192+512), display:standalone. iOS: apple-touch-icon 180px. HTTPS обязателен. |
| 2 | Офлайн через SW | RESEARCHED | Background Sync: 78.75% охват (не 85% как в #1001). Ручной fallback обязателен для Safari/Firefox/iOS. |
| 3 | POST → Telegram | RESEARCHED | sendMessage: chat_id + text. Лимит группы: 20/мин. 400 одновременных > лимит. Webhook не нужен. |
| 4 | Stateless UUID auth | RESEARCHED | crypto.randomUUID(). iOS приватный режим: пишет исключение. Clear data = потеря UUID. Это ЖЁСТКОЕ требование (#1003). |
| 5 | 300-400 юзеров | RESEARCHED | SQLite WAL: справится. Telegram 20/мин в группу — критическое ограничение. |
| 6 | Геолокация (optional) | RESEARCHED | HTTPS + permission. GPS: 3-5м, 5-60с. WiFi: 10-20м, 1-3с. POST body: geo: {lat, lon, accuracy} или null. |
---
*Источники: [web.dev/learn/pwa](https://web.dev/learn/pwa/web-app-manifest), [MDN PWA](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable), [caniuse Background Sync](https://caniuse.com/background-sync), [Telegram Bot FAQ](https://core.telegram.org/bots/faq), [SQLite WAL](https://sqlite.org/wal.html), [firt.dev iOS PWA](https://firt.dev/notes/pwa-ios/)*