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-status、ageheader,無法驗證快取真的有作用。
要讓快取在 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 的 getEmDashCollection 與 getEmDashEntry 會回傳一個 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 帶來的 tags 跟 lastModified 仍然有用(給未來 tag-based purge 用),不要捨棄。
雷區 4:偵錯 cache headers 不能用 curl -I
curl -I 是 HEAD 請求。Cloudflare 對 HEAD 不一定會回 cf-cache-status、age、cache-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 → urlsmapping,在 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