本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。
背景
外部サービスのブラウザベースのデモアプリ一式を、自サイト(Nuxt 2 / target: 'static')の static/ 配下に埋め込むことになりました。
static/
├── vdiff/
│ ├── index.html # 埋め込んだ外部ツールのトップ
│ ├── vdiffjs/...
│ └── opencv/...
└── ...
/vdiff/?img1=...&img2=... のようなリンクで、自サイト内のページからこの埋め込みアプリに飛ばす構成です。リンクは Vue テンプレートで以下のように書きました。
<a :href="`/vdiff/?img1=${...}&img2=${...}`">比較</a>
ローカルの npm run dev では正常に動きますが、本番(yarn generate:production → GitHub Pages)にデプロイすると、/vdiff/ を踏んだときに Nuxt の「This page could not be found」ページが返る という現象に遭遇しました。
不可解だったのは、
/vdiff/vdiffjs/vdiff.bundle.min.js等の深い静的ファイルは正常配信されるstatic/vdiff/index.htmlは確かに 909 バイトで存在する- なのに
/vdiff/(末尾スラッシュ)でアクセスすると 378KB ある Nuxt の 404 アプリシェルが返ってくる
という、index.html だけが Nuxt 404 ページに置き換わっている状態だったことです。
原因: Nuxt 2 generate の crawler
Nuxt 2 generate(target: 'static')にはデフォルトで crawler が走ります。
- 各ルートを SSR してリンクを抽出
- 抽出した相対 URL のうち
pages/で定義されていない経路に対しても、fallback HTML(404 アプリシェル)をdist/<route>/index.htmlに書き出す
私の場合、/picture/face/ ページから /vdiff/?img1=... への <a href> が大量に出力されていました(123 件)。crawler はクエリストリングを除いた /vdiff/ を未知ルートとして拾い、
dist/vdiff/index.html ← 404 アプリシェルで上書き
を生成。タイミングとして、static/ のコピーよりルート生成のほうが後勝ちだったため、私が置いた 909 バイトの index.html が上書きされた、というのが起きていたことでした。
なぜ既存の static/mirador3/ は無事だったのか
同じサイトには以前から static/mirador3/index.html を置いていて、そちらは問題なく /mirador3/ で動いていました。違いはリンクの作り方でした。
// mirador3 側(無事)
url: process.env.BASE_URL + '/mirador3/?params=' + encodeURIComponent(...)
// → SSR 後の HTML には絶対 URL が出る:
// <a href="https://example.com/mirador3/?params=...">
// vdiff 側(壊れた)
:href="`/vdiff/?img1=${...}`"
// → SSR 後の HTML には相対 URL が出る:
// <a href="/vdiff/?img1=...">
Nuxt 2 の crawler は内部実装上 / 始まりの相対 URL のみを内部ルート候補として拾う(startsWith('/') && !startsWith('//') 判定)ため、絶対 URL(https://...)は先頭が / でないので結果的に対象外になります。逆に相対 URL(/vdiff/?...)は内部ルートとして拾われるため、上記の挙動差が出ていました。
対処: generate.exclude
修正方法は 2 通りあります。
- リンク側を絶対 URL に揃える(mirador3 と同じ流儀)
generate.excludeで対象パスを crawler のルート生成対象から除外する
私は後者を選びました。理由は、
- ミラー対象のパス全部を一度に除外できる
- リンク構築ロジックを触らずに済むので、影響範囲が
nuxt.config.js1 ファイルに閉じる - 後から増えるミラーディレクトリに対しても、配列に正規表現を足すだけで対応できる
// nuxt.config.js
export default {
// ...
generate: {
fallback: true,
exclude: [/^\/vdiff(\/|$)/],
routes() { /* ... */ },
},
}
/^\/vdiff(\/|$)/ のように末尾を (\/|$) でくくっておくと、/vdiff でも /vdiff/ でもマッチします。複数ツールを埋め込む場合は配列に追加するか、
exclude: [/^\/(vdiff|vdiff-seq|iiif-curation-viewer)(\/|$)/]
のようにまとめても良いでしょう。
なお、exclude は crawler 由来のルート生成のほか、generate.routes() で明示的に返したルートには影響しません。あくまで「未知の経路を見つけたとき fallback HTML を書き出さないようにする」設定です。
もう 1 つの落とし穴: 旧 @nuxtjs/pwa の Service Worker
ここまでで /vdiff/ から 909 バイトの正しい index.html が返るようになり、サーバ側の検証は OK になりました。しかしユーザによっては、依然として古いコードが走り続けるという現象が残りました。
具体的には、リンク先 URL を生成する getVDiffUrl() が新コード(/vdiff/?...)に書き換わっているのに、ブラウザ上では旧コード(http://codh.rois.ac.jp/...)の URL が生成されてしまう、という挙動です。
サーバ側のレスポンス自体は新しい _nuxt/* バンドルを返しているのに、ブラウザに届いていない。これは典型的に Service Worker の cache-first が効いている挙動でした。
調べてみると、このサイトはかつて @nuxtjs/pwa で PWA 化されており、/sw.js に workbox 製の SW が register 済みでした。ナビゲーションは NetworkFirst ですが、ビルド成果物(/_nuxt/* 配下のハッシュ付き JS / CSS)は ランタイムで CacheFirst 配信される(@nuxtjs/pwa の workbox サブモジュールで cacheAssets がデフォルト ON)設定になっていて、これが新版 JS のフェッチを阻害していました。
- nuxt.config.js の
workbox.runtimeCachingで/_nuxt/をCacheFirst、/.*をStaleWhileRevalidate(30 日)と設定(workbox v4 系のhandler値は PascalCase) - 結果、初回フェッチで Cache Storage に積まれた
/_nuxt/*が、コード差し替え後も SW で先回り配信され続ける
退役の手順自体はフレームワーク移行後も退役してくれない旧 Service Worker を kill-switch SW で撤去する でまとめられている内容にそのまま準拠しました。要点は
1. @nuxtjs/pwa の workbox を停止
nuxt.config.js で workbox サブモジュールを無効化します。
export default {
modules: [
// '@nuxtjs/pwa' は manifest / icon サブモジュールが要るなら残す
'@nuxtjs/pwa',
],
pwa: {
workbox: false, // ★ SW 生成を停止
},
}
これで以後のビルドで dist/sw.js が PWA モジュールに上書き生成されなくなります。
2. /sw.js を kill-switch SW に上書き
旧 SW が update チェックで取りにくる先(同じ URL /sw.js)に、自身を unregister するだけの SW を置きます。
// static/sw.js — kill-switch Service Worker
self.addEventListener('install', () => {
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys()
await Promise.all(keys.map((k) => caches.delete(k)))
await self.registration.unregister()
const clients = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true,
})
for (const client of clients) {
try { client.navigate(client.url) } catch (_) { /* noop */ }
}
})()
)
})
注意点:
fetchハンドラは書かない(書くと結局新 SW が intercept してしまう)clients.matchAllにはincludeUncontrolled: trueを渡す(活性化したばかりでまだ control していない状態だと、デフォルトでは空配列)client.navigate(url)は仕様上 cross-origin URL のとき返り Promise がTypeErrorで reject する(同期 throw ではない点に注意)。kill-switch では同一オリジンを渡すので実害は無いが、上のコードのようにtry/catchで囲むなら.catch(() => {})のほうが本来は安全Cache-Controlは短めに。GitHub Pages の場合は max-age=600 が既定なので追加対応は不要だが、独自配信ならmax-age=0, must-revalidate程度に下げておくと安全- 退役完了後も 数週間〜数か月は kill-switch を残す。ロングテール再訪ユーザのブラウザの中ではまだ旧 SW が動いているため
ところで、Nuxt 2 のリポジトリには static/sw.js が .gitignore 入りになっているケースがよくあります(自動生成物として扱う流儀)。kill-switch SW は手動で版管理したいので、
git add -f static/sw.js
と force add しておくか、.gitignore に !static/sw.js の例外行を追加します。
3. 念のため、ページ側でも撤去を走らせる
kill-switch SW の activate は、新 /sw.js の update チェックがブラウザ側で走らないと起動しません。ロングテール再訪ユーザに早く効かせるため、ページ側のクライアントプラグインでも明示的に撤去します。
// plugins/sw-cleanup.client.js
export default () => {
try {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then((rs) => {
rs.forEach((r) => r.unregister())
})
}
if (typeof caches !== 'undefined') {
caches.keys().then((ks) => ks.forEach((k) => caches.delete(k)))
}
} catch (_) { /* noop */ }
}
// nuxt.config.js
plugins: [{ src: '~/plugins/sw-cleanup.client.js', mode: 'client' }],
これで「ページ訪問時に強制 unregister + cache 削除」が走るので、kill-switch SW の活性化を待たずにクリーンになります。
まとめ
static/<ツール名>/ に外部の埋め込みアプリを置く構成では、
- リンクを 相対 URL で書くと crawler がクロール対象として拾い、404 アプリシェルで
index.htmlを上書きする - 対処は
generate.excludeで対象パスを除外、あるいは絶対 URL に揃える - 過去に
@nuxtjs/pwaの workbox を有効にしていたサイトでは、新版反映が SW キャッシュで阻害される - workbox を停止し、kill-switch SW + クライアントプラグインで旧 SW + Cache Storage を撤去
の 4 点を押さえると、嵌りどころを通り抜けられます。
Nuxt 3 / Vite 系では SW のデフォルト挙動が opt-in になっていて、同じ問題は起きにくいはずですが、Nuxt 2 + @nuxtjs/pwa の系譜のサイトをいま運用している場合は、サイト改修のたびに同様の現象に遭遇する可能性があります。
参考
- CODH の vdiff.js を Wayback Machine から復元してデジタル源氏物語の「パタパタ顔比較」を一時復旧する — 本記事のきっかけになったミラー作業(Wayback
id_フラグの使い方など) - CODH ツール群の暫定ミラー専用リポジトリを GitHub Pages で立てる — ミラーを独立リポジトリ化してまとめて配信する続編
- フレームワーク移行後も退役してくれない旧 Service Worker を kill-switch SW で撤去する
- Nuxt 2 generate.exclude 仕様
- self-destroying-sw

