Добавление условной загрузки документов

CloudflareBeginner
Практиковаться сейчас

Введение

Средству просмотра документов часто нужны только следующие несколько байтов или подтверждение того, что его кэшированная копия ещё актуальна. Загрузка всего файла при каждом запросе приводит к лишней работе. Вы добавите HTTP-валидаторы и загрузку отдельных диапазонов байтов в защищённый Worker, использующий приватное хранилище R2.

Сначала пройдите лабораторную работу «Потоковая передача документов через Worker». Эта лабораторная работа начинается в новой виртуальной машине с Node.js 22.22.0, Wrangler 4.131.1 и предоставленным модулем проверки токена; вы создадите новый бакет и развернёте новый Worker. Подписка R2 и разрешения учебной учётной записи должны быть готовы заранее. Ознакомьтесь с тарифами R2 за операции и хранение данных. Пользовательский домен не нужен. Храниться будут только синтетические текстовые данные; перед выходом выполните очистку.

Подключение бакета приложения

На этом шаге вы авторизуете виртуальную машину и создадите отдельный приватный бакет для приложения. Авторизация устройства подтверждает вашу учебную учётную запись. Для управления бакетом R2 используется отдельный API-токен, ограниченный этой учётной записью.

Запустите Bash, чтобы использовать приведённый ниже синтаксис команд, затем перейдите в подготовленный проект и проверьте инструменты. Не закрывайте этот терминал: переменные с именами ресурсов должны оставаться доступными:

bash
cd /home/labex/project/r2-lab
export PATH="$PWD/.tools/node-v22.22.0-linux-x64/bin:$PATH"
node --version
npx wrangler --version

Авторизуйте отображённый код устройства в своём браузере. Перед предоставлением согласия подтвердите учебную учётную запись, а также запрошенные области доступа к чтению учётной записи и пользователя:

npx wrangler login --device --browser=false --scopes account:read user:read workers_scripts:write workers_kv:write
npx wrangler whoami --json

Убедитесь, что loggedIn: true. Прочитайте имя учётной записи, даже если в списке указана только одна. Замените YOUR_ACCOUNT_ID ниже фактическим идентификатором этой учётной записи из 32 символов. Команда openssl rand -hex 6 создаёт двенадцать случайных шестнадцатеричных символов, поэтому имя этого запуска не совпадёт с предыдущим. Here-документ создаёт стандартный файл конфигурации; оболочка подставляет в него значения переменных.

ACCOUNT_ID=YOUR_ACCOUNT_ID
RUN_ID=$(openssl rand -hex 6)
NAME="labex-c05-r03-$RUN_ID"
BUCKET="$NAME-docs"
cat > wrangler.jsonc <<JSON
{"name":"$NAME","account_id":"$ACCOUNT_ID","main":"src/index.js","workers_dev":true,"compatibility_date":"2026-07-30","r2_buckets":[{"binding":"DOCUMENTS","bucket_name":"$BUCKET"}]}
JSON

Для управления бакетом откройте страницу API Tokens в профиле Cloudflare и создайте пользовательский токен с именем этой лабораторной работы. Предоставьте разрешение Account → Workers R2 Storage → Edit, а в разделе Account Resources ограничьте его учебной учётной записью, идентификатор которой вы сохранили. Установите короткий срок действия. Не добавляйте другие учётные записи и несвязанные разрешения. Этот токен управления предназначен для администрирования бакетов, включая их создание и удаление. В этом практическом задании Worker обращается к объектам R2 через привязку DOCUMENTS.

Один раз скопируйте токен в скрытое приглашение виртуальной машины. umask 077 ограничивает доступ к файлу вашим пользователем, а read -s скрывает ввод. Файл использует стандартную переменную токена Wrangler и исключён из Git.

umask 077
read -r -s -p 'R2 management API token: ' R2_MANAGEMENT_TOKEN; printf '\n'
printf 'CLOUDFLARE_API_TOKEN=%s\n' "$R2_MANAGEMENT_TOKEN" > .env.management
unset R2_MANAGEMENT_TOKEN

Используйте --env-file=.env.management только для команд управления R2; обычная команда whoami продолжит проверять авторизацию устройства на виртуальной машине.

