跳到內容

用 Hono 打好 API 骨架

查證日期
驗證環境hono@4.12.32·zod@4.4.3·@hono/zod-validator@0.9.0·valibot@1.4.2·wrangler@4.114.0
對應範例examples/ch05-hono

裸寫 fetch handler 可以撐到大約第三條路由。之後你會開始需要 routing、middleware、驗證、統一錯誤格式 —— 也就是一個框架。

Hono 是 Workers 上事實標準:4.12.32,沒有 v5,自 2024 年 2 月以來都是往上加功能而非破壞相容。Cloudflare 自己的 React 官方 template 裡的 API 層用的就是它。

但有三件事你不會在 Hono 官網讀到,而它們會決定你的 API 好不好維護:

  1. Hono 有一個自己的 Env 型別,會和 wrangler types 產生的 Env 撞在一起,而 TypeScript 給出的錯誤訊息是 Type 'Env' has no properties in common with type 'Env'
  2. 你的驗證函式庫可能佔掉 bundle 的 80%。 實測 zod v4 讓一個小 API 從 16 KiB 膨脹到 98 KiB(gzip 後)。
  3. zValidator 預設會繞過你的 app.onError(),所以你辛苦設計的統一錯誤格式,對最常見的那種錯誤(驗證失敗)完全沒生效。

Hono 的泛型參數期待的是一個容器:

type HonoEnv = { Bindings: ...; Variables: ... };

wrangler types 預設產生的全域介面也叫 Env,內容是一包扁平的 binding。兩個名字一樣、形狀完全不同。

寫成這樣會怎樣:

import { Hono } from "hono";
const app = new Hono<Env>(); // 想用 wrangler 的 Env
app.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 的泛型形狀集中在一個檔案:

src/types.ts
export type AppEnv = {
Bindings: CloudflareBindings;
Variables: {
requestId: string;
tenantId: string | null;
};
};

之後所有地方 —— new Hono<AppEnv>()createMiddleware<AppEnv>()MiddlewareHandler<AppEnv> —— 都用 AppEnvBindings 是平台給的,Variables 是 middleware 之間傳遞的 request-scoped 資料。

第 1 篇講過 Worker bundle 上限是壓縮後 Free 3 MB / Paid 10 MB,而且模組頂層的執行有 1 秒 startup CPU 預算。所以依賴大小不是美學問題。

實測同一支小 API 逐層加上依賴(wrangler deploy --dry-run,2026-07-28):

堆疊Uploadgzip
裸 Worker0.30 KiB0.23 KiB
+ Hono core63.66 KiB15.37 KiB
+ cors / factory / http-exception67.63 KiB16.38 KiB
+ zod v4 + @hono/zod-validator620.55 KiB97.87 KiB
+ zod/mini88.83 KiB21.09 KiB
+ valibot72.30 KiB17.31 KiB
+ valibot + @hono/standard-validator80.09 KiB19.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 是最小的組合,而且 sValidatorStandard Schema v1,換底層驗證器不用改路由程式碼 —— 從相容性角度是最有未來性的選擇。

我的建議:新專案用 valibot + @hono/standard-validator;既有 zod 專案先換 zod/mini 但如果你的 Worker 離 3 MB 還很遠,用完整版 zod 也完全合理 —— 重點是知道自己付了什麼代價,而不是無意識地付。

⚠️ 另一個相容性細節:@hono/zod-openapi 從 v1 起只吃 Zod 4,不再支援 Zod 3。

假設你設計了統一的錯誤格式:

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,全專案只用這個:

src/validate.ts
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("; "),
});
}
});

換掉之後:

Terminal window
$ 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 結構。

同樣的問題也存在於 sValidatorvValidator —— 它們都接受第三個 hook 參數。任何 Hono validator 都應該包一層。

Middleware:createMiddlewareVariables

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,所以要先比長度。

Hono 的 hc<AppType> 讓前端拿到端對端型別。它有幾個硬性條件

// ✅ Chained — types can be inferred
export 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 lost
const links = new Hono<AppEnv>();
links.get("/", ...);
links.post("/", ...);

以及:

  • 兩端 hono 版本必須完全相同
  • tsconfig.json 兩端都要 "strict": true
  • 一定要明寫 statusc.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。

// ❌ Deprecated — was tied to Workers Sites / __STATIC_CONTENT KV
import { serveStatic } from "hono/cloudflare-workers";

改用第 7 篇的 assets 設定。同一個模組裡的 upgradeWebSocketgetConnInfo 仍然是現行 API,只有 serveStatic 被棄用。


完整程式碼: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 export

index.ts 的組裝順序很重要:

const app = new Hono<AppEnv>();
app.use("*", requestId); // outermost: everything gets an id
app.use("*", accessLog); // then logging
app.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 之外 —— 健康檢查不該需要憑證。

Terminal window
npm install
echo 'API_KEY="dev-key-123"' > .dev.vars
npm run cf-typegen
npm run dev
Terminal window
B=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,看兩種錯誤格式的差別。


apps/api 採用上面的分層,加上三個 LinkForge 特有的決策:

① Middleware 順序固定

requestId → accessLog → cors → rateLimit → auth → tenantScope → routes

tenantScope 從 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-workersserveStatic

已棄用,改用 assets 設定(第 7 篇)。

⑥ 用 === 比對 API key

crypto.subtle.timingSafeEqual(),並先比長度。

⑦ 把 c.env 一路傳到深層函式

深層 util 直接 import { env } from "cloudflare:workers"(第 4 篇)。c.env 留給需要在測試中替換的 I/O 依賴。


  1. --env-interface CloudflareBindings 不是潔癖,是為了避開 Type 'Env' has no properties in common with type 'Env'
  2. **驗證函式庫可能是你 bundle 裡最大的東西。**zod v4 gzip 後 98 KiB,valibot 19 KiB。
  3. **Validator 預設繞過 onError。**不包一層 hook,你的統一錯誤格式對最常見的錯誤是無效的。


下一篇06. 快取:Cache API、Workers Cache 與 fetch() 三層 —— 2026 年 Cloudflare 有三套快取機制,官方已經建議新專案不要用 Cache API 了。