包包baobaolin.com
date
entry
005
topic
infrastructure
rev

sync --delete 為什麼會在部署當下白屏:SPA 的舊 chunk 被你自己刪了

這個壞法有時效性。部署完成後才開頁面的人一切正常,部署當下已經開著頁面的人會白屏——而瀏覽器給的錯誤訊息指向 MIME type,跟真正的原因隔了三層。

使用者回報畫面變白,console 裡是這一條:

Failed to load module script: Expected a JavaScript-or-Wasm module script
but the server responded with a MIME type of "text/html". Strict MIME type
checking is enforced for module scripts per HTML spec.

或者是它的另一個變體:

Uncaught (in promise) TypeError: Failed to fetch dynamically imported module:
https://app.example.com/assets/SomeChunk-A1b2C3.js

兩條都在講「拿到的東西型別不對」。實際發生的事是:那個檔案在幾十秒前還在,現在被刪掉了,刪的人是你的部署腳本。

時間軸

前提是兩個各自都很常見的設定:部署跑 aws s3 sync dist/ s3://bucket --delete, 以及 CloudFront 把來源的 403/404 改寫成 index.html 回 200——SPA 前端路由要能運作就得這樣設。

T+0:00  使用者打開頁面
        HTML 引用 /assets/index-A.js,抓回正常 JS,頁面正常運作

T+0:30  你 push code,CI 開始部署
        新 build 的 hash 是 B
        sync --delete 刪掉 index-A.js,上傳 index.html 與 index-B.js
        CloudFront invalidation /*

T+0:35  使用者點了一個按鈕,觸發 dynamic import
        瀏覽器裡跑的還是舊那份 app,它記得的 hash 是 A
        GET /assets/index-A.js
          → S3 回 NoSuchKey
          → CloudFront 錯誤規則把它換成 index.html,狀態碼 200
          → 瀏覽器收到 Content-Type: text/html
          → module script 的嚴格檢查拒收 → 白屏

錯誤訊息之所以在講 MIME type,是因為它是這條鏈的最後一環。瀏覽器只知道自己要一個 JS module 卻拿到 HTML;它看不到前面兩層的改寫。

這是同一個 SPA fallback 規則的第二種副作用。上一次它讓 /blog/ 變成首頁,這次它把「檔案不存在」偽裝成「檔案是 HTML」。

兩個 curl 就能確認

先問線上的 HTML 現在引用哪個 hash:

curl -s https://app.example.com/ | grep -oE '/assets/index-[A-Za-z0-9_-]+\.js'

再問那個檔案回什麼型別:

curl -sI "https://app.example.com/assets/index-A1b2C3.js" | head -3
# 正常:content-type: text/javascript
# 中招:content-type: text/html   ← fallback 生效了,這個 key 不存在

第二條回 text/html 就確定了。要確認是不是部署造成的,看 CI 有沒有正在跑,以及部署腳本裡那行 sync:

grep 's3 sync' .github/workflows/deploy.yml

hard refresh 不算修好。它確實有效——重新抓 index.html 就拿到新的 hash——但那是要求每個當下在線上的使用者自己補救。而且如果邊緣節點還沒吃到 invalidation,refresh 也會拿到舊的 HTML,等於沒解。

修法:把 --delete 拿掉

Vite 和 CRA 的 production 檔名帶 content hash,本來就是 immutable——內容變了檔名就會變。既然如此,舊檔留著不會被覆蓋,也不會被誤用,它唯一的成本是 S3 的儲存空間。

- name: Deploy to S3
  run: |
    # 不加 --delete:舊 bundle 留著,給已開頁面的使用者一段緩衝
    aws s3 sync dist/ s3://$BUCKET

index.html 是同名檔案,還是會被新版覆蓋,所以新訪客拿到的一定是新版。改變的只是「舊 hash 還抓得到」。

累積的舊檔交給 S3 lifecycle 處理,不要用腳本刪:

{
  "Rules": [{
    "ID": "expire-old-asset-bundles",
    "Status": "Enabled",
    "Filter": {"Prefix": "assets/"},
    "Expiration": {"Days": 60}
  }]
}
aws s3api put-bucket-lifecycle-configuration \
  --bucket "$BUCKET" --lifecycle-configuration file://lifecycle.json

60 天是我用的值,沒有什麼道理,只是遠大於任何人會讓分頁開著的時間。重點是這件事由 S3 按時間做,而不是由部署按「這次 build 沒有的檔案」做——後者的判斷基準從一開始就是錯的。

為什麼 --delete 會出現在腳本裡

因為它看起來是對的。「把來源目錄同步到 bucket」的直覺結論就是兩邊應該一致,多出來的是垃圾。這個直覺在同步資料時成立,在同步已發佈的資產時不成立——舊版本的 bundle 不是垃圾,它是還在被引用的東西。

另一條路是改 CloudFront,讓 /assets/* 走一個不套用錯誤改寫規則的 cache behavior,這樣舊 hash 會誠實地回 404,前端至少能攔到。可行,但多一份要維護的設定,而且它只是把白屏換成一個比較好懂的錯誤。先把 --delete 拿掉,問題就不存在了。

不要做的事

  • 叫使用者按 Ctrl+Shift+R——部署期間每個人都會踩到,這不是解法,是把成本轉嫁出去
  • 為了清空間手動 aws s3 rm assets/——這會一次打斷所有正在使用的 session,比部署更慘
  • 把 SPA fallback 整個拿掉——你的 /some/route 會直接 404,換一個更大的問題

如果只記得一件事

部署不是一個瞬間,是一段區間。在那段區間裡,同時存在兩個版本的 app:伺服器上的新版,和使用者瀏覽器裡還跑著的舊版。舊版還在發請求,所以它需要的檔案得繼續存在一陣子。刪得太快,壞的不是你的部署,是別人正在做的事。

修訂紀錄

  1. 首次發布