KernelRanch
返回文章列表

用 Hono 写 Workers 应用:路由、中间件、错误处理一次装好

2026-11-10 #Cloudflare

跟着这一篇做完,你会有一个在本地跑起来、结构像正常 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=1200{"hello":"world","q":"1"}
GET /api/status201{"ok":true}
POST /api/echo {"a":1}200{"got":{"a":1}}
GET /api/private/me401{"error":""}
GET /api/private/me 带 Bearer200{"user":"kernelranch"}
GET /api/boom418{"error":"teapot"}
GET /nope404{"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-workersWorkers 上的入口写法与部署方式(未抓取)
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.js 73330 字节。
  • 未部署到线上:本篇只用本地 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 文档的通用写法)。