# 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-тег: `` - Стандарт 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-регион):** - Восстановлен в iOS 17.4.1+ после давления EU/OWA, но push notifications в 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/)*