裸写 Cloudflare Workers 的项目,五百行是舒适区,五千行是绞刑架。渡劫code 的 Worker 端一万多行代码还能 maintain,靠的是 Hono 加一套工程化纪律。这篇全部用线上真实代码举例——哪些可以照抄,哪些是抄不动的设计决策。

第一层:Worker 入口只做分流

默认导出不写任何业务逻辑,唯一职责是决定请求去向:/api/* 交给 Hono 应用,其余全部交给静态资源绑定:

const worker: ExportedHandler<Env> = {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url)
    if (url.pathname.startsWith('/api/')) {
      return app.fetch(request, env, ctx)
    }
    try {
      return await env.ASSETS.fetch(request)
    } catch {
      return jsonError('NOT_FOUND', '资源不存在', 404)
    }
  },
}

SPA 的前端路由回退由 wrangler.toml 的 not_found_handling = "single-page-application" 声明,不写在代码里——配置归配置,代码归代码,这一行配置替我们省掉整个 fallback 中间件,也让入口函数保持在十行以内。

中间件顺序:注册序即执行序

三个全局中间件的顺序是排过雷的:requestId → CORS → JWT 鉴权 + 限流。requestId 必须最先,否则后面任何环节出错都拿不到追溯 ID;CORS 次之,预检请求(OPTIONS)要在鉴权之前被 204 掉,否则未登录的预检会先撞出 401;鉴权垫底,它前面所有环节都不该看到用户身份。顺序写错不会报错,只会以「OPTIONS 请求 401」这种玄学形式出现——中间件的 bug 往往是顺序的 bug,不是逻辑的 bug。

第二层:路由按资源域挂载

Hono 的 app.route() 把路由模块挂到不同前缀,每个文件一个资源域:

app.route('/api/auth', authRoutes)            // 注册/登录/登出/me
app.route('/api/providers', providerRoutes)    // 供应商 CRUD + test + models
app.route('/api/workspaces', workspaceRoutes)  // 工作区 + 文件树/读写/搜索
app.route('/api/workspaces', shellRoutes)      // 终端执行(同前缀,路径不冲突)
app.route('/api', conversationRoutes)          // 会话/消息/审批/审计
app.route('/api', backendRoutes)               // 执行后端/agent token/WS 升级
app.route('/api/blog', blogRoutes)             // 博客互动

注意 workspaceRoutes 和 shellRoutes 同前缀挂载:路径不冲突时 Hono 按注册序匹配,这是官方支持的用法,比把所有端点堆在一个文件里可维护得多。判断标准很简单:一个路由文件超过三百行,就是该拆的信号。

免鉴权端点用一张显式白名单表管理,配两个防御细节——去尾斜杠后精确匹配(容忍 /api/auth/login/ 写法),HEAD 归一为 GET(Hono 的 GET 路由按 HTTP 规范自动兼容 HEAD,豁免判断不同步就会漏掉一类请求):

function isAuthExempt(c: Context<AppEnv>): boolean {
  if (c.req.method === 'OPTIONS') return true
  const method = c.req.method === 'HEAD' ? 'GET' : c.req.method
  const path = c.req.path.replace(/\/+$/, '') || '/'
  return AUTH_EXEMPTIONS.some((r) => r.method === method && r.path === path)
}

第三层:类型安全从 Bindings 开始

Cloudflare 的绑定(KV/D1/R2/DO/secret)在裸 Worker 里是 any 的重灾区。我们的纪律是 Env 接口即文档——每个字段带注释说明存什么、谁能动:

export interface Env {
  DB: D1Database        // 用户/供应商/工作区/消息/审批/审计
  BLOG_DB: D1Database   // 博客互动(三端静态站共用)
  KV: KVNamespace       // 会话/限流/中止/放行名单
  R2: R2Bucket          // 工作区文件 ws/{id}/{path}
  TERMINAL_ROOM: DurableObjectNamespace
  ASSETS: Fetcher       // dist/ 静态资源
  ENCRYPTION_KEY: string // 信封加密主密钥(wrangler secret)
  JWT_SECRET: string     // HS256 签名密钥
}

然后定义 AppEnv = { Bindings: Env; Variables: AppVariables },全项目 new Hono<AppEnv>() 一处声明。收益是连锁的:c.env.KV 有类型补全、绑定名手滑拼错编译期就红、c.set('userId', ...) 与 c.get('userId') 全程受检。中间件注入的上下文变量(userId/email/jti)也走 Variables 类型——这是 Hono 相对 Express 系最舒服的一点:请求上下文是有类型的袋子,不是 any。

第四层:错误归一化,一个信封走天下

全项目只有一种错误出口:业务代码抛 httpError(code, message, status),app.onError 统一渲染成 {code, message, details?} JSON 信封;未预期异常一律 500 INTERNAL,细节只进日志,不泄露给客户端:

app.onError((err, c) => {
  const requestId = requestIdByRequest.get(c.req.raw) ?? 'unknown'
  if (isHttpError(err)) {
    console.log(JSON.stringify({ level: 'warn', requestId, path: c.req.path, code: err.code }))
    return jsonError(err.code, err.message, err.status, err.details)
  }
  console.error(JSON.stringify({ level: 'error', requestId, message: String(err) }))
  return jsonError('INTERNAL', '服务器内部错误', 500)
})

配套两个细节。其一,requestId 用 WeakMap 挂在原始 Request 上,而不是上下文变量——onError 拿到的上下文可能不完整,SSE 流式响应场景同样要能取到同一个 ID;WeakMap 键弱引用,无泄漏风险。其二,未匹配路由走 app.notFound 返回 JSON 404,而不是 Hono 默认的纯文本——前端错误处理只解析一种格式,联调时少一类「时灵时不灵」。

SSE 是这条链路的特例:Agent 事件流一旦建立就是分钟级长连接,中间件里的访问日志在响应头返回时即可记录,不等流式 body 结束;keepalive 心跳由 wrangler.toml 的 SSE_KEEPALIVE_SEC 控制,网关超时前把连接「续上」。错误信封在流开始之后就失效了——SSE 传输中的失败靠事件协议里的 error 事件表达,这是两种错误通道并存的原因:握手前用信封,握手中用事件。

调试:wrangler dev 的三个段位

  • 本地模式(默认):KV/D1 走 .wrangler/state 本地模拟,秒级热重载。坑:本地 D1 与线上 schema 要手动同步,忘了就会出现「本地跑通、上线炸」
  • --remote:连真实线上绑定调试,数据是真的、冷启动也是真的。只在本地复现不了的问题上用——改错一行,炸的是生产库
  • wrangler tail:结构化日志按 requestId 检索,配合上面那套 JSON 日志格式,线上问题从「用户说打不开」到定位到具体请求,平均一分钟

还有一个反复咬人的坑:本地 node_modules/.cache/wrangler/ 会缓存旧账号 ID,报 AuthenticationError 时别怀疑令牌,先删缓存目录。CI 里 fresh checkout 无此问题——本地能跑 CI 不能跑或反之,先查缓存差异。

小结

工程化不是上框架,是定纪律:入口只分流、路由按域拆、Bindings 全类型、错误一个信封。四条都不新鲜,但每一行都有渡劫code 的真实代码背书。照抄这套结构,五千行的 Worker 项目也能睡得着觉。下一篇讲把这些纪律跑在多仓库 CI 里的 Git 组织方案。