ビルドを終えて、同じ名前のまま JS を差し替えたときのことです。CDN 側で該当 URL をパージし、新しい版が配信されているはずなのに、手元のブラウザでは古い挙動が続いておりました。
パージは確かに効いていたのだと思います。効いていなかったのは、そのもう一段手前——利用者のブラウザの中でした。長期キャッシュを付けた資産を同じ URL で上書きするという、自分で決めた運用の裏側を踏んだのです。
最初にお伝えしたいのは結論です。同じ URL で中身を変えるなら、長期キャッシュは付けられません。長期キャッシュを付けるなら、中身が変わったら URL を変えます。 その線引きを 4 つの配信方式で並べ、手元の Chromium で確かめた結果と、ビルドの最後に置いているハッシュ付与のスクリプトを書き残します。
まず、層を切り分けます
「古いものが出る」と感じたとき、握っている層は三つ考えられます。CDN のエッジ、利用者のブラウザ、そして HTML が指している URL そのものです。パージが届くのはエッジだけで、ブラウザのキャッシュには何も届きません。
切り分けは、ブラウザの開発者コンソールで次の 2 行を打つだけで済みます。同じ URL を、キャッシュを使う設定と使わない設定で一度ずつ取ります。
// 何を解決するか: 古い版を握っているのがブラウザか、それより奥かを見分ける
const u = '/assets/app.js'; // 疑っている資産の URL に置き換えてください
const a = await (await fetch(u, { cache: 'default' })).text(); // ブラウザのキャッシュを使う
const b = await (await fetch(u, { cache: 'reload' })).text(); // 必ずサーバーまで取りに行く
console.log({ same: a === b, defaultHead: a.slice(0, 60), reloadHead: b.slice(0, 60) });
// なぜこう書くか: same が false なら、ブラウザが古い版を握っています。
// same が true で、それでも古いなら、握っているのはエッジかオリジンです(エッジの確認は別途 curl -I で)。
切り分けに使ったのは、この cache オプションの違いです。default では古い版、reload では新しい版が返ってきた時点で、原因は CDN ではなくブラウザ側だと決まります。パージを何度やり直しても無駄だったわけです。
4 つの配信方式を並べます
同じ資産を差し替えたとき、利用者に新しい版が届くかどうかを、方式ごとに見ていきます。
表は設計上の期待値です。次の節で、これが実際のブラウザでどうなるかを確かめます。
手元の Chromium で確かめました
検証は小さなサーバーと Playwright の Chromium(141)で行いました。サーバーは方式ごとに Cache-Control を切り替え、アクセス数を数えます。手順は、初回に v1 を取得し、サーバー側で中身を v2 に差し替え、もう一度ページを開いて、実行された版を読む、という流れです。
// server.mjs(抜粋)— 方式ごとのヘッダーとアクセス数の記録
import http from 'node:http';
import crypto from 'node:crypto';
let version = 1; // /__bump で 2 に上げる
const hits = {}; // path -> サーバーに届いた回数
const body = () => `window.APP_VERSION = ${version};\n`;
const etag = () => '"' + crypto.createHash('sha1').update(body()).digest('hex').slice(0, 12) + '"';
const CC = {
'/immutable/app.js': 'public, max-age=31536000, immutable', // A, D
'/maxage/app.js': 'public, max-age=31536000', // B
'/revalidate/app.js': 'no-cache', // C
};
http.createServer((req, res) => {
const u = new URL(req.url, 'http://x'); const p = u.pathname;
if (p === '/__bump') { version = 2; res.end('ok'); return; }
if (p === '/__reset') { for (const k in hits) delete hits[k]; version = 1; res.end('ok'); return; }
if (p === '/__hits') { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(hits)); return; }
const m = p.match(/^\/hashed\/app\.([0-9a-f]{8})\.js$/); // E: URL の中身は固定
if (m) {
hits[p] = (hits[p] || 0) + 1;
res.setHeader('cache-control', 'public, max-age=31536000, immutable');
res.setHeader('content-type', 'text/javascript');
res.end(`window.APP_VERSION = ${m[1] === 'aaaaaaaa' ? 1 : 2};\n`); return;
}
if (CC[p]) {
hits[p] = (hits[p] || 0) + 1;
res.setHeader('cache-control', CC[p]); res.setHeader('etag', etag());
if (req.headers['if-none-match'] === etag()) { res.statusCode = 304; res.end(); return; }
res.setHeader('content-type', 'text/javascript'); res.end(body()); return;
}
if (p === '/page.html') { // HTML は常に都度確認。参照先 src だけ変えて返す
res.setHeader('cache-control', 'no-cache'); res.setHeader('content-type', 'text/html');
res.end(`<!doctype html><script src="${u.searchParams.get('src')}"></script><body>x</body>`); return;
}
res.statusCode = 404; res.end('nf');
}).listen(8765);
HTML を no-cache にしているのは、HTML まで古いと検証の意味がなくなるからです。実際の運用でも、HTML は都度確認、資産は長期、という分け方が土台になります。
# run.py(抜粋)— 初回→差し替え→再訪→リロード→fetch の cache オプションを順に確認する
import json, urllib.request
from playwright.sync_api import sync_playwright
B = 'http://127.0.0.1:8765'
def g(p): return urllib.request.urlopen(B + p).read().decode()
def hits(): return json.loads(g('/__hits'))
# (ラベル, 初回 src, 差し替え後 src, fetch の検証対象)
cases = [
('A 同じURL + immutable', '/immutable/app.js', '/immutable/app.js', '/immutable/app.js'),
('B 同じURL + max-age', '/maxage/app.js', '/maxage/app.js', '/maxage/app.js'),
('C 同じURL + no-cache', '/revalidate/app.js', '/revalidate/app.js', '/revalidate/app.js'),
('D クエリ ?v=2', '/immutable/app.js', '/immutable/app.js?v=2', '/immutable/app.js'),
('E 内容ハッシュ名', '/hashed/app.aaaaaaaa.js', '/hashed/app.bbbbbbbb.js', '/hashed/app.aaaaaaaa.js'),
]
with sync_playwright() as pw:
br = pw.chromium.launch(); print('chromium', br.version)
for label, s1, s2, probe_path in cases:
g('/__reset') # 毎回 v1 に戻し、アクセス数を空にする
ctx = br.new_context(); page = ctx.new_page() # 毎回空のキャッシュで始める
page.goto(f'{B}/page.html?src={s1}'); v_first = page.evaluate('window.APP_VERSION')
g('/__bump') # 中身を v2 に差し替える
page.goto(f'{B}/page.html?src={s2}'); v_revisit = page.evaluate('window.APP_VERSION')
v_reload = '-'
if s1 == s2:
page.reload(); v_reload = page.evaluate('window.APP_VERSION')
probe = lambda mode: page.evaluate(
"async ([u,m]) => (await (await fetch(u,{cache:m})).text()).trim()", [probe_path, mode])
print(label, v_first, v_revisit, v_reload, probe('default'), probe('reload'), sum(hits().values()))
ctx.close()
br.close()
結果です。同じ手順を 2 回流して、同じ値になることを確かめております。
ここから読み取れることが三つあります。
一つ目は、A と B が同じ挙動だったことです。私は、古い版を握らせているのは immutable の一語だと思い込んでおりました。実際には、max-age=31536000 が付いていれば、Chromium ではリロードでもサーバーに取りに行きません。immutable を外しても直りません。外して意味が出るのは、リロード時に再確認する流儀のブラウザです。私の環境の Chromium ではその差は見えませんでしたので、他のブラウザでどうなるかは、手元で試した範囲を超えて書かないでおきます。
二つ目は、C は確かに届くものの、サーバーへの到達が 5 回と最も多いことです。毎回「変わっていませんか」と尋ねに行くので、変わらない日も通信は減りません。小さなサイトなら十分ですが、画像や JS が多いページでは、この往復が積み上がります。
三つ目は、D の旧 URL です。?v=2 を付けた新しい HTML を開けば新しい版が動きますが、クエリなしの旧 URL を default で取ると、まだ v1 が返ります。つまり、参照している箇所をすべて書き換えない限り、古い URL を指している場所だけが古いままです。E も同じで、旧ハッシュの URL は v1 のまま残ります。これは欠点ではなく、ハッシュ名の URL は中身が変わらない約束だから、むしろ正しい挙動です。
私が落ち着いた形
結局、私はサイトの資産には E を、HTML には都度確認を使っております。理由は三つです。
- 長期キャッシュの利点(二回目以降の通信がほぼ発生しない)をそのまま残せます。
- 差し替えが「新しい URL を HTML が指す」ことで届くので、パージの成否を気にしなくて済みます。
- 古い版が必要な人(古い HTML を開いたままの人)も、古いハッシュ名のファイルが残っていれば壊れません。
D のクエリ付きも悪くはありません。ただ、クエリを無視する CDN の設定があったり、手で ?v= を増やす作業が残ったりして、人の手が入る余地が大きいのです。E は内容から機械的にハッシュを作るので、増やし忘れが起きません。
ビルドの最後にハッシュを付けるスクリプト
次のスクリプトは、dist/assets 配下のファイルに内容ハッシュ(8 桁)を付けて改名し、dist 直下の HTML の参照を書き換えます。二度流しても二重にハッシュが付かないようにしてあります。一時ディレクトリで実際に動かし、出力も確かめました。
// hash-assets.mjs — dist/assets 配下に内容ハッシュを付け、HTML の参照を書き換える
// 使い方: node hash-assets.mjs dist
import { readdirSync, readFileSync, renameSync, writeFileSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join, extname, basename } from 'node:path';
const root = process.argv[2] ?? 'dist';
const assetsDir = join(root, 'assets');
const HASHED = /\.[0-9a-f]{8}\.[a-z0-9]+$/; // すでにハッシュ済みなら二重に付けない
const map = new Map(); // 旧パス → 新パス
for (const name of readdirSync(assetsDir)) {
const full = join(assetsDir, name);
if (!statSync(full).isFile() || HASHED.test(name)) continue;
const hash = createHash('sha256').update(readFileSync(full)).digest('hex').slice(0, 8);
const ext = extname(name);
const next = `${basename(name, ext)}.${hash}${ext}`;
renameSync(full, join(assetsDir, next));
map.set(`/assets/${name}`, `/assets/${next}`);
}
// HTML だけを書き換える(JS の import や CSS の url() は下の落とし穴を参照)
for (const name of readdirSync(root).filter((n) => n.endsWith('.html'))) {
const p = join(root, name);
let html = readFileSync(p, 'utf8');
for (const [from, to] of map) html = html.split(from).join(to);
writeFileSync(p, html);
}
console.log([...map].map(([a, b]) => `${a} -> ${b}`).join('\n'));
$ node hash-assets.mjs dist
/assets/app.js -> /assets/app.8e8ee1fe.js
/assets/site.css -> /assets/site.d9ff348e.css
$ cat dist/index.html
<link rel="stylesheet" href="/assets/site.d9ff348e.css"><script src="/assets/app.8e8ee1fe.js"></script>
内容が同じなら同じハッシュになりますから、変わっていない資産の URL は変わりません。利用者のブラウザは、変わった資産だけを取り直します。
配信側は、assets 配下にだけ長期キャッシュを付けます。Cloudflare Pages なら _headers に 2 行を書けば足ります。
# _headers — assets 配下だけ 1 年。HTML は触らず、都度確認のまま
/assets/*
Cache-Control: public, max-age=31536000, immutable
HTML 側の Cache-Control は、ホスティングの既定に任せるか、no-cache を明示します。ここが長期になってしまうと、ハッシュを付けた意味がなくなります。
落とし穴は 4 つありました
移行の途中で引っかかりやすい所を、起きる順に並べます。それぞれ、症状と対処をひと組にしてあります。
HTML の外にある参照
JS の import や CSS の url() が旧ファイル名を指したままだと、そこだけ 404 になるか、古い版のままです。対処としては、ハッシュ付きのファイル名をバンドラーに出力させ、参照の書き換えも任せる形を推奨します。上のスクリプトは、HTML だけを対象にした最小形にとどめております。
古いハッシュ名のファイルを消さない
古い HTML を開いたままの人がいます。その人の画面から旧ファイルが消えると、読み込みエラーが出ます。次のデプロイまでは前の世代も置いておき、消すのは数世代あとにまとめて行います。
HTML 自体のキャッシュ
HTML が長期で握られていると、読者は新しいハッシュ名を知る手段がありません。ハッシュを付ける日の前に、HTML の Cache-Control を見直してください。この場合は、HTML を no-cache にしてから資産側を長期にする順番が安全です。
空のキャッシュだけで確認しない
シークレットウィンドウは、キャッシュが空なので古い版が出ません。「直った」と判断する前に、普段使いのブラウザで default と reload の差を見ます。私の場合は、この確認を手順の最後に必ず置いております。
エージェントに頼むときの文面
ハッシュ付与のスクリプトや _headers を Antigravity のエージェントに書かせる場合も、検証だけは手元に残すと決めております。頼み方は、たとえば次のとおりです。
dist/assets 配下のファイル名に内容ハッシュ(8桁)を付け、dist 直下の HTML の参照を書き換える
Node スクリプトを書いてください。
条件: 二度流しても二重にハッシュを付けない/HTML 以外は書き換えない/最後に旧パス→新パスの一覧を出す。
動作確認は一時ディレクトリで行い、実行したコマンドと出力をそのまま貼ってください。
「出力をそのまま貼る」ところが肝です。動いたと言われる代わりに、私が読める出力をもらいます。パージの成否ではなく、URL が変わったかどうかで確かめます。 差し替えのたびに、この一文を思い出すようにしております。
次にデプロイする前に、今の環境で上の 2 行の fetch を、差し替え済みの資産に対して打ってみていただければと思います。default と reload で違う答えが返ってきたら、その資産はハッシュ名に移す候補です。
関連して、上書きした画像が古いまま届いた別の件は、Cloudflare をパージしても古い壁紙が届き続けた原因にまとめております。そちらはオリジン側の層の話で、この記事のブラウザ側とは握っている場所が違います。