我的展覽追蹤 app(art-tracking)是一個 PWA,放在 Cloudflare Access 後面,用 Google 登入。最近登入一段時間後再打開,畫面會出現「Failed to fetch」,而且頁面一直重新載入,怎麼樣都進不去。

打開 DevTools 的 Network,看到的是這四種結果:

請求結果疑點
頁面200、0 B大小欄寫 (ServiceWorker) 是什麼意思
⚙ stats 等三個 API302⚙ 是什麼,302 要導去哪
⚙ …?kid=…失敗為什麼導過去之後就失敗
⚙ venues401為什麼只有它是 401

但這裡面的 service worker、CORS、redirect,我都不太熟,所以這次沒有直接修,而是先請 AI 把這些概念由淺入深整理成一份筆記,我讀懂之後才來選修法(畢竟理解之後才能做決策)。這篇是那份筆記整理後的版本,概念都是這次學的,補足原本缺失的。

先看沒有 service worker 的時候,登入是怎麼運作的。有四個角色:

  • 瀏覽器(頁面):在我的電腦上,跑的是 React app,用 fetch() 跟後端要資料。
  • Cloudflare Access:在網路上,每個請求都先經過它,檢查有沒有登入。
  • Worker:在網路上,是 app 的後端,回傳頁面檔案和 API 資料。
  • Google 登入:在網路上,真正選帳號的地方。

登入成功後,Access 會在 app 的網域存一個 CF_Authorization cookie,裡面是一個 JWT,記錄我是誰(email)和這張通行證什麼時候到期(exp)。之後瀏覽器送出的每一個請求都會自動帶上它。

瀏覽器自己不會判斷有沒有過期,它只負責帶上 cookie,判斷是伺服器端做的:

  • JWT 有效:Access 放行,交給 Worker。
  • JWT 過期:Access 回 302,要瀏覽器跳去 <team>.cloudflareaccess.com 重新登入。

而 venues 會是 401,是因為 /api/venues 這幾個路徑也要給爬蟲 agent 用,所以在 Access 設成 Bypass(不擋),改由 Worker 自己驗 JWT,過期就回 401,不是 302。

頁面請求會跟著 302 跳,fetch 會被 CORS 擋下

如同前面所講的,登入過期時 Access 會回 302,要瀏覽器跳去別的網域。但瀏覽器發出的請求分成兩種,遇到這種 302 時,結果完全不同:

頁面請求fetch 請求
怎麼發出網址列、點連結、reload()JS 呼叫 fetch()
遇到跨網域 302跟著跳被 CORS 擋下
回應交給誰整個分頁換成對方的網站交回給我們的 JS

差別在最後一列。頁面請求是把整個分頁換成對方的網站,我們的 JS 跟著原本的頁面一起消失,讀不到對方任何內容,所以不需要對方同意。fetch 的回應會交回給我們的 JS,程式可以讀出內容,所以必須由對方明確允許。

CORS 要防的是這種狀況:我開著一個惡意網站,它的 JS 偷偷 fetch("https://我的銀行.com/帳戶")。瀏覽器會自動帶上我在銀行的 cookie,如果沒有 CORS,惡意網站就能讀到我的帳戶資料。所以瀏覽器規定,跨網域的回應要交給 JS 之前,對方必須回 Access-Control-Allow-Origin header 表示同意。

套到這個 app:cloudflareaccess.com 沒有同意讓 app 的 JS 讀它的登入頁,所以 fetch 被擋下,丟出 TypeError: Failed to fetch。頁面請求只是把我帶過去,沒問題。

登入過期、還沒有 service worker 時的時序圖:fetch 被 CORS 擋下,頁面請求能跟著 302 去登入

所以登入過期時,fetch 沒辦法帶我去登入,只有頁面請求可以。

Service worker 是裝在瀏覽器裡的小 proxy

Service Worker API(MDN)、Workbox 文件

前面都還沒有提到 service worker,這是 PWA 會用到的。Service worker 是網站裝進瀏覽器的一段 JS,在使用者的電腦上執行,不是伺服器。頁面發出的每個請求(頁面請求和 fetch 都算)都會先交給它,由它決定要回快取、上網,或兩者都做。

頁面 ──請求──▶ Service worker ──(可能)──▶ 網路 / Cloudflare
                    │
                    └──▶ 快取(Cache Storage)

這個 app 用它,是希望像手機 app 一樣秒開,沒網路也能看 24 小時內抓過的資料。做法是把頁面檔案先存在瀏覽器裡,打開時直接從快取拿。

Service worker 是用 Google 的 Workbox 函式庫產生的,規則依順序比對,先符合的先處理。跟這個 bug 有關的是這幾條:

