RPC
RPC 功能允许在服务端和客户端之间共享 API 规范。
首先,在服务端代码中导出 Hono 应用的 typeof 类型(通常命名为 AppType),也可以只导出希望提供给客户端的路由类型。
Hono 客户端接受 AppType 作为泛型参数,从而推断验证器指定的输入类型,以及返回 c.json() 的处理程序产生的输出类型。
NOTE
要让 RPC 类型在 monorepo 中正确工作,请在客户端和服务端的 tsconfig.json 文件的 compilerOptions 中都设置 "strict": true。了解更多。
服务端
服务端只需编写验证器并创建 route 变量。以下示例使用 Zod 验证器。
const route = app.post(
'/posts',
zValidator(
'form',
z.object({
title: z.string(),
body: z.string(),
})
),
(c) => {
// ...
return c.json(
{
ok: true,
message: 'Created!',
},
201
)
}
)TIP
Standard Schema 验证器也适用,因此可以使用 Valibot 等任意支持 Standard Schema 的库。
然后导出类型,与客户端共享 API 规范。
export type AppType = typeof route客户端
在客户端,首先导入 hc 和 AppType。
import type { AppType } from '.'
import { hc } from 'hono/client'hc 是创建客户端的函数。将 AppType 作为泛型参数传入,并通过参数指定服务端 URL。
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',
},
})
// This request will now include any cookies you might have set
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) // Specify 404
}
return c.json({ post }, 200) // Specify 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
>全局响应
Hono RPC 客户端不会自动推断 app.onError() 等全局错误处理程序或全局中间件的响应类型。可以使用 ApplyGlobalResponse 类型辅助工具,将全局错误响应类型合并到所有路由中。
import type { ApplyGlobalResponse } from 'hono/client'
const app = new Hono()
.get('/api/users', (c) => c.json({ users: ['alice', 'bob'] }, 200))
.onError((err, c) => c.json({ error: err.message }, 500))
type AppWithErrors = ApplyGlobalResponse<
typeof app,
{
500: { json: { error: string } }
}
>
const client = hc<AppWithErrors>('http://localhost')现在,客户端同时知道成功响应和错误响应的类型:
const res = await client.api.users.$get()
if (res.ok) {
const data = await res.json() // { users: string[] }
}
// InferResponseType includes the global error type
type ResType = InferResponseType<typeof client.api.users.$get>
// { users: string[] } | { error: string }也可以一次定义多个全局错误状态码:
type AppWithErrors = ApplyGlobalResponse<
typeof app,
{
401: { json: { error: string; message: string } }
500: { json: { error: string; message: string } }
}
>未找到
如果希望使用客户端,不应通过 c.notFound() 返回未找到响应,否则无法正确推断客户端从服务端获取的数据。
// 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 is unknown请使用 c.json() 并指定未找到响应的状态码。
export const routes = new Hono().get(
'/posts',
zValidator(
'query',
z.object({
id: z.string(),
})
),
async (c) => {
const { id } = c.req.valid('query')
const post = await getPost(id)
if (!post) {
return c.json({ error: 'not found' }, 404) // Specify 404
}
return c.json({ post }, 200) // Specify 200
}
)另外,也可以使用模块扩充来扩展 NotFoundResponse 接口,让 c.notFound() 返回带类型的响应:
// server.ts
import { Hono, TypedResponse } from 'hono'
declare module 'hono' {
interface NotFoundResponse
extends Response,
TypedResponse<{ error: string }, 404, 'json'> {}
}
const app = new Hono()
.get('/posts/:id', async (c) => {
const post = await getPost(c.req.param('id'))
if (!post) {
return c.notFound()
}
return c.json({ post }, 200)
})
.notFound((c) => c.json({ error: 'not found' }, 404))
export type AppType = typeof app现在,客户端可以正确推断 404 响应的类型。
路径参数
也可以处理包含路径参数或查询值的路由。
const route = app.get(
'/posts/:id',
zValidator(
'query',
z.object({
page: z.coerce.number().optional(), // coerce to convert to number
})
),
(c) => {
// ...
return c.json({
title: 'Night',
body: 'Time to sleep',
})
}
)路径参数和查询值都必须以 string 传入,即使底层值属于其他类型。
使用 param 指定要放入路径的字符串,使用 query 指定查询值。
const res = await client.posts[':id'].$get({
param: {
id: '123',
},
query: {
page: '1', // `string`, converted by the validator to `number`
},
})多个参数
处理包含多个参数的路由。
const route = app.get(
'/posts/:postId/:authorId',
zValidator(
'query',
z.object({
page: z.string().optional(),
})
),
(c) => {
// ...
return c.json({
title: 'Night',
body: 'Time to sleep',
})
}
)添加多个 [''],以指定路径中的参数。
const res = await client.posts[':postId'][':authorId'].$get({
param: {
postId: '123',
authorId: '456',
},
query: {},
})包含斜杠
hc 函数不会对 param 的值进行 URL 编码。要在参数中包含斜杠,请使用正则表达式。
// client.ts
// Requests /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: {
// Request body
},
},
{
// RequestInit object
init: {
signal: abortController.signal,
},
}
)
// ...
abortController.abort()说明
通过 init 定义的 RequestInit 对象优先级最高,可以覆盖由 body | method | headers 等其他选项设置的内容。
$url()
使用 $url() 可以获取访问端点的 URL 对象。
注意
要使用此功能,必须传入绝对 URL。如果传入相对 URL /,会产生以下错误。
Uncaught TypeError: Failed to construct 'URL': Invalid URL
// ❌ Will throw error
const client = hc<AppType>('/')
client.api.post.$url()
// ✅ Will work as expected
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`带类型的 URL
可以将基础 URL 作为 hc 的第二个类型参数传入,获得更精确的 URL 类型:
const client = hc<typeof route, 'http://localhost:8787'>(
'http://localhost:8787/'
)
const url = client.api.posts.$url()
// url is TypedURL with precise type information
// including protocol, host, and path如果希望将 URL 用作 SWR 等库中的类型安全键,这会很有用。
$path()
$path() 与 $url() 类似,但返回路径字符串而非 URL 对象。与 $url() 不同,它不包含基础 URL 的源,因此无论传给 hc 的基础 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 path = client.api.posts.$path()
console.log(path) // `/api/posts`
path = client.api.posts[':id'].$path({
param: {
id: '123',
},
})
console.log(path) // `/api/posts/123`也可以传入查询参数:
const path = client.api.posts.$path({
query: {
page: '1',
limit: '10',
},
})
console.log(path) // `/api/posts?page=1&limit=10`文件上传
可以通过表单请求体上传文件:
// 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 示例脚本中,使用服务绑定的 fetch 方法替代默认 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),
})自定义查询序列化器
可以通过 buildSearchParams 选项自定义查询参数的序列化方式。当数组需要使用方括号表示法或其他自定义格式时,这会很有用:
const client = hc<AppType>('http://localhost', {
buildSearchParams: (query) => {
const searchParams = new URLSearchParams()
for (const [k, v] of Object.entries(query)) {
if (v === undefined) {
continue
}
if (Array.isArray(v)) {
v.forEach((item) => searchParams.append(`${k}[]`, item))
} else {
searchParams.set(k, v)
}
}
return searchParams
},
})类型推断
使用 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 contains the parsed response body (automatically parsed based on Content-Type)
const result = await parseResponse(client.hello.$get()).catch(
(e: DetailedError) => {
console.error(e)
}
)
// parseResponse automatically throws an error if response is not ok使用 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 都会执行这项耗时任务。如果路由很多,IDE 就可能显著变慢。
不过,我们有一些方法可以缓解此问题。
Hono 版本不一致
如果后端与前端分离,位于不同目录,需要确保 Hono 版本一致。如果后端和前端使用不同 Hono 版本,就会出现“Type instantiation is excessively deep and possibly infinite”(类型实例化过深且可能无限递归)等问题。
TypeScript 项目引用
与 Hono 版本不一致的情况一样,后端与前端分离时可能会遇到问题。如果希望在前端访问后端代码(例如 AppType),需要使用项目引用。TypeScript 项目引用允许一个 TypeScript 代码库访问和使用另一个代码库中的代码。(来源:Hono RPC 与 TypeScript 项目引用)。
使用前编译代码(推荐)
tsc 可以在编译时执行类型实例化等繁重任务!这样,tsserver 就不必在每次使用时实例化所有类型参数,可以显著提高 IDE 速度。
将客户端连同服务端应用一起编译,可以获得最佳性能。在项目中加入以下代码:
import { app } from './app'
import { hc } from 'hono/client'
// this is a trick to calculate the type when compiling
const client = hc<typeof app>('')
export type Client = typeof client
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)
)只指定一个类型参数就能改善性能,但路由较多时,这可能耗费大量时间和精力。
将应用和客户端拆分到多个文件
如在大型应用中使用 RPC所述,可以将应用拆分为多个应用,也可以为每个应用创建客户端:
// 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 就无需一次实例化所有路由的类型。
返回 Promise 链的处理程序
直接返回 .then() 链的处理程序会丢失响应类型,客户端因此推断为 unknown:
const app = new Hono().get('/', (c) =>
Promise.resolve({ hello: 'world' }).then((d) => c.json(d))
)
const client = hc<typeof app>('')
const res = await client.index.$get()
const data = await res.json() // unknown这是 TypeScript 类型推断的限制:无法通过 .then() 链推断响应类型。请改用 async/await:
const app = new Hono().get('/', async (c) => {
const d = await Promise.resolve({ hello: 'world' })
return c.json(d)
})
const client = hc<typeof app>('')
const res = await client.index.$get()
const data = await res.json() // { hello: string }如果无法避免使用链式调用,为 then() 添加类型注解也可以:
import type { TypedResponse } from 'hono/types'
const app = new Hono().get('/', (c) =>
Promise.resolve({ hello: 'world' }).then<
TypedResponse<{ hello: string }, 200, 'json'>
>((d) => c.json(d, 200))
)