Размещайте --env-file в конце каждой команды Wrangler, чтобы имя команды не попадало в список аргументов с файлами. Если после создания каждого бакета Wrangler предложит добавить привязку в конфигурацию, введите n и нажмите Enter. Нужная привязка уже указана в конфигурации.

npx wrangler r2 bucket create "$BUCKET" --env-file=.env.management

Выведите список бакетов и найдите точное сгенерированное имя. Остальные бакеты принадлежат другим работам; не изменяйте их.

npx wrangler r2 bucket list --env-file=.env.management

В Dashboard откройте Storage & databases → R2 → Overview, выберите этот бакет и проверьте пустой список объектов. В его настройках оставьте URL для публичной разработки и пользовательские домены отключёнными. Имя бакета в Dashboard подтверждает его идентичность; последующие проверки загрузки подтвердят сохранённые байты.

Разрешение для скриптов Worker позволяет выполнять развёртывание. Разрешение KV нужно Wrangler для учёта операций удаления; эта лабораторная работа не создаёт пространства имён KV. Токен управления R2 остаётся отдельными учётными данными, ограниченными этой учётной записью.

Реализация условного чтения и чтения частями

На этом шаге вы будете использовать метаданные R2, чтобы определить, требуется ли тело ответа. ETag работает как метка версии файла. Если у клиента уже есть копия, он отправляет эту метку в If-None-Match, чтобы узнать, изменился ли файл. При совпадении сервер возвращает 304 Not Modified без тела, поэтому те же байты не загружаются повторно. Запрос Range позволяет средству просмотра получить часть большого файла или продолжить прерванную загрузку. В нём указываются включительные позиции байтов, а сервер возвращает 206 Partial Content и заголовок Content-Range, описывающий этот фрагмент.

Используйте этот обработчик. Метод head() читает метаданные без байтов. Последующий вызов get() включает onlyIf.etagMatches, поэтому объект, изменившийся между этими вызовами, не будет возвращён на основе устаревших метаданных. Эта конечная точка поддерживает один диапазон и If-Range на основе ETag; неподдерживаемый синтаксис нескольких диапазонов возвращает 400. Если ETag в If-Range отличается, полный ответ 200 позволяет клиенту заменить старую копию.

