沉默的 500:一次 Nuxt 3.21 中文 URL prerender 的除錯紀錄

前一篇結尾預告了這次改版唯一一個讓我卡住超過幾小時的 bug。獨立寫一篇,因為它的「形狀」比細節更值得記。


現象

把 Nuxt 從 3.9 升到 3.21、Nitro 也跟著上 2.13 之後,pnpm generate 跑下去,4 個中文 tag 頁全部 prerender 500

[500] /tag/隨筆
[500] /tag/前端開發
[500] /tag/台股
[500] /tag/技術筆記

奇怪的點來了:

  • pnpm dev 打同樣 URL ── 200 OK,畫面正常
  • 英文 tag 頁 prerender ── 全部 200
  • Nitro 完全沒吐出任何錯誤訊息。Body 空、沒有 stack trace、沒有 throw 進 error hook
  • 只有那行 [500]

換句話說:Nitro 知道 render 失敗了,但拒絕告訴我為什麼。 這是這個 bug 最反直覺的地方 ── 在「沒有錯誤訊息」的前提下,要憑現象反推根因。


第一個彎路:懷疑 <VApp>

直覺反應:「中文 + Vuetify ── 大概是 <VApp> 包 SSR 的時候對 unicode route 處理有 bug 吧。」

當時 Phase A 還沒拔 Vuetify。我跑去把 app.vue<VApp> 拿掉,再跑一次 generate。

結果:4 個 500 變成 1 個 500。

「啊有效!剩 1 個應該是另一個 bug!」── 這就是這次除錯最大的陷阱。


致命的假希望:crawler 短路

「4 個變 1 個」是真的,但不是「修好了 3 個」

事實是:拿掉 <VApp> 之後,/ 首頁也跟著掛掉了(Vuetify 的 component 散落在 layout 跟 component 裡,沒了 <VApp> 直接 render error)。

而 Nitro 的 prerender 是 crawler-based ── 從 entry route 開始,跟著頁面裡的 <a href> 爬下去。首頁掛掉之後:

/  →  ❌ 500(render error,crawler 拿不到 HTML)
                ↓
                沒有 link 可以爬
                ↓
          /tag/隨筆 / 前端開發 / 台股 / 技術筆記 → 根本沒被嘗試
                ↓
          唯一還在 prerender queue 的:之前手動加進 routes 的某個英文 tag
                ↓
                那個也 500
                ↓
            最終 log 看起來:「只剩 1 個 500」

我以為 4 變 1 是「拿掉 <VApp> 修好了 3 個」,實際上是「拿掉 <VApp> 之後 3 個中文頁根本沒被測試」

這個假希望讓我繼續往「Vuetify 是兇手」的方向追了一陣 ── 改 Vuetify 的 SSR 設定、試各種 plugin 順序,全部白工。

第一個帶走的東西:當「修了某件事之後問題變少」的時候,要先確認問題真的被測到了,而不是被跳過了。Crawler-based 的 prerender 特別容易製造這種錯覺。


兩個更短的彎路

回到「中文 URL」這個現象本身,後面又連續走了兩個彎路:

