REST API 架构
/api/v1 业务 API —— Hono 端点、类型化客户端、problem+json 错误契约与 SSR 进程内分发。
浏览器可见的业务操作全部走版本化的 REST 契约 /api/v1/*。TanStack Start 负责
路由、SSR、hydration 与 SEO;Hono(src/server/api/)负责应用 API。两端在
同一个 Cloudflare Worker 里按路径分发(/api/v1/* → Hono,其余 → TanStack)。
边界:什么进 /api/v1,什么不进
- 进:所有业务操作 —— 公共配置、会话/账户、waitlist、projects、leads、
feedback、billing、sponsorship、admin、docs/changelog 元数据
(
src/server/api/routes/*,每个 feature 一个路由模块)。 - 不进:协议/资产/文档表面 —— Better Auth(
/api/auth/*)、Stripe webhook、 头像读取(/api/avatars/*)、docs markdown 与搜索(/docs-md/*)、admin CSV 导出、sitemap/robots/llms。它们各有协议级的安全模型(签名、session、权限), 不套用业务 API 的中间件链。
加一个端点的固定流程
以「文章详情」为例,五步:
// 1. schema —— src/server/api/schemas/posts.ts(纯 zod,禁止 import hono/env:
// 同一份 schema 驱动运行时校验、OpenAPI 文档与客户端返回类型)
import { z } from 'zod'
export const PostResponseSchema = z.object({
id: z.string(),
title: z.string(),
})
export type PostResponse = z.infer<typeof PostResponseSchema>// 2. 路由 —— src/server/api/routes/posts.ts(handler 内惰性 import 服务层;
// 业务规则留在 feature 的 *.server.ts,不下沉到 handler)
import { Hono } from 'hono'
import { describeRoute, resolver } from 'hono-openapi'
export const postRoutes = new Hono<HonoEnv>()
postRoutes.get(
'/posts/:id',
requireUser(), // 401 problem,不重定向
validate('param', PostIdParamSchema), // 运行时校验 + 自动登记 OpenAPI 参数
describeRoute({
operationId: 'getPost',
tags: ['Posts'],
security: [{ cookieAuth: [] }],
responses: { 200: { content: { 'application/json': { schema: resolver(PostResponseSchema) } } } },
}),
async (c) => {
const { getPost } = await import('@/features/posts/posts.server')
return c.json(await getPost(createDb(c.env.DB), c.req.valid('param').id))
},
)// 3. 注册 —— src/server/api/app.ts: app.route('/api/v1', postRoutes)
// 4. 客户端 op —— src/lib/api/operations.ts(type-only import 保持浏览器包干净)
export function getPost(postId: string, opts: OperationOptions = {}): Promise<PostResponse> {
return apiRequest<PostResponse>(`/api/v1/posts/${encodeURIComponent(postId)}`, opts, ...)
}
// 并在 src/lib/api/index.ts 导出# 5. 再生成契约(仓库根 openapi.json 是金样,过期时 openapi.node.test.ts 失败)
pnpm openapi:update配套测试:<模块名>.workers.test.ts(真实 D1 的端点契约测试,与路由模块同目录)。
中间件链与鉴权
src/server/api/app.ts 的固定顺序:
requestId → logger → errorHandler → csrf(不安全方法) → sessionUser → 路由- CSRF:不安全方法校验精确配置的 origin +
Sec-Fetch-Site: same-origin(本地开发放行 localhost);普通 JSON mutation 还要求Content-Type: application/json(否则 415)。无 token、无 permissive CORS。 - 会话:sessionUser 从 Better Auth 的 HttpOnly cookie 派生身份;路由内用
requireUser()/ admin 门禁 —— REST 只回状态码,绝不重定向:401 未认证、 404 隐藏存在(非本人/非管理员同形)、403/409/422/429 按语义。 - 前端解释状态:loader 用
requireUserClient(401 → locale 正确的登录页 redirect)、requireAdminClient(401 → 登录页,404 → notFound 边界), 组件 mutation 按错误码映射 i18n 文案 —— 都来自@/lib/api。
错误契约:application/problem+json
所有错误共用统一信封(RFC 9457):{ type, title, status, code, requestId, detail?, errors? }。code 是稳定枚举(src/server/api/problems.ts 的
ERROR_CODES,只增不改),requestId 与 x-request-id 响应头及服务端日志关联;
字段级校验失败带 errors[](field/code)。客户端(src/lib/api)把非 2xx 统一
解析成 ApiError,isApiError / err.status / err.code 驱动 UI 分支。
SSR:进程内分发,无公网回环
src/lib/api 环境自适应:
- 浏览器:same-origin
fetch('/api/v1/...')(带 cookie、abort signal)。 - SSR(loader 在 Worker 里跑):构造标准
Request进程内递给同一个 Hono 实例(src/server/api/ssr.ts)—— 不向部署域名发回环请求;cookie、 locale、请求头按白名单转发,CSRF/会话判定输入与真实请求一致。
关键数据在 loader 里渲染前取回,SEO 元数据(canonical/og/正文)全部留在
服务端渲染的 HTML 里;公开只读端点带 Cache-Control(config/changelog 60s、
docs 300s —— 内容只在部署时变化)。