PRレビューからCORSデバッグまで
朝、2つのオープンPRをマージするだけの軽い一日になるはずだった。タブレット音声修正PRとコンテンツPRをレビューし、Chrome DevToolsでiPadエミュレーションまで通して「OK」と思った。Cloudflare Pagesにデプロイした瞬間、本番環境の音声が全て404を返し始め、そこから半日かけてCORSの沼にはまり込んだ。
PRレビューと修正
2つのオープンPR
タブレットでの音声再生修正PRと、動画版コンテンツのPRが開いていた。両方のレビューと修正を並列で進めようとした。
サブエージェント並列実行の失敗: Claude Codeのサブエージェントで2つのPRを同時にレビュー・修正しようとしたが、権限不足でどちらも途中で止まった。結局、手動で1つずつ順番にレビューして修正を入れた。並列化で時短を狙ったが、権限設定を詰めていなかったので逆に時間を食った。
Chrome DevToolsでiPadエミュレーション
タブレット音声修正PRの検証で、Chrome DevToolsのデバイスエミュレーションでiPad Airを選択して動作確認した。タッチイベント周りの挙動がデスクトップとは違うため、実機相当の検証が必要だった。エミュレーションで問題なく動作することを確認してマージ。
本番デプロイで音声が消える
環境変数の罠
Cloudflare Pagesにデプロイした直後、本番で音声ファイルが全て404になった。ローカルでは問題なく再生できていたので、最初は何が起きたかわからなかった。
原因は NUXT_PUBLIC_AUDIO_BASE_URL の環境変数がCloudflare Pagesの本番環境に設定されていなかったこと。音声のベースURLがundefinedのまま解決され、存在しないパスにリクエストが飛んでいた。環境変数を正しく設定して再デプロイし、404は解消した。
ステレオパニングでCORSエラー発覚
404が直って音声は再生できるようになったが、Web Audio APIの createMediaElementSource() でステレオパニング(話者を左右に振り分ける機能)を有効にすると、Chromeコンソールに見慣れないエラーが出た。
MediaElementAudioSource outputs zeroes due to CORS access restrictions
<audio> タグでの通常再生は動く。しかしWeb Audio APIに接続した瞬間、音声データがゼロで埋められて完全に無音になる。例外は投げないのでtry-catchでは捕捉できず、しかも一度 createMediaElementSource() を呼ぶと通常再生に戻せない。
crossorigin追加で完全破壊 → 即ロールバック
最初の「修正」が致命傷になる
CORSエラーを見て、まず <audio> タグに crossorigin="anonymous" を追加した。教科書的には正しいはずの対応だった。
結果、ステレオパニングどころか音声が一切ロードできなくなった。ブラウザが MEDIA_ELEMENT_ERROR を返し、通常再生すら不可能になった。crossoriginなしなら再生できていた音声が、追加した途端に壊れる。
原因はブラウザのメディアキャッシュにあった。先にcrossoriginなしでリクエストした音声がCORSヘッダーなしでキャッシュされており、crossoriginを追加しても古いキャッシュが使われてCORSチェックに失敗する。curlで確認するとR2のCORS設定自体は正しく動いているのに、ブラウザでは壊れるという状況だった。
状況が悪化する一方だったので、crossoriginの追加を即座にロールバックした。
ここが核心: 「正しい修正」が壊す理由
問題の本質は以下の流れだった:
<audio src="url">(crossoriginなし) → ブラウザがno-corsでリクエスト → CORSヘッダーなしのレスポンスをキャッシュ<audio crossorigin="anonymous" src="url">→ ブラウザがキャッシュを確認 → CORSヘッダーなしのキャッシュを使用 → CORSチェック失敗 →ERR_FAILED
Fetch APIは mode: cors と mode: no-cors でキャッシュを分離するが、<audio> のメディアローダーはこの分離をしない。この仕様を知らなかった。
Codexレビューから解決策へ
3段階CORS検証 + キャッシュバスター
ロールバック後、Codexにレビューを依頼した。Codexが提案したのは「CORSが使えるか事前確認してから createMediaElementSource() を呼ぶ」という3段階のアプローチだった。
フロー:
- 1行目: crossoriginなしで通常再生(ステレオパニングなし)。バックグラウンドで
fetch(HEAD, cors)してCORS対応を確認 - 2行目: CORSが通るなら
crossorigin="anonymous"+?_cors=1キャッシュバスター付きURLで再生。canplayで成功確認 - 3行目以降: ステレオパニング有効で再生
キャッシュバスターの ?_cors=1 がポイントだった。R2はクエリパラメータを無視して同じオブジェクトを返すが、ブラウザは別URLとして別キャッシュエントリに保存する。これで1行目のno-corsキャッシュが2行目以降を汚染しない。
R2 CORS設定の適用
wrangler CLIでR2バケットにCORS設定を適用した。
npx wrangler r2 bucket cors set eurekapu-assets --file r2-cors.json
許可オリジンに本番ドメイン、pages.devプレビュードメイン、localhost:3200 を列挙。ワイルドカードサブドメインはR2で正しく動作しない可能性があるため、個別指定にした。
技術的な詳細は別記事にまとめた: R2カスタムドメイン × Web Audio APIのCORS問題と解決策
その他の作業
コンテンツPRのアーカイブリンク追加
動画版コンテンツのPRに、動画プラットフォーム上の元コンテンツへのアーカイブリンクを追加した。コンテンツの出典を辿れるようにするための対応。
e-taxプロジェクトのCLAUDE.md作成
別プロジェクトの確定申告ツール用に CLAUDE.md を作成する短い作業。プロジェクト構成とdev手順を記載した。10分程度。
今日の試行錯誤
| # | テーマ | 試したこと | 結果 | 気づき |
|---|---|---|---|---|
| 1 | PR並列レビュー | サブエージェント2つ同時起動 | 権限不足で両方失敗 | 権限設定を詰めてから並列化すべき |
| 2 | タブレット検証 | Chrome DevTools iPadエミュレーション | 問題なく動作 | 実機なくても一次検証には十分 |
| 3 | 本番音声404 | デプロイ後に音声全滅 | 環境変数 NUXT_PUBLIC_AUDIO_BASE_URL 未設定 | 環境変数チェックリストが必要 |
| 4 | CORSエラー | crossorigin="anonymous" 追加 | 音声が完全にロードできなくなる | メディアキャッシュがcors/no-corsを分離しない |
| 5 | 〃 | 即ロールバック | 通常再生は復旧 | 壊れたらまず戻す判断が早くて助かった |
| 6 | 〃 | Codexレビュー依頼 | 3段階CORS検証 + キャッシュバスター案 | 自分では思いつかなかった ?_cors=1 の発想 |
| 7 | R2 CORS設定 | wrangler CLIで設定適用 | 本番でステレオパニング動作確認 | curlでの事前確認が効いた |
今日の学び
<audio>のメディアローダーは Fetch API と違い、cors/no-cors でキャッシュを分離しない。この仕様を知っているかどうかでデバッグ時間が数時間変わるcreateMediaElementSource()は一度呼ぶと不可逆。CORSが通るか先に確認してから呼ばないと、フォールバックできない無音状態に陥る- crossorigin属性を追加する「正しい修正」が、キャッシュ汚染で逆に全てを壊すことがある。修正が裏目に出たら即ロールバックが鉄則
- キャッシュバスター
?_cors=1は、R2がクエリパラメータを無視する性質を利用してブラウザキャッシュだけを分離する手法。覚えておく - 環境変数の本番設定漏れは何度やっても踏む。デプロイ前チェックリストに環境変数の項目を入れる