Skip to content

RPC

RPC 功能允许在服务端与客户端之间共享 API 规格。

首先,从服务端代码导出 Hono 应用的 typeof(通常称为 AppType),或仅导出希望在客户端可用的路由。

AppType 作为泛型参数传入后,Hono 客户端即可同时推断验证器指定的输入类型,以及通过 c.json() 返回的输出类型。

NOTE

目前客户端无法推断中间件返回的响应类型。详见相关 issue

NOTE

在单仓库(monorepo)中,为使 RPC 类型正常工作,请在客户端与服务端的 tsconfig.json 中将 compilerOptions.strict 设为 true了解更多

服务端

服务端只需编写验证器并创建一个 route 变量。下面的示例使用 Zod Validator

ts
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 规范。

ts
export type AppType = typeof route

客户端

在客户端引入 hcAppType

ts
import type { AppType } from '.'
import { hc } from 'hono/client'

hc 是用于创建客户端的函数。将 AppType 作为泛型,并传入服务端地址。

ts
const client = hc<AppType>('http://localhost:8787/')

调用 client.{path}.{method},并传入要发送到服务端的数据即可。

ts
const res = await client.posts.$post({
  form: {
    title: 'Hello',
    body: 'Hono is a cool project',
  },
})

res 与原生 fetch 的 Response 兼容,可通过 res.json() 获取服务端数据。

ts
if (res.ok) {
  const data = await res.json()
  console.log(data.message)
}

如果需要让客户端在每次请求时都携带 Cookie,可在创建客户端时添加 { init: { credentials: 'include' } }

ts
// client.ts
const client = hc<AppType>('http://localhost:8787/', {
  init: {
    credentials: 'include',
  },
})

// 之后的请求都会自动携带 Cookie
const res = await client.posts.$get({
  query: {
    id: '123',
  },
})

状态码

c.json() 中显式指定 200404 等状态码时,该状态码也会体现在传给客户端的类型中。

ts
// 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

客户端可以依据状态码解析数据:

ts
// 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,否则客户端无法正确推断数据类型。

ts
// 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:

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.json({ error: 'not found' }, 404) // 指定 404
    }

    return c.json({ post }, 200) // 指定 200
  }
)

路径参数

也可以处理包含路径参数的路由。

ts
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 指定路径中包含的字符串:

ts
const res = await client.posts[':id'].$get({
  param: {
    id: '123',
  },
  query: {},
})

包含斜杠

hc 不会对 param 的值进行 URL 编码。如果需要在参数中包含斜杠,请使用正则表达式

ts
// 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 对参数编码。

请求头

你可以在请求中添加自定义头部:

ts
const res = await client.search.$get(
  {
    // ...
  },
  {
    headers: {
      'X-Custom-Header': 'Here is Hono Client',
      'X-User-Agent': 'hc',
    },
  }
)

若要为所有请求添加公共头部,可在调用 hc 时统一指定:

ts
const client = hc<AppType>('/api', {
  headers: {
    Authorization: 'Bearer TOKEN',
  },
})

init 选项

可以通过 init 选项向请求传递 fetchRequestInit 对象。以下示例展示如何终止请求:

ts
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 对象优先级最高,可覆盖其他选项(如 bodymethodheaders)的设置。

$url()

通过 $url() 可以获取访问端点所需的 URL 对象。

WARNING

必须传入绝对地址,否则会抛出错误。例如传入 / 会出现:

Uncaught TypeError: Failed to construct 'URL': Invalid URL

ts
// ❌ 会报错
const client = hc<AppType>('/')
client.api.post.$url()

// ✅ 正常工作
const client = hc<AppType>('http://localhost:8787/')
client.api.post.$url()
ts
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 请求体上传文件:

ts
// client
const res = await client.user.picture.$put({
  form: {
    file: new File([fileToUpload], filename, {
      type: fileToUpload.type,
    }),
  },
})
ts
// server
const route = app.put(
  '/user/picture',
  zValidator(
    'form',
    z.object({
      file: z.instanceof(File),
    })
  )
  // ...
)

自定义 fetch

你可以注入自定义的 fetch 实现。

以下示例展示了在 Cloudflare Worker 中使用服务绑定(Service Bindings)的 fetch,替代默认实现。

toml
# wrangler.toml
services = [
  { binding = "AUTH", service = "auth-service" },
]
ts
// src/client.ts
const client = hc<CreateProfileType>('http://localhost', {
  fetch: c.env.AUTH.fetch.bind(c.env.AUTH),
})

类型推断辅助

使用 InferRequestTypeInferResponseType 可以了解请求与响应的类型。

ts
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。

ts
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 库使用。

tsx
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

对于构建大型应用等场景,需要特别注意类型推断。 一种简单的方法是将处理函数串联起来,以确保类型始终能被推断出来。

ts
// 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
ts
// 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

接着像平常一样引入子路由,并记得链式调用它们。由于这是应用的最外层路由,我们最终导出它的类型。

ts
// 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 越慢。主要原因在于推断应用类型需要大量的类型实例化。

例如,你的应用存在如下路由:

ts
// app.ts
export const app = new Hono().get('foo/:id', (c) =>
  c.json({ ok: true }, 200)
)

Hono 推断出的类型类似于:

ts
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 性能。

将服务端应用与客户端一并编译是最有效的做法。可以在项目中加入以下代码:

ts
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,即可获得已经计算好类型的客户端:

ts
const client = hcWithType('http://localhost:8787/')
const res = await client.posts.$post({
  form: {
    title: 'Hello',
    body: 'Hono is a cool project',
  },
})

如果你的项目是 monorepo,可以配合 turborepo 等工具,将服务端与客户端拆分成独立项目,同时维护它们之间的依赖关系。这里有一个可运行的示例

你也可以使用 concurrentlynpm-run-all 等工具手动协调构建流程。

手动指定类型参数

虽然略显繁琐,但你可以通过手动指定类型参数来避免复杂的类型实例化。

ts
const app = new Hono().get<'foo/:id'>('foo/:id', (c) =>
  c.json({ ok: true }, 200)
)

即便只指定一个类型参数,也能在一定程度上提升性能。不过当路由过多时,编写成本较高。

拆分应用与客户端

正如大型应用中的使用方式所述,可以将应用拆分为多个子应用,并为每个子应用创建独立客户端:

ts
// 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 无需一次性为所有路由实例化类型。

Released under the MIT License.