Модуль 11. Базовый PWA и конфликт сборок Vite/Next.js

Цель модуля: превратить мессенджер в PWA (устанавливается, работает офлайн) и решить реальный конфликт сборок — Service Worker должен кэшировать файлы UI Kit из Vite, о которых Next.js ничего не знает.

К концу модуля у вас:

  • Веб-манифест, иконки, install prompt — приложение устанавливается на домашний экран.
  • Service Worker кэширует Route Handlers (NetworkFirst), статику Next.js (CacheFirst) и статику UI Kit из Vite (StaleWhileRevalidate).
  • Fallback-страница /offline — нет белого экрана при отсутсутствии сети.
  • Офлайн-индикатор в UI.
  • Playwright e2e на офлайн-сценарий.

Перед началом: Модули 1-10. Инструменты: next-pwa, Service Worker. Время: ~6 ч.


🔥 Инцидент. Вы добавляете next-pwa — и сборка падает: Error: conflicting generateSW from Vite and Next.js. Vite (UI Kit) и Next.js (приложение) оба пытаются зарегистрировать Service Worker. Конфликт плагинов, и простого npm install недостаточно — нужно понимать, кто чем собирает.


📖 Теория

11.1 Зачем PWA мессенджеру

ВозможностьБез PWAС PWA
Установка на домашний экран❌✅ Иконка, запуск как нативное приложение
Работа офлайн❌ Белый экран✅ Последние сообщения из кэша
Push-уведомления (М12)❌✅ Уведомление о новом сообщении
Кэширование статикиТолько HTTP cache✅ Service Worker — контролируемое кэширование

11.2 Веб-манифест

Next.js App Router поддерживает манифест через файл app/manifest.ts:

