Согласованность HTML и ресурсов
В основной части HTML содержал своё оформление. Теперь вынесем CSS и добавим схему XML-запроса. Сохранение одного документа перестаёт означать полную автономность: глава может открыться без стилей или с потерянным изображением. Будем готовить согласованный пакет и выбирать его только после загрузки обязательного набора.
У нашей учебной XML-страницы постоянный URL остаётся прежним. В её HTML размещаются метка выпуска <meta name="pw-release" content="v1">, ссылка на /assets/site.v1.css и изображение /assets/xml.v1.svg. Пути ресурсов содержат версию. Это позволяет оставить прежний набор работоспособным, когда новая статья будет ссылаться на site.v2.css.
Описываем пакет
Следующий полный data/release.json описывает отдельный XML-пакет, а не всю библиотеку. Главная, реестр и модули продолжают принадлежать оболочке worker v3. Для Markdown потребуется собственный согласованный список, если у него появятся внешние зависимости. Нельзя считать, что список XML автоматически покрывает каждую другую главу.
{
"release_id": "v1",
"article_id": "xml",
"resources": [
{"url": "/my/LINQ/linq_xml/level7/7_1.php", "type": "text/html"},
{"url": "/assets/site.v1.css", "type": "text/css"},
{"url": "/assets/xml.v1.svg", "type": "image/svg+xml"}
]
}
В минимальном примере реестр ресурсов написан явно. В будущем сборщик может подготовить его из зависимостей, но такой механизм должен учитывать вложенные изображения CSS, шрифты и импортируемые модули. Наш список не притворяется универсальным анализатором HTML. Он объясняет конкретный пакет из трёх известных ответов.
Полный модуль assets/packages.js готовит уникальный кеш-кандидат. Для запросов подготовки используется собственный заголовок X-PW-Prepare, который далее научимся пропускать через worker к сети. Заголовок служит внутреннему протоколу публичного стенда и не заменяет права доступа. После всех записей сохраняются описатель пакета и указатель выбранного кеша.
const STATE = "pw-reader-state";
const SELECTED = "/__offline-selection.json";
export async function preparePackage(plan, {signal} = {}) {
if (!/^v\d+$/.test(plan.release_id)) throw new Error("Неверный выпуск");
const name = "pw-package-" + plan.release_id + "-" + crypto.randomUUID();
const candidate = await caches.open(name);
try {
for (const item of plan.resources) {
const url = new URL(item.url, location.origin);
if (url.origin !== location.origin || url.search || url.hash) {
throw new Error("Неверный адрес ресурса");
}
const response = await fetch(url.href, {
cache: "reload", signal, headers: {"X-PW-Prepare": "1"}
});
if (!response.ok || !(response.headers.get("content-type") ?? "").includes(item.type)) {
throw new Error("Не получен ресурс: " + item.url);
}
if (item.type === "text/html") {
const body = await response.clone().text();
const marker = 'name="pw-release" content="' + plan.release_id + '"';
if (!body.includes(marker)) throw new Error("HTML другого выпуска");
}
await candidate.put(url.href, response);
}
await candidate.put("/__offline-ready.json", new Response(JSON.stringify(plan), {
headers: {"Content-Type": "application/json"}
}));
const state = await caches.open(STATE);
await state.put(SELECTED, new Response(JSON.stringify({cacheName: name}), {
headers: {"Content-Type": "application/json"}
}));
return name;
} catch (error) {
await caches.delete(name);
throw error;
}
}
Ожидаемый результат: при отказе изображения прежний выбранный пакет остаётся выбранным. Уникальное имя не позволяет двум попыткам писать разные версии в один кандидат. Переключение указателя происходит последним. Это протокол поверх нескольких операций, а не транзакция Cache API с автоматическим откатом всего origin. Cache API.
Метка HTML проверяет согласованный выпуск нашего подготовленного документа. Она не доказывает криптографическую целостность каждого байта. Для более строгого контроля потребовались бы хеши файлов и проверенный описатель. Даже с меткой редакция должна обеспечить, чтобы versioned-ресурсы не менялись под прежними именами.
Разберём отказ на последнем файле. Кандидат уже содержит HTML и CSS, но запрос SVG завершился ошибкой. Функция удаляет только уникальное имя этой попытки и возвращает ошибку вызывающему интерфейсу. Указатель прежнего пакета ещё не менялся, поэтому старое чтение продолжается по прежнему выбору. Если вместо этого писать в общий кеш v1, промежуточный HTML мог бы заменить работающую главу до получения её изображения.
Теперь представьте успешную подготовку. Все три ответа записаны, затем добавлен описатель ready. Только после этого выбранный указатель получает имя нового кеша. Если запись указателя не завершится, кандидат тоже будет удалён в ветке ошибки, а прежний выбор сохранится. Приложение сообщает о завершении подготовки после return, а не после получения одного HTML. Это ожидаемая последовательность, которую позже нужно наблюдать на собственном стенде.
Кеш состояния содержит указатель, а не саму статью. Если он исчезнет, ресурсы пакета могут оставаться на устройстве, но программа не знает, какой набор выбрал пользователь. Нельзя автоматически объявлять последнюю строку из caches.keys самым новым выпуском: порядок ключей не является редакционной историей. Восстановление потребует проверки готовых пакетов и явного выбора.
Описатель ready содержит список ресурсов того выпуска, который был подготовлен. Он помогает проверить пакет после перезапуска страницы и не зависит от переменных закрытой вкладки. Поэтому нельзя заменять его списком только из текущего серверного release.json: сервер уже может описывать другой выпуск. Для осмотра сохранённой копии используется её собственный описатель.
Разделение оболочки и главы также нужно показать человеку. Главная может быть доступна, а выбранная XML-копия не подготовлена. Или глава сохранена, но новый модуль интерфейса отсутствует. Общая надпись «Всё готово» скрывала бы разные критерии. Показывайте состояние автономного запуска отдельно от состояния конкретного материала, особенно после очистки части хранилища.
Читаем выбранный пакет
Ниже полный заменяющий обработчик fetch для worker v3. Install и обработчик запроса имени кеша остаются из прежних уроков. CORE дополняется модулями packages.js, данными release.json, site.v1.css и xml.v1.svg с соответствующими MIME-группами. Эти два ресурса нужны и обязательному XML оболочки, который теперь имеет внешние зависимости. Указатель хранится в отдельном кеше состояния, поэтому завершение worker не стирает выбор. Сохранённые ресурсы выбранного пакета теперь отдаются прежде сети: читатель получает именно согласованную копию.
self.addEventListener("fetch", event => {
const request = event.request;
const url = new URL(request.url);
if (request.method !== "GET" || url.origin !== self.location.origin) return;
if (request.headers.get("X-PW-Prepare") === "1") return;
event.respondWith((async () => {
try {
const state = await caches.open("pw-reader-state");
const pointer = await state.match("/__offline-selection.json");
if (pointer) {
const {cacheName} = await pointer.json();
if (typeof cacheName === "string" && cacheName.startsWith("pw-package-")) {
const packageCache = await caches.open(cacheName);
if (await packageCache.match("/__offline-ready.json")) {
const copy = await packageCache.match(request);
if (copy) return copy;
}
}
}
} catch (error) {
console.warn("Метаданные выбранного пакета недоступны", error);
}
try { return await fetch(request); }
catch {
const core = await caches.open(CACHE);
const saved = await core.match(request);
if (saved) return saved;
if (request.mode !== "navigate") return Response.error();
const fallback = await core.match("/offline.html");
return new Response(fallback ? await fallback.text() : "Недоступно", {
status: 503, headers: {"Content-Type": "text/html; charset=utf-8"}
});
}
})());
});
Этот этап меняет политику чтения для выбранного пакета. Даже при наличии сети XML открывается из сохранённого согласованного набора. Проверка свежести выполняется отдельной подготовкой, а не скрытой заменой каждого ресурса на случайный сетевой ответ. Интерфейс должен называть эту копию сохранённой и показывать её release_id.
Переключение указателя не перерисовывает уже открытый HTML. Для нового пакета понадобится новая навигация. Вкладки и активная подготовка создают дополнительные границы; четырнадцатый урок рассмотрит их отдельно. Старые пакеты пока сохраняются, но этот обработчик ещё не ищет в них ресурсы прежних вкладок. Нельзя обещать их автономную доступность только по наличию старого кеша. В базовом v1 обязательные ресурсы также входят в CORE; произвольные последующие версии нуждаются в отдельном протоколе.
Повреждённый JSON указателя также отличается от сетевого отказа. Чтение метаданных обёрнуто своей веткой catch, после которой запрос продолжит обычную сеть или CORE. Мы не выдаём сбой pointer.json за окончательный отказ всех запросов. При этом fallback из CORE не доказывает восстановление выбранного пакета: интерфейс должен отдельно сообщить об утрате его состояния.
Неполнота и границы
Маркер ready подтверждает завершение нашей подготовки в тот момент. Позднейшая очистка отдельного ресурса может сделать пакет неполным. В пятнадцатом уроке введём проверку всего списка перед восстановлением. Сегодня не называем одно наличие маркера вечной гарантией сохранности.
Полная библиотека из тысяч страниц не входит в обязательный пакет. Выбранная глава имеет небольшой понятный набор, а оболочка сохраняется отдельно. Это снижает расход и делает ошибку локальной: отказ схемы XML не заставляет объявить все другие статьи повреждёнными. Следующий урок добавит оценку размера и осознанную политику объёма.