Cloudflare R2カスタムドメイン × Web Audio API: ブラウザのメディアキャッシュが引き起こすCORS地獄
この記事が解決する問題
Cloudflare R2のカスタムドメインから音声を配信し、createMediaElementSource() でステレオパニングを実装したところ、curlではCORSが通るのにブラウザでは無音になるという問題に遭遇した。
原因は ブラウザのメディアキャッシュがcors/no-corsリクエストを同一キャッシュエントリとして扱う という仕様だった。
構成
info-accounting.com (Cloudflare Pages)
└── <audio src="https://assets.info-accounting.com/audio/...">
└── createMediaElementSource() → StereoPannerNode → destination
- フロントエンド: Nuxt 4 / Vue 3(Cloudflare Pages)
- 音声配信: Cloudflare R2バケット
eurekapu-assets(カスタムドメインassets.info-accounting.com) - 目的: 話者ごとにステレオパニング(左/右/中央)を振り分ける
症状
<audio> タグでの通常再生は問題なく動作する。しかし、createMediaElementSource() で Web Audio API に接続すると:
- Chromeコンソール:
MediaElementAudioSource outputs zeroes due to CORS access restrictions - 音声データがゼロで埋められ、ステレオパニングどころか完全に無音になる
createMediaElementSource()は例外を投げない(try-catchでは捕捉不能)- 一度呼ぶと、音声は AudioContext 経由でしか出力されないため、フォールバックも不能
なぜcurlでは動くのにブラウザでは動かないのか
R2のCORS設定は正しい
$ curl -sI -H "Origin: https://info-accounting.com" \
"https://assets.info-accounting.com/audio/example.wav"
# → Access-Control-Allow-Origin: https://info-accounting.com ✓
# → Vary: Origin ✓
HEAD、GET、Range (206) すべてでCORSヘッダーが返る。
ブラウザのメディアキャッシュが犯人
問題の本質は ブラウザのメディアキャッシュがFetch APIのキャッシュと異なる挙動をする こと。
1. <audio src="https://assets.../file.wav"> (crossoriginなし)
→ sec-fetch-mode: no-cors
→ R2はOriginヘッダーを受け取らない
→ レスポンスにAccess-Control-Allow-OriginもVary: Originもなし
→ ブラウザがこのレスポンスをメディアキャッシュに保存
2. <audio crossorigin="anonymous" src="https://assets.../file.wav">
→ ブラウザがメディアキャッシュを確認
→ 同じURLのキャッシュエントリを発見(手順1のもの)
→ キャッシュにVary: Originがないので、キャッシュヒットと判定
→ CORSヘッダーのないキャッシュ済みレスポンスを使用
→ Access-Control-Allow-Originがない → CORSチェック失敗
→ ERR_FAILED
Fetch APIは mode: 'cors' と mode: 'no-cors' でキャッシュキーを分離するが、<audio> / <video> のメディアローダーはこの分離をしない。
Originヘッダーなしのレスポンスに Vary: Origin がない
R2(および一般的なオブジェクトストレージ)は、Originヘッダーが送られなかったリクエストに対して Vary: Origin を返さない。
# Originヘッダーなし → Vary: Origin なし
$ curl -sI "https://assets.../file.wav"
# HTTP/1.1 200 OK
# ETag: "abc123"
# (Vary: Origin がない)
# Originヘッダーあり → Vary: Origin あり
$ curl -sI -H "Origin: https://info-accounting.com" "https://assets.../file.wav"
# Access-Control-Allow-Origin: https://info-accounting.com
# Vary: Origin ✓
Vary: Origin がないレスポンスがキャッシュされると、ブラウザは「このレスポンスはOriginに依存しない」と判断し、後続のCORSリクエストにもそのキャッシュを使ってしまう。
追加の落とし穴
cf-cache-status: DYNAMIC — CDNキャッシュは無関係
R2カスタムドメインのレスポンスは cf-cache-status: DYNAMIC で、CloudflareのCDNではキャッシュされていない。
「CDNキャッシュをpurgeすれば直る」は誤り。問題はブラウザのローカルキャッシュ。
createMediaElementSource の不可逆性
createMediaElementSource() を一度呼ぶと、その <audio> 要素の音声は AudioContext経由でしか出力されなくなる。CORSが通らない場合は無音(ゼロ出力)になり、「通常の <audio> 再生にフォールバック」はできない。
つまり try-catch で保護しても意味がない:
// ❌ これは機能しない
try {
sourceNode = audioCtx.createMediaElementSource(audioEl)
// → 例外は投げないが、CORSエラー時はゼロ出力
} catch {
// ここには来ない
// しかも audioEl は既にAudioContextにバインド済み
// 通常再生にフォールバックできない
}
R2のCORS AllowedOriginsにワイルドカードサブドメインを使えない
// ❌ R2はサブドメインワイルドカードをstrict matchしない可能性
"origins": ["https://*.eurekapu-nuxt4.pages.dev"]
// ✅ 個別に列挙する
"origins": ["https://eurekapu-nuxt4.pages.dev"]
解決策: 3段階CORS検証 + キャッシュバスター
設計方針
- CORSが使えるか事前確認してから
createMediaElementSource()を呼ぶ - ブラウザキャッシュの汚染を回避するため、CORS用URLにキャッシュバスターを付与
- 1行目は遅延なしで再生開始し、CORSチェックはバックグラウンドで行う
フロー
[1行目再生]
└─ crossoriginなし → 通常再生(ステレオパニングなし)
└─ バックグラウンド: fetch(HEAD, cors, cache:'no-cache') → corsAvailable = true/false
[2行目再生] (corsAvailable = true の場合)
└─ crossorigin="anonymous" + src="url?_cors=1"
└─ canplay → stereoEnabled = true → createMediaElementSource → ステレオパニング有効
└─ error → corsAvailable = false → crossorigin除去 → 通常再生にフォールバック
[3行目以降] (stereoEnabled = true の場合)
└─ crossorigin="anonymous" + src="url?_cors=1"
└─ createMediaElementSource → ステレオパニング有効
キャッシュバスター ?_cors=1
const corsUrl = (url: string) =>
url + (url.includes('?') ? '&' : '?') + '_cors=1'
R2カスタムドメインはクエリパラメータを無視してオブジェクトを返すが、ブラウザは ?_cors=1 付きURLを別のキャッシュエントリとして扱う。
これにより:
file.wav→ 非CORSキャッシュ(1行目用)file.wav?_cors=1→ CORSキャッシュ(2行目以降用、CORS対応レスポンスが保存される)
1行目のキャッシュが2行目以降を汚染しない。
実装コード(Vue 3 Composition API)
let corsChecked = false
let corsAvailable = false
let stereoEnabled = false
const checkCorsSupport = (url: string): Promise<boolean> =>
fetch(url, { method: 'HEAD', mode: 'cors', cache: 'no-cache' })
.then(res => res.ok)
.catch(() => false)
const corsUrl = (url: string) =>
url + (url.includes('?') ? '&' : '?') + '_cors=1'
const ensureAudioContext = () => {
if (audioCtx || !audioEl.value) return
audioCtx = new AudioContext()
sourceNode = audioCtx.createMediaElementSource(audioEl.value)
pannerNode = audioCtx.createStereoPanner()
sourceNode.connect(pannerNode)
pannerNode.connect(audioCtx.destination)
}
const verifyCorsAndPlay = (url: string) => {
if (!audioEl.value) return
audioEl.value.crossOrigin = 'anonymous'
audioEl.value.src = corsUrl(url)
audioEl.value.playbackRate = playbackRate.value
const onSuccess = () => {
audioEl.value?.removeEventListener('error', onFail)
stereoEnabled = true
ensureAudioContext()
setPan(currentLine.value.speaker)
}
const onFail = () => {
audioEl.value?.removeEventListener('canplay', onSuccess)
corsAvailable = false
if (!audioEl.value) return
audioEl.value.removeAttribute('crossorigin')
audioEl.value.src = url // キャッシュバスターなし → 非CORSキャッシュを使用
audioEl.value.playbackRate = playbackRate.value
audioEl.value.play()?.catch(() => {})
}
audioEl.value.addEventListener('canplay', onSuccess, { once: true })
audioEl.value.addEventListener('error', onFail, { once: true })
audioEl.value.play()?.catch(() => {})
}
const playCurrentLine = () => {
stopAudio()
if (!audioEl.value) return
const url = resolveAudioUrl(currentLine.value.audio)
if (stereoEnabled) {
audioEl.value.crossOrigin = 'anonymous'
ensureAudioContext()
setPan(currentLine.value.speaker)
audioEl.value.src = corsUrl(url)
audioEl.value.playbackRate = playbackRate.value
audioEl.value.play()
return
}
if (corsAvailable) {
verifyCorsAndPlay(url)
return
}
// 通常再生(ステレオパニングなし)
audioEl.value.src = url
audioEl.value.playbackRate = playbackRate.value
audioEl.value.play()
if (!corsChecked) {
corsChecked = true
checkCorsSupport(url).then(ok => { corsAvailable = ok })
}
}
テンプレート
<!-- crossoriginは動的に設定するため、テンプレートには書かない -->
<audio ref="audioEl" @ended="onAudioEnded" />
R2 CORS設定
{
"rules": [{
"allowed": {
"origins": [
"https://info-accounting.com",
"https://www.info-accounting.com",
"https://eurekapu-nuxt4.pages.dev",
"http://localhost:3200"
],
"methods": ["GET", "HEAD"],
"headers": ["Range", "Content-Type"]
},
"exposed": {
"headers": ["Content-Length", "Content-Range", "Accept-Ranges", "ETag"]
},
"maxAgeSeconds": 86400
}]
}
適用コマンド:
npx wrangler r2 bucket cors set eurekapu-assets --file r2-cors.json
検証結果
本番環境(info-accounting.com)で動作確認済み:
| 行 | crossOrigin | URL | ステレオパニング |
|---|---|---|---|
| 1行目 | なし | file.wav | なし |
| 2行目 | anonymous | file.wav?_cors=1 | canplay で有効化 |
| 3行目以降 | anonymous | file.wav?_cors=1 | 有効 |
- CORSエラーなし
createMediaElementSourceで実際の音声データ(amplitude > 0.1)が流れることを確認- ネットワークリクエストはすべて 206 で成功
まとめ
| 問題 | 原因 | 対策 |
|---|---|---|
| curlでCORS通るがブラウザで失敗 | メディアキャッシュがcors/no-corsを分離しない | ?_cors=1 キャッシュバスター |
createMediaElementSource で無音 | CORSエラー時にゼロ出力(例外なし) | CORS事前確認してから呼ぶ |
| try-catch フォールバック不能 | 一度バインドすると AudioContext 経由でしか出力されない | 3段階検証で安全に有効化 |
| CDNキャッシュ purge で解決? | cf-cache-status: DYNAMIC でCDN未キャッシュ | 不要(問題はブラウザキャッシュ) |
| AllowedOrigins ワイルドカード | R2のstrict matchに不適合の可能性 | 個別に列挙 |