404处理

要点

  • 请求没有命中任何路由时,Hono 默认返回 404 Not Found 纯文本响应
  • app.notFound() 可以自定义 404 响应格式,但只对顶层 app 生效
  • 路由没匹配到(框架级 404)和资源不存在(业务级 404)是两件事,不要混在一起
  • 通配符 * 可以做兜底路由,SPA 场景下用它来 fallback 到 index.html
  • 尾部斜杠、大小写、basePath 配置错误是 unexpected 404 的常见来源
  • 404 日志能帮助发现前端错误链接和 API 调用问题,值得投入监控

1. 默认 404 行为

当一个请求的路径没有匹配到任何已注册的路由时,Hono 返回纯文本响应:

404 Not Found

状态码 404,Content-Type 是 text/plain,body 只有这一行字符串。没有 JSON,没有 HTML,没有任何业务信息。前端拿到这个响应后,无法区分「路径写错了」还是「服务本身有问题」。

先看一个最简单的 app:

// index.ts
import { Hono } from 'hono'
 
const app = new Hono()
app.get('/users', (c) => c.json({ users: [] }))
 
export default app

请求 GET /posts 会命中默认 404,因为 /posts 没有注册过。

2. app.notFound():自定义 404 响应

app.notFound() 接收一个回调函数,签名和路由处理函数一样。当没有路由匹配时,Hono 调用这个回调而不是返回默认纯文本:

// index.ts
import { Hono } from 'hono'
 
const app = new Hono()
 
app.notFound((c) => {
  return c.json(
    { success: false, error: 'Not Found', path: c.req.path },
    404
  )
})
 
app.get('/users', (c) => c.json({ users: [] }))
 
export default app

请求 GET /posts 返回:

{ "success": false, "error": "Not Found", "path": "/posts" }

c.req.path 把请求路径放进响应,调试时可以直接确认路径是否正确,不用翻请求日志。

这里有一个重要的边界:

  • app.notFound:没有任何路由匹配,请求连路由处理函数都没进
  • HTTPException(404):路由匹配到了,但业务逻辑判断资源不存在(如数据库查不到用户 ID)

两者的触发时机完全不同。HTTPException 的用法在 03.09 错误处理那篇展开过,这一篇聚焦在框架级别的 404。

app.notFound 只对顶层 app 生效

app.notFound() 注册在子 app 上不会报错,但挂载后不生效。子 app 内部的未匹配路由会向上冒泡到顶层 app,由 app.notFound 处理:

// users.ts — 子 app 的 notFound 只在独立使用时生效
const users = new Hono()
users.notFound((c) => c.json({ error: 'users: not found' }, 404))
users.get('/', (c) => c.json({ users: [] }))
// index.ts — 挂载后,404 统一由顶层 app.notFound 处理
const app = new Hono()
app.route('/users', users)
app.notFound((c) => c.json({ error: 'Not Found', path: c.req.path }, 404))

请求 GET /users/nonexistent 时,users 模块内部没有匹配的路由,但不会触发 users.notFound——它冒泡到顶层 app,由 app.notFound 处理。

app.notFound 是整个应用最后的 404 出口,注册在顶层 app 上才能兜住所有未匹配的路径。

3. API 与 HTML 场景下的 404 格式

API 场景:结构化 JSON

// index.ts
app.notFound((c) => {
  return c.json(
    {
      success: false,
      error: 'Route not found',
      path: c.req.path,
      method: c.req.method,
    },
    404
  )
})

method 也放进响应,能区分「GET /users 不存在」和「POST /users 不存在」——前者可能是路径错误,后者可能是方法用错了。

HTML 场景:按 Accept 协商

// index.ts
app.notFound((c) => {
  const accept = c.req.header('Accept') ?? ''
  if (accept.includes('text/html')) {
    return c.html('<html><body><h1>404</h1></body></html>')
  }
  return c.json({ success: false, error: 'Not Found', path: c.req.path }, 404)
})

API 调用方带 Accept: application/json,浏览器带 Accept: text/html,同一个回调服务两种客户端。

4. 兜底路由:通配符 *

通配符 * 注册一个兜底路由,效果类似 app.notFound() 但更灵活——它是真正的路由处理函数,可以挂载中间件、做条件判断。

// index.ts
app.get('/users', (c) => c.json({ users: [] }))
app.get('/posts', (c) => c.json({ posts: [] }))
 
// 兜底路由必须放在所有具体路由之后
app.all('*', (c) => {
  return c.json(
    { success: false, error: `${c.req.method} ${c.req.path} not found` },
    404
  )
})

通配符兜底和 app.notFound() 的区别:

  • app.notFound() 更简洁,适合只需要自定义响应格式的场景
  • 通配符 * 可以挂载中间件,做条件判断
  • app.get('*', ...) 只兜底 GET 请求,需要兜底所有方法用 app.all('*', ...)

