1. 概述
接下来,我们要约定在 web 端和 api 端之间共享请求类型,并使用 Hono RPC 进行通信。
目前 API 侧现在只有一个 GET /health,返回的还是裸 JSON。前端侧也没有真正成型的请求层。继续往下写业务,很快就会碰到三个问题:
- 请求参数靠口头约定
- 成功和失败返回格式各写各的
- 前端拿到接口后,类型还是得自己再补一遍
这类问题一开始不明显,接口一多就会变成维护负担。你会看到同一个错误,在 web、admin、api 三处分别写一遍;同一个字段一旦改名,类型漂移会沿着整条链路扩散。
因此,我们的目标很明确:
- 将 contract 放进共享包,前后端共用同一份类型和 schema
- 统一 success / failure 返回结构
- 前端直接基于 Hono RPC 调用,拿到真实类型推导
- 用首页上的一次
ping调用验证整条链路是否通顺
2. 定义 contract 边界
shared contract 只放跨端稳定约定,不放业务实现。放进 packages/contracts 的内容控制在这几类:
- 业务异常码
BizCode - 统一响应元信息
ApiMeta - 成功结构
ApiSuccess<T>和 失败结构ApiFailure<E> - 总响应类型
ApiResponse<T, E> ping这条最小链路的输入输出 schema 与类型
这里最关键的点有两个。
第一,输入输出都要有 schema
只写 TypeScript type 不够,因为 type 只在编译期存在,真正到接口入口时,传进来的 JSON 还是运行时数据。这里直接用 zod 定义 PingRequestSchema 和 PingResponseSchema,这样 API 可以校验入参,前端也能复用同一份 contract。
第二,响应 envelope 也要共享
如果每个 route 自己拼 { ok, data, error, meta },后面一多就会失控。所以直接在共享包里提供 buildSuccess 和 buildFailure 这类纯数据 helper,把结构先固定下来。
可以先把 contract 包写成这样:
01import { z } from 'zod'0203export const BizCode = {04COMMON_INVALID_REQUEST: 'COMMON.INVALID_REQUEST',05COMMON_NOT_FOUND: 'COMMON.NOT_FOUND',06AUTH_UNAUTHORIZED: 'AUTH.UNAUTHORIZED',07AUTH_FORBIDDEN: 'AUTH.FORBIDDEN',08BIZ_CONFLICT: 'BIZ.CONFLICT',09BIZ_RULE_VIOLATION: 'BIZ.RULE_VIOLATION',10SYSTEM_INTERNAL_ERROR: 'SYSTEM.INTERNAL_ERROR',11SYSTEM_UPSTREAM_TIMEOUT: 'SYSTEM.UPSTREAM_TIMEOUT',12} as const1314export type BizCode = (typeof BizCode)[keyof typeof BizCode]1516export interface ApiMeta {17requestId: string18timestamp: string19}2021export interface ApiSuccess<T> {22ok: true23data: T24meta: ApiMeta25}2627export interface ApiError<E = unknown> {28code: BizCode29message: string30details?: E31}3233export interface ApiFailure<E = unknown> {34ok: false35error: ApiError<E>36meta: ApiMeta37}3839export type ApiResponse<T, E = unknown> = ApiSuccess<T> | ApiFailure<E>4041export const PingRequestSchema = z.object({42name: z.string().trim().min(1),43})4445export const PingResponseSchema = z.object({46service: z.literal('api'),47message: z.string(),48})4950export type PingRequest = z.infer<typeof PingRequestSchema>51export type PingResponse = z.infer<typeof PingResponseSchema>5253export function buildSuccess<T>(data: T, meta: ApiMeta): ApiSuccess<T> {54return { ok: true, data, meta }55}5657export function buildFailure<E = unknown>(58error: ApiError<E>,59meta: ApiMeta,60): ApiFailure<E> {61return { ok: false, error, meta }62}
到这里,前后端至少已经在「说同一种语言 」。
3. 统一响应格式
接口返回值建议从一开始就分成两层语义:
HTTP status,表示传输层结果error.code,表示业务语义
成功结构统一为:
01{02"ok": true,03"data": {04"service": "api",05"message": "pong, web"06},07"meta": {08"requestId": "d7c4f4ef-67c3-4f48-90b2-0cb6c6f7ea4f",09"timestamp": "2026-04-28T08:00:00.000Z"10}11}
失败结构统一成:
01{02"ok": false,03"error": {04"code": "COMMON.INVALID_REQUEST",05"message": "Invalid request payload",06"details": {07"fieldErrors": {08"name": ["String must contain at least 1 character(s)"]09}10}11},12"meta": {13"requestId": "8c9b0b52-ef80-44d8-b2e1-d40c6b90a40d",14"timestamp": "2026-04-28T08:00:01.000Z"15}16}
这里的 meta 不只是为了好看。
requestId 让日志串联有锚点,timestamp 让排查时能快速定位请求时间。后面真接日志平台、链路追踪或者 Sentry,这两个字段都能直接复用。
配套的业务异常码可以先收一组通用常量:
COMMON.INVALID_REQUESTCOMMON.NOT_FOUNDAUTH.UNAUTHORIZEDAUTH.FORBIDDENBIZ.CONFLICTBIZ.RULE_VIOLATIONSYSTEM.INTERNAL_ERRORSYSTEM.UPSTREAM_TIMEOUT
这组常量先别追求全面,关键是命名风格和职责边界要稳定。后面新增业务 route 时,直接在这个集合里继续扩展。
4. RPC 与异常处理
这里做一个很实用的拆分:
apps/api/src/app.ts负责定义路由、错误处理、导出AppTypeapps/api/src/index.ts只负责export default app
这样做的目的可以让前端 type-only 导入 AppType,拿到 Hono route 的真实类型推导。
先看 app.ts 的最小形态:
01import {02BizCode,03PingRequestSchema,04buildFailure,05buildSuccess,06type ApiMeta,07} from '@repo/contracts'08import { Hono } from 'hono'09import { HTTPException } from 'hono/http-exception'10import { validator } from 'hono/validator'1112type AppErrorStatus = 400 | 401 | 403 | 404 | 409 | 422 | 500 | 5041314class AppError extends Error {15constructor(16readonly code: BizCode,17message: string,18readonly status: AppErrorStatus,19readonly details?: unknown,20) {21super(message)22}23}2425const app = new Hono()2627function createMeta(): ApiMeta {28return {29requestId: crypto.randomUUID(),30timestamp: new Date().toISOString(),31}32}3334app.onError((error, c) => {35const meta = createMeta()3637if (error instanceof AppError) {38const errorMsg = { code: error.code, message: error.message, details: error.details }39const res = buildFailure(errorMsg, meta);40return c.json(res, error.status);41}4243if (error instanceof HTTPException) {44const errorMsg = { code: BizCode.COMMON_INVALID_REQUEST, message: error.message }45const res = buildFailure(errorMsg, meta);46return c.json(res, error.status);47}4849console.error(error)5051const errorMsg = { code: BizCode.SYSTEM_INTERNAL_ERROR, message: 'Internal server error' }52const res = buildFailure(errorMsg, meta);53return c.json(res, 500);54})5556app.notFound((c) => {57const errorMsg = { code: BizCode.COMMON_NOT_FOUND, message: 'Not found' }58const res = buildFailure(errorMsg, createMeta());59return c.json(res, 404);60})6162const routes = app63.get('/health', (c) => {64const res = buildSuccess({ service: 'api' }, createMeta());65return c.json(res);66})67.post('/rpc/system/ping', validator('json', (value, c) => {68const parsed = PingRequestSchema.safeParse(value)6970if (!parsed.success) {71const errorMsg = {72code: BizCode.COMMON_INVALID_REQUEST,73message: 'Invalid request payload',74details: parsed.error.flatten(),75}76return c.json(buildFailure(errorMsg, createMeta()), 400);77}7879return parsed.data80}),81(c) => {82const payload = c.req.valid('json')83const successMsg = { service: 'api', message: `pong, ${payload.name}` }84const res = buildSuccess(successMsg, createMeta());85return c.json(res);86});8788export type AppType = typeof routes;8990export default app;
入口文件就保持极简:
1import app from './app'23export default app
这一步的价值在于,route 结构、入参校验、返回值 envelope、异常码映射,已经统一到一个地方。
5. 前端直接走 typed RPC
后端 contract 定好了,接下来就该让前端真正共享这份类型收益。
这里不额外包一层请求 SDK,先在 web 首页直接走一次最小调用,目的就是验证链路,而不是过早抽象。
要做的接入只有三步。
第一步,补 workspace 依赖。
apps/web/package.json 增加:
@repo/api@repo/contractshono
apps/api/package.json 也要补 workspace 包名和 exports,让 AppType 能被前端引用。
01{02"name": "@repo/api",03"type": "module",04"exports": {05".": "./src/app.ts"06},07"scripts": {08"dev": "wrangler dev",09"deploy": "wrangler deploy --minify",10"cf-typegen": "wrangler types --env-interface CloudflareBindings",11"check-types": "tsc --noEmit"12},13"dependencies": {14"@repo/contracts": "workspace:*",15"hono": "^4.12.14"16},17"devDependencies": {18"typescript": "catalog:",19"wrangler": "^4.4.0"20}21}
apps/web/next.config.js 也要把共享包加进 transpilePackages:
1/** @type {import('next').NextConfig} */2const nextConfig = {3transpilePackages: ['@repo/ui', '@repo/contracts', '@repo/api'],4}56export default nextConfig
第二步,首页直接连 RPC。
这里用 hc<AppType>() 建 client,前端就能拿到 route 对应的参数和返回值推导。
01import type { AppType } from '@repo/api'02import {03BizCode,04type ApiResponse,05type PingRequest,06type PingResponse,07} from '@repo/contracts'08import { hc, type InferResponseType } from 'hono/client'0910const apiBaseUrl = process.env.API_BASE_URL ?? 'http://127.0.0.1:8787'11const rpcPayload: PingRequest = { name: 'web' }1213type PingRpcResponse = InferResponseType<14ReturnType<typeof hc<AppType>>['rpc']['system']['ping']['$post']15>1617async function getPingResponse(): Promise<PingRpcResponse> {18const client = hc<AppType>(apiBaseUrl)1920try {21const response = await client.rpc.system.ping.$post({22json: rpcPayload,23})2425return await response.json()26} catch (error) {27return {28ok: false,29error: {30code: BizCode.SYSTEM_UPSTREAM_TIMEOUT,31message: error instanceof Error ? error.message : 'API request failed',32},33meta: {34requestId: 'unavailable',35timestamp: new Date().toISOString(),36},37} satisfies ApiResponse<PingResponse>38}39}
第三步,把调用结果直接展示在首页。
这个展示区块不需要做花活,能看清请求体、返回值和错误码就够了。
01const requestBody = JSON.stringify(rpcPayload, null, 2)02const responseBody = JSON.stringify(pingResult, null, 2)0304<section className="py-10">05<Card className="overflow-hidden border border-border bg-background shadow-soft">06<CardContent className="space-y-5 p-6">07<div className="space-y-2">08<p className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">09RPC validation10</p>11<h2 className="text-2xl font-semibold tracking-tight text-foreground">12Shared request and response contract13</h2>14</div>15<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">16<span className="rounded-full border border-border px-3 py-1">17POST /rpc/system/ping18</span>19<span className="rounded-full border border-border px-3 py-1">20{pingResult.ok ? 'ok=true' : `code=${pingResult.error.code}`}21</span>22</div>23<div className="grid gap-4 lg:grid-cols-2">24<div className="rounded-2xl border border-border bg-muted/40 p-4">25<p className="text-sm font-medium text-foreground">Request</p>26<pre className="mt-3 overflow-x-auto whitespace-pre-wrap break-all text-xs leading-6 text-muted-foreground">27{requestBody}28</pre>29</div>30<div className="rounded-2xl border border-border bg-muted/40 p-4">31<p className="text-sm font-medium text-foreground">Response</p>32<pre className="mt-3 overflow-x-auto whitespace-pre-wrap break-all text-xs leading-6 text-muted-foreground">33{responseBody}34</pre>35</div>36</div>37</CardContent>38</Card>39</section>
这样,我们就可以直接在首页上验证共享请求类型和响应类型是否跑通。
6. 验证
做到这里,其实已经验证了四件关键的事。
共享请求类型已经跑通。
PingRequestSchema 和 PingRequest 来自同一个共享包,API 用它校验,前端用它约束入参。字段一旦变化,前后端会一起感知。
共享响应类型已经跑通。
PingResponse 和 ApiResponse<T> 让成功结构、失败结构、元信息结构都固定下来。后面新增接口,不需要每次重新发明一套返回格式。
异常码已经有了统一出口。
无论是 notFound、参数校验失败,还是运行时异常,最后都会汇总到统一的 failure envelope。前端读取错误信息时,也不再猜字段名。
Hono RPC 的类型推导已经接上。
前端通过 hc<AppType>() 直接消费 API route 类型,这意味着 route 路径、请求体、返回值三者已经串到一起。
这个阶段先别急着抽出通用 rpcClient、fetcher、service layer。当前目标只是验证方向,最小链路能跑通,后面的抽象才有依据。
跑通之后,后面的用户、鉴权、任务、消息这些业务 route,就都有统一入口可接了:先在 packages/contracts 补 schema 和类型,再在 apps/api/src/app.ts 补 route,最后让前端通过 hc<AppType>() 消费。