本記事は生成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 通りあります。

  1. リンク側を絶対 URL に揃える(mirador3 と同じ流儀)
  2. generate.exclude で対象パスを crawler のルート生成対象から除外する

私は後者を選びました。理由は、

  • ミラー対象のパス全部を一度に除外できる
  • リンク構築ロジックを触らずに済むので、影響範囲が nuxt.config.js 1 ファイルに閉じる
  • 後から増えるミラーディレクトリに対しても、配列に正規表現を足すだけで対応できる
// 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/<ツール名>/ に外部の埋め込みアプリを置く構成では、

  1. リンクを 相対 URL で書くと crawler がクロール対象として拾い、404 アプリシェルで index.html を上書きする
  2. 対処は generate.exclude で対象パスを除外、あるいは絶対 URL に揃える
  3. 過去に @nuxtjs/pwa の workbox を有効にしていたサイトでは、新版反映が SW キャッシュで阻害される
  4. workbox を停止し、kill-switch SW + クライアントプラグインで旧 SW + Cache Storage を撤去

の 4 点を押さえると、嵌りどころを通り抜けられます。

Nuxt 3 / Vite 系では SW のデフォルト挙動が opt-in になっていて、同じ問題は起きにくいはずですが、Nuxt 2 + @nuxtjs/pwa の系譜のサイトをいま運用している場合は、サイト改修のたびに同様の現象に遭遇する可能性があります。

参考