Service Worker × Astro でデプロイ後に JS が 404・保存が20秒|実例から学ぶ SW と Turnstile の落とし穴

(更新: 2026年6月29日 ) Service Worker Astro Cloudflare Turnstile PWA デバッグ
結論
  • 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)で、次の症状が重なった。

  1. デプロイ直後、Console に _astro/MemoTool....js net::ERR_ABORTED 404
  2. Cloudflare Turnstile が 600010 / PAT 401 で失敗
  3. メモ保存に 約20秒 かかる
  4. それでも [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

なぜ「今まで動いていた」のか

この不具合は 常時 ではなく デプロイ × 再訪問 のときだけ表面化する。

1

ユーザーが memo ページを訪問 → SW が HTML を stale-while-revalidate でキャッシュ

2

開発者が push → Astro ビルドで _astro のファイル名(ハッシュ)が変わる

3

同じブラウザで再訪問 → SW が古い HTML を先に返す

4

古い 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/htmlnetwork-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 がクリティカルパスに載っている

再現確認の手順

  1. シークレットウィンドウで開く → SW なしで動けば SW 起因
  2. DevTools → Application → Service Workers → Unregister + Clear site data
  3. Network タブで HTML が参照する _astro/*.js200 か確認
  4. 保存ボタン押下から [API] Sending までの 秒数 を Console タイムスタンプで計測

実装チェックリスト(再発防止)

  • HTML は network-first(ハッシュ付き JS との整合性)
  • fetchurl.origin === self.location.origin のみ
  • /api/* は SW を通さない
  • インストール時の cache.addAllHTML を入れない
  • CACHE_NAME 変更 + activate で旧キャッシュ削除
  • SW 更新時に既存タブを reload(任意だがデプロイ直後に有効)
  • Turnstile / CAPTCHA は 保存をブロックしない(短い race + fail-open)
  • data-error-callback で Uncaught TurnstileError を防ぐ
  • _headers/sw.jsCache-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 404SW が古い HTML を返すHTML network-first、SW 更新時 reload
503 on 外部 URLSW が third-party を intercept同一オリジンのみ
Turnstile 600010ブラウザ環境(PAT 等)保存をブロックしない、error-callback
保存20秒Turnstile 10s + 10s 待機保存時は最大1秒、再取得廃止

PWA に SW を入れるとオフライン体験は良くなるが、Astro のようなハッシュ付きバンドル と組み合わせるときは「HTML の鮮度」が生命線になる。Turnstile 等の第三者スクリプトは クリティカルパスから外す か、短いタイムアウト + fail-open で 遅延と可用性 を両立させる。

関連記事

トピック記事
Service Worker 戦略全般Service Worker キャッシュ戦略ガイド
ボット対策と TurnstileWebスクレイピング anti-bot 対策
API キャッシュAPIキャッシュ設計ガイド
実際のツール暗号化メモツール