Service worker, который отдаёт флоу

Без service worker PWA не бывает: браузер не предложит «установить приложение», и офлайн-открытие сведётся к ошибке. В «КБЖУ Дневнике» SW делает приложение устанавливаемым и открывает /app без сети. Кэширование статики нужно именно для этого.
Проверка занимает десять секунд. Включаем авиарежим и кликаем по иконке на домашнем экране: оболочка приложения — HTML-страница со стилями и скриптами — открывается из кэша, дневник читается из IndexedDB, о которой была прошлая часть. Об офлайн-режиме сообщает лишь жёлтая капсула в шапке.
Сама платформа здесь необычна: файлов sw.js и manifest.webmanifest на сервере физически нет, оба отдаются флоу.
Манифест и SW как флоу
Маршрут GET /manifest.webmanifest привязан к флоу site_manifest из одного действия: respond_json с готовым JSON-объектом, content_type: "application/manifest+json" и кэшем на 300 секунд. Внутри обычный манифест: start_url: "/app", scope: "/", display: "standalone" и три иконки размером 192, 512 и maskable-512. Maskable-версия отличается от остальных: у неё есть отступы от краёв, потому что Android вписывает иконку в кружок или скруглённый квадрат, и обычная иконка на весь холст при этом теряет края.
Кэш у двух флоу разный, и у каждого своя причина. Манифест меняется только вместе с иконками и именем, так что 300 секунд ему не помеха. У /sw.js TTL 0, потому что браузер проверяет обновление SW по HTTP-кэшу, и застывший в кэше service worker задержал бы каждое обновление приложения на срок кэша.
Тело флоу site_sw — это JS-исходник строкой, без Liquid-обработки, с content_type: "application/javascript" и TTL 0. Оригинал лежит в репозитории (static/sw.js) и встраивается во флоу при публикации.
Путь /sw.js выбран не случайно. Область действия service worker по умолчанию — всё, что до последнего слэша в URL скрипта, так что /sw.js в корне даёт область /, то есть весь сайт, и без единого дополнительного заголовка. Если положить его в /static/sw.js, область сузится до /static/, и понадобится заголовок Service-Worker-Allowed: /. Поэтому файл и лежит в корне.
Регистрация SW — пять строк в шаблоне оболочки:
<script type="module" src="/static/app.js?v={{ site.version }}"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('/sw.js').catch(function () {});
});
}
</script>
Обработчик fetch
Перехватываем только GET. POST-запросы (сохранение записи, профиль) идут прямо в сеть, у них своя очередь в IndexedDB.
/api/* мы не пропускаем через SW: respondWith для таких запросов не вызывается, браузер ходит в сеть сам, будто service worker отсутствует. Кэшировать данные закрытой части нельзя: офлайн-ответ из кэша показал бы неверные калории за сегодня. Сами эти данные и так лежат в IndexedDB.
Навигация на /app работает по network-first. Если сеть есть, отдаём свежую оболочку и попутно обновляем кэш. Если сети нет, отдаём закэшированную:
if (req.mode === 'navigate' && url.pathname === APP_PATH) {
const cache = await caches.open(CACHE);
try {
const res = await fetch(req);
if (res && res.ok) {
await cache.put(APP_PATH, res.clone());
event.waitUntil(precacheShellStatics()); // оболочка обновилась → загружаем статику в кэш
}
return res;
} catch (err) {
const cached = await cache.match(APP_PATH);
if (cached) return cached;
throw err;
}
}
Последний случай — /static/*: работает stale-while-revalidate, ответ отдаётся из кэша мгновенно и в фоне обновляется из сети, так что CSS пользователь не ждёт.
Оболочка и её статика
Оболочка без своей статики офлайн бесполезна: HTML откроется без стилей и скриптов. Поэтому при install, а затем и после каждого обновления оболочки SW парсит закэшированный HTML, собирает все src/href на /static/ (вместе с ?v=) и загружает каждый файл с cache: 'reload', минуя HTTP-кэш браузера, чтобы новая версия гарантированно попала в кэш.
Нюанс в том, что в HTML нет db.js и api.js: их динамически импортирует сам app.js. SW добавляет их к списку сам, наследуя ту же ?v=:
if (url.startsWith('/static/app.js')) {
const v = new URL(url, self.location.origin).searchParams.get('v');
const q = v ? `?v=${encodeURIComponent(v)}` : '';
urls.add(`/static/db.js${q}`);
urls.add(`/static/api.js${q}`);
}
При активации мы наводим порядок: старые кэши kbzhu-shell-* удаляются, а из текущего вычищается статика, которой нет в свежей оболочке (например, протухшие ?v=). skipWaiting на install и clients.claim на activate позволяют новому SW начать работать сразу, не дожидаясь закрытия всех вкладок и не требуя «перезагрузите страницу». Кэш при этом не растёт бесконечно.

Грабли: кэш модулей, который держал старый db.js
Самая незаметная проблема всего цикла касалась импортов. CSS и точку входа версионирует шаблон: ?v={{ site.version }} меняется при каждом релизе, и браузер загружает свежие файлы. Внутренние импорты (import './db.js') подчиняются другим правилам: версий у них нет, и браузер держит такой модуль в своём кэше до часа.
Симптом выглядел странно. Сайт обновлён, в консоли TypeError: db.pruneFoods is not a function. Новый app.js уже загрузился, а db.js остался от прошлой версии. Функция в исходнике есть, а у загруженного модуля её нет. Автотест поймал это даже на «свежем» сайте, где обновляться было не с чего.
Проблему решает динамический импорт с суффиксом версии, взятой от URL самого app.js:
const __v = new URL(import.meta.url).searchParams.get('v');
const __sib = (name) => `./${name}${__v ? `?v=${__v}` : ''}`;
const db = await import(__sib('db.js'));
const api = await import(__sib('api.js'));
Теперь db.js и api.js получают тот же ?v=, что и точка входа: обновилась оболочка, обновились и они. Загрузка из предыдущего раздела кладёт их в кэш SW с теми же версиями, и офлайн оболочка со статикой не расходится.
Константа VERSION в SW осталась без изменений с первого дня: она равна kbzhu-v1. Обновление статики работает и без её смены: каждая публикация меняет ?v= и оболочку, SW замечает новую оболочку при network-first и сам заново загружает файлы в кэш. Поднимать VERSION значило бы тщательно ловить момент: старый SW и новый кэш сосуществуют до активации, и в этом окне легко оставить работающее приложение без статики.
Слабое место в этой схеме — любой файл без версии в URL: один такой импорт, и браузер подмешивает старый модуль к обновлённым файлам. У нас он нашёлся на 20 строк ниже шапки документа.
Что дальше. Клиентская часть на этом закрыта: офлайн-очередь работает, оболочка кэшируется. В следующей части — «Подводные камни Liquid и динамической схемы»: что происходит, когда бэкенд пишется на Liquid-флоу, а библиотека запросов не принимала GraphQL с Liquid-вставками.
Все записи