設定意思
globPatterns(含 html)build 時把 js、css、index.html 預先存進快取(precache)
navigateFallback: "/index.html"頁面請求直接回快取的 index.html,不上網
/api/* 用 NetworkFirst(3 秒)API 先上網,失敗或 3 秒沒回應才用快取
cacheableResponse: { statuses: [200] }只存 200 的回應,401、302 不存
registerType: "prompt"有新版時先問使用者,不自動更新

前面 DevTools 的兩個疑點也解開了:

  • 大小欄寫 (ServiceWorker):這個回應是 service worker 給的,沒有經過網路。
  • 名稱前有 ⚙:這是 service worker 自己發出的網路請求。所以同一個 API 會出現兩列,頁面向 service worker 要一次,service worker 再向網路要一次。

上面兩件事疊起來 (頁面請求+快取),就成了無限循環

前面講到只有頁面請求能去登入,而頁面請求又會被 service worker 用快取回應,這兩件事合起來就是這次的 bug。

登入過期、加上 service worker 的時序圖:reload 的頁面請求又被快取回應,形成無限循環

  1. JWT 過期了,但我不知道,照常打開 app。頁面請求被 service worker 用快取回應,沒經過 Access,所以這時沒有人發現過期。
  2. App 啟動後同時發出四個 API 請求。stats、exhibitions、alerts 這三個被 Access 回 302。
  3. Service worker 替頁面抓的這個請求,跨網域被 CORS 擋下,改找快取也沒有,頁面的 fetch() 收到 TypeError: Failed to fetch。
  4. 同一時間 venues 被 Access 放行,Worker 驗 JWT 失敗,回 401。
  5. 程式看到 401,呼叫 reload(),想讓我重新登入。
  6. reload() 的頁面請求又被 service worker 用快取回應,沒送到 Access。
  7. App 重新啟動,回到第 2 步。

所以這個 bug 是兩個問題疊在一起:

  1. 看不出過期:302 那三個 API 變成 TypeError,程式不知道是登入過期,只顯示「Failed to fetch」。
  2. 到不了 Cloudflare:401 觸發的 reload() 方向是對的(它是頁面請求),但被 service worker 用快取回應,Access 永遠沒機會帶我去登入。

這裡容易誤會成「TypeError 之後才改去抓 venues」,但四個請求是 app 一打開就同時發出的,結果不同只是因為路徑不同。

讓程式看得出過期,請求帶上 X-Requested-With

Cloudflare Access:Session management,「AJAX」一節

先處理第一個問題。Cloudflare 文件給的做法是,所有背景請求都帶上 X-Requested-With: XMLHttpRequest,登入過期時 Access 就回 401,不回 302。文件也建議收到 401 後重新整理頁面,或顯示登入過期的訊息。

2026-11-08 在正式站用沒帶 cookie 的請求實測,結果跟文件一致:

請求回應
GET /api/stats302,導向 team domain 的登入網址
同一個請求,加上這個 header401

帶著過期 cookie 的請求我還沒實測,文件的說法是一樣會回 401 (這個等部署後再確認)。

程式本來就會處理 401,所以只要加一行:

const res = await fetch(path, {
  ...init,
  headers: {
    accept: "application/json",
    // Makes Cloudflare Access answer an expired session with 401 instead of a
    // cross-origin 302, which fetch can only report as "Failed to fetch".
    "X-Requested-With": "XMLHttpRequest",
    // ...
  },
  credentials: "same-origin",
});

回 401 就沒有跳轉,也就沒有 CORS 的問題。這是同網域的請求,加自訂 header 不會觸發 CORS 預檢。

另一條路是 fetch 的 redirect 選項

RequestInit: redirect(MDN)、Response: type(MDN)

redirect 是頁面的 JS 發請求時自己設定的,告訴瀏覽器遇到 302 時要怎麼處理。Access 回 302 時,三個值各自會拿到:

值fetch() 拿到什麼
"follow"(預設)跟著跳,被 CORS 擋下,丟 TypeError,跟斷網看起來一樣
"manual"不跳,回傳 type === "opaqueredirect" 的 response
"error"遇到跳轉就丟 TypeError,一樣分不出斷網還是過期

opaqueredirect 是瀏覽器產生的,Access 只發一個普通的 302。它是瀏覽器在 "manual" 模式下把 302 包起來交給 JS 的形式:status 是 0、header 是空的,看不到要被導去哪裡。

用 "manual" 也能偵測過期,但最後沒有採用:

X-Requested-Withredirect: "manual"
官方文件有寫沒有,是通用的瀏覽器行為
程式拿到明確的 401status 0
同網域的正常 302不受影響也變成 opaqueredirect

讓重新整理真的送到 Access,頁面請求改成先上網

Cloudflare Access:Authorization cookie、Location(MDN)

接著是第二個問題:要讓頁面請求碰到 Access,它必須真的上網。

我原本選的做法(也是當時 AI 推薦的),是直接跳到 app 網域上的 /cdn-cgi/access/login/<host>。/cdn-cgi/ 是 Cloudflare 在每個經過它的網域上保留的路徑,由 Cloudflare 自己處理,不會送到 Worker,而 service worker 的規則本來就放行這個路徑。

但在正式站實測,這個路徑回 404。真正的登入網址在 team domain 上,還帶著 kid 和 meta 參數,這些是 Access 組好放在 302 裡的,前端沒有文件化的方式自己組出來,所以這條路就放棄了。

重新整理之後剩兩個方向:

  • 方向 A,改 service worker 的規則:讓頁面請求先上網,沒網路才用快取。reload() 不用改,就會送到 Access。
  • 方向 B,繞過 service worker:規則不動,跳到一個 service worker 不會回 index.html 的 /api 路徑,登入後由 Worker 把人送回原本的頁面。

重寫之後 AI 建議的是 A:它就是 Cloudflare 文件說的「收到 401 後重新整理」,只要讓重新整理真的上網,不用新增 endpoint。我也就選了 A。

修好之後的時序圖:API 收到 401 後 reload,頁面請求先上網,由 Access 帶去登入

改的是 service worker 的設定:

// ...
navigateFallback: null,
globPatterns: ["**/*.{js,css,png,svg,webmanifest}"],
runtimeCaching: [
  {
    urlPattern: ({ request, url }) => request.mode === "navigate" && !/^\/(api|img|cdn-cgi)\//.test(url.pathname),
    handler: "NetworkFirst",
    options: {
      cacheName: "pages",
      networkTimeoutSeconds: 3,
      cacheableResponse: { statuses: [200] },
      // Every route is the same SPA shell, so one entry serves them all offline,
      // including routes reached by client-side navigation.
      plugins: [
        {
          cacheKeyWillBeUsed: async () => "/",
          // Opening a static file directly must not replace the shell.
          cacheWillUpdate: async ({ response }) => (response.headers.get("content-type")?.includes("text/html") ? response : null),
        },
      ],
    },
  },
  // ...
],
  • globPatterns 拿掉 html:index.html 不再預先快取。
  • navigateFallback: null:頁面請求不再直接回快取。
  • 頁面請求用 NetworkFirst:先上網,沒網路或 3 秒沒回應才用快取。
  • 單一 key /:所有路由都是同一份 HTML,存成一筆,離線時任何路由都能打開。
  • 只存 200 的 HTML:直接開靜態檔(例如 icon)時,不會蓋掉這一筆。

