RPC
RPC 功能允许在服务端与客户端之间共享 API 规格。
首先,从服务端代码导出 Hono 应用的 typeof(通常称为 AppType),或仅导出希望在客户端可用的路由。
将 AppType 作为泛型参数传入后,Hono 客户端即可同时推断验证器指定的输入类型,以及通过 c.json() 返回的输出类型。
NOTE
目前客户端无法推断中间件返回的响应类型。详见相关 issue。
NOTE
在单仓库(monorepo)中,为使 RPC 类型正常工作,请在客户端与服务端的 tsconfig.json 中将 compilerOptions.strict 设为 true。了解更多。
服务端
服务端只需编写验证器并创建一个 route 变量。下面的示例使用 Zod Validator。
const route = app.post(
'/posts',
zValidator(
'form',
z.object({
title: z.string(),
body: z.string(),
})
),
(c) => {
// ...
return c.json(
{
ok: true,
message: 'Created!',
},
201
)
}
)然后导出该类型,以便与客户端共享 API 规范。
export type AppType = typeof route客户端
在客户端引入 hc 与 AppType:
import type { AppType } from '.'
import { hc } from 'hono/client'hc 是用于创建客户端的函数。将 AppType 作为泛型,并传入服务端地址。
const client = hc<AppType>('http://localhost:8787/')调用 client.{path}.{method},并传入要发送到服务端的数据即可。
const res = await client.posts.$post({
form: {
title: 'Hello',
body: 'Hono is a cool project',
},
})res 与原生 fetch 的 Response 兼容,可通过 res.json() 获取服务端数据。
if (res.ok) {
const data = await res.json()
console.log(data.message)
}Cookie
如果需要让客户端在每次请求时都携带 Cookie,可在创建客户端时添加 { init: { credentials: 'include' } }。
// client.ts
const client = hc<AppType>('http://localhost:8787/', {
init: {
credentials: 'include',
},
})
// 之后的请求都会自动携带 Cookie
const res = await client.posts.$get({
query: {
id: '123',
},
})状态码
在 c.json() 中显式指定 200、404 等状态码时,该状态码也会体现在传给客户端的类型中。
// server.ts
const app = new Hono().get(
'/posts',
zValidator(
'query',
z.object({
id: z.string(),
})
),
async (c) => {
const { id } = c.req.valid('query')
const post: Post | undefined = await getPost(id)
if (post === undefined) {
return c.json({ error: 'not found' }, 404) // 指定 404
}
return c.json({ post }, 200) // 指定 200
}
)
export type AppType = typeof app客户端可以依据状态码解析数据:
// client.ts
const client = hc<AppType>('http://localhost:8787/')
const res = await client.posts.$get({
query: {
id: '123',
},
})
if (res.status === 404) {
const data: { error: string } = await res.json()
console.log(data.error)
}
if (res.ok) {
const data: { post: Post } = await res.json()
console.log(data.post)
}
// { post: Post } | { error: string }
type ResponseType = InferResponseType<typeof client.posts.$get>
// { post: Post }
type ResponseType200 = InferResponseType<
typeof client.posts.$get,
200
>处理 404
如果要配合客户端使用,请勿调用 c.notFound() 返回 404,否则客户端无法正确推断数据类型。
// server.ts
export const routes = new Hono().get(
'/posts',
zValidator(
'query',
z.object({
id: z.string(),
})
),
async (c) => {
const { id } = c.req.valid('query')
const post: Post | undefined = await getPost(id)
if (post === undefined) {
return c.notFound() // ❌
}
return c.json({ post })
}
)
// client.ts
import { hc } from 'hono/client'
const client = hc<typeof routes>('/')
const res = await client.posts[':id'].$get({
param: {
id: '123',
},
})
const data = await res.json() // 🙁 data 的类型是 unknown请使用 c.json() 并明确指定状态码来返回 404:
export const routes = new Hono().get(
'/posts',
zValidator(
'query',
z.object({
id: z.string(),
})
),
async (c) => {
const { id } = c.req.valid('query')
const post: Post | undefined = await getPost(id)
if (post === undefined) {
return c.json({ error: 'not found' }, 404) // 指定 404
}
return c.json({ post }, 200) // 指定 200
}
)路径参数
也可以处理包含路径参数的路由。
const route = app.get(
'/posts/:id',
zValidator(
'query',
z.object({
page: z.string().optional(),
})
),
(c) => {
// ...
return c.json({
title: 'Night',
body: 'Time to sleep',
})
}
)通过 param 指定路径中包含的字符串:
const res = await client.posts[':id'].$get({
param: {
id: '123',
},
query: {},
})包含斜杠
hc 不会对 param 的值进行 URL 编码。如果需要在参数中包含斜杠,请使用正则表达式。
// client.ts
// 请求 /posts/123/456
const res = await client.posts[':id'].$get({
param: {
id: '123/456',
},
})
// server.ts
const route = app.get(
'/posts/:id{.+}',
zValidator(
'param',
z.object({
id: z.string(),
})
),
(c) => {
// id: 123/456
const { id } = c.req.valid('param')
// ...
}
)NOTE
不带正则的基础路径参数不会匹配斜杠。如果通过 hc 传入包含斜杠的 param,服务端可能无法正确路由。推荐使用 encodeURIComponent 对参数编码。
请求头
你可以在请求中添加自定义头部:
const res = await client.search.$get(
{
// ...
},
{
headers: {
'X-Custom-Header': 'Here is Hono Client',
'X-User-Agent': 'hc',
},
}
)若要为所有请求添加公共头部,可在调用 hc 时统一指定:
const client = hc<AppType>('/api', {
headers: {
Authorization: 'Bearer TOKEN',
},
})init 选项
可以通过 init 选项向请求传递 fetch 的 RequestInit 对象。以下示例展示如何终止请求:
import { hc } from 'hono/client'
const client = hc<AppType>('http://localhost:8787/')
const abortController = new AbortController()
const res = await client.api.posts.$post(
{
json: {
// 请求体
},
},
{
// RequestInit 对象
init: {
signal: abortController.signal,
},
}
)
// ...
abortController.abort()INFO
通过 init 指定的 RequestInit 对象优先级最高,可覆盖其他选项(如 body、method、headers)的设置。
$url()
通过 $url() 可以获取访问端点所需的 URL 对象。
WARNING
必须传入绝对地址,否则会抛出错误。例如传入 / 会出现:
Uncaught TypeError: Failed to construct 'URL': Invalid URL
// ❌ 会报错
const client = hc<AppType>('/')
client.api.post.$url()
// ✅ 正常工作
const client = hc<AppType>('http://localhost:8787/')
client.api.post.$url()const route = app
.get('/api/posts', (c) => c.json({ posts }))
.get('/api/posts/:id', (c) => c.json({ post }))
const client = hc<typeof route>('http://localhost:8787/')
let url = client.api.posts.$url()
console.log(url.pathname) // `/api/posts`
url = client.api.posts[':id'].$url({
param: {
id: '123',
},
})
console.log(url.pathname) // `/api/posts/123`文件上传
可以通过 form 请求体上传文件:
// client
const res = await client.user.picture.$put({
form: {
file: new File([fileToUpload], filename, {
type: fileToUpload.type,
}),
},
})// server
const route = app.put(
'/user/picture',
zValidator(
'form',
z.object({
file: z.instanceof(File),
})
)
// ...
)自定义 fetch
你可以注入自定义的 fetch 实现。
以下示例展示了在 Cloudflare Worker 中使用服务绑定(Service Bindings)的 fetch,替代默认实现。
# wrangler.toml
services = [
{ binding = "AUTH", service = "auth-service" },
]// src/client.ts
const client = hc<CreateProfileType>('http://localhost', {
fetch: c.env.AUTH.fetch.bind(c.env.AUTH),
})类型推断辅助
使用 InferRequestType 与 InferResponseType 可以了解请求与响应的类型。
import type { InferRequestType, InferResponseType } from 'hono/client'
// InferRequestType
const $post = client.todo.$post
type ReqType = InferRequestType<typeof $post>['form']
// InferResponseType
type ResType = InferResponseType<typeof $post>使用类型安全的响应解析辅助函数
parseResponse() 可以帮助你以类型安全的方式解析 hc 返回的 Response。
import { parseResponse, DetailedError } from 'hono/client'
// result 包含解析后的响应体(会根据 Content-Type 自动解析)
const result = await parseResponse(client.hello.$get()).catch(
(e: DetailedError) => {
console.error(e)
}
)
// 如果响应不是 ok,parseResponse 会自动抛出异常配合 SWR 使用
也可以结合 SWR 等 React Hook 库使用。
import useSWR from 'swr'
import { hc } from 'hono/client'
import type { InferRequestType } from 'hono/client'
import type { AppType } from '../functions/api/[[route]]'
const App = () => {
const client = hc<AppType>('/api')
const $get = client.hello.$get
const fetcher =
(arg: InferRequestType<typeof $get>) => async () => {
const res = await $get(arg)
return await res.json()
}
const { data, error, isLoading } = useSWR(
'api-hello',
fetcher({
query: {
name: 'SWR',
},
})
)
if (error) return <div>failed to load</div>
if (isLoading) return <div>loading...</div>
return <h1>{data?.message}</h1>
}
export default App在大型应用中使用 RPC
对于构建大型应用等场景,需要特别注意类型推断。 一种简单的方法是将处理函数串联起来,以确保类型始终能被推断出来。
// authors.ts
import { Hono } from 'hono'
const app = new Hono()
.get('/', (c) => c.json('list authors'))
.post('/', (c) => c.json('create an author', 201))
.get('/:id', (c) => c.json(`get ${c.req.param('id')}`))
export default app// books.ts
import { Hono } from 'hono'
const app = new Hono()
.get('/', (c) => c.json('list books'))
.post('/', (c) => c.json('create a book', 201))
.get('/:id', (c) => c.json(`get ${c.req.param('id')}`))
export default app接着像平常一样引入子路由,并记得链式调用它们。由于这是应用的最外层路由,我们最终导出它的类型。
// index.ts
import { Hono } from 'hono'
import authors from './authors'
import books from './books'
const app = new Hono()
const routes = app.route('/authors', authors).route('/books', books)
export default app
export type AppType = typeof routes现在就可以使用导出的 AppType 创建客户端,并像往常一样调用。
已知问题
IDE 性能
当使用 RPC 时,路由越多,IDE 越慢。主要原因在于推断应用类型需要大量的类型实例化。
例如,你的应用存在如下路由:
// app.ts
export const app = new Hono().get('foo/:id', (c) =>
c.json({ ok: true }, 200)
)Hono 推断出的类型类似于:
export const app = Hono<BlankEnv, BlankSchema, '/'>().get<
'foo/:id',
'foo/:id',
JSONRespondReturn<{ ok: boolean }, 200>,
BlankInput,
BlankEnv
>('foo/:id', (c) => c.json({ ok: true }, 200))这只是单个路由的类型实例化。虽然用户无需手动编写这些参数,但类型实例化非常耗时。IDE 使用的 tsserver 每次分析应用时都要执行这项耗时操作,路由越多越慢。
以下是缓解这一问题的几点建议:
Hono 版本不一致
如果后端与前端在不同目录中,请确保两侧使用的 Hono 版本一致。否则会遇到 “Type instantiation is excessively deep and possibly infinite” 等错误。
TypeScript 项目引用
与版本不一致类似,当前后端分离时,需要通过 Project References 访问后端代码(例如 AppType)。Project References 允许一个 TypeScript 代码库引用另一个代码库。(参考:Hono RPC And TypeScript Project References)
编译后再使用(推荐)
tsc 可以在编译阶段完成大量类型实例化,这样 tsserver 就不必每次都重新计算,大幅提升 IDE 性能。
将服务端应用与客户端一并编译是最有效的做法。可以在项目中加入以下代码:
import { app } from './app'
import { hc } from 'hono/client'
// 利用编译阶段计算类型
export type Client = ReturnType<typeof hc<typeof app>>
export const hcWithType = (...args: Parameters<typeof hc>): Client =>
hc<typeof app>(...args)编译后,使用 hcWithType 代替 hc,即可获得已经计算好类型的客户端:
const client = hcWithType('http://localhost:8787/')
const res = await client.posts.$post({
form: {
title: 'Hello',
body: 'Hono is a cool project',
},
})如果你的项目是 monorepo,可以配合 turborepo 等工具,将服务端与客户端拆分成独立项目,同时维护它们之间的依赖关系。这里有一个可运行的示例。
你也可以使用 concurrently、npm-run-all 等工具手动协调构建流程。
手动指定类型参数
虽然略显繁琐,但你可以通过手动指定类型参数来避免复杂的类型实例化。
const app = new Hono().get<'foo/:id'>('foo/:id', (c) =>
c.json({ ok: true }, 200)
)即便只指定一个类型参数,也能在一定程度上提升性能。不过当路由过多时,编写成本较高。
拆分应用与客户端
正如大型应用中的使用方式所述,可以将应用拆分为多个子应用,并为每个子应用创建独立客户端:
// authors-cli.ts
import { app as authorsApp } from './authors'
import { hc } from 'hono/client'
const authorsClient = hc<typeof authorsApp>('/authors')
// books-cli.ts
import { app as booksApp } from './books'
import { hc } from 'hono/client'
const booksClient = hc<typeof booksApp>('/books')这样 tsserver 无需一次性为所有路由实例化类型。