条件付きドキュメントダウンロードの追加

CloudflareBeginner
オンラインで実践に進む

はじめに

ドキュメントビューアーでは、次の数バイトだけ、またはキャッシュ済みのコピーがまだ最新かどうかの確認だけが必要になることがあります。リクエストのたびにファイル全体をダウンロードすると、無駄な処理が発生します。この実験では、プライベート R2 ストレージをバックエンドとする保護された Worker に、HTTP バリデーターと単一バイト範囲のダウンロードを追加します。

先に「Worker を介したドキュメントのストリーミング」を完了してください。この実験は、Node.js 22.22.0、Wrangler 4.131.1、トークンチェックモジュールが用意された新しい VM で開始します。新しいバケットを作成し、新しい Worker をデプロイします。R2 のサブスクリプションと学習用アカウントの権限は、あらかじめ準備しておく必要があります。操作とストレージの料金については、R2 pricing を確認してください。カスタムドメインは必要ありません。保存するのは合成テキストだけなので、終了前に必ずクリーンアップしてください。

アプリケーション用バケットに接続する

このステップでは、この VM を認証し、アプリケーション専用のプライベートバケットを作成します。デバイス認証によって、学習用アカウントを確認します。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 になっていることを確認します。アカウントが 1 つだけ表示される場合でも、アカウント名を確認してください。以下の YOUR_ACCOUNT_ID を、そのアカウントの実際の 32 文字の ID に置き換えます。openssl rand -hex 6 は 12 個のランダムな 16 進数文字を生成するため、この実験で以前の実行と名前が衝突することはありません。ヒアドキュメントによって標準の設定ファイルが作成され、シェルがその中の変数を置き換えます。

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

バケットを管理するには、Cloudflare プロフィールの API Tokens ページを開き、この実験の名前を付けたカスタムトークンを作成します。Account → Workers R2 Storage → Edit を付与し、Account Resources を、保存した ID の学習用アカウントだけに制限します。短い有効期限を設定してください。他のアカウントや関係のない権限は含めないでください。この管理トークンは、作成や削除などのバケット管理に使用します。この実験では、Worker は DOCUMENTS バインディングを通じて R2 オブジェクトにアクセスします。

トークンを一度だけ、この非表示の VM プロンプトに貼り付けます。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 は、引き続き VM のデバイス認証を確認します。

--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 リクエストを使うと、大きなファイルの一部だけを取得したり、中断したダウンロードを再開したりできます。Range は包含的なバイト位置を指定し、スライスを説明する Content-Range ヘッダーとともに 206 Partial Content を返します。

次のハンドラーを使用します。head() はバイト列を取得せずにメタデータを読み取ります。後続の get() では onlyIf.etagMatches も指定するため、2 つの呼び出しの間にオブジェクトが変更されても、古いメタデータに基づく内容は返されません。このエンドポイントは単一範囲と ETag ベースの If-Range をサポートします。サポート対象外の複数範囲構文には 400 を返します。If-Range の ETag が異なる場合は、クライアントが古いコピーを置き換えられるよう、完全な 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

要求する開始位置は 0 始まりです。bytes=-3 のようなサフィックスは、最後の 3 バイトを意味します。オブジェクトのサイズを超える開始位置には、Content-Range: bytes */SIZE とともに 416 を返します。条件付き検証は範囲の選択よりも優先されます。If-None-Match と日付による検証が両方指定されている場合は、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、および一致する正確に 5 バイトが返ることを確認します。次に、満たせない開始位置をリクエストします。

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 を BASE_URL に設定します。ヘルスチェックが ok を返すまで待ちます。新しいデプロイの反映に時間がかかっている場合は、最大 1 分間、読み取りを再試行してください。

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

記憶しているローカルの値ではなく、remote.headers にあるリモートの ETag を使います。条件付きリクエストと部分リクエストを繰り返します。

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、最初の 5 バイトのフィクスチャを含む 206、正しいサイズ境界を含む 416 が返ることを確認します。Dashboard で、正確な Worker バインディングとバケット内のオブジェクトを確認します。バケットのパブリック URL とカスタムドメインは無効のままにしてください。HTTP ヘッダーと本文の比較を、範囲の検証における正式な証拠とします。

Worker の DOCUMENTS バインディングと非公開 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 だけです。他のオブジェクトが存在する場合は、この正確なバケットを確認し、所有者を特定してから削除してください。

Dashboard で Worker とバケットの一覧を更新し、プラットフォームのクリーンアップチェックを実行します。認証またはネットワークのエラーは判断材料にならず、削除成功を意味しません。

残りの認証情報を閉じる

このステップでは、プロフィールの API Tokens ページでこの実験の管理トークンを失効させ、ローカルアプリケーションシークレットを削除し、VM の認証を終了します。前のクリーンアップチェックが成功してから実行してください。

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

loggedIn: false になっていることを確認します。管理トークンの失効は Dashboard で行う別の手動チェックポイントです。ローカルファイルを削除しただけでは、トークンは失効しません。通常の Dashboard ログインや、他の実験で使用しているトークンには手を付けないでください。

まとめ

R2 のメタデータを条件付きレスポンスに使用し、単一バイト範囲をストリーミングし、範囲を満たせないリクエストを処理して、プライベートダウンロードサービスをクリーンアップします。