Прерванная загрузка материалов
Загрузка пакета может прерваться после двух успешных файлов. В девятом уроке отказ удалял весь уникальный кандидат: это простой и понятный исходный вариант. Теперь сохраним завершённые ресурсы для повторной попытки. При этом частичный набор не станет выбранным учебником и не получит маркер ready до полного завершения.
Работа выполняется в открытой странице offline-lab. Мы не предполагаем, что браузер обязательно продолжит операцию после закрытия окна. Background Sync и Background Fetch имеют отдельные ограничения и не нужны для этого учебного механизма. Автономность чтения и фоновая загрузка — разные возможности. Офлайн-операции и фоновые задачи.
Контрольная точка
Единица нашего продолжения — успешно записанный целый Response. Прерванное тело не считается такой единицей. Cache.put должен завершиться, прежде чем следующий просмотр cache.match подтвердит наличие ответа. Мы не записываем процент загрузки как доказательство готовности файла. Он может описывать только временное наблюдение сетевой операции.
Состояние одной активной задачи хранится в pw-reader-state под /__offline-job.json. Оно содержит имя уникального кандидата и копию его плана. Это не указатель выбранного пакета: прежний /__offline-selection.json остаётся прежним. Такое разделение позволяет читателю продолжать пользоваться рабочей копией, пока новая попытка ещё не готова.
Следующий полный модуль assets/downloads.js заменяет простую подготовку packages.js для сценария с продолжением. Два механизма не запускаются одновременно для одной задачи. createDownload создаёт описание, readDownload читает его после нового открытия, resumeDownload готовит недостающие ответы. План фиксируется при создании, поэтому последующее изменение серверного release.json не меняет середину начатой задачи.
const STATE = "pw-reader-state";
const JOB = "/__offline-job.json";
async function putJSON(cache, key, value) {
await cache.put(key, new Response(JSON.stringify(value), {
headers: {"Content-Type": "application/json"}
}));
}
export async function createDownload(plan) {
if (!/^v\d+$/.test(plan.release_id) || !Array.isArray(plan.resources)) {
throw new Error("Неверный план");
}
const job = {
cacheName: "pw-package-" + plan.release_id + "-" + crypto.randomUUID(),
plan: JSON.parse(JSON.stringify(plan))
};
await putJSON(await caches.open(STATE), JOB, job);
return job;
}
export async function readDownload() {
const value = await (await caches.open(STATE)).match(JOB);
return value ? value.json() : null;
}
export async function resumeDownload(job, {signal, onProgress = () => {}} = {}) {
if (!/^pw-package-v\d+-[a-f0-9-]+$/.test(job.cacheName)) {
throw new Error("Неверное имя кандидата");
}
const cache = await caches.open(job.cacheName);
let completed = 0;
for (const item of job.plan.resources) {
if (signal?.aborted) throw new DOMException("Отменено", "AbortError");
const url = new URL(item.url, location.origin);
if (url.origin !== location.origin || url.search || url.hash) {
throw new Error("Неверный адрес ресурса");
}
let response = await cache.match(url.href);
const existed = Boolean(response);
if (!response) {
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 marker = 'name="pw-release" content="' + job.plan.release_id + '"';
if (!(await response.clone().text()).includes(marker)) {
throw new Error("HTML другого выпуска");
}
}
if (!existed) await cache.put(url.href, response);
completed += 1;
onProgress({completed, total: job.plan.resources.length, url: item.url});
}
if (signal?.aborted) throw new DOMException("Отменено", "AbortError");
await putJSON(cache, "/__offline-ready.json", job.plan);
const state = await caches.open(STATE);
await putJSON(state, "/__offline-selection.json", {cacheName: job.cacheName});
await state.delete(JOB);
return job.cacheName;
}
Успешные ответы остаются внутри кандидата после сетевого отказа. При повторной попытке найденный точный ответ проверяется по статусу, MIME и метке HTML, а отсутствующий запрашивается снова. Это учебная оптимизация для ресурсов, которые мы сами записали по фиксированному плану. Повреждённая существующая запись останавливает подготовку; целевое восстановление рассмотрим отдельно.
Ожидаемая последовательность для XML: HTML записан, CSS записан, SVG не получен. Ready отсутствует, выбранный пакет прежний. При продолжении программа использует первые две завершённые единицы и получает SVG. Только после этого создаётся ready и меняется указатель. Наличие имени кандидата в списке кешей не заменяет ни одного из этих условий.
Кнопка отмены
В обработчике начала создайте AbortController и передайте signal в resumeDownload. Кнопка «Остановить загрузку» вызывает controller.abort. Отказ с именем AbortError получает сообщение «Подготовка остановлена; завершённые ресурсы можно использовать при продолжении». Остальные ошибки показываются отдельно. Не называйте каждый отказ сети сознательной отменой пользователя. AbortController.
Для новой попытки создаётся новый AbortController: уже отменённый signal не становится активным обратно. При закрытии страницы временный объект теряется, но описание задачи и успешно записанные ответы могут оставаться. Следующее открытие читает readDownload и предлагает продолжение. Оно не запускает повторную массовую загрузку без понятного действия пользователя.
Разберём сообщение «два из трёх ресурсов готовы». Оно означает два завершённых ответа внутри конкретного кандидата, а не две трети всех байтов и не готовую страницу. Третий файл способен быть существенно тяжелее первых. Поэтому надпись остаётся счётчиком единиц и рядом показывает имя текущего ресурса. Для готового чтения нужен отдельный результат финализации всего набора.
Если после нового открытия JOB существует, а кеш кандидата исчез, cache.match не найдёт прежние единицы. Цикл получит их заново. Такая ситуация возможна после очистки данных или частичного удаления и не должна скрываться за сохранённым числом completed. Мы не храним это число как источник истины: оно пересчитывается по фактически найденным и проверенным ответам.
Если наоборот остался кандидат без записи JOB, приложение не начинает читать его как выбранную главу. Это потерянная задача, которую можно осмотреть и убрать отдельно. Поиск любого похожего имени и молчаливое продолжение создали бы риск использовать план другого выпуска. Рабочий указатель и маркер готовности продолжают решать свои отдельные задачи.
Abort не является транзакционным откатом Cache API. Запись, которая уже завершилась, не исчезает. Более того, после последней проверки signal начинается финализация готового набора; позднее нажатие может совпасть с записью указателя. Интерфейс должен отличать загрузку от завершения и не обещать отменить каждую уже законченную операцию.
Один кандидат за раз
У нашего состояния одна запись JOB и один выбранный пакет. Новый createDownload перезапишет описание прежней задачи. Поэтому интерфейс сначала читает существующую задачу и предлагает продолжить либо явно отказаться от неё. Код не является готовой очередью разных курсов и не обещает выбрать несколько независимых пакетов одним указателем.
Учебная подготовка здесь выполняется только в одной вкладке; остальные не создают и не продолжают задачи. Сам единственный ключ JOB не является межвкладочной блокировкой. Две одновременные операции способны перезаписать описание, а завершение первой — удалить запись второй. Для общего многоклиентного интерфейса понадобятся координация и проверка владельца задачи, которые данный модуль не реализует.
Финализация ready→selection→удаление JOB тоже не является общей транзакцией. Если последний шаг откажет, выбранный пакет уже может быть готов, хотя функция сообщит ошибку. Перед предложением повторного продолжения нужно сравнить JOB с фактическим pointer и проверить готовность пакета. Старое описание задачи само не доказывает незавершённую загрузку.
Отказ от задачи удаляет её конкретный кандидат и запись JOB после согласованного решения. Он не удаляет выбранный рабочий пакет и не затрагивает IndexedDB закладок. Если кандидат уже оказался выбранным, это другой сценарий: сначала нужно определить рабочий выбор. Универсальная очистка всех pw-пакетов в обработчике кнопки отмены была бы слишком широкой.
Для будущей многоглавной библиотеки схема выбора может стать отображением article_id→cacheName либо единым пакетом курса. Это отдельное развитие модели. Нельзя незаметно использовать текущий единственный указатель как список всего сохранённого. Наш пример учит продолжению одной подготовки и удерживает эту границу.
Повторяемость результата
Versioned-ресурсы должны оставаться неизменными под прежними именами. Иначе первая половина задачи может содержать старый CSS, а повторная попытка получит изменённое изображение с тем же URL. Фиксированный план и метка HTML уменьшают риск, но не заменяют проверку целостности всех файлов. Редакционная политика выпусков здесь так же важна, как цикл fetch.
Матрица получает остановку до первого ответа, отказ в середине, повторное открытие, ошибку записи и отмену во время финализации. Для каждого случая отмечаются ready, JOB и выбранный указатель отдельно. Так продолжение становится объяснимой функцией, а не надеждой на то, что браузер «как-нибудь докачает» после потери сети.