reload() 本身沒有改。location 是瀏覽器提供給 JavaScript 的 API,location.reload()、location.replace(url) 這些方法發出的都是頁面請求,所以規則改了之後,同一個 reload() 就會送到 Access。

這個做法有代價:

  • 每次打開 app 都多一次網路往返,網路很慢時最多多等 3 秒才退回快取。
  • 離線時用的是上網時存下來的 HTML,所以新版 service worker 裝好後,至少要在線上打開過一次。
  • 新舊版交替時,快取的舊 HTML 可能指向已經清掉的舊 JS,離線時可能打不開,上線打開一次就恢復。

「頁面請求經過 NetworkFirst 之後,能跟著跨網域的 302 去登入」這件事,是在本機用一個假的 Access server 加上 Chromium 驗的;正式站帶著過期 cookie 的情況還沒驗。

另外,這次沒有放寬任何權限。真正擋人的是伺服器端,Access 擋沒登入的請求,Worker 驗 JWT,這兩層都沒有改,前端只改了被拒絕之後怎麼帶使用者去登入。

自動換頁要有防循環保險

前面讓收到 401 之後的 reload() 真的送到 Access,但只要有「偵測到過期就自動換頁」,就有再次無限循環的風險,例如網路太慢,3 秒後又退回快取。所以用 sessionStorage 記下上次自動換頁的時間,規則是:

  • 30 秒內只自動換頁一次。四個 API 同時 401,也只有第一個會換頁。
  • 冷卻期間改顯示「登入已過期,請重新整理」,不再顯示 Failed to fetch。
  • 任何一個 API 成功就清掉記錄,下次過期時才能再自動換頁。
  • sessionStorage 不能讀寫時,寧可不換頁。
const LOGIN_RELOAD_KEY = "access-login-at";
const LOGIN_RELOAD_COOLDOWN_MS = 30_000;

function reloadForLogin(): boolean {
  try {
    const last = Number(sessionStorage.getItem(LOGIN_RELOAD_KEY) ?? 0);
    if (Date.now() - last < LOGIN_RELOAD_COOLDOWN_MS) return false;
    sessionStorage.setItem(LOGIN_RELOAD_KEY, String(Date.now()));
  } catch {
    return false; // Without storage nothing could stop a reload loop.
  }
  window.location.reload();
  return true;
}

收到 401 時就呼叫它,回傳 false 代表還在冷卻,改丟出那句登入過期的訊息:

throw new ApiError(res.status, reloadForLogin() ? "session expired" : "登入已過期,請重新整理");
sessionStoragecookie
範圍每個分頁各一份同網域所有分頁共用
什麼時候消失關掉分頁;reload 不會到期,或關掉瀏覽器
會送到伺服器嗎不會每個請求自動帶上
這個 app 的用途防循環保險登入憑證

防循環保險需要的正好是「reload 後還在、關掉分頁就清掉、不用傳給伺服器」。localStorage 會跨分頁共用而且永遠留著,比較不適合。