把應用放上邊緣執行環境之後,會多出一整類本機不存在的故障。它們的共同形狀是:你的程式碼沒有錯,錯的是你和執行環境對某件事的假設不一致。
而且因為本機一切正常,第一反應幾乎必然是「我的 code 沒問題啊」——這個判斷是對的,也正是它讓人卡住的原因。
一、CDN 快取鍵不含 Accept,把 CSS 餵成 module script
症狀
dev server 經由 tunnel 存取時整片白畫面。同一份程式碼在 127.0.0.1 完全正常。
Failed to load module script: Expected a JavaScript-or-Wasm module script but the
server responded with a MIME type of "text/css". Strict MIME type checking is
enforced for module scripts per HTML spec.
診斷時有兩件事會強力誤導:
- 所有 network 請求都是 200,沒有任何 failed request。
entry.js自己 fetch 起來也是 200 加正確的text/javascript。壞的是它 import 的main.css curl怎麼打都對。curl 對同一個 URL 拿到no-store、BYPASS、正確的 content-type。只有真的瀏覽器會拿到max-age=14400加text/css
根因
Vite dev 對同一個 .css URL 依請求的 Accept 回兩種不同的東西:
| 請求方 | Accept | Vite 回應 |
|---|---|---|
<link rel="stylesheet"> | text/css,*/*;q=0.1 | text/css,真的 CSS |
import '...css'(module graph) | */* | text/javascript,注入 style 的 JS wrapper |
而 Vite 自己回的 Vary header 是 Vary: Origin——不含 Accept。
CDN 那一側對 .css 這類副檔名套用預設的瀏覽器快取 TTL(4 小時,max-age=14400,會蓋掉 origin 的 no-cache),快取鍵沿用 Vary: Origin,不區分 Accept。於是:
- 頁面的
<link>先請求main.css,CDN 存下 text/css 那一份加 ETag - 同一頁的 module import 請求同一個 URL,CDN 拿 ETag 向 origin revalidate
- Vite 看 ETag 相符回 304,CDN 就把快取裡的 text/css 交出去
- 瀏覽器對 module script 做 strict MIME 檢查,拒絕,client entry chain 整條斷,白畫面
依 URL 清快取解不掉這個條目——purge 回 success: true,下一次瀏覽器請求仍然是 revalidate 命中。
為什麼所有檢查都沒攔到
- 本機永遠正常。沒有 CDN 層,Vite 依
Accept各回各的,行為完全正確 - curl 驗不出來。curl 不會像瀏覽器那樣先發 stylesheet 請求再發 module 請求,拿不到被污染的那個快取條目。用 curl 檢查 header 會得到「已經修好了」的錯誤結論
- CI 不碰 tunnel。E2E 與 build 都打本機或 preview
第二點值得單獨拿出來講。這是一個很經典的錯誤:用一個不會觸發問題的工具去驗證問題是否還在,然後把「沒觸發」讀成「已修好」。curl 與瀏覽器在這裡是不同的觀測儀器,而只有一種儀器看得到。
修法
根本解是讓快取層知道 Accept 是快取鍵的一部分——設定 CDN 的 cache key 納入 Accept,或對 dev 路徑整段 bypass 快取。
實務上,dev server 經過 CDN 這件事本身就值得重新考慮。開發迴圈需要的是「所見即所得」,中間多一層有自己快取策略的東西,換來的除錯成本通常高於它省下的設定成本。
二、dev 模式不套 Cloudflare preset,binding 全數缺席
症狀
有 Cloudflare binding(D1 / R2)的專案,任何碰 binding 的 server route 在 nuxt dev 下回 500:
env: Cloudflare env is unavailable.
訊息文字依 runtime 而異,但意思都是「環境不可用」。
根因
Nuxt 的 nitro.preset 不會進到 Nitro 的 dev preset 解析。
Nitro 在 dev 為真時,只從它自己的設定來源取 preset(nitro.config.*、rc、package.json):
if (configOverrides.dev) {
preset =
preset && preset !== 'nitro-dev'
? await resolvePreset(preset, { dev: true }).then((p) => p?._meta?.name || 'nitro-dev')
: 'nitro-dev'
}
寫在 nuxt.config.ts 裡的 nitro: { preset: 'cloudflare_module' } 不在那些來源裡。NITRO_PRESET 環境變數也不是——兩者都實測過,都無效。
於是 dev 固定落到 nitro-dev,負責呼叫 getPlatformProxy() 並設定 globalThis.__env__ 的 cloudflare-dev preset 從頭到尾沒有被載入。
runtime 端依序找 ALS store、globalThis.__env__、cloudflare:workers,三者全空就 throw。看起來像「binding 沒設定好」,實際上是「接線根本不存在」。
底線與連字號(cloudflare_module 對 cloudflare-module)不是變因,兩種都試過,結果相同。
判定
判準是dev bundle 裡有沒有那個 plugin,不是跑探針:
# 先起一次 dev server 產生 .nuxt/dev/index.mjs,然後:
grep -c "wrangler" .nuxt/dev/index.mjs
# 0 = plugin 沒接上
這裡有一個容易被忽略的方法論細節:grep 回 0 的時候,你要先證明 grep 對這個檔案是有效的。
加一個 known-positive 對照:
grep -c "<某個你確定一定在裡面的字串>" .nuxt/dev/index.mjs
# 必須 > 0,否則你的 0 沒有意義
沒有這個對照,「grep 回 0」同時相容於「東西不在」與「我 grep 錯檔案了」。這兩件事在輸出上完全同形。
修法
把 preset 宣告移到 Nitro 自己讀得到的位置——獨立的 nitro.config.ts,而不是 nuxt.config.ts 裡的 nitro 欄位。
或者接受 dev 模式不帶 binding,改用能在本機跑起來的替身,並且明確驗過替身與真實 binding 的行為差異在哪。第二條路比較常見,但它的風險是差異會慢慢累積成一組沒人驗過的假設。
講到這裡你可能會想,那別用框架的 dev server、直接跑真的 worker runtime 不就沒有落差了? 它確實把這一個落差消掉,換來的是 HMR 與 source map 的品質下降——每改一行都要重新 build 的迴圈,長期會讓人少驗很多東西。務實的分法是:日常迭代留在 dev server,任何動到 binding 或 preset 的改動,收工前在真的 runtime 上跑一次。落差消不掉,但可以決定它什麼時候被發現。
三、Nitro 追不到動態載入的 WASM
症狀
build、typecheck、測試全部成功。部署之後才在 production runtime 炸未處理例外:
RuntimeError: Aborted(Error: ENOENT: no such file or directory,
open '/server/node_modules/libpg-query/wasm/libpg-query.wasm')
錯誤追蹤的有效 frame 只落在套件自己的 WASM loader 與 Node task queue,沒有任何 application frame——所以從 stack trace 完全看不出是自己哪一段程式碼觸發的。
根因
那個套件在 runtime 以動態路徑載入 .wasm 檔。Nitro 的 dependency tracer 能收進 JavaScript package,但無法從靜態 import graph 推導出這個 runtime 的開檔行為,因此產出的 .output/server/node_modules/ 裡缺少那個 WASM。
這不是套件沒安裝,也不是檔案權限問題。同一份 lockfile 下,舊的 release 目錄缺檔、加上 nitro.externals.traceInclude 之後的新 release 有 1.7 MB 的 artifact 而且真的 parse 得動 SQL。
為什麼測不到
- Nitro build 不會驗證動態
open()的目標有沒有進到 server bundle - TypeScript 與單元測試跑在原始的
node_modules,那裡本來就有 WASM - CI 只驗 build 的 exit code,沒有檢查最終
.output的 runtime contract
第二點是這個坑的核心。測試環境與部署產物是兩個不同的檔案樹,而測試只證明了前者是完整的。
判定與修法
判定要打在 deploy artifact 上,不是原始 node_modules:
test -s .output/server/node_modules/<pkg>/wasm/<file>.wasm
修法是在 Nitro 設定裡明確宣告要一起追進去的檔案:
nitro: {
externals: {
traceInclude: ['node_modules/<pkg>/wasm/<file>.wasm'],
},
}
但真正該留下的不是這一行設定,而是一條 CI 檢查:build 完之後斷言 .output 裡該有的 runtime artifact 都在。任何用到動態載入資源(WASM、native binding、資料檔)的專案都值得補這一條,否則同一類問題會換個套件再來一次。
四、CDN 自動注入的 analytics beacon 撞上 CSP
症狀
DevTools console 持續報 CSP 違規:
Loading the script 'https://static.cloudflareinsights.com/beacon.min.js/v<hash>'
violates the following Content Security Policy directive: "script-src 'self' ...".
The action has been blocked.
只要站台經過 CDN proxy 就會撞——dev tunnel、staging、production 都一樣。
根因
這件事的關鍵在於:注入是在 CDN edge 做的,你的程式碼裡沒有這一行。
- CDN 的 zone 預設啟用 Web Analytics 的自動安裝
- edge proxy 對所有 HTML response 注入一段
<script defer src="https://static.cloudflareinsights.com/beacon.min.js/..."> - script 載入後 POST 一筆 RUM event 到另一個 host
- 站台的 CSP 白名單是照著自己的原始碼寫的,不含這兩個 host
- script 被
script-src擋、RUM POST 被connect-src擋
修法
CSP 兩個指令都要加:
security: {
headers: {
contentSecurityPolicy: {
'script-src': [
"'self'",
// ...
'https://static.cloudflareinsights.com', // beacon CDN
],
'connect-src': [
"'self'",
// ...
'https://cloudflareinsights.com', // RUM 端點
],
},
},
}
或者反過來——如果不打算用那個 analytics,就到 CDN 設定裡把自動注入關掉。這通常是比較乾淨的選擇:不要為了一個你不用的功能放寬 CSP。
一般化
這個坑本身很小,但它指向一個容易被漏掉的認知:CSP 白名單要涵蓋的是「最終送到瀏覽器的 HTML」,不是「你寫的 HTML」。
任何會改寫 response 的中間層都可能加東西進來——CDN 的 analytics、A/B testing 的注入、企業網路的 proxy。CSP 是對最終產物的約束,所以每加一層中間層,都要重新確認一次白名單。
共同的形狀
四個坑,一句話總結:本機的執行環境不是部署的執行環境,而你的驗證幾乎都跑在本機。
| 坑 | 本機與部署的差異在哪 |
|---|---|
| CDN MIME | 本機沒有會忽略 Accept 的快取層 |
| dev preset | 本機的 dev preset 與部署的 preset 是兩套 |
| WASM | 測試跑在原始 node_modules,部署跑在 traced bundle |
| CSP beacon | 本機的 HTML 沒有被 edge 改寫過 |
實務上的處理原則有兩條。
第一,驗證要打在最終產物上。 .output 裡有什麼、瀏覽器實際收到的 HTML 是什麼、runtime CSS 裡有沒有那個 class——這些才是部署環境的事實。原始碼是意圖,產物才是結果。
第二,換工具驗證等於換觀測儀器,結論不可互換。 curl 拿到正確的 header,不代表瀏覽器會拿到;測試環境有那個檔案,不代表部署產物有。用一個不會觸發問題的工具去確認問題消失了,是這四個坑裡最貴的一種錯誤。