用 Hono 打好 API 骨架
這篇要解決的問題
Section titled “這篇要解決的問題”裸寫 fetch handler 可以撐到大約第三條路由。之後你會開始需要 routing、middleware、驗證、統一錯誤格式 —— 也就是一個框架。
Hono 是 Workers 上事實標準:4.12.32,沒有 v5,自 2024 年 2 月以來都是往上加功能而非破壞相容。Cloudflare 自己的 React 官方 template 裡的 API 層用的就是它。
但有三件事你不會在 Hono 官網讀到,而它們會決定你的 API 好不好維護:
- Hono 有一個自己的
Env型別,會和wrangler types產生的Env撞在一起,而 TypeScript 給出的錯誤訊息是Type 'Env' has no properties in common with type 'Env'。 - 你的驗證函式庫可能佔掉 bundle 的 80%。 實測 zod v4 讓一個小 API 從 16 KiB 膨脹到 98 KiB(gzip 後)。
zValidator預設會繞過你的app.onError(),所以你辛苦設計的統一錯誤格式,對最常見的那種錯誤(驗證失敗)完全沒生效。
先解決 Env 撞名
Section titled “先解決 Env 撞名”Hono 的泛型參數期待的是一個容器:
type HonoEnv = { Bindings: ...; Variables: ... };而 wrangler types 預設產生的全域介面也叫 Env,內容是一包扁平的 binding。兩個名字一樣、形狀完全不同。
寫成這樣會怎樣:
import { Hono } from "hono";const app = new Hono<Env>(); // 想用 wrangler 的 Envapp.get("/", (c) => c.text(c.env.APP_NAME));實測 TypeScript 的回應:
error TS2559: Type 'Env' has no properties in common with type 'Env'.error TS18048: 'c.env' is possibly 'undefined'.error TS2339: Property 'APP_NAME' does not exist on type 'object'.Type 'Env' has no properties in common with type 'Env' —— 這個訊息完全沒有幫助,而且會讓人懷疑人生。
解法在第 2 篇就提過:把 wrangler 的介面改名。
{ "scripts": { "cf-typegen": "wrangler types --env-interface CloudflareBindings" } }然後把 Hono 的泛型形狀集中在一個檔案:
export type AppEnv = { Bindings: CloudflareBindings; Variables: { requestId: string; tenantId: string | null; };};之後所有地方 —— new Hono<AppEnv>()、createMiddleware<AppEnv>()、MiddlewareHandler<AppEnv> —— 都用 AppEnv。Bindings 是平台給的,Variables 是 middleware 之間傳遞的 request-scoped 資料。
🔴 你的驗證函式庫有多大?
Section titled “🔴 你的驗證函式庫有多大?”第 1 篇講過 Worker bundle 上限是壓縮後 Free 3 MB / Paid 10 MB,而且模組頂層的執行有 1 秒 startup CPU 預算。所以依賴大小不是美學問題。
實測同一支小 API 逐層加上依賴(wrangler deploy --dry-run,2026-07-28):
| 堆疊 | Upload | gzip |
|---|---|---|
| 裸 Worker | 0.30 KiB | 0.23 KiB |
| + Hono core | 63.66 KiB | 15.37 KiB |
| + cors / factory / http-exception | 67.63 KiB | 16.38 KiB |
+ zod v4 + @hono/zod-validator | 620.55 KiB | 97.87 KiB |
+ zod/mini | 88.83 KiB | 21.09 KiB |
| + valibot | 72.30 KiB | 17.31 KiB |
+ valibot + @hono/standard-validator | 80.09 KiB | 19.31 KiB |
zod v4 一個套件就加了 546 KiB(gzip 後約 80 KiB)—— 是 Hono 本身的八倍。
怎麼看這件事:
- Hono 本身很便宜(gzip 15 KiB)。框架不是問題。
- 完整版 zod 佔掉 Free 方案 3 MB 預算的 3.3%。以絕對值看還好,但它是你整包東西裡最大的單一項目,而且會拖慢冷啟動時的 parse。
zod/mini幾乎和 valibot 一樣小(21 vs 17 KiB gzip),API 略有不同但概念相同。如果團隊已經熟 zod,這是成本最低的優化。- valibot +
@hono/standard-validator是最小的組合,而且sValidator走 Standard Schema v1,換底層驗證器不用改路由程式碼 —— 從相容性角度是最有未來性的選擇。
我的建議:新專案用 valibot + @hono/standard-validator;既有 zod 專案先換 zod/mini。 但如果你的 Worker 離 3 MB 還很遠,用完整版 zod 也完全合理 —— 重點是知道自己付了什麼代價,而不是無意識地付。
⚠️ 另一個相容性細節:
@hono/zod-openapi從 v1 起只吃 Zod 4,不再支援 Zod 3。
🔴 zValidator 會繞過 app.onError()
Section titled “🔴 zValidator 會繞過 app.onError()”假設你設計了統一的錯誤格式:
app.onError((err, c) => { const status = err instanceof HTTPException ? err.status : 500; return c.json({ error: { message: err.message, requestId: c.get("requestId") } }, status);});然後丟一個壞的 payload 進去,實測拿到:
{"success":false,"error":{"name":"ZodError","message":"[\n {\n \"origin\": \"string\",\n \"code\": \"invalid_format\", ... "}}完全不是你設計的格式。 因為 zValidator 驗證失敗時是直接回傳自己的 Response,不是 throw —— onError 根本沒被叫到。
結果就是:你的 API 有兩套錯誤格式,而且佔比最高的那種錯誤(客戶端送錯資料)用的是你沒設計過的那一套,還會把內部 schema 結構洩漏給呼叫端。
解法是給它一個會 throw 的 hook。把它包成自己的 validator,全專案只用這個:
import { HTTPException } from "hono/http-exception";import { zValidator as baseZValidator } from "@hono/zod-validator";import type { ZodType } from "zod";import type { ValidationTargets } from "hono";
export const validate = <T extends ZodType, Target extends keyof ValidationTargets>( target: Target, schema: T,) => baseZValidator(target, schema, (result) => { if (!result.success) { throw new HTTPException(400, { message: result.error.issues .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`) .join("; "), }); } });換掉之後:
$ curl -X POST .../v1/links -d '{"slug":"BAD SLUG","url":"not-a-url"}'{"error":{"message":"slug: Invalid string: must match pattern /^[a-z0-9-]+$/; url: Invalid URL", "requestId":"67fb24a2-3e03-45f8-a54c-ae6dca1b000f"}}
$ curl ".../v1/links?limit=9999"{"error":{"message":"limit: Too big: expected number to be <=100", "requestId":"393a126a-8d02-4446-968a-e7f1ab5c6de5"}}一種格式、帶 requestId、不洩漏 schema 結構。
同樣的問題也存在於
sValidator和vValidator—— 它們都接受第三個 hook 參數。任何 Hono validator 都應該包一層。
Middleware:createMiddleware 與 Variables
Section titled “Middleware:createMiddleware 與 Variables”用 createMiddleware<AppEnv>() 而不是裸寫函式,型別才會通:
export const requestId = createMiddleware<AppEnv>(async (c, next) => { const id = c.req.header("cf-ray") ?? crypto.randomUUID(); c.set("requestId", id); // typed by AppEnv["Variables"] await next(); c.header("x-request-id", id); // runs AFTER the handler});await next() 前後的程式碼分別在請求進入時與回應產生後執行 —— 這是 onion model,和 Koa 一樣。
cf-ray 優先於自己產生的 UUID,因為那是 Cloudflare 自己的請求識別碼,你去開 support ticket 時對得上。
順帶一提 Workers 有 crypto.subtle.timingSafeEqual(),比對 token 應該用它而不是 ===:
const ok = token.length === expected.length && // must check length first crypto.subtle.timingSafeEqual( new TextEncoder().encode(token), new TextEncoder().encode(expected), );長度不同時它會 throw,所以要先比長度。
RPC:路由必須用鏈式寫法
Section titled “RPC:路由必須用鏈式寫法”Hono 的 hc<AppType> 讓前端拿到端對端型別。它有幾個硬性條件:
// ✅ Chained — types can be inferredexport const links = new Hono<AppEnv>() .get("/", validate("query", ListQuery), async (c) => c.json({ items: [] }, 200)) .post("/", validate("json", CreateLink), async (c) => c.json({ slug, url }, 201));
// ❌ Separate statements — inference is lostconst links = new Hono<AppEnv>();links.get("/", ...);links.post("/", ...);以及:
- 兩端
hono版本必須完全相同。 tsconfig.json兩端都要"strict": true。- 一定要明寫 status:
c.json(data, 200),不要只寫c.json(data)。 - RPC 路由上不要用
c.notFound()。
型別匯出是零 runtime 成本的:
const routes = app.route("/v1/links", links);export default app;export type AppType = typeof routes; // type-only第 26 篇會把它接上 TanStack Query。
一個 2026 年的棄用
Section titled “一個 2026 年的棄用”// ❌ Deprecated — was tied to Workers Sites / __STATIC_CONTENT KVimport { serveStatic } from "hono/cloudflare-workers";改用第 7 篇的 assets 設定。同一個模組裡的 upgradeWebSocket 和 getConnInfo 仍然是現行 API,只有 serveStatic 被棄用。
動手做:可維護的 API 骨架
Section titled “動手做:可維護的 API 骨架”完整程式碼:
examples/ch05-hono/
分層:
src/├── types.ts # AppEnv — the single source of the Hono generic shape├── middleware.ts # requestId, accessLog, requireApiKey├── validate.ts # zValidator wrapper that throws├── routes/│ └── links.ts # chained routes, RPC-compatible└── index.ts # composition, onError, notFound, AppType exportindex.ts 的組裝順序很重要:
const app = new Hono<AppEnv>();
app.use("*", requestId); // outermost: everything gets an idapp.use("*", accessLog); // then loggingapp.use("/v1/*", cors({ origin: ["https://example.com"], maxAge: 86400 }));app.use("/v1/*", requireApiKey); // auth only on /v1
app.get("/health", (c) => c.json({ ok: true, app: c.env.APP_NAME }));
const routes = app.route("/v1/links", links);/health 刻意放在 auth 之外 —— 健康檢查不該需要憑證。
npm installecho 'API_KEY="dev-key-123"' > .dev.varsnpm run cf-typegennpm run devB=localhost:8787; H='Authorization: Bearer dev-key-123'
curl -s $B/health# {"ok":true,"app":"ch05-hono"}
curl -s $B/v1/links# {"error":{"message":"Invalid API key","requestId":"0ecda102-..."}}
curl -s -X POST $B/v1/links -H "$H" -H 'content-type: application/json' \ -d '{"slug":"cf","url":"https://developers.cloudflare.com/workers/"}'# {"slug":"cf","url":"https://developers.cloudflare.com/workers/"}
curl -s -X POST $B/v1/links -H "$H" -H 'content-type: application/json' \ -d '{"slug":"cf","url":"https://example.com"}'# {"error":{"message":"slug \"cf\" already exists","requestId":"d313c942-..."}}
curl -s -X POST $B/v1/links -H "$H" -H 'content-type: application/json' \ -d '{"slug":"BAD SLUG","url":"not-a-url"}'# {"error":{"message":"slug: Invalid string: must match pattern /^[a-z0-9-]+$/; url: Invalid URL", ...}}
curl -s -D- -o /dev/null $B/health | grep -i x-request-id# x-request-id: 26ca623f-7469-4814-942e-9cd2ff76e1ce練習:把 src/validate.ts 裡的 hook 拿掉(改回裸 zValidator),再送一次壞 payload,看兩種錯誤格式的差別。
接進 LinkForge
Section titled “接進 LinkForge”apps/api 採用上面的分層,加上三個 LinkForge 特有的決策:
① Middleware 順序固定
requestId → accessLog → cors → rateLimit → auth → tenantScope → routestenantScope 從 API key 或 JWT 解出 tenantId 塞進 c.Variables,所有資料存取一律從 c.get("tenantId") 取,不從 request body 或 query 取。這是第 41 篇多租戶隔離的第一道防線 —— 隔離規則只寫在一個地方。
② rateLimit 用第 4 篇的 ratelimits binding
export const rateLimit = createMiddleware<AppEnv>(async (c, next) => { const key = c.get("tenantId") ?? c.req.header("cf-connecting-ip") ?? "anon"; const { success } = await c.env.ABUSE.limit({ key }); if (!success) throw new HTTPException(429, { message: "Too many requests" }); await next();});③ 驗證用 valibot
apps/api 會長期成長,98 KiB 的 zod 是我們最大的單一依賴。用 valibot + sValidator 讓 gzip 從 ~98 KiB 降到 ~19 KiB,而且靠 Standard Schema 保留了未來換掉的餘地。
本篇交付物:apps/api 完整骨架(middleware stack、統一錯誤格式、AppType 匯出),路由暫時打在記憶體 map 上 —— KV 在第 8 篇、D1 在第 9 篇接上。
① new Hono<Env>() 撞到 wrangler 的全域 Env
用 wrangler types --env-interface CloudflareBindings,並把 Hono 的形狀集中成 AppEnv。
② 驗證錯誤沒有走 onError
所有 validator 都要包一層會 throw 的 hook。
③ 沒注意到驗證函式庫的體積
zod v4 完整版 = gzip 98 KiB。定期跑 wrangler deploy --dry-run 看數字。
④ RPC 路由沒有鏈式宣告
分開寫 app.get(...) / app.post(...) 會讓 hc<AppType> 推不出型別。
⑤ 用 hono/cloudflare-workers 的 serveStatic
已棄用,改用 assets 設定(第 7 篇)。
⑥ 用 === 比對 API key
用 crypto.subtle.timingSafeEqual(),並先比長度。
⑦ 把 c.env 一路傳到深層函式
深層 util 直接 import { env } from "cloudflare:workers"(第 4 篇)。c.env 留給需要在測試中替換的 I/O 依賴。
本篇要記住的三句話
Section titled “本篇要記住的三句話”--env-interface CloudflareBindings不是潔癖,是為了避開Type 'Env' has no properties in common with type 'Env'。- **驗證函式庫可能是你 bundle 裡最大的東西。**zod v4 gzip 後 98 KiB,valibot 19 KiB。
- **Validator 預設繞過
onError。**不包一層 hook,你的統一錯誤格式對最常見的錯誤是無效的。
- Hono on Cloudflare Workers
- Hono RPC
- Hono middleware 列表
- Standard Schema ——
sValidator的基礎 - Zod 4 /
zod/mini - valibot
下一篇:06. 快取:Cache API、Workers Cache 與 fetch() 三層 —— 2026 年 Cloudflare 有三套快取機制,官方已經建議新專案不要用 Cache API 了。