• #cloudflare
  • #r2
  • #cors
  • #web-audio-api
  • #browser-cache
  • #nuxt
開発eurekapu-nuxt4メモ

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検証 + キャッシュバスター

設計方針

  1. CORSが使えるか事前確認してから createMediaElementSource() を呼ぶ
  2. ブラウザキャッシュの汚染を回避するため、CORS用URLにキャッシュバスターを付与
  3. 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)で動作確認済み:

crossOriginURLステレオパニング
1行目なしfile.wavなし
2行目anonymousfile.wav?_cors=1canplay で有効化
3行目以降anonymousfile.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に不適合の可能性個別に列挙