Модуль 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

Установка:

bash
npm 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 Антипаттерны

АнтипаттернКак правильно
Хранение очереди в localStorageIndexedDB — асинхронный, не блокирует main thread, больший объём
Отправка без clientMessageIdВсегда генерировать UUID → сервер дедуплицирует
Прерывание цикла отправки при первой ошибкеПродолжать — остальные сообщения могут уйти
Нет ограничения retriesЭкспоненциальный backoff или max 3 попытки
Push без userVisibleOnly: trueChrome требует этого — тихий 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 Практика

  1. Установите idb-keyval.
  2. Реализуйте слой db.ts (IndexedDB операции).
  3. Реализуйте стор очереди (model.ts) с эффектами.
  4. Добавьте useOfflineQueue и подключите к ChatInput.
  5. Добавьте POST /api/messages с дедупликацией.
  6. Напишите unit-тест стора и Playwright e2e.
bash
npm run verify

12.10 Артефакты сдачи М12

АртефактКритерий
Unit-тест стораОчередь добавляется, отправляется при networkRestored
Playwright e2eОфлайн → отправить → онлайн → сообщение ушло
ДедупликацияДвойной клик → одно сообщение (через X-Client-Message-Id)

Вперёд → Модуль 13: тест-стратегия как слой.


🧩 Проверь себя

1. Офлайн-очередь исходящих сообщений — зачем?
2. Push и офлайн-очередь — это одно и то же?
3. Background-sync (Service worker) + IndexedDB — как связаны?
4. Service Worker sync event для offline-queue — кто триггерит?
5. IndexedDB vs localStorage для offline-queue — разница?

🔗 Мостик к Модулю 13

Офлайн работает: сообщения попадают в очередь, push приходят. Теперь убедимся, что всё покрыто тестами — unit, контрактными, e2e и a11y.