症状
Next.js 14(App Router)のRoute Handlerで、生成したMP3をCloudflare R2にS3互換APIでアップロードしたところ、次のエラーで失敗しました。
411 MissingContentLength: You must provide the Content-Length HTTP header.
署名には aws4fetch を使っています。同じコードを素のNode.jsスクリプトで実行すると成功するので、署名の問題ではありません。
// Route Handler 内では 411 になる
const signed = await client.sign(url, { method: "PUT", body, headers: { "Content-Type": "audio/mpeg" } });
await fetch(signed); // ← R2: 411 MissingContentLength
原因
Next.jsのサーバー実行環境では fetch がパッチされています。この環境では Buffer のbodyがストリームとして送られ、Content-Length ヘッダーが付きませんでした(chunked転送)。
R2の通常の PUT(マルチパートではないアップロード)は Content-Length が必須なので、411が返ります。
対処:署名はaws4fetch、送信はaxios
aws4fetch は署名だけに使い、実際の送信は Buffer の長さから Content-Length を設定してくれるクライアントに任せます。ここではaxiosを使いました。
import { AwsClient } from "aws4fetch";
import axios from "axios";
const client = new AwsClient({
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
service: "s3",
region: "auto",
});
export async function uploadToR2(key: string, body: Buffer) {
const url = `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${process.env.R2_BUCKET}/${encodeURI(key)}`;
// 署名だけ行う(送信はしない)
const signed = await client.sign(url, {
method: "PUT",
body,
headers: {
"Content-Type": "audio/mpeg",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
// 署名済みヘッダーをそのまま使って axios で送る(Content-Length が付く)
await axios.put(url, body, {
headers: Object.fromEntries(signed.headers.entries()),
maxBodyLength: Infinity,
timeout: 120000,
});
}
ポイントは2つです。
-
client.sign()は署名済みのRequestを返すだけで、送信はしません。そのヘッダー(authorization、x-amz-date、x-amz-content-sha256など)をaxiosに渡します。 - 署名時と同じ
bodyを送るので、x-amz-content-sha256と中身が一致し、署名エラーにはなりません。
ハマりどころ:フォールバックが失敗を隠す
私のコードは、アップロードに失敗すると外部APIが返す一時URLをそのまま保存していました。そのため数日間は問題なく再生でき、一時URLの期限が切れた時点で初めて404になりました。
フォールバックする処理がある場合は、フォールバックしたこと自体をエラーログに残しておくことをおすすめします。
まとめ
- Next.jsのサーバー環境の
fetchでR2にPUTすると、Content-Lengthが付かず411になることがある - 署名は
aws4fetch、送信はContent-Lengthを付けるクライアント(axiosなど)に分けると解決する - 同じコードがNext.jsの外で動くかどうかを試すと、原因の切り分けが早い
この構成は、歌詞からAIで曲を作るサービス AISong.Fun で実際に使っています。