在 Next.js Route Handler 中引入 Hono

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
HonoNext.jsBunRoute Handler

我的博客原来有四组独立的 Next.js Route Handler:健康检查、搜索、内容附件和 MCP。代码并不多,但路由入口分散,缓存头和错误处理也各写各的。

我决定引入 Hono 时,最先遇到的不是路由怎么写,而是应该使用哪个适配器。项目由 Bun 运行,Hono 文档又给 Next.js 示例用了 hono/vercel,看起来似乎应该在 hono/bunhono/vercel 之间选一个。

实际答案是:这里不需要二选一。Next.js 管服务器,Hono 只处理收到的标准 Request

先确定谁拥有 HTTP Server

当前生产进程是:

tini -> bun server.js

server.js 是 Next.js standalone 生成的入口。它负责页面渲染、静态资源、Route Handler、缓存和整个服务器生命周期。Bun 是执行这个入口的 JavaScript 运行时。

如果采用 Hono 的 Bun 启动方式,通常会导出:

export default {
  port: 3000,
  fetch: app.fetch,
};

这代表 Hono 把 app.fetch 交给 Bun,让 Bun 创建 HTTP Server。它适合独立 Hono 服务,却不适合嵌在现有 Next.js 应用中。否则就要再启动一个端口,自己处理页面请求如何回到 Next.js,等于重写服务器边界。

用 catch-all Route Handler 接住 /api

Hono 的 Next.js 指南建议在 App Router 中创建:

app/api/[[...route]]/route.ts

我也采用这个目录,但入口保持得很薄:

import { createApi } from '@/lib/api';
import { getBlogSearchPayload } from '@/lib/data';
import { mcpHandler } from '@/lib/mcp';
import { readPublicAttachment } from './read-public-attachment';

export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';

const api = createApi({
  getBlogSearchPayload,
  handleMcp: mcpHandler,
  readPublicAttachment,
});

const handler = (request: Request) => api.fetch(request);

export { handler as GET, handler as POST };

Next.js 把请求交给 Route Handler,Route Handler 再交给 Hono。没有新端口,也没有第二个服务器。

为什么没有使用 hono/vercel

官方示例写的是:

import { handle } from 'hono/vercel';

export const GET = handle(app);
export const POST = handle(app);

这个写法完全正确,也更接近 Hono 文档。不过我检查了项目安装的 Hono 4.13.5,当前 Vercel handler 对 App Router 的核心实现就是把 Request 传给 app.fetch()

既然这个入口没有使用适配器的其他能力,我选择直接写:

const handler = (request: Request) => api.fetch(request);

这不是说 hono/vercel 没用。部署环境需要它提供额外转换时应该继续用;这里只是没有必要给一层等价包装取一个容易让人误解为“必须部署到 Vercel”的名字。

Hono 核心只依赖 Web API

API 的依赖被定义为普通函数:

type ApiDependencies = {
  getBlogSearchPayload: () => Promise<unknown>;
  handleMcp: (request: Request) => Promise<Response>;
  readPublicAttachment: (publicPath: string) => Promise<Blob | null>;
};

路由本身只接触 RequestResponseBlob

export function createApi({
  getBlogSearchPayload,
  handleMcp,
  readPublicAttachment,
}: ApiDependencies) {
  return new Hono()
    .basePath('/api')
    .get('/health', (context) => context.json({ ok: true }))
    .get('/search', async (context) => {
      context.header(
        'Cache-Control',
        'public, max-age=300, stale-while-revalidate=3600',
      );
      return context.json(await getBlogSearchPayload());
    })
    .on(['GET', 'POST'], '/mcp', (context) =>
      handleMcp(context.req.raw),
    );
}

Hono 的 fetch() API面向标准 Fetch 接口。这个边界让测试很直接:创建 Hono app,传入假的搜索和 MCP 函数,然后用 api.request()api.fetch() 发请求,不必启动 Next 开发服务器。

附件接口也适合交给 Hono

一开始我保留了原来的:

app/api/content-attachments/[access]/[...path]/route.ts

后来发现这没有必要。附件本来就是一个 HTTP 边界,Hono 可以继续保持原 URL:

.get('/content-attachments/:access/:path{.+}', async (context) => {
  if (context.req.param('access') !== 'public') {
    return context.notFound();
  }

  const file = await readPublicAttachment(context.req.param('path'));
  if (!file) {
    return context.notFound();
  }

  return new Response(file, {
    headers: {
      'Cache-Control': 'public, max-age=31536000, immutable',
      'Content-Type': file.type || 'application/octet-stream',
    },
  });
})

:path{.+} 可以捕获包含斜杠的嵌套附件路径。access 仍先限制为 public,文件不存在则返回 404,缓存头和 MIME 与迁移前保持一致。

Bun.file() 留在存储适配层

本地读取函数只有一个职责:把公开路径解析成磁盘文件,再返回一个 Blob

export async function readPublicAttachment(
  publicPath: string,
): Promise<Blob | null> {
  await contentMetadataReady;

  const filePath = await resolvePublicContentAttachment(publicPath);
  if (!filePath) return null;

  return Bun.file(filePath);
}

Bun.file() 出现在这里,不代表 Hono 核心绑定 Bun。它实现的是“当前容器怎样读取附件”。以后换成对象存储,只需要替换这个函数:

async function readPublicAttachment(path: string) {
  return env.ATTACHMENTS.get(path);
}

Hono 路由不需要知道文件来自本地磁盘还是对象存储。

这还不能叫边缘部署完成

Hono 使用 Web API,确实让核心路由更容易移植。但当前 Route Handler 明确使用 Next.js nodejs runtime,内容初始化会扫描本地 Markdown,MCP 依赖也没有在边缘平台验证。

所以我只把这次改动称为“留下了存储替换点”。要部署到边缘环境,还要处理:

  • 内容在构建时生成还是放入对象存储。
  • 附件使用 R2、S3 或其他 Blob 存储。
  • MCP handler 是否使用目标平台不支持的 API。
  • Next.js 部署适配器怎样生成和调用路由入口。
  • 缓存是否能跨实例共享。

如果没有真实构建和部署结果,写“已经支持 Edge”只是在提前宣布成功。

验证时保留原来的 HTTP 契约

迁移后的测试没有检查文件名或 package script 字符串,而是实际请求:

  • GET /api/health 返回 { "ok": true }
  • GET /api/search 返回搜索数据和原缓存头。
  • GET /api/content-attachments/public/... 返回 JPEG 内容。
  • 非公开附件返回 404。
  • POST /api/mcp 继续交给原 MCP handler。

生产构建的路由表只剩一个 /api/[[...route]] 动态入口,Docker 冒烟测试则验证了健康检查、搜索、附件和 MCP 初始化都返回 200。

这次引入 Hono 没有改变谁启动服务器,也没有为了未来的 Edge 想象重写内容系统。它只把分散的 API 收到一个可测试、可替换存储实现的 Web API 边界里。对这个博客来说,这已经够了。