Service Worker × Astro でデプロイ後に JS が 404・保存が20秒|実例から学ぶ SW と Turnstile の落とし穴
- JS 404 は Turnstile ではなく Service Worker が古い HTML を返している サインが多い(Astro の
_astro/*.jsはデプロイごとにハッシュが変わる)。 - SW の fetch は同一オリジンのみ 横取りする。
challenges.cloudflare.comや Analytics を触ると503 Offlineや Turnstile 失敗の原因になる。 - Turnstile は保存のクリティカルパスに載せない。prefetch 失敗環境では最大1秒で打ち切り、fail-open で API を即送る。
- Console の
[Save] Memo saved successfullyと[Turnstile] Error: 600010は 両立しうる。ログを混同しない。
何が起きたか(実例)
暗号化メモツール(Astro + Cloudflare Pages + R2)で、次の症状が重なった。
- デプロイ直後、Console に
_astro/MemoTool....js net::ERR_ABORTED 404 - Cloudflare Turnstile が
600010/PAT 401で失敗 - メモ保存に 約20秒 かかる
- それでも
[Save] Memo saved successfullyは出る
「Turnstile の設定が間違っている」「API が壊れた」と最初は疑いがちだが、原因は複数レイヤーに分散していた。
症状1: デプロイ後だけ JS が 404
Console の典型
GET .../_astro/MemoTool.astro_..._lang.udQ48AIH.js 404 (Not Found)
一方、curl やシークレットウィンドウで HTML を取得すると、参照先は 別ハッシュ になっている。
/_astro/MemoTool.astro_..._lang.CipuQCZU.js → 200 OK
なぜ「今まで動いていた」のか
この不具合は 常時 ではなく デプロイ × 再訪問 のときだけ表面化する。
ユーザーが memo ページを訪問 → SW が HTML を stale-while-revalidate でキャッシュ
開発者が push → Astro ビルドで _astro のファイル名(ハッシュ)が変わる
同じブラウザで再訪問 → SW が古い HTML を先に返す
古い HTML が存在しない JS を読みに行く → 404 → MemoTool 全体が動かない
初めての訪問者やシークレットウィンドウでは再現しない。「自分のブラウザだけ壊れた」 パターンになりやすい。
問題だった SW コード(抜粋)
// ❌ 全 GET をキャッシュ優先 + HTML も事前キャッシュ
const ASSETS_TO_CACHE = ['/tools/memo/', '/manifest.json', '/favicon.svg'];
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
const fetchPromise = fetch(event.request).then(/* ... */);
return cachedResponse || fetchPromise; // 古い HTML を即返す
})
);
Astro / Vite / webpack 系は /_astro/* に content hash が付く。HTML だけ古いと参照先 JS が消え、404 になる。
修正の要点
| 対象 | 戦略 |
|---|---|
HTML(navigate / Accept: text/html) | network-first |
/_astro/*(ハッシュ付き) | cache-first / SWR でも可(ファイル名自体がバージョン) |
/api/* | SW 横取りしない |
| 外部オリジン | SW 横取りしない |
// ✅ 同一オリジンのみ intercept
const url = new URL(event.request.url);
if (url.origin !== self.location.origin) return;
if (request.mode === 'navigate' || request.headers.get('accept')?.includes('text/html')) {
// network-first: 常に最新 HTML → 正しい _astro ハッシュ
event.respondWith(fetch(request).then(/* 成功時のみ cache.put */).catch(/* offline fallback */));
return;
}
加えて SW 更新時 に CACHE_NAME を bump し、activate で旧キャッシュ削除 + 全タブ client.navigate(url) または controllerchange で reload すると、既存ユーザーにも新 HTML が届く。
詳細な戦略の背景は Service Worker キャッシュ戦略ガイド を参照。
症状2: 外部通信が 503 Offline になる
Console に次が出ていた。
GET https://static.cloudflareinsights.com/beacon.min.js net::ERR_ABORTED 503 (Offline)
503 + Offline は 自前 SW が synthetic Response を返した ときの statusText と一致する。fetch ハンドラが Cloudflare Analytics や Turnstile(challenges.cloudflare.com)まで横取り し、ネットワーク失敗時に new Response('Offline', { status: 503 }) を返していた。
// ❌ オリジンを問わず fetch を intercept
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
// challenges.cloudflare.com もここに入ってしまう
});
教訓: SW のスコープは / でも、intercept 対象は 自サイトの origin に限定 するのが安全。
症状3: Turnstile 失敗なのに保存は成功する
ログの読み方
| ログ | 意味 |
|---|---|
[Turnstile] Pre-fetching token on load... | ページ読み込み時に裏でトークン取得開始 |
PAT ... 401 (Unauthorized) | ブラウザの PAT 取得失敗(Edge Tracking Prevention 等) |
[Turnstile] Error: 600010 | チャレンジ全体の失敗(汎用コード) |
[API] Sending consolidated request... | トークンなしでも POST 開始 |
[Save] Memo saved successfully | 保存自体は OK |
Turnstile ダッシュボードで Hostname(example.pages.dev)と Secret Key が正しくても、クライアント側チャレンジは失敗しうる。fail-open により API は通るため、「Turnstile エラー = 保存失敗」とは限らない。
fail-open のコード(サーバー側)
if (turnstileToken && turnstileToken.trim()) {
// siteverify 実行。失敗時のみ 403
} else {
console.warn('[API:Memo] Turnstile token missing (fail-open: allow).');
}
ボット対策を必須にしたいなら fail-closed に変えるが、Turnstile が環境依存で落ちる 以上、UX とのトレードオフになる。
症状4: 保存が20秒かかる
タイムライン(修正前)
t=0s ページ読み込み → Turnstile prefetch 開始(10秒タイムアウト)
t=7s ユーザーが保存クリック
→ prefetch 待ち or 再取得開始(さらに10秒)
t≈20s ようやく API 送信
Turnstile が Edge でほぼ成功しない 環境では、待ってもトークンは来ない。20秒は「待ち時間の合算」 だった。
修正後の方針
async function getTurnstileTokenForSave() {
if (isTokenValid()) return cachedToken;
if (!tokenPromise) return null; // prefetch 失敗済み → 即 null
// 進行中の prefetch を最大1秒だけ待つ
return Promise.race([
tokenPromise,
new Promise((resolve) => setTimeout(() => resolve(null), 1000)),
]);
}
- 保存時に reset→execute で10秒待つ処理は廃止
- prefetch はバックグラウンド(8秒で打ち切り)のみ
- 保存クリティカルパスへの影響は 最大1秒
結果、保存は 暗号化 + API 通信(1〜3秒程度) に収まった。
Console ログの切り分けチェックリスト
デプロイ後に memo / PWA 系で怪しいとき、次の順で見る。
| ログ・現象 | 疑う場所 |
|---|---|
| _astro/*.js 404、HTML の hash と不一致 | Service Worker の古い HTML キャッシュ |
| 503 (Offline) on 外部ドメイン | SW が third-party を intercept |
| 600010 / PAT 401 | ブラウザプライバシー・拡張機能(設定は正しくても) |
| ERR_BLOCKED_BY_CLIENT on beacon.min.js | 広告ブロッカー / Edge Tracking Prevention |
| 保存成功 + Turnstile エラー | fail-open。Turnstile と保存は独立 |
| 保存だけ20秒 | Turnstile await がクリティカルパスに載っている |
再現確認の手順
- シークレットウィンドウで開く → SW なしで動けば SW 起因
- DevTools → Application → Service Workers → Unregister + Clear site data
- Network タブで HTML が参照する
_astro/*.jsが 200 か確認 - 保存ボタン押下から
[API] Sendingまでの 秒数 を Console タイムスタンプで計測
実装チェックリスト(再発防止)
- HTML は network-first(ハッシュ付き JS との整合性)
-
fetchはurl.origin === self.location.originのみ -
/api/*は SW を通さない - インストール時の
cache.addAllに HTML を入れない -
CACHE_NAME変更 +activateで旧キャッシュ削除 - SW 更新時に既存タブを reload(任意だがデプロイ直後に有効)
- Turnstile / CAPTCHA は 保存をブロックしない(短い race + fail-open)
-
data-error-callbackで Uncaught TurnstileError を防ぐ -
_headersで/sw.jsにCache-Control: max-age=0, must-revalidate
最小構成の SW スケッチ(Astro + Pages 向け)
const CACHE_NAME = 'app-v9';
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE_NAME).then((c) => c.addAll(['/manifest.json'])));
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
e.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)));
const clients = await self.clients.matchAll({ type: 'window' });
await Promise.all(clients.map((c) => c.navigate(c.url)));
})());
self.clients.claim();
});
self.addEventListener('fetch', (e) => {
if (e.request.method !== 'GET') return;
const url = new URL(e.request.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith('/api/')) return;
const isHtml =
e.request.mode === 'navigate' ||
e.request.headers.get('accept')?.includes('text/html');
if (isHtml) {
e.respondWith(
fetch(e.request)
.then((res) => {
if (res.ok) {
const clone = res.clone();
caches.open(CACHE_NAME).then((c) => c.put(e.request, clone));
}
return res;
})
.catch(() => caches.match(e.request))
);
}
// 静的アセットは必要に応じて SWR を追加
});
まとめ
| 問題 | 根本原因 | 対策 |
|---|---|---|
| JS 404 | SW が古い HTML を返す | HTML network-first、SW 更新時 reload |
| 503 on 外部 URL | SW が third-party を intercept | 同一オリジンのみ |
| Turnstile 600010 | ブラウザ環境(PAT 等) | 保存をブロックしない、error-callback |
| 保存20秒 | Turnstile 10s + 10s 待機 | 保存時は最大1秒、再取得廃止 |
PWA に SW を入れるとオフライン体験は良くなるが、Astro のようなハッシュ付きバンドル と組み合わせるときは「HTML の鮮度」が生命線になる。Turnstile 等の第三者スクリプトは クリティカルパスから外す か、短いタイムアウト + fail-open で 遅延と可用性 を両立させる。
関連記事
| トピック | 記事 |
|---|---|
| Service Worker 戦略全般 | Service Worker キャッシュ戦略ガイド |
| ボット対策と Turnstile | Webスクレイピング anti-bot 対策 |
| API キャッシュ | APIキャッシュ設計ガイド |
| 実際のツール | 暗号化メモツール |