Модуль 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:
bashnpx @vite-pwa/assets-generator --preset minimal-2023 public/icon.svg
display: 'standalone'— приложение открывается без адресной строки браузера, как нативное.display: 'fullscreen'— без статус-бара (для игр).
11.3 Service Worker: настраиваем next-pwa
Установка
bashnpm 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
- Откройте приложение → дождитесь загрузки Service Worker (Application → Service Workers).
- DevTools → Network → Offline.
- Перезагрузите страницу → должна открыться закэшированная версия (или
/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 локально:
bashnpm 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 miss | cleanUrls: 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
bashnpm 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-сборку
bashnpm 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. Запустите:
bashnpm run build:app && npx playwright test offline
Шаг 8. Проверка
bashnpm run verify
11.11 Артефакты сдачи М11
| Артефакт | Как получить | Критерий |
|---|---|---|
| Скриншот Manifest | DevTools → Application → Manifest | Видны имя, иконки, display: standalone |
| Скриншот Service Worker | DevTools → Application → Service Workers | Статус: activated |
| Скриншот офлайн | Network → Offline → Reload | Страница работает (не белый экран) |
| Playwright офлайн-тест | npx playwright test offline | Тест проходит |
Скриншот OfflineIndicator | Network → 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 (опциональный).
🧩 Проверь себя
🔗 Мостик к Модулю 12
PWA настроено, Service Worker кэширует ассеты. Теперь реализуем офлайн-очередь сообщений и Push-уведомления — чтобы мессенджер работал без сети.