Модуль 12. Офлайн-очередь и push (опциональный, продвинутый)
Цель модуля: для студентов, желающих дойти до offline-first архитектуры. Модуль помечен опциональным — прохождение без него не влияет на базовый курс.
К концу модуля у вас:
- Очередь исходящих сообщений в IndexedDB — отправляются при восстановлении сети.
- Идемпотентность через
clientMessageId— нет дублей при ретраях. - Push-уведомления о новых сообщениях через Web Push API.
- Playwright e2e: отправить в офлайне → восстановить сеть → сообщение появилось.
Перед началом: Модуль 11. Инструменты: IndexedDB, Web Push API. Время: ~6 ч.
🔥 Инцидент. Пользователь пишет сообщение в метро. Интернет пропал на 5 минут — и сообщение... исчезло. Не сохранено, не в очереди, не отправлено при восстановлении сети. Каждый мессенджер должен работать офлайн, но это не «добавить localStorage» — нужна очередь с идемпотентностью.
📖 Теория
12.1 Архитектура офлайн-очереди
[Пользователь пишет сообщение]
↓
[Сеть недоступна?]
↓ Да ↓ Нет
[В IndexedDB] [Отправить на сервер]
↓ ↓
[Оптимистичный [Успех → добавить в $messages]
рендер: "отправляется"]
↓
[Сеть восстановлена → background sync → отправить очередь]
↓
[Для каждого сообщения: POST /api/messages]
↓
[Успех → удалить из очереди + статус "sent"]
[Провал → оставить в очереди + ретрай]
12.2 IndexedDB через idb-keyval
Установка:
bashnpm install idb-keyval
Слой работы с очередью
ts// app/features/offline-queue/db.ts import { get, set, del, keys } from 'idb-keyval' export interface QueuedMessage { clientMessageId: string // UUID — для идемпотентности text: string chatId: string createdAt: string // ISO timestamp status: 'pending' | 'sending' | 'sent' | 'error' } const QUEUE_KEY = 'offline-message-queue' export async function getQueue(): Promise<QueuedMessage[]> { return (await get<QueuedMessage[]>(QUEUE_KEY)) ?? [] } export async function addToQueue(msg: QueuedMessage): Promise<void> { const queue = await getQueue() queue.push(msg) await set(QUEUE_KEY, queue) } export async function removeFromQueue(clientMessageId: string): Promise<void> { const queue = await getQueue() await set(QUEUE_KEY, queue.filter((m) => m.clientMessageId !== clientMessageId)) } export async function updateQueueStatus(clientMessageId: string, status: QueuedMessage['status']): Promise<void> { const queue = await getQueue() const msg = queue.find((m) => m.clientMessageId === clientMessageId) if (msg) { msg.status = status await set(QUEUE_KEY, queue) } }
12.3 Стор очереди на Effector
ts// app/features/offline-queue/model.ts import { createEffect, createEvent, createStore, sample } from 'effector' import { type QueuedMessage, getQueue, addToQueue, removeFromQueue, updateQueueStatus } from './db' // --- Stores --- export const $queue = createStore<QueuedMessage[]>([]) export const $isOnline = createStore(true) // --- Events --- export const messageQueued = createEvent<Omit<QueuedMessage, 'status'>>() export const networkRestored = createEvent<void>() export const networkLost = createEvent<void>() // --- Effects --- const loadQueueFx = createEffect(async () => getQueue()) const addToQueueFx = createEffect(async (msg: QueuedMessage) => addToQueue(msg)) const sendMessagesFx = createEffect(async (messages: QueuedMessage[]) => { for (const msg of messages) { try { await updateQueueStatus(msg.clientMessageId, 'sending') const res = await fetch('/api/messages', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Client-Message-Id': msg.clientMessageId }, body: JSON.stringify({ text: msg.text, chatId: msg.chatId }), }) if (!res.ok) throw new Error(`HTTP ${res.status}`) await removeFromQueue(msg.clientMessageId) } catch (e) { await updateQueueStatus(msg.clientMessageId, 'error') // Не прерываем цикл — остальные сообщения могут отправиться } } }) // --- Связи --- $queue.on(loadQueueFx.doneData, (_, q) => q) $queue.on(addToQueueFx.doneData, () => {}) // reload after add $isOnline.on(networkRestored, () => true) $isOnline.on(networkLost, () => false) // При добавлении в очередь — сохраняем в IndexedDB sample({ clock: messageQueued, fn: (msg) => ({ ...msg, status: 'pending' as const }), target: addToQueueFx, }) // После добавления — перезагружаем очередь в стор sample({ clock: addToQueueFx.done, target: loadQueueFx }) // При восстановлении сети — отправляем все pending сообщения sample({ source: $queue, clock: networkRestored, filter: (queue) => queue.some((m) => m.status === 'pending' || m.status === 'error'), fn: (queue) => queue.filter((m) => m.status === 'pending' || m.status === 'error'), target: sendMessagesFx, }) // После отправки — перезагружаем очередь sample({ clock: sendMessagesFx.done, target: loadQueueFx }) // Стартовая загрузка sample({ clock: loadQueueFx, target: loadQueueFx })
12.4 Идемпотентность через `clientMessageId
Ключевой принцип офлайн-очереди: одно и то же сообщение не должно создаться дважды, даже если:
- Пользователь отправил, сеть прервалась, приложение перезапустилось → ретрай.
- Сервер получил запрос, обработал, но ответ потерялся → ретрай.
Решение: каждый раз генерируем clientMessageId (UUID) до отправки. Сервер дедуплицирует по этому ID:
ts// Клиент: генерируем ID один раз const clientMessageId = crypto.randomUUID() await addToQueue({ clientMessageId, text, chatId, createdAt, status: 'pending' }) // При каждом ретрае отправляем тот же ID fetch('/api/messages', { headers: { 'Content-Type': 'application/json', 'X-Client-Message-Id': clientMessageId, // ← сервер проверит, не было ли уже }, body: JSON.stringify({ text, chatId }), })
Route Handler с дедупликацией (mock)
ts// app/app/api/messages/route.ts import { NextResponse } from 'next/server' // В реальном проекте — проверка в YDB/Redis const seenIds = new Set<string>() export async function POST(request: Request) { const clientMessageId = request.headers.get('X-Client-Message-Id') if (!clientMessageId) { return NextResponse.json({ error: 'X-Client-Message-Id required' }, { status: 400 }) } if (seenIds.has(clientMessageId)) { // Идемпотентный ответ: сообщение уже создано — возвращаем как при первом успехе return NextResponse.json({ status: 'duplicate', message: 'Already processed' }) } seenIds.add(clientMessageId) const body = await request.json() // Здесь была бы запись в БД... return NextResponse.json({ status: 'ok', id: crypto.randomUUID() }) }
12.5 Push-уведомления (Web Push)
Регистрация Service Worker для push
ts// app/features/push-notifications/register.ts 'use client' export async function registerForPush() { if (!('serviceWorker' in navigator) || !('PushManager' in window)) return const permission = await Notification.requestPermission() if (permission !== 'granted') return const reg = await navigator.serviceWorker.ready const subscription = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY, }) // Отправляем подписку на сервер await fetch('/api/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(subscription), }) }
Обработка push в Service Worker
next-pwa позволяет добавить кастомный SW-код через customWorkerDir:
js// app/worker/index.ts self.addEventListener('push', (event) => { const data = event.data?.json() ?? {} event.waitUntil( self.registration.showNotification('Новое сообщение', { body: data.message ?? 'Откройте мессенджер', icon: '/icon-192.png', badge: '/icon-192.png', data: { url: data.url ?? '/' }, }), ) }) self.addEventListener('notificationclick', (event) => { event.notification.close() event.waitUntil(self.clients.openWindow(event.notification.data.url)) })
12.6 React: подключение очереди
tsx// app/features/offline-queue/useOfflineQueue.ts 'use client' import { useEffect, unit } from 'effector-react' import { useUnit } from 'effector-react' import { $queue, $isOnline, messageQueued, networkRestored, networkLost } from './model' export function useOfflineQueue() { const [queue, isOnline] = useUnit([$queue, $isOnline]) // Слушаем online/offline события useEffect(() => { const goOnline = () => networkRestored() const goOffline = () => networkLost() window.addEventListener('online', goOnline) window.addEventListener('offline', goOffline) return () => { window.removeEventListener('online', goOnline) window.removeEventListener('offline', goOffline) } }, []) const queueMessage = useUnit(messageQueued) return { queue, isOnline, queueMessage } }
Использование в ChatInput
tsx// В ChatInput (из Модуля 4) добавляем: const { isOnline, queueMessage } = useOfflineQueue() const handleSubmit = () => { if (!message.trim()) return if (!isOnline) { // Офлайн: добавляем в очередь queueMessage({ clientMessageId: crypto.randomUUID(), text: message, chatId: 'current-chat', createdAt: new Date().toISOString(), }) toast.info('Сообщение будет отправлено при восстановлении сети') } else { // Онлайн: отправляем сразу sendMessage(message) } setMessage('') }
12.7 Тестирование
Unit-тест стора очереди
ts// app/features/offline-queue/model.test.ts import { describe, it, expect, vi } from 'vitest' import { fork, allSettled } from 'effector' // Мокаем IndexedDB vi.mock('idb-keyval', () => { let store: any[] = [] return { get: vi.fn(async () => store), set: vi.fn(async (_, val) => { store = val }), del: vi.fn(), } }) describe('offline queue', () => { it('adds message to queue', async () => { const scope = fork() await allSettled(messageQueued, { scope, params: { clientMessageId: '1', text: 'Hi', chatId: 'c1', createdAt: '2026-01-01' }, }) const queue = scope.getState($queue) expect(queue).toHaveLength(1) expect(queue[0].status).toBe('pending') }) it('sends on network restore', async () => { const scope = fork({ values: [[$isOnline, false]] }) // Добавляем сообщение await allSettled(messageQueued, { scope, params: { clientMessageId: '2', text: 'Hi', chatId: 'c1', createdAt: '2026-01-01' }, }) // Восстанавливаем сеть await allSettled(networkRestored, { scope }) // Очередь должна обработаться await vi.waitFor(() => { expect(scope.getState($queue)).toHaveLength(0) }) }) })
Playwright e2e: офлайн-отправка
ts// e2e/offline-send.spec.ts import { test, expect } from '@playwright/test' test('queue message offline, send on reconnect', async ({ page, context }) => { await page.goto('/ui-gallery') // Офлайн await context.setOffline(true) // Пишем и отправляем await page.getByPlaceholder(/message/i).fill('Hello offline') await page.getByRole('button', { name: /send|отправить/i }).click() // Toast: "будет отправлено при восстановлении сети" await expect(page.getByText(/восстановлении сети/i)).toBeVisible() // Восстанавливаем сеть await context.setOffline(false) await page.waitForTimeout(2000) // Toast об успехе await expect(page.getByText(/отправлено|sent/i)).toBeVisible({ timeout: 10000 }) })
12.8 Антипаттерны
| Антипаттерн | Как правильно |
|---|---|
Хранение очереди в localStorage | IndexedDB — асинхронный, не блокирует main thread, больший объём |
Отправка без clientMessageId | Всегда генерировать UUID → сервер дедуплицирует |
| Прерывание цикла отправки при первой ошибке | Продолжать — остальные сообщения могут уйти |
| Нет ограничения retries | Экспоненциальный backoff или max 3 попытки |
Push без userVisibleOnly: true | Chrome требует этого — тихий push запрещён |
Компромиссы (Trade-offs)
| Решение | Плюсы | Минусы |
|---|---|---|
IndexedDB (idb-keyval) | Async, большой объём, структурированные данные | Сложнее API, нет в старых браузерах |
localStorage для очереди | Простой sync-API | Блокирует main thread, лимит 5 MB, только строки |
clientMessageId (UUID) | Идемпотентность, нет дублей при ретрае | Сервер должен хранить seen-IDs |
| Background Sync API | Автоотправка при возврате сети | Поддержка не везде (Safari) |
| Web Push | Уведомления о новых сообщениях | Требует VAPID-ключи, userVisibleOnly |
💻 12.9 Практика
- Установите
idb-keyval. - Реализуйте слой
db.ts(IndexedDB операции). - Реализуйте стор очереди (
model.ts) с эффектами. - Добавьте
useOfflineQueueи подключите к ChatInput. - Добавьте
POST /api/messagesс дедупликацией. - Напишите unit-тест стора и Playwright e2e.
bashnpm run verify
12.10 Артефакты сдачи М12
| Артефакт | Критерий |
|---|---|
| Unit-тест стора | Очередь добавляется, отправляется при networkRestored |
| Playwright e2e | Офлайн → отправить → онлайн → сообщение ушло |
| Дедупликация | Двойной клик → одно сообщение (через X-Client-Message-Id) |
Вперёд → Модуль 13: тест-стратегия как слой.
🧩 Проверь себя
sync event для offline-queue — кто триггерит?🔗 Мостик к Модулю 13
Офлайн работает: сообщения попадают в очередь, push приходят. Теперь убедимся, что всё покрыто тестами — unit, контрактными, e2e и a11y.