ts
// app/app/manifest.ts
import type { MetadataRoute } from 'next'

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: 'Messenger — UI Kit Course',
    short_name: 'Messenger',
    description: 'Мессенджер на React + Effector + Next.js',
    start_url: '/',
    display: 'standalone',
    background_color: '#0d1117',
    theme_color: '#2563eb',
    icons: [
      { src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
      { src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
      { src: '/icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
    ],
  }
}

Иконки положите в app/public/. Сгенерировать можно через realfavicongenerator.net или pwa-asset-generator:

bash
npx @vite-pwa/assets-generator --preset minimal-2023 public/icon.svg

display: 'standalone' — приложение открывается без адресной строки браузера, как нативное. display: 'fullscreen' — без статус-бара (для игр).


11.3 Service Worker: настраиваем next-pwa

Установка

bash
npm install -D @ducanh2912/next-pwa

Мы используем @ducanh2912/next-pwa — активный форк оригинального next-pwa (оригинал заброшен с 2023). Совместим с Next 15 и Workbox 7.

Конфигурация

Обновляем app/next.config.js:

js
// app/next.config.js
import withPWAInit from '@ducanh2912/next-pwa'

const withPWA = withPWAInit({
  dest: 'public',           // куда генерировать SW
  cacheOnFrontEndNav: true, // кэшировать при клиентской навигации
  disable: process.env.NODE_ENV === 'development', // отключить в dev (см. 11.7)
  workboxConfigs: {
    // Критично: размер кэшируемых файлов
    maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5 MB
  },
  extendRuntimeCaching(runtimeCaching) {
    // Добавляем свои правила к дефолтным next-pwa
    return [
      ...runtimeCaching,
      // --- Route Handlers: NetworkFirst ---
      {
        urlPattern: '/api/(.*)',
        handler: 'NetworkFirst',
        options: {
          cacheName: 'api-cache',
          networkTimeoutSeconds: 5,
          expiration: { maxEntries: 50, maxAgeSeconds: 300 }, // 5 мин
        },
      },
      // --- UI Kit статики из Vite: StaleWhileRevalidate ---
      // Проблема: Vite генерирует файлы с хешами (box-a1b2c3.js),
      // Next.js не знает об этих файлах. Явно указываем glob.
      {
        urlPattern: /\.(?:js|css)$/,
        handler: 'StaleWhileRevalidate',
        options: {
          cacheName: 'ui-kit-static',
          expiration: { maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60 }, // 30 дней
        },
      },
    ]
  },
})

const nextConfig = {
  transpilePackages: ['@myorg/ui-kit'],
  outputFileTracingRoot: new URL('..', import.meta.url).pathname,
  reactStrictMode: true,
  experimental: {
    optimizePackageImports: ['@myorg/ui-kit'],
  },
}

export default withPWA(nextConfig)

Почему именно эти стратегии

Что кэшируемСтратегияПочему
/api/* (Route Handlers)NetworkFirstСначала идём в сеть (свежие данные), при таймауте/офлайне — из кэша
JS/CSS файлы (включая Vite-хеши)StaleWhileRevalidateМгновенный ответ из кэша + фоновое обновление. Хеши Vite гарантируют: новый билд = новый URL = автоматическое обновление
Шрифты, иконкиCacheFirst (дефолт next-pwa)Редко меняются, отдать из кэша сразу
HTML-страницыNetworkFirst (дефолт next-pwa)Свежая версия страницы, fallback на кэш

11.4 Конфликт сборок Vite/Next.js — главный урок модуля

Суть проблемы

[UI Kit на Vite] → собирает → dist/box-a1b2c3.js, dist/text-d4e5f6.css
                                        ↓
[Next.js] → transpilePackages → обрабатывает исходники UI Kit
                                        ↓
[next-pwa] → генерирует Service Worker → кэширует статику Next.js
            НО не знает про dist/ файлы Vite напрямую

Next.js через transpilePackages встраивает UI Kit в свой бандл. Файлы из ui-kit/dist/ попадают в финальный бандл Next.js под новыми хешами. Service Worker от next-pwa кэширует статику Next.js — а значит, UI Kit-файлы тоже кэшируются (они внутри чанков Next.js).

Но если UI Kit публикуется отдельно (npm)

Если UI Kit ставится из npm (а не из workspace), ситуация меняется. Пакет лежит в node_modules/@myorg/ui-kit/dist/. Next.js через transpilePackages встраивает его в бандл — и снова файлы внутри чанков Next.js. Service Worker кэширует их автоматически.

Практический вывод

При использовании transpilePackages (наш кейс) — отдельная конфигурация кэширования Vite-статики не требуется. UI Kit встраивается в чанки Next.js и кэшируется дефолтными правилами next-pwa. Отдельное правило для .js/.css (см. конфиг выше) — это страховка на случай, если кто-то подключает UI Kit через CDN или прямой <link>.

Для курса: показываем студенту, что transpilePackages решает проблему. Упомянуть кейс CDN/прямого импорта — и тогда нужно явное правило кэширования. Это и есть «практический урок конфликта сборок».


11.5 Fallback-страница /offline

Когда Service Worker не может загрузить страницу (нет сети и нет кэша) — показываем fallback:

tsx
// app/app/offline/page.tsx
import { Box, Text, Button } from '@myorg/ui-kit'

export default function OfflinePage() {
  return (
    <main style={{ padding: 'var(--s-lg)', maxWidth: 480, margin: '0 auto', textAlign: 'center' }}>
      <Box direction="column" gap="md" padding="xl">
        <Text variant="h1">Нет соединения</Text>
        <Text color="muted">
          Приложение работает офлайн. Последние сообщения доступны из кэша.
          Новые сообщения будут отправлены при восстановлении сети.
        </Text>
        <Button variant="primary" onClick={() => window.location.reload()}>
          Попробовать снова
        </Button>
      </Box>
    </main>
  )
}

Эту страницу Service Worker кэширует заранее через fallback в next-pwa конфиге:

js
// в withPWAInit({...})
extendDefaultRuntimeCaching(runtimeCaching) {
  return [
    ...runtimeCaching,
    // ... другие правила ...
  ]
},
// Fallback при ошибке навигации (нет сети + нет кэша)
fallbacks: {
  document: '/offline',  // показываем эту страницу
},

11.6 Офлайн-индикатор

Компонент показывает статус сети в реальном времени:

tsx
// app/features/offline-indicator/view.tsx
'use client'

import { useState, useEffect } from 'react'
import { Box, Text } from '@myorg/ui-kit'

export function OfflineIndicator() {
  const [isOnline, setIsOnline] = useState(true)

  useEffect(() => {
    const goOnline = () => setIsOnline(true)
    const goOffline = () => setIsOnline(false)
    window.addEventListener('online', goOnline)
    window.addEventListener('offline', goOffline)
    setIsOnline(navigator.onLine)
    return () => {
      window.removeEventListener('online', goOnline)
      window.removeEventListener('offline', goOffline)
    }
  }, [])

  if (isOnline) return null

  return (
    <Box
      padding="sm"
      style={{
        position: 'fixed',
        bottom: 0,
        left: 0,
        right: 0,
        background: 'var(--c-danger)',
        color: 'white',
        textAlign: 'center',
        zIndex: 'var(--z-toast)',
      }}
    >
      <Text>Нет соединения — работа в офлайн-режиме</Text>
    </Box>
  )
}

Добавьте <OfflineIndicator /> в app/layout.tsx.


11.7 Тестирование офлайн-сценариев

Вручную через DevTools

  1. Откройте приложение → дождитесь загрузки Service Worker (Application → Service Workers).
  2. DevTools → Network → Offline.
  3. Перезагрузите страницу → должна открыться закэшированная версия (или /offline).

Playwright e2e

ts
// e2e/offline.spec.ts
import { test, expect } from '@playwright/test'

test('UI Gallery works offline after first visit', async ({ page, context }) => {
  // Первый визит — кэшируем
  await page.goto('/ui-gallery')
  await expect(page.getByRole('heading', { name: 'UI Gallery' })).toBeVisible()

  // Ждём, пока Service Worker активируется
  await page.waitForTimeout(3000)

  // Переходим в офлайн
  await context.setOffline(true)

  // Перезагружаем — страница должна работать из кэша
  await page.reload()
  await expect(page.getByRole('heading', { name: 'UI Gallery' })).toBeVisible()

  await context.setOffline(false)
})

11.8 Нюансы и отладка

«Бесконечная перезагрузка» из-за Service Worker

Симптом: страница перезагружается раз за разом. Причина: старый SW с ошибкой кэширует свой бумажный index.html, и новая версия не может загрузиться.

Решение:

js
// В Service Worker (next-pwa делает это автоматически через skipWaiting + clients.claim)
// Но если нужно вручную отрегистрировать воркер:
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
  navigator.serviceWorker.getRegistrations().then((registrations) => {
    registrations.forEach((reg) => reg.unregister())
    console.log('SW unregistered, reloading...')
    window.location.reload()
  })
}

Service Worker в режиме разработки

next-pwa с disable: process.env.NODE_ENV === 'development' — SW не регистрируется в dev. Это правильно: SW мешает HMR, кэширует старые ассеты и ломает разработку.

Если нужно тестировать SW локально:

bash
npm run build:app && npm run start:app  # production build → SW активен

skipWaiting + clients.claim

  • skipWaiting — новый SW активируется сразу, не дожидаясь закрытия всех вкладок.
  • clients.claim — новый SW сразу перехватывает управление открытыми вкладками.

next-pwa включает оба по умолчанию. Это значит: после деплоя новой версии — следующий визит пользователя получит новый SW и новый кэш автоматически.


11.9 Антипаттерны

АнтипаттернПочему плохоКак правильно
CacheFirst для /api/*Пользователь видит устаревшие данныеNetworkFirst с networkTimeoutSeconds: 5
cleanUrls: true с хешами ViteБесконечный cache misscleanUrls: false (дефолт)
Нет fallback-страницыБелый экран в глубоком офлайнеfallbacks: { document: '/offline' }
SW активен в devМешает HMR, кэширует староеdisable: NODE_ENV === 'development'
Нет maximumFileSizeToCacheInBytesБольшие файлы не кэшируются (молча)Явно указать 5–10 MB
registerType: 'autoUpdate' без тестовНовый SW может сломать открытое приложениеТестировать через npm run build && npm run start

Компромиссы (Trade-offs)

РешениеПлюсыМинусы
next-pwa (Workbox)Дефолтные стратегии, fallbacks, интеграция с NextКонфликт с Vite-плагинами, нужен transpilePackages
Vite SW (injectManifest)Полный контроль над SW-кодомNext не знает про Vite-ассеты
NetworkFirst для /api/*Свежие данные, fallback в кэшМедленнее CacheFirst при плохой сети
CacheFirst для статикиМгновенный ответ из кэшаСтарые хеши могут задержаться
SW активен в devМожно тестировать офлайн локальноЛомает HMR, кэширует старые ассеты

💻 11.10 Практика

Шаг 1. Установить и настроить next-pwa

bash
npm install -D @ducanh2912/next-pwa

Обновите next.config.js по образцу из §11.3.

Шаг 2. Создать манифест и иконки

Создайте app/app/manifest.ts по образцу из §11.2. Положите иконки в app/public/.

Шаг 3. Создать fallback /offline

Реализуйте app/app/offline/page.tsx по образцу из §11.5.

Шаг 4. Добавить OfflineIndicator

Реализуйте и добавьте в layout.tsx.

Шаг 5. Проверить production-сборку

bash
npm run build:app && npm run start:app

Откройте http://localhost:3000, проверьте:

  • Application → Manifest виден, иконки есть.
  • Application → Service Workers — зарегистрирован.
  • Установите приложение (Chrome → иконка установки в адресной строке).

Шаг 6. Тест офлайн

DevTools → Network → Offline → reload → страница работает из кэша.

Шаг 7. Playwright e2e

Добавьте e2e/offline.spec.ts из §11.7. Запустите:

bash
npm run build:app && npx playwright test offline

Шаг 8. Проверка

bash
npm run verify

11.11 Артефакты сдачи М11

АртефактКак получитьКритерий
Скриншот ManifestDevTools → Application → ManifestВидны имя, иконки, display: standalone
Скриншот Service WorkerDevTools → Application → Service WorkersСтатус: activated
Скриншот офлайнNetwork → Offline → ReloadСтраница работает (не белый экран)
Playwright офлайн-тестnpx playwright test offlineТест проходит
Скриншот OfflineIndicatorNetwork → OfflineКрасный баннер внизу экрана

11.12 Чек-лист перед Модулем 12

  • next-pwa установлен и настроен (dest: 'public', disable в dev).
  • Веб-манифест генерируется через app/manifest.ts.
  • Fallback-страница /offline работает.
  • OfflineIndicator показывает статус сети.
  • Production-сборка: SW активен, приложение устанавливается.
  • Playwright офлайн-тест проходит.
  • Я понимаю разницу между NetworkFirst, StaleWhileRevalidate и CacheFirst.
  • Я знаю, почему transpilePackages решает конфликт Vite/Next для кэширования.

Вперёд → Модуль 12: офлайн-очередь и push (опциональный).


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

1. Конфликт сборок Vite (UI Kit lib) и Next.js (App) — в чём суть?
2. Как разрешить конфликт — кто что собирает?
3. vite-plugin-pwa в Next.js App — проблема?
4. Почему Vite и Next.js конфликуют в монорепо?
5. Решение: UI Kit → build → npm package → Next.js import. Зачем?

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

PWA настроено, Service Worker кэширует ассеты. Теперь реализуем офлайн-очередь сообщений и Push-уведомления — чтобы мессенджер работал без сети.