在 EmDash 上接 Cloudflare 邊緣快取的完整流程(含四個踩雷紀錄)

EmDash 是 server-rendered CMS,預設每個請求都會打 D1。本文紀錄如何在 Cloudflare Pages 上把回應丟進邊緣快取(caches.default),以及在 EmDash + Astro + Cloudflare 三層之間踩過的四個不直覺雷區。

EmDash 是 server-rendered 的 CMS,預設每個請求都會打 D1 拉資料。低流量站還好,但只要有一點流量就會看到 D1 的延遲與成本累積。最直覺的解法是把回應丟進 Cloudflare 的邊緣快取(caches.default),由 CDN 替你扛流量。

但實際做起來會踩到 EmDash + Astro + Cloudflare 三層的「不直覺行為」。這篇紀錄這個站的完整實作以及四個雷區。

為什麼不用 Astro 內建 memoryCache

Astro 6 的 defineCacheProvider 預設提供 memoryCache,看起來最省事,但有兩個盲點:

  • memoryCache 只在「同一個 Worker isolate」內生效,不跨 colo / isolate 共享。Cloudflare 一個 region 內就有一堆 isolate,命中率很差。
  • 看不到 cf-cache-statusage header,無法驗證快取真的有作用。

要讓快取在 Cloudflare 邊緣生效,必須走 Cloudflare Cache API caches.default),這就需要寫自訂的 CacheProvider

整體流程

請求 → CacheProvider.onRequest
       ├─ HIT  → caches.default.match() 回應,標 x-edge-cache: HIT
       └─ MISS → next() 渲染 → 讀 CDN-Cache-Control → 改寫 Cache-Control
                              → ctx.waitUntil(caches.default.put())

CacheProvider 實作

// src/cache-provider.ts
import type { CacheProvider, CacheProviderFactory } from "astro";

const factory: CacheProviderFactory = (config) => {
  const exclude = config?.exclude ?? ["/_emdash"];

  return {
    name: "cloudflare-edge",
    async onRequest(ctx, next) {
      if (ctx.request.method !== "GET") return next();
      if (exclude.some((p) => ctx.url.pathname.startsWith(p))) return next();
      if (ctx.url.search) return next(); // 帶 query 不快取,避免 cache key 爆炸

      const cache = (globalThis as any).caches?.default;
      if (!cache) return next();

      const cacheKey = ctx.url.toString();

      // 嘗試命中
      const cached = await cache.match(cacheKey);
      if (cached) {
        const headers = new Headers(cached.headers);
        headers.set("x-edge-cache", "HIT");
        return new Response(cached.body, {
          status: cached.status,
          statusText: cached.statusText,
          headers,
        });
      }

      // MISS:渲染下游
      const fresh = await next();
      if (fresh.status !== 200) return fresh;
      if (fresh.headers.has("Set-Cookie")) return fresh; // 個人化內容不快取

      // 從 Astro 寫的 CDN-Cache-Control 拿每頁 TTL
      const cdnCC = fresh.headers.get("CDN-Cache-Control") ?? "";
      const maxAge = parseInt(/max-age=(\d+)/.exec(cdnCC)?.[1] ?? "0", 10);
      const swr = parseInt(/stale-while-revalidate=(\d+)/.exec(cdnCC)?.[1] ?? "0", 10);
      if (maxAge === 0) return fresh; // 頁面沒呼叫 Astro.cache.set 就不快取

      // 必須建新 Response(next() 的 headers 是 immutable,見雷區 2)
      const headers = new Headers(fresh.headers);
      headers.set(
        "Cache-Control",
        `public, max-age=${maxAge}, s-maxage=${maxAge}` +
          (swr > 0 ? `, stale-while-revalidate=${swr}` : ""),
      );
      headers.set("x-edge-cache", "MISS");

      const cacheable = new Response(fresh.body, {
        status: fresh.status,
        statusText: fresh.statusText,
        headers,
      });
      ctx.waitUntil?.(cache.put(cacheKey, cacheable.clone()));
      return cacheable;
    },
  };
};

export default factory;

關鍵設計決定:TTL 不寫死在 provider,從每頁實際的 `CDN-Cache-Control` 動態讀。這樣每頁可以呼叫 Astro.cache.set() 自己決定要快取多久,provider 只負責搬運。

在頁面呼叫 Astro.cache.set

EmDash 的 getEmDashCollectiongetEmDashEntry 會回傳一個 cacheHint,但只帶 `tags` 跟 `lastModified`,沒有 `maxAge`。直接傳給 Astro.cache.set 不會觸發快取(這是雷區 3):

