30 KiB
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×192display— одно из:standalone,fullscreen,minimal-ui
Без display: standalone — не устанавливается как PWA (остаётся закладкой).
Дополнительно рекомендованы:
background_color— цвет сплэш-экрана при запускеtheme_color— цвет браузерного хромированияdescription— для магазинов и SEO
Иконки: обязательные размеры
Android (Chrome, Samsung Internet):
192×192px PNG — минимум для установки512×512px 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/Safariicon-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 стратегия для статики
// Ручной 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
// В 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)
// В service worker
self.addEventListener('sync', event => {
if (event.tag === 'flush-outbox') {
event.waitUntil(flushOutboxFromSW());
}
});
Регистрация синка:
// Когда добавляем в 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)
При отправке сигнала:
- Попробовать fetch()
- При ошибке → сохранить в IndexedDB
- На каждый
window.addEventListener('online')→ попытка flush - Кнопка в 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:
{
"chat_id": -1001234567890,
"text": "🚨 Экстренный сигнал от пользователя abc123\nВремя: 2026-03-20T10:30:00Z\nГеолокация: 55.7558, 37.6173",
"parse_mode": "HTML"
}
Ответ при успехе:
{
"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)
// Инициализация при первом запуске
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_BUSYsynchronous=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 минуту → одно сообщение «N сигналов получено»
- Throttling: очередь отправки с задержкой 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:
// Получить текущую позицию
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
{
"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)
Схема:
- Пользователь нажал кнопку → сохраняем в IndexedDB
- Если online → немедленная попытка отправки
- Если offline → регистрируем BackgroundSync tag
- При появлении сети: SW получает
syncevent → flush очереди - 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
Схема:
- Сохраняем в
localStorageкак JSON-массив 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, MDN PWA, caniuse Background Sync, Telegram Bot FAQ, SQLite WAL, firt.dev iOS PWA