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

30 KiB
Raw Permalink Blame History

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-регион):

  • Восстановлен в 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)

При отправке сигнала:

  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:

{
  "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_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:

// Получить текущую позицию
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)

Схема:

  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, MDN PWA, caniuse Background Sync, Telegram Bot FAQ, SQLite WAL, firt.dev iOS PWA