用 Hono 写 Workers 应用:路由、中间件、错误处理一次装好
跟着这一篇做完,你会有一个在本地跑起来、结构像正常 Web 框架的 Workers 应用:路由带路径参数、/api 前缀统一挂 CORS、日志中间件自动打印每条请求、私有分组用 Bearer token 保护、404 和 500 都有 JSON 兜底。全部代码在一个文件里,打包体积 71.61 KiB,gzip 后 18.45 KiB。这些数字来自 2026-11-10 本机执行 wrangler deploy --dry-run,接口状态码来自本机 wrangler dev 的真实响应。
1. 为什么在 Workers 上用 Hono
原生 Workers 的写法是 fetch(request) 里手写 if (pathname === "/x"),两三条路由还行,十几条就开始难受。Hono 补的是这一层组织能力:路径参数、分组、中间件链、统一错误处理,加上一个不到 20KB 的核心。它是纯 ESM,没有 Node 内置模块依赖,直接被打进 Worker 里运行,不需要额外的运行时。
和 Pages 的取舍见 52-workers-vs-pages.md;如果你还没写过原生 fetch 处理函数,先看 54-workers-fetch-handler-tutorial.md,Hono 的 c.req 就是 Request 的一层包装,底下的对象和 57-workers-request-response.md 里讲的是同一个。
2. 建项目
一个 package.json 加一个 wrangler.toml:
{
"name": "cf58-hono-demo",
"private": true,
"type": "module",
"dependencies": { "hono": "^4.6.0" }
}
name = "cf58-hono"
main = "src/index.js"
compatibility_date = "2026-10-08"
compatibility_flags = ["nodejs_compat"]
npm install
实测装完是 hono 4.13.17,只有一个包,没有传递依赖。
nodejs_compat 这个 flag 不是所有 Hono 用法都需要,用到 Buffer 或某些中间件时才加上。加它不影响体积。
3. 一个文件写完路由
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { bearerAuth } from "hono/bearer-auth";
import { HTTPException } from "hono/http-exception";
const app = new Hono();
app.use("*", logger());
app.use("/api/*", cors());
app.get("/", (c) => c.html("<h1>hono on workers</h1>"));
app.get("/api/hello/:name", (c) => {
const name = c.req.param("name");
const q = c.req.query("q") ?? "";
return c.json({ hello: name, q });
});
app.post("/api/echo", async (c) => {
const body = await c.req.json().catch(() => null);
return c.json({ got: body });
});
四个入口各自干一件事:c.req.param() 取路径段,c.req.query() 取查询串,c.req.json() 解 body,c.json() 序列化并按需设状态码。c.html() 会自动带 text/html; charset=UTF-8。
状态码用第二个参数给:
app.get("/api/status", (c) => c.json({ ok: true }, 201));
4. 中间件与分组
app.use() 挂中间件,第一个参数是匹配路径,* 是全部,/api/* 是前缀。cors() 和 logger() 都是官方自带的,不用自己写。
私有接口用子应用挂载,比在每个 handler 里判断 token 干净:
const guarded = new Hono();
guarded.use("*", bearerAuth({ token: "s3cret" }));
guarded.get("/me", (c) => c.json({ user: "kernelranch" }));
app.route("/api/private", guarded);
app.route() 把子应用挂到前缀下,子应用里的 /me 最终暴露为 /api/private/me。这种写法可以按业务拆文件,主文件只留挂载语句。
5. 错误处理
HTTPException 用来在业务逻辑里主动中断,app.onError 统一把它转成 JSON:
app.get("/api/boom", () => {
throw new HTTPException(418, { message: "teapot" });
});
app.notFound((c) => c.json({ error: "not found", path: c.req.path }, 404));
app.onError((err, c) => {
if (err instanceof HTTPException) {
return c.json({ error: err.message }, err.status);
}
return c.json({ error: "internal" }, 500);
});
notFound 管没匹配上的路径,onError 管 handler 里抛出来的异常,两者分开。没写 onError 的话,未捕获异常会被 Workers 运行时转成纯文本 500,前端拿到的就不是 JSON 了。
中间件里抛的异常也会走 onError,所以鉴权失败、CORS 预检失败这些都能统一成同一种响应形状。需要把"响应之后才做"的活挂住时,用 c.executionCtx.waitUntil(promise),它对应 Workers 的 ctx.waitUntil,写日志、上报统计都适合放这里,不阻塞返回。
6. 本地跑起来:实测响应
wrangler dev --port 8788 --local
8 个请求挨个打过去的真实结果:
| 请求 | 状态 | 响应体 |
|---|---|---|
GET / | 200 | <h1>hono on workers</h1>,text/html; charset=UTF-8 |
GET /api/hello/world?q=1 | 200 | {"hello":"world","q":"1"} |
GET /api/status | 201 | {"ok":true} |
POST /api/echo {"a":1} | 200 | {"got":{"a":1}} |
GET /api/private/me | 401 | {"error":""} |
GET /api/private/me 带 Bearer | 200 | {"user":"kernelranch"} |
GET /api/boom | 418 | {"error":"teapot"} |
GET /nope | 404 | {"error":"not found","path":"/nope"} |
401 的响应体是空的 {"error":""},因为 bearerAuth 默认不带消息。想给它一句话就传 bearerAuth({ token, invalidTokenMessage: "需要 token" })。
logger() 的输出长这样,每条请求两行:
<-- GET /api/hello/world?q=1
--> GET /api/hello/world?q=1 200 1ms
前面的 <-- 是进,--> 是出,末尾是 handler 内部耗时。这只在 dev 的终端里出现,线上要看日志得用 wrangler tail,见 66-workers-observability.md。
7. 体积和部署
部署前先量一下,--dry-run 不会真的上传:
wrangler deploy --dry-run --outdir=dist
Total Upload: 71.61 KiB / gzip: 18.45 KiB
dist/index.js 是 73330 字节,外加一个 138KB 的 sourcemap(不上传)。这个体量对免费档的脚本上限来说很宽松,具体额度对照见 56-free-tier-limits-cheatsheet.md。
真正上线就是把 --dry-run 去掉:
wrangler deploy
main 指向 src/index.js,wrangler 会用 esbuild 把 hono 一起打成一个文件,不需要自己配构建步骤。这也意味着 node_modules 不用上传,Worker 里跑的是打包产物。
8. 常见问题
问:本地 wrangler dev 报 This Worker requires compatibility date "2026-11-01", but the newest date supported by this server binary is "2026-10-08",怎么办? 答:本机 workerd 二进制支持的日期上限比远程边缘低,本地开发时把 compatibility_date 调到报错里给出的那个日期即可。这次实测就是把 2026-11-01 改成 2026-10-08 之后启动成功的。远程部署不受这个限制,但两边日期不一致可能带来行为差异,团队里统一成一个值更省事。
问:Hono 的 c.req.json() 遇到空 body 会抛错吗? 答:会。空 body 或不合法 JSON 都会抛 SyntaxError,所以要像上面那样 .catch(() => null) 包一层,否则前端少发一个字段就变成 500。
问:同一份代码能部署到 Pages Functions 吗? 答:能。Pages Functions 的入口是 functions/[[path]].js,里面 export const onRequest = (context) => app.fetch(context.request, context.env) 就行。路由和中间件代码不用改。Pages 侧的细节见 68-pages-functions.md。
小结
Hono 在 Workers 上解决的是组织问题,不是能力问题:fetch(request) 能做的事它都能做,只是把路由匹配、中间件顺序、错误兜底从手写 if 换成了声明式写法。代价是一个 71.61 KiB 的打包产物和一层 c 对象的间接。
实际用起来值得记住三点:app.use 的路径匹配决定了中间件作用范围,写错前缀会让鉴权漏掉一批接口;notFound 和 onError 要一起写,否则 404 是 JSON、500 是纯文本,前端解析逻辑要写两套;compatibility_date 在本地和远程可能不一致,卡住时先看报错里给出的上限日期。下一步接存储,从 KV 开始最省事,见 59-workers-kv-tutorial.md。
数据来源与检索时间
检索时间 2026-11-10。状态码、响应体、日志格式、打包体积均为本机真实执行结果。
| 来源 | URL | 对应数据 |
|---|---|---|
| Hono 官方文档 | https://hono.dev/docs/ | 路由、中间件、c.json / c.html 用法(未抓取) |
| Hono 的 Cloudflare Workers 指南 | https://hono.dev/docs/getting-started/cloudflare-workers | Workers 上的入口写法与部署方式(未抓取) |
| Wrangler 命令参考 | https://developers.cloudflare.com/workers/wrangler/commands/ | deploy --dry-run、dev --local 参数(页面正文未完整提取,仅取入口) |
核验说明
- 真做了:本机新建
_cf58src临时项目,npm install装到 hono 4.13.17,wrangler dev --port 8788 --local启动成功,8 个请求逐一实测并记录状态码与响应体;wrangler deploy --dry-run --outdir=dist得到 71.61 KiB / gzip 18.45 KiB 与dist/index.js73330 字节。 - 未部署到线上:本篇只用本地 dev 与 dry-run 验证,没有执行
wrangler deploy上传,因此不声称线上可访问。本地 workerd 与线上边缘的运行时版本不同,行为可能有个别差异。 - 第 8 节第一条 FAQ 是真报错:首次用
compatibility_date = "2026-11-01"启动时,本机 workerd 报newest date supported by this server binary is "2026-10-08",改成该日期后启动成功。原始日志在C:\Users\36376\.wrangler\logs\下。 - 官方 Hono 文档与 Wrangler 命令页没有抓到正文(Wrangler 页面返回的是导航结构),相关用法以本机实测为准。
- 未做:没有测
nodejs_compat关掉之后的行为差异,没有测 Hono 的 TypeScript 类型推导,没有验证 Pages Functions 的onRequest挂载(该结论依据 Hono 文档的通用写法)。