---
// src/pages/index.astro
import { getEmDashCollection } from "emdash";

const { entries: posts, cacheHint } = await getEmDashCollection("posts", { limit: 10 });

// 這樣不會快取,因為 cacheHint 沒有 maxAge
// Astro.cache.set(cacheHint);

// 必須補 maxAge / swr
Astro.cache.set({ ...cacheHint, maxAge: 60, swr: 300 });
---

每頁都要加這行;放在 frontmatter 最後,下游 render 完 Astro 才會把 CDN-Cache-Control 寫進 response header,CacheProvider 才讀得到。

註冊到 astro.config.mjs

// astro.config.mjs
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import cacheProvider from "./src/cache-provider.ts";

export default defineConfig({
  output: "server",
  adapter: cloudflare(),
  experimental: {
    cacheProvider: cacheProvider({ exclude: ["/_emdash"] }),
  },
});

四個雷區

這是這篇文章的重點。每個都是 documentation 不會明說、但會吃掉你幾小時的東西。

雷區 1:*.workers.dev 不支援 Cache API

caches.default.match()cache.put()*.workers.dev 子網域上完全是 no-op。沒有 console error、沒有 type error,cache.match() 就是永遠回 undefined。Cloudflare 預設不在 workers.dev 啟用 CDN,這是平台層的限制。

解法:必須綁自訂網域。本站綁 emdash-test.fripig.tw 後才看到 cf-cache-status: HIT

雷區 2:Astro middleware 不能改 next() 的 response headers

很自然會想寫個 middleware 在 next() 後改 Cache-Control

// 在 Cloudflare 上會 throw:next() 回傳的 Response.headers 是 immutable
const response = await next();
response.headers.set("Cache-Control", "public, max-age=60");
return response;

解法:必須建新 Response

const response = await next();
const headers = new Headers(response.headers);
headers.set("Cache-Control", "public, max-age=60");
return new Response(response.body, {
  status: response.status,
  statusText: response.statusText,
  headers,
});

這也是為什麼上面 CacheProvider 那麼囉嗦地建新 Response —— 不是寫醜,是不能不寫。

雷區 3:EmDash 的 cacheHint 沒有 maxAge

如同前面所說。這個雷的陷阱是「看起來該動作的東西不動作」—— 沒有 console error、沒有 type error,就是不快取,而且 cacheHint 看起來該完整無缺。

解法

Astro.cache.set({ ...cacheHint, maxAge: 60, swr: 300 });

cacheHint 帶來的 tagslastModified 仍然有用(給未來 tag-based purge 用),不要捨棄。

雷區 4:偵錯 cache headers 不能用 curl -I

curl -I 是 HEAD 請求。Cloudflare 對 HEAD 不一定會回 cf-cache-statusagecache-control 這些 cache 相關 header。明明伺服器已經快取,但 HEAD 看起來沒有 —— 然後你會懷疑前面三個雷區都白搞了。

解法:用 GET 但不下載 body:

curl -D - -o /dev/null https://emdash-test.fripig.tw/

-D - 把 response headers 印到 stdout、-o /dev/null 把 body 丟掉。這樣才看得到完整的 cache header。

驗證

部署後測試:

# 第一次:MISS(背景才寫進 cache)
curl -D - -o /dev/null https://emdash-test.fripig.tw/ 2>&1 | grep -iE "cache|age"

# 等 1-2 秒再打第二次:應該 HIT
curl -D - -o /dev/null https://emdash-test.fripig.tw/ 2>&1 | grep -iE "cache|age"

預期 header:

cf-cache-status: HIT
x-edge-cache: HIT
age: 12
cache-control: public, max-age=60, s-maxage=60, stale-while-revalidate=300

age: 12 表示這是 12 秒前的快取版本,符合 max-age=60 的 TTL 內。

還沒做的事

  • Tag-based purge:Cloudflare Cache API 沒有原生 tag 支援,需自行用 KV 維護 tag → urls mapping,在 EmDash publish hook 觸發時 batch delete。目前 invalidate() 只實作 path-based。
  • 帶 query 的頁面快取:搜尋、UTM 等帶 query 的請求目前一律退出快取,避免 cache key 爆炸。未來可挑特定 query key 列入 cache key(例如 ?page=2)。
  • 個人化頁面:含 Set-Cookie 的回應一律不快取。會員系統上線後,可考慮對非會員流量單獨開 cache lane。

對想在 EmDash 站做邊緣快取的開發者,希望能省你幾小時的撞牆時間。

No comments yet