はじめに
ドキュメントビューアーでは、次の数バイトだけ、またはキャッシュ済みのコピーがまだ最新かどうかの確認だけが必要になることがあります。リクエストのたびにファイル全体をダウンロードすると、無駄な処理が発生します。この実験では、プライベート 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 ヘッダーと本文の比較を、範囲の検証における正式な証拠とします。

この例では、DOCUMENTS が対象の非公開バケットに接続されています。生成される名前の接尾辞は異なります。

オブジェクト行には 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 のメタデータを条件付きレスポンスに使用し、単一バイト範囲をストリーミングし、範囲を満たせないリクエストを処理して、プライベートダウンロードサービスをクリーンアップします。