兜底路由必须放在所有具体路由之后,否则后面的路由永远不会被匹配。

SPA Fallback

单页应用的客户端路由可能请求任意路径(如 /dashboard),这些路径在服务端没有对应路由。服务端需要返回 index.html 让客户端路由接管:

// index.ts
app.get('/api/users', (c) => c.json({ users: [] }))
app.get('/api/posts', (c) => c.json({ posts: [] }))
app.use('/assets/*', serveStatic({ root: './' }))
 
app.get('*', (c) => {
  const path = c.req.path
 
  // API 请求返回 JSON 404
  if (path.startsWith('/api/')) {
    return c.json({ success: false, error: 'API not found', path }, 404)
  }
 
  // 其他路径返回 index.html(实际项目中读取文件内容)
  return c.html('<!DOCTYPE html><!-- index.html -->')
})

按路径前缀区分 API 和 SPA 路由。/api/ 开头的是接口请求,返回 JSON 错误;其他路径交给客户端路由处理。

5. 嵌套路由中的 404

使用 app.route() 挂载子 app 时,404 的触发条件需要结合挂载路径来看:

// users.ts
const users = new Hono()
users.get('/', (c) => c.json({ users: [] }))
users.get('/:id', (c) => c.json({ id: c.req.param('id') }))
export default users
// index.ts
const app = new Hono()
app.route('/users', users)
app.notFound((c) => c.json({ error: 'Not Found', path: c.req.path }, 404))
请求路径结果
GET /users/命中 GET /,返回 200
GET /users/42命中 GET /:id,返回 200
GET /users/42/posts未匹配,触发 app.notFound,404
GET /posts未匹配,触发 app.notFound,404
POST /users/未注册 POST,触发 404

GET /users/42/posts 有两个路径段,不匹配 /:id(只匹配一段),也不匹配 /,所以未命中。需要匹配更深层路径的话,应该在子模块里继续定义:

// users.ts
users.get('/:id/posts', (c) => {
  return c.json({ userId: c.req.param('id'), posts: [] })
})

子 app 独立运行时的 404

子 app 可以独立测试。独立运行时,子 app 自己的 notFound 生效;挂载到主 app 后,由顶层 notFound 接管。这让子 app 在测试和独立部署时都能有合适的 404 响应。

6. 常见 unexpected 404 来源

尾部斜杠

Hono 严格匹配路径字符串,/users/users/ 是两个不同的路径:

app.get('/users', (c) => c.json({ users: [] }))
// GET /users   → 200 ✅
// GET /users/  → 404 ❌(尾部斜杠)

这和 Nginx 的默认行为不同——Nginx 通常会自动重定向。Hono 不做这个处理,可以用中间件规范化:

// middleware/trailing-slash.ts
export const stripTrailingSlash = createMiddleware(async (c, next) => {
  if (c.req.path !== '/' && c.req.path.endsWith('/')) {
    const url = new URL(c.req.url)
    url.pathname = c.req.path.replace(/\/+$/, '')
    return c.redirect(url.toString(), 301)
  }
  await next()
})

大小写敏感

Hono 路由匹配大小写敏感。/Users/users 是不同的路径:

app.get('/users', (c) => c.json({ users: [] }))
// GET /users  → 200 ✅
// GET /Users  → 404 ❌

API 路径约定使用全小写。如果调用方有时传大写路径,可以在中间件里做路径规范化,或在文档里明确说明。

basePath 配置

basePath 会改变所有路由的实际路径。前端请求没包含前缀时直接 404:

const app = new Hono().basePath('/api/v1')
app.get('/users', (c) => c.json({ users: [] }))
// GET /api/v1/users → 200 ✅
// GET /users        → 404 ❌(缺少 basePath 前缀)
// GET /api/users    → 404 ❌(版本路径不对)

这类问题在版本升级时容易出现——前端还在请求 /api/v1/users,后端已切到 /api/v2/users。在 404 响应里带上 path 信息能帮助快速定位。

7. Soft 404:路由匹配但资源不存在

还有一种情况是路由匹配到了,但业务逻辑认为资源不存在。框架层面请求命中了路由,业务层面资源并不存在。

// users.ts
import { HTTPException } from 'hono/http-exception'
 
const db: Record<string, { id: string; name: string }> = {
  '1': { id: '1', name: 'Alice' },
}
 
app.get('/users/:id', (c) => {
  const user = db[c.req.param('id')]
  if (!user) {
    throw new HTTPException(404, { message: 'User not found' })
  }
  return c.json(user)
})