cat > src/index.js <<'JS'
import { authorized } from "./auth.js";
export default {
  async fetch(request, env) {
    const path = new URL(request.url).pathname;
    if (path === "/health") return new Response("ok");
    if (!await authorized(request, env)) return new Response("Unauthorized", { status: 401 });
    if (request.method !== "GET") return new Response("Method not allowed", { status: 405 });
    if (path !== "/documents/report.txt") return new Response("Not found", { status: 404 });
    const key = path.slice(1);
    const metadata = await env.DOCUMENTS.head(key);
    if (!metadata) return new Response("Not found", { status: 404 });
    const headers = new Headers({ "ETag": metadata.httpEtag,
      "Last-Modified": metadata.uploaded.toUTCString(), "Accept-Ranges": "bytes",
      "Cache-Control": "private, no-store" });
    metadata.writeHttpMetadata(headers);
    // GET validators use weak comparison: W/"value" and "value" can match.
    const noneMatch = request.headers.get("If-None-Match");
    if (noneMatch && noneMatch.split(",").some(tag => tag.trim() === "*" || tag.trim().replace(/^W\//, "") === metadata.httpEtag))
      return new Response(null, { status: 304, headers });
    const since = Date.parse(request.headers.get("If-Modified-Since") || "");
    const uploadedSeconds = Math.floor(metadata.uploaded.getTime() / 1000) * 1000;
    if (!noneMatch && Number.isFinite(since) && uploadedSeconds <= since)
      return new Response(null, { status: 304, headers });
    let range = request.headers.get("Range");
    const ifRange = request.headers.get("If-Range");
    if (ifRange && ifRange !== metadata.httpEtag) range = null;
    let start = 0, end = metadata.size - 1;
    if (range) {
      const match = /^bytes=(\d*)-(\d*)$/.exec(range);
      // This endpoint supports exactly one range, not multipart ranges.
      if (!match || (!match[1] && !match[2]))
        return new Response("Invalid range", { status: 400 });
      if (!match[1]) { start = Math.max(0, metadata.size - Number(match[2])); }
      else { start = Number(match[1]); if (match[2]) end = Math.min(Number(match[2]), end); }
      if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start >= metadata.size) {
        headers.set("Content-Range", `bytes */${metadata.size}`);
        return new Response("Range not satisfiable", { status: 416, headers });
      }
      headers.set("Content-Range", `bytes ${start}-${end}/${metadata.size}`);
    }
    // Do not mix a HEAD result with bytes from an object replaced in between.
    const object = await env.DOCUMENTS.get(key, { onlyIf: { etagMatches: metadata.etag },
      ...(range ? { range: { offset: start, length: end - start + 1 } } : {}) });
    if (!object) return new Response("Not found", { status: 404 });
    if (!("body" in object)) return new Response("Object changed; retry", { status: 412 });
    headers.set("Content-Length", String(range ? end - start + 1 : metadata.size));
    return new Response(object.body, { status: range ? 206 : 200, headers });
  }
};
JS

Запрошенная начальная позиция отсчитывается от нуля. Суффикс, например bytes=-3, означает последние три байта. Если начальная позиция выходит за пределы объекта, сервер возвращает 416 с Content-Range: bytes */SIZE. Условная проверка выполняется до выбора диапазона. Если указаны оба заголовка, If-None-Match имеет приоритет над проверкой даты.

Создайте локальный секрет приложения и проверьте пакет:

umask 077
printf "ACCESS_TOKEN=%s\n" "$(openssl rand -hex 24)" > .dev.vars
npx wrangler deploy --dry-run

Сравнение полных ответов и ответов с диапазоном в локальной среде

На этом шаге вы заполните только локальное хранилище и проверите реальные HTTP-заголовки. Локальный объект отделён от последующего удалённого объекта, хотя оба используют один и тот же ключ.

npx wrangler r2 object put "$BUCKET/documents/report.txt" --local --file document.txt --content-type text/plain
npx wrangler dev --ip 127.0.0.1 --port 8787 > dev.log 2>&1 &
DEV_PID=$!

Дождитесь сообщения о готовности в dev.log, затем загрузите синтетический секрет приложения:

cat dev.log
set -a
source .dev.vars
set +a

Сохраните заголовки и тело полного ответа в отдельные файлы. Параметр -D записывает заголовки в файл:

curl -fsS -D full.headers -H "Authorization: Bearer $ACCESS_TOKEN" http://127.0.0.1:8787/documents/report.txt -o full.txt
cmp document.txt full.txt
cat full.headers

Убедитесь, что ответ содержит 200, сохранённый тип содержимого, ETag в кавычках и Accept-Ranges: bytes. Скопируйте точный ETag вместе с двойными кавычками в ETAG, заключив значение в одинарные кавычки, как показано ниже:

ETAG='"COPY_ETAG_HERE"'
curl -i -H "Authorization: Bearer $ACCESS_TOKEN" -H "If-None-Match: $ETAG" http://127.0.0.1:8787/documents/report.txt

Убедитесь, что сервер вернул 304 без тела. Актуальный валидатор позволяет избежать полной передачи данных, но не делает бакет публичным.

curl -sS -D range.headers -H "Authorization: Bearer $ACCESS_TOKEN" -H "Range: bytes=0-4" http://127.0.0.1:8787/documents/report.txt -o range.txt
head -c 5 document.txt > expected-range.txt
cmp expected-range.txt range.txt
cat range.headers

Убедитесь, что сервер вернул 206, Content-Range: bytes 0-4/SIZE и ровно пять совпадающих байтов. Теперь запросите недопустимую начальную позицию:

curl -i -H "Authorization: Bearer $ACCESS_TOKEN" -H "Range: bytes=99999-" http://127.0.0.1:8787/documents/report.txt

Убедитесь, что сервер вернул 416, заголовок bytes */SIZE и сообщение Range not satisfiable. Проверка платформы независимо повторяет эти операции чтения.

Проверка условной доставки из удалённого хранилища

На этом шаге вы отдельно подготовите удалённый тестовый объект и опубликуете обработчик. Остановите локальный сервер, затем загрузите тот же синтетический файл с явным флагом --remote:

kill "$DEV_PID"
wait "$DEV_PID" 2>/dev/null || true
npx wrangler r2 object put "$BUCKET/documents/report.txt" --remote --file document.txt --content-type text/plain --env-file=.env.management
npx wrangler deploy
npx wrangler secret bulk .dev.vars

Скопируйте URL развёрнутого Worker в BASE_URL. Дождитесь, пока проверка состояния вернёт ok; если новое развёртывание ещё распространяется, повторяйте запросы в течение не более одной минуты.

BASE_URL=https://YOUR_WORKER.YOUR_SUBDOMAIN.workers.dev
curl -i "$BASE_URL/health"
curl -fsS -D remote.headers -H "Authorization: Bearer $ACCESS_TOKEN" "$BASE_URL/documents/report.txt" -o remote.txt
cmp document.txt remote.txt
cat remote.headers

Используйте удалённый ETag из remote.headers, а не сохранённое локальное значение. Повторите условные запросы и запросы диапазона:

ETAG='"COPY_REMOTE_ETAG_HERE"'
curl -i -H "Authorization: Bearer $ACCESS_TOKEN" -H "If-None-Match: $ETAG" "$BASE_URL/documents/report.txt"
curl -i -H "Authorization: Bearer $ACCESS_TOKEN" -H "Range: bytes=0-4" "$BASE_URL/documents/report.txt"
curl -i -H "Authorization: Bearer $ACCESS_TOKEN" -H "Range: bytes=99999-" "$BASE_URL/documents/report.txt"

Убедитесь, что сервер вернул 304 без тела, 206 с первыми пятью байтами тестового файла и 416 с правильной границей размера. В Dashboard проверьте точную привязку Worker и объект в бакете. Оставьте публичный URL бакета и пользовательские домены отключёнными; основным подтверждением работы диапазонов служат HTTP-заголовки и сравнение тел ответов.

Привязка DOCUMENTS Worker к закрытому бакету R2

В этом примере DOCUMENTS подключён к нужному закрытому бакету. Суффикс вашего сгенерированного имени будет другим.

Синтетический отчёт в закрытом бакете Standard

В строке объекта report.txt указаны text/plain, Standard и 41 B, а Public Access остаётся Disabled. Сгенерированные имена и даты приведены для примера. Сводный показатель Bucket Size может пока показывать 0 B из-за задержки обновления; строка объекта и сравнение байтов подтверждают существование файла. HTTP-заголовки и сравнение тела ответа подтверждают поведение условных запросов и запросов диапазонов.

Удаление удалённого приложения и бакета

На этом шаге вы удалите только Worker и объекты этой лабораторной работы, пока авторизация ещё действует. Приватный бакет не исчезает при удалении Worker.

npx wrangler delete

Подтвердите точное сгенерированное имя Worker. Явно удалите единственный загруженный объект, затем удалите бакет:

BUCKET=$(node -p "JSON.parse(require('fs').readFileSync('wrangler.jsonc')).r2_buckets[0].bucket_name")
npx wrangler r2 object delete "$BUCKET/documents/report.txt" --remote --env-file=.env.management
npx wrangler r2 bucket delete "$BUCKET" --env-file=.env.management

Удалённо был создан только documents/report.txt. Если существуют другие объекты, проверьте этот конкретный бакет и установите их владельца, прежде чем удалять их.

Обновите списки Worker и бакетов в Dashboard и запустите проверку очистки платформы. Ошибки аутентификации или сети не подтверждают успешное удаление.

Отзыв оставшихся учётных данных

На этом шаге отзовите токен управления этой лабораторной работы на странице API Tokens вашего профиля, удалите локальный секрет приложения и завершите авторизацию виртуальной машины. Выполняйте это только после успешного прохождения предыдущей проверки очистки.

rm .env.management .dev.vars
unset ACCESS_TOKEN
npx wrangler logout
npx wrangler whoami --json || true

Убедитесь, что loggedIn: false. Отзыв токена управления — отдельная ручная проверка в Dashboard; одно лишь удаление локального файла токен не отзывает. Не изменяйте обычный вход в Dashboard и токены других лабораторных работ.

Резюме

Используйте метаданные R2 для условных ответов, передавайте отдельные диапазоны байтов, обрабатывайте невыполнимые запросы и удалите приватный сервис загрузки.