跳到內容

Static Assets 與全端 Worker

查證日期
驗證環境wrangler@4.114.0·workerd@1.20260722.1·compatibility_date: 2026-07-24

Part 1 的最後一塊:讓一支 Worker 同時服務靜態檔案和 API。這是全端應用的基礎,第 24、25 篇的 React Router 和 Astro 都建立在它上面。

assets 只有五個設定欄位,看起來很簡單。但其中一個的預設值會讓所有 SPA 開發者踩坑,另一個的 glob 比對規則有個沒人會預期的邊界。加上「Pages 到底還能不能用」這個所有人都會問、而網路上答案都不太準的問題。

三個要點:

  1. not_found_handling 預設是 "none" SPA 沒明確設定的話,深層路由直接 404 空白頁 —— 我實測過。
  2. run_worker_first 的否定 pattern 有尾斜線陷阱。 !/api/docs/* 排除不了 /api/docs(沒有尾斜線的那個)。
  3. 靜態資源請求完全免費且不計入 Worker 請求數。 這是架構決策的重要輸入。

請求進來
├─ run_worker_first 有命中這個路徑?
│ └─ 是 ──▶ 執行 Worker(可自行呼叫 env.ASSETS.fetch())
├─ assets.directory 裡有對應檔案?(套用 html_handling)
│ └─ 有 ──▶ 直接回傳檔案,**Worker 完全不執行、不計費**
├─ 套用 not_found_handling
│ ├─ "single-page-application" ──▶ 回 /index.html,狀態 200
│ ├─ "404-page" ──▶ 回最近的 404.html,狀態 404
│ └─ "none"(預設) ──▶ 往下
└─ 有設定 main(Worker)?
├─ 有 ──▶ 交給 Worker 的 fetch handler
└─ 沒有 ──▶ 404

最關鍵的一句:路徑同時匹配到 asset 和 Worker 路由時,預設是 asset 贏,而且你的 Worker 根本不會被執行。

實測:public/app.js 存在,Worker 也會處理所有路徑,結果 ——

Terminal window
$ curl -s localhost:8787/app.js
console.log("asset js"); # 檔案,不是 Worker 的回應

唯一能攔截的方法是 run_worker_first

欄位型別預設
directorystring
bindingstring
html_handling"auto-trailing-slash" | "force-trailing-slash" | "drop-trailing-slash" | "none""auto-trailing-slash"
not_found_handling"single-page-application" | "404-page" | "none""none"
run_worker_firstboolean | string[]false

就這五個。沒有別的。

🔴 not_found_handling 的預設值會讓 SPA 白畫面

Section titled “🔴 not_found_handling 的預設值會讓 SPA 白畫面”

實測,同一組檔案(public/index.html 存在)、沒有 Worker,只改這個欄位:

設定GET /deep/route
不設定("none"404,body 是空的
"single-page-application"200,回 index.html
"404-page"404,回 404.html(文件行為,本篇未實測)

任何有 client-side routing 的框架 —— React Router、Vue Router、TanStack Router —— 都需要「未知路徑回 index.html 讓前端接手」。不設定就是深層路由和重新整理直接壞掉,而且是空白 404,連錯誤訊息都沒有。

{
"assets": {
"directory": "./dist",
"not_found_handling": "single-page-application" // 不要漏
}
}

順帶一提,如果你有 Worker 且 not_found_handling"none",未匹配的路徑會落到 Worker 手上:

Terminal window
$ curl -s localhost:8787/nope
worker fallback: /nope

這也是一種合法設計 —— 讓 Worker 自己決定 404 長什麼樣。但要刻意選它,不是不小心用到。

預設的 auto-trailing-slash 會做規範化重導:

Terminal window
$ curl -s -o /dev/null -w "%{http_code} %{redirect_url}" localhost:8787/about.html
307 http://localhost:8787/about
$ curl -s localhost:8787/about
<!doctype html><title>about</title><h1>about.html</h1>

四種模式:

模式/file/file.html/folder/folder/
auto-trailing-slash(預設)200 file.html307 → /file307 → /folder/200 folder/index.html
force-trailing-slash307 → /file/200 folder/index.html
drop-trailing-slash200 file.html200 folder/index.html307 → /folder
none只做精確匹配,其餘落到 not_found_handling

如果你在意 SEO 的正規網址,這個欄位要和 sitemap 保持一致。

🔴 run_worker_first 的否定 pattern 有尾斜線陷阱

Section titled “🔴 run_worker_first 的否定 pattern 有尾斜線陷阱”

陣列形式支援 * 深度匹配和 ! 否定,而且否定優先於非否定(不管順序):

{ "assets": { "run_worker_first": ["/api/*", "!/api/docs/*"] } }

直覺讀法:「/api/ 底下都跑 Worker,但 /api/docs/ 底下走靜態檔案。」

實測:

Terminal window
$ curl -s localhost:8787/api/hello
{"from":"worker","path":"/api/hello"} # Worker ✓ 符合預期
$ curl -s localhost:8787/api/docs
{"from":"worker","path":"/api/docs"} # Worker ✗ 沒被排除!
$ curl -s localhost:8787/api/docs/
<h1>public/api/docs/index.html (static)</h1> # 靜態檔案 ✓

/api/docs(沒有尾斜線)沒有被排除。 因為 glob /api/docs/* 要求那個斜線存在,所以 /api/docs 只匹配到 /api/*,就跑了 Worker。

而使用者在瀏覽器打 /api/docs 是完全正常的行為。要真的排除整個子樹,兩條都要寫:

{ "run_worker_first": ["/api/*", "!/api/docs", "!/api/docs/*"] }

文件對這個 pattern 語法只有一句話和一個範例,沒有說明尾斜線的邊界。寫完一定要逐條 curl 驗證。

binding 設了之後,Worker 就能自己去拿檔案:

const res = await env.ASSETS.fetch(new URL("/about.html", url));

只有一個方法,接受 Request / URL / stringhostname 完全被忽略,只看 pathname —— 所以 new URL("/x", "https://whatever.invalid") 也行。

回傳的內容一樣會套用 html_handlingnot_found_handling

典型用途是「Worker 先跑,處理完再把 shell 吐出去」:

export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname.startsWith("/api/")) return handleApi(request, env);
// Inject bootstrap data into the SPA shell before serving it.
const shell = await env.ASSETS.fetch(new URL("/index.html", url));
return new HTMLRewriter()
.on("head", new BootstrapInjector(await loadUserState(request, env)))
.transform(shell);
},
};

_headers_redirects:本機就會生效

Section titled “_headers 與 _redirects:本機就會生效”

兩個純文字檔,放在 assets.directory 根目錄,會被上傳但不會被當成靜態檔案送出。

public/_headers
/app/*
X-Frame-Options: DENY
Cache-Control: public, max-age=3600
public/_redirects
/old-about /about 301
/go/* /app/:splat 302

實測本機 wrangler dev 兩個都生效(和第 6 篇的快取不同,這裡沒有本機/production 分歧):

Terminal window
$ curl -sD- -o /dev/null localhost:8787/app/ | grep -iE "x-frame|cache-control"
Cache-Control: public, max-age=3600
x-frame-options: DENY
$ curl -s -o /dev/null -w "%{http_code} %{redirect_url}" localhost:8787/old-about
301 http://localhost:8787/about
$ curl -s -o /dev/null -w "%{http_code} %{redirect_url}" localhost:8787/go/index.html
302 http://localhost:8787/app/index.html

啟動時 wrangler 會告訴你解析了幾條:

✨ Parsed 1 valid header rule.
✨ Parsed 2 valid redirect rules.

⚠️ 兩者都不作用於 Worker 產生的回應。 官方原文:

“Redirects defined in the _redirects file are not applied to requests served by your Worker code, even if the request URL matches a rule.”

所以 SSR 框架(React Router、Astro)的回應完全不受這兩個檔案影響 —— header 要在 Response 上自己設。

限制:_headers 100 條規則 / 每行 2,000 字元;_redirects 2,000 條靜態 + 100 條動態 = 2,100 條上限 / 每條 1,000 字元。_redirects 的狀態碼預設是 302,支援 301/302/303/307/308,以及 200(相對路徑的 proxy 改寫)。第一條匹配的規則勝出,所以靜態規則要放在動態規則前面。

官方原文:

“Requests to static assets are free and unlimited. Requests to the Worker script (for example, in the case of SSR content) are billed according to Workers pricing.”

三個推論,直接影響架構:

  1. asset 請求不計入 Worker 請求數、不收儲存費。 一個純靜態站放 Workers 上是零成本。
  2. run_worker_first: true 會把這個優勢整個丟掉 —— 每個請求都變成計費的 Worker invocation。所以陣列形式(只針對需要的路徑)幾乎永遠比 true 好。
  3. ⚠️ Free 方案開了 run_worker_first 之後,超過每日請求上限會回 429,而不是退回去送靜態檔案。 也就是說配額用完時你的網站是整個掛掉,不是只有 API 掛掉。

限制:

FreePaid
檔案數 / 版本20,000100,000
單檔大小25 MiB25 MiB

網路上兩種說法都有,兩種都不準。事實是:

Cloudflare 從來沒有正式宣告 Pages 棄用。 沒有 deprecation banner、沒有 sunset 日期、沒有 maintenance mode 公告。

最接近官方立場的是遷移指南的開場:

“You can deploy full-stack applications, including front-end static assets and back-end APIs, as well as server-side rendered pages (SSR), with Cloudflare Workers. Like Pages, requests for static assets on Workers are free, and Pages Functions invocations are charged at the same rate as Workers, so you can expect a similar cost structure. Unlike Pages, Workers has a distinctly broader set of features available to it (including Durable Objects, Cron Triggers, and more comprehensive Observability).”

真正該看的是相容性矩陣。Workers 只有六列不是完全支援,其中只有一列是硬性的 ❌:

功能WorkersPages
Early Hints🟡 需自己送 Link header
Branch Deploy Controls🟡 可設定性較低
Custom Branch Aliases⏳ 規劃中
File-based Routing(Pages Functions)🟡 靠框架或 Wrangler 編譯
Pages Plugins🟡
非 Cloudflare 託管的網域

反過來,Pages 拿不到的 Workers 功能就多了:Cloudflare Vite plugin、Gradual Deployments、Remote Development、Workers Logs、Logpush、Tail Workers、Source Maps、Cron Triggers、Email Workers、Queue Consumers、Rate Limiting binding、非根路徑路由。

實務結論

  • 新專案用 Workers。 唯一的例外是網域的 nameserver 不在 Cloudflare —— 那是唯一的硬阻斷。
  • 既有的 Pages 專案不用急著搬。 它沒有要被關掉。
  • 但如果你要用 Vite plugin(第 24、25、26 篇都會用到),Pages 上根本不支援。 這比任何「建議」都更有決定性。

另外一個容易忘的細節:wrangler pages dev 預設埠是 8788wrangler dev8787


完整程式碼:examples/ch07-static-assets/

Terminal window
cd examples/ch07-static-assets && npm install && npm run dev
Terminal window
B=localhost:8787
p() { printf "%-18s " "$1"; curl -s -o /tmp/b -w "%{http_code} %{redirect_url}" "$B$1"; printf " | "; head -c 55 /tmp/b; echo; }
p / # 200 index.html
p /about # 200 about.html
p /about.html # 307 -> /about (html_handling)
p /nope # 200 worker fallback (not_found_handling: none + main)
p /api/hello # 200 worker (run_worker_first)
p /api/docs # 200 worker ← 沒被 !/api/docs/* 排除
p /api/docs/ # 200 static file
p /app.js # 200 asset 贏過 worker
p /old-about # 301 -> /about (_redirects)
p /go/index.html # 302 -> /app/index.html
p /asset-binding # 200 env.ASSETS.fetch()

專案裡另外附了三份設定示範 not_found_handling

Terminal window
npx wrangler dev -c spa-a.jsonc # 不設定 -> /deep/route 404 空白
npx wrangler dev -c spa-b.jsonc # SPA -> /deep/route 200 index.html
npx wrangler dev -c spa-c.jsonc # 404-page

LinkForge 有兩個會用到靜態資源的 app,策略刻意不同。

apps/site(Astro 行銷站 + 文件)—— 幾乎全靜態

{
"assets": {
"directory": "./dist",
"not_found_handling": "404-page",
"run_worker_first": ["/api/newsletter"]
}
}

只有電子報訂閱那一條需要 Worker。其餘全部走免費的靜態路徑。第 25 篇會接上 Astro adapter。

apps/dashboard(React Router v8 SSR)—— 幾乎全動態

SSR 應用的路由基本上都要跑 Worker,靜態的只有 /assets/* 那些 hash 過的 build 產物。第 24 篇會看到 Vite plugin 自動產生 assets 設定,通常不需要手寫。

兩個共通決策

① 絕不使用 run_worker_first: true 一律用陣列列出真正需要的路徑。理由就是上面的計費模型 —— 靜態資源免費是 Cloudflare 最大的優勢之一,true 會把它整個丟掉,還在 Free 方案上把配額耗盡的後果從「API 掛掉」放大成「整站掛掉」。

_headers 集中管理安全標頭,但知道它救不了 SSR。

/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
/assets/*
Cache-Control: public, max-age=31536000, immutable

/assets/* 那條是因為 build 產物的檔名有 hash,可以放心永久快取。但因為 _headers 不作用於 Worker 回應,dashboard 的 SSR 頁面得靠第 5 篇的 middleware 補同樣的標頭 —— 兩個地方要一致,這件事寫進 checklist。

本篇交付物:兩個 app 的 assets 設定、共用的 _headers 安全標頭基準、以及一份「哪些路徑必須跑 Worker」的清單(每一條都要說得出理由,因為每一條都是錢)。


① SPA 沒設 not_found_handling

深層路由 404 空白頁。預設是 "none" 不是 SPA。

② 以為 !/api/docs/* 排除了 /api/docs

沒有尾斜線的那個沒被排除。兩條都要寫,而且寫完要 curl 驗證。

③ 用 run_worker_first: true

把「靜態資源免費」整個丟掉,Free 方案配額用完時全站 429。

④ 期待 _headers / _redirects 作用於 SSR 回應

它們只作用於靜態資源。Worker 產生的回應要自己設 header。

⑤ 沒發現 asset 悄悄贏過了 Worker 路由

public/ 裡多一個同名檔案,你的 API 路由就被靜默覆蓋了。

⑥ 用 hono/cloudflare-workersserveStatic

已棄用(第 5 篇),改用 assets

⑦ 說「Pages 已經棄用了」

沒有。硬阻斷只有一條:非 Cloudflare 託管的網域。但 Vite plugin 不支援 Pages,這對現代前端專案才是真正的決定因素。


  1. asset 預設贏過 Worker,而且 Worker 完全不執行。run_worker_first 是唯一的攔截點。
  2. **not_found_handling 預設 "none"。**SPA 沒設就是空白 404。
  3. **靜態資源免費且不計入 Worker 請求。**所以 run_worker_first 一律用陣列、絕不用 true


Part 1 到此結束。 你已經有了完整的 Worker 心智模型、工具鏈、API 骨架、快取策略和靜態資源。

下一篇08. Workers KV:最終一致性要怎麼用才對 —— Part 2 資料層開始。KV 的「每個 key 每秒 1 次寫入」限制會判死三種你可能正想用它做的事。