彎路二:useAsyncData 的 key 含中文。 Tag 頁的 query 是 useAsyncData(\tag-${tag}`, ...),key 直接帶中文進去。懷疑 Nuxt 對 cache key 的 serialization 對 unicode 有 bug。把 key 改成 useAsyncData(encodeURIComponent(tag), ...)` ── 沒救。

彎路三:router.push 的 redirect 迴圈。 pages/tag/[tag]/index.vue 裡有一段 watch({ immediate: true }),會 router.push(tagLink(tag.value)) ── 我懷疑這是「推到自己」的迴圈把 prerender 卡死。把 watch 拿掉 ── 還是 500。

(這段 redirect-to-self 後來證實是另一個 pre-existing bugindex.vue 永遠 no-op,原本就該刪。一併清掉了,但跟這次的 500 無關。)

到這裡為止,所有「站在現象上面看」的假設都試完了。三個彎路、零進展,連一個有用的錯誤訊息都拿不到。


換工具:Nitro 的 stderr 是黑洞

走到這個地步,我意識到一件事 ── 我從頭到尾沒看過任何錯誤訊息,是因為 Nitro 在 prerender 階段把 stderr 過濾掉了。

console.error 在 prerender phase 不會出現在 build log。我在 page setup 裡塞了一堆 console.error('HERE 1') / console.error('HERE 2'),build 跑完一行都沒看到。這就是為什麼 Nitro 只給我一行 [500] ── 不是它沒拋錯,是它的 stderr 不會冒出來。

換工具:直接寫檔。

ts
// pages/tag/[tag]/index.vue
import { appendFileSync } from 'node:fs'

const log = (msg: string) => {
  if (import.meta.server) {
    appendFileSync('/tmp/prerender-debug.log', `${new Date().toISOString()} ${msg}\n`)
  }
}

log(`tag page setup start, tag=${route.params.tag}`)
const { data } = await useAsyncData(...)
log(`tag page setup done, count=${data.value?.length}`)

tail -f /tmp/prerender-debug.log 開著、跑 generate ── 終於看到 setup 跑完了、data 也拿到了,500 是發生在 setup 之後的某個階段。問題不在 page render,是在 page render 之後 Nitro 想做的事

第二個帶走的東西:Nitro prerender 階段,fs.appendFileSyncconsole.error 可靠。stderr 是黑洞,檔案系統不是。Debug prerender bug 的時候直接寫檔。


真兇:payloadExtraction

「page render 之後 Nitro 想做的事」是什麼?翻 Nuxt 的 experimental options1,第一個進視線的就是:

ts
// 預設值
experimental: {
  payloadExtraction: true
}

這個 option 開啟的時候,Nuxt 會把每個 prerender route 的 data 額外輸出成一個 _payload.json 檔,存在 /route/_payload.json 路徑下,給 client-side navigation 抓 data 用2

也就是說,prerender 一個 route 實際上會產出兩個檔案

  • /tag/隨筆/index.html
  • /tag/隨筆/_payload.json

而 Nuxt 3.21 + Nitro 2.13 在處理「中文 URL 的 payload 寫入」這一步的時候 crash。Crash 沒被 catch、沒被 log、直接讓整條 render 流程回 500。Page setup 已經跑完了,data 也拿到了,但寫 _payload.json 的時候掛了,最終報出來的是 page 的 500,不是 payload 的 500。

解法一行:

ts
// nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    payloadExtraction: false
  }
})

跑 generate ── 4 個中文 tag 頁全綠。


Trade-off

關掉 payloadExtraction 的代價是:client-side 換頁的時候,Nuxt 會抓整個目標 HTML 而不是只抓一個 JSON payload。

對純靜態 blog 而言:

  • 我的頁面平均 HTML 大小本來就不大(沒有大量 SSR data)
  • 文章內容是 MDC 編譯後的 HTML,pages 之間不會共用一個 JSON payload
  • Pagefind / RSS / sitemap 的產出完全不依賴 payload extraction

所以這個 trade-off 對這個 blog 來說近乎零成本。payloadExtraction 真正會發揮價值的是「動態列表、SSR-heavy、client navigation 頻繁」的場景,跟 content-first blog 不在同一個象限。

未來 Phase B 升 Nuxt 4 的時候會回來重測 ── 行為可能已經改了,能恢復預設就恢復。


兩個帶走的東西

把這次踩坑學到的兩件事整理出來,未來踩到同類型 bug 可以拿來用:

1. 沉默的失敗最貴。 當錯誤訊息被框架吞掉、log 只剩一行 [500] 的時候,第一件事不是猜根因,是換工具拿到訊號console.error 不行就 appendFileSyncappendFileSync 不行就在每一步插 explicit checkpoint。沒有訊號就沒有偵錯,先把這條補上再開始推論。

2. 「修了某件事之後問題變少」未必是修對。 Crawler-based、retry-based、queue-based 的系統特別容易製造「症狀減少」的錯覺。確認你的修改沒有把待測項目跳過,再相信減少是真的減少。這次是 <VApp> 拿掉之後 crawler 從首頁短路,把中文 tag 頁全部跳過 ── 看起來「4 變 1」其實是「3 個沒被測」。


回呼一下前兩篇 ── 設計篇在找詞彙、工程篇在找分界線、這篇在找訊號。三個改版過程裡實際發生的「卡住」,本質都是「我手上的工具不足以表達或測量這個問題」。詞彙、分界線、訊號 ── 拿到工具,問題才開始能被解。


環境資訊

  • Nuxt 3.21.2
  • Nitro 2.13.3
  • 部署:nuxt generate → GitHub Pages(SSG)
  • Phase B 升 Nuxt 4 / Content v3 時會回來重測 payloadExtraction 是否能恢復預設

Reference

  1. Nuxt experimental.payloadExtraction 設定說明:https://nuxt.com/docs/api/nuxt-config#experimental
  2. Nitro prerender 機制官方文件:https://nitro.build/guide/cache#prerender