baton/docs/tech_research_raw.md

574 lines
30 KiB
Markdown
Raw Permalink Normal View History

2026-03-20 20:44:00 +02:00
# 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-регион):**
2026-03-20 20:52:45 +02:00
- Восстановлен в iOS 17.4.1+ после давления EU/OWA, но push notifications в EU всё ещё недоступны
2026-03-20 20:44:00 +02:00
**Хранилище на 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/)*