背景
自分のツールサイトに、Cache-Control ヘッダーの値を貼ると各ディレクティブの意味と矛盾を出すページを置いています。
https://hashitosystem.com/tools/cachecontrol/
Cache-Control は、ブラウザや中継サーバーに「この応答を何秒キャッシュしてよいか」などを伝える HTTP ヘッダーだ。
max-age=600 や no-cache のように、カンマ区切りでディレクティブ(指示)を並べます。
最近 RFC を読み返していて、no-cache に値を付ける書き方があることを知りました。
自分のツールに入れてみたら、エラーが出た。
仕様と照らし合わせた記録です(HTTP のキャッシュは何回読んでも自信が持てません…)。
ツールに入れた結果
ツールの解析部分は CacheControlLib.analyze(値) という関数で、ページから切り出して Node.js 24 でもそのまま動きます。
戻り値の browser / shared は、ブラウザのキャッシュと共有キャッシュのそれぞれで応答がどう扱われるかの判定、findings は警告やエラーの一覧だ。
下記の小さな関数で、値ごとに結果を表示しました。
const show = v => {
const r = CacheControlLib.analyze(v);
console.log(JSON.stringify(v));
console.log(' browser:', r.browser.state, '| shared:', r.shared.state);
r.findings.forEach(f => console.log(' [' + f.level + ']', f.text));
};
show('no-cache="Set-Cookie", max-age=600');
show('private="Set-Cookie", max-age=600');
出力が下記です。
"no-cache=\"Set-Cookie\", max-age=600"
browser: 毎回オリジンへ確認 | shared: 毎回オリジンへ確認
[warn] no-cache があるため、max-age=600 があっても再利用のたびにオリジンへ確認が入ります。秒数は実質効きません。
[error] "no-cache="Set-Cookie"": 値を取らないディレクティブに値が付いています。
"private=\"Set-Cookie\", max-age=600"
browser: 新鮮 | shared: 保存されない(private)
private="Set-Cookie" は通るのに、no-cache="Set-Cookie" は「値を取らないディレクティブに値が付いています」というエラーになりました。
共有キャッシュは、CDN(世界中に置いた中継サーバーでコンテンツを配る仕組み)やプロキシのように、複数の利用者で同じ応答を使い回すキャッシュのことです。
仕様ではどうなっているか
HTTP のキャッシュは RFC 9111 で定義されています。
https://www.rfc-editor.org/rfc/rfc9111.txt
5.2.2.4 節の no-cache と 5.2.2.7 節の private は、どちらも「Argument syntax: #field-name」と書かれている。
#field-name は、ヘッダー名をカンマ区切りで並べたリストという意味だ。
つまり no-cache も private も、値を付けてよいディレクティブでした。
値付き(qualified form)の意味は、値なしとは少し違います。
no-cache に値が無い場合は「オリジンサーバーに確認してからでないと、この応答を再利用してはいけない」。
値が付いている場合は「挙げたヘッダーを外せば、確認なしで再利用してよい」になる。
no-cache="Set-Cookie" なら、本文は使い回してよいが Set-Cookie だけは他の人の応答に混ぜるな、という指示です。
private も同様で、値付きなら「挙げたヘッダーだけは共有キャッシュに保存するな、残りは保存してよい」だ。
なのでツールは間違っていた
ツールの中には、ディレクティブごとに「値を取るかどうか」の表があります。
下記がその一部で、arg が値の種類です(none は値を取らない、optfield はヘッダー名を任意で取る)。
var DIRECTIVES = {
"no-cache": {arg: "none", who: "both"},
"private": {arg: "optfield", who: "both"},
// ...
};
private は optfield なのに、no-cache だけ none になっていた。
これが、同じ形の2つで結果が分かれた理由です。
試しに no-cache を optfield に書き換えて同じ値を流すと、エラーの行だけが消えました。
"no-cache=\"Set-Cookie\", max-age=600"
browser: 毎回オリジンへ確認 | shared: 毎回オリジンへ確認
[warn] no-cache があるため、max-age=600 があっても再利用のたびにオリジンへ確認が入ります。秒数は実質効きません。
ただし「値なしと同じ扱い」が普通らしい
ここで終わりにしようと思ったのですが、RFC の同じ節に注記がありました。
no-cache にも private にも、値付きの形は多くのキャッシュで値なしと同じに扱われ、特別な扱いはあまり実装されていない、という趣旨の Note が付いている。
つまり no-cache="Set-Cookie" と書いても、実際のキャッシュの多くは「毎回確認」として動く可能性が高いっぽいです。
そう考えると、ツールが出している「毎回オリジンへ確認」「保存されない(private)」という判定自体は、現実の挙動に近い。
間違っていたのは、文法として正しい書き方を「エラー」と言い切っていた部分だけでした。
エラーにするより「仕様上は正しいが、多くのキャッシュは値なしと同じに扱う」と出すほうが、読む人の判断材料になると思います。
書く側として
値付きの形で得をしたいのは、Set-Cookie を含む応答を、Cookie を外した形で共有キャッシュに載せたい場面です。
でも、その特別扱いをしてくれる保証がないので、この書き方に頼ると「載らない」か「毎回確認になる」かのどちらかになりやすい。
Set-Cookie を返す応答は、最初から private(ブラウザだけに保存させる)か no-store(どこにも保存させない)にしておくのが確実です。
公開中のツールは、この記事の時点ではまだ no-cache の値をエラーとして出します。
no-cache と private はヘッダー名を値に取れるので、no-cache="Set-Cookie" は文法上正しい。
ただし値付きの形は値なしと同じに扱われることが多いので、Set-Cookie を共有キャッシュから外したいなら値付きの形に頼らず、その応答自体を private にしてください。
本記事はAI補助で執筆した、個人開発の紹介記事です。