请求 GET /users/999 命中 /:id 路由,但 db['999'] 不存在,抛出 HTTPException(404)。这个 404 不触发 app.notFound——它走错误处理链路,由 app.onError 捕获。

区分两种 404 对前端有影响:

  • 框架级 404:路径本身不存在,通常是链接错误或 API 配置错误
  • 业务级 404:路径正确,但资源已删除或从未创建

前端处理策略不同——前者需要检查链接生成逻辑,后者需要显示「资源不存在」的提示。

8. 404 日志与监控

404 日志能帮助发现前端错误链接、爬虫扫描、API 调用方的路径错误。

在 notFound 中记录日志

// index.ts
app.notFound((c) => {
  console.warn(JSON.stringify({
    level: 'warn',
    message: 'Route not found',
    method: c.req.method,
    path: c.req.path,
    referer: c.req.header('Referer') ?? '-',
    userAgent: c.req.header('User-Agent') ?? '-',
  }))
  return c.json({ success: false, error: 'Not Found', path: c.req.path }, 404)
})

Referer 在排查时特别有用——大量 404 来自同一个 Referer 通常是前端某处链接写错了。User-Agent 帮助识别爬虫扫描,这类 404 通常不需要关注。

用中间件统计 404 频率

// middleware/404-counter.ts
const notFoundCount: Record<string, number> = {}
 
export const track404 = createMiddleware(async (c, next) => {
  await next()
  if (c.res.status === 404) {
    notFoundCount[c.req.path] = (notFoundCount[c.req.path] ?? 0) + 1
  }
})

await next() 之后检查 c.res.status,404 时记录一次。实际项目中把 notFoundCount 换成 Prometheus counter 或发到日志采集服务。

9. 测试 404 响应

测试自定义 notFound

// tests/not-found.test.ts
import { describe, it, expect } from 'vitest'
import { Hono } from 'hono'
 
const app = new Hono()
app.notFound((c) => c.json({ success: false, error: 'Not Found', path: c.req.path }, 404))
app.get('/users', (c) => c.json({ users: [] }))
 
describe('自定义 404', () => {
  it('未注册路径返回 JSON 404', async () => {
    const res = await app.request('/nonexistent')
    expect(res.status).toBe(404)
    expect(await res.json()).toEqual({ success: false, error: 'Not Found', path: '/nonexistent' })
  })
 
  it('未注册方法返回 404', async () => {
    const res = await app.request('/users', { method: 'DELETE' })
    expect(res.status).toBe(404)
  })
})

区分框架级与业务级 404

// tests/not-found-distinction.test.ts
const app = new Hono()
app.get('/users/:id', (c) => {
  throw new HTTPException(404, { message: 'User not found' })
})
app.notFound((c) => c.json({ error: 'Route 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 Server Error' }, 500)
})
 
describe('404 类型区分', () => {
  it('路由未匹配 → notFound', async () => {
    const res = await app.request('/nonexistent')
    expect(res.status).toBe(404)
    expect(await res.json()).toEqual({ error: 'Route not found', path: '/nonexistent' })
  })
 
  it('路由匹配但资源不存在 → onError', async () => {
    const res = await app.request('/users/999')
    expect(res.status).toBe(404)
    expect(await res.json()).toEqual({ error: 'User not found' })
  })
})

/nonexistent 没有路由匹配,走 app.notFound/users/999 匹配了 /:id 但抛出 HTTPException(404),走 app.onError。两条链路的响应格式可以不同。

延伸阅读

总结

404 处理涉及两个层面:框架层面的「路由未匹配」和业务层面的「资源不存在」。Hono 分别提供了 app.notFound()HTTPException(404) 来覆盖这两种情况,两者的触发时机和处理链路不同。

回顾这一篇涉及到的要点:

  1. 默认行为:无路由匹配时返回 404 Not Found 纯文本
  2. app.notFound():自定义 404 响应格式,只对顶层 app 生效
  3. API vs HTML:通过 Accept header 做内容协商,同一回调服务不同客户端
  4. 兜底路由:通配符 *app.notFound() 更灵活,可以挂载中间件
  5. SPA fallback:用通配符路由返回 index.html,与 API 404 按路径前缀区分
  6. 嵌套路由:子 app 的 404 向上冒泡到顶层 app 的 notFound
  7. 常见来源:尾部斜杠、大小写、basePath 配置是 unexpected 404 的高频原因
  8. Soft 404:路由匹配但资源不存在,走 HTTPException(404)app.onError
  9. 日志监控:在 notFound 中记录 method、path、referer、user-agent
  10. 测试:用 app.request() 验证 404 状态码、响应格式和两种 404 的链路区别

下一篇进入路由测试——app.request() 的完整用法、中间件测试、依赖注入与 mock、快照测试和集成测试模式。