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

客户端 ​

在客户端引入 hc 与 AppType:

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() 中显式指定 200、404 等状态码时,该状态码也会体现在传给客户端的类型中。

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 选项向请求传递 fetch 的 RequestInit 对象。以下示例展示如何终止请求:

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

$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),
})

类型推断辅助 ​

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

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 等工具,将服务端与客户端拆分成独立项目,同时维护它们之间的依赖关系。这里有一个可运行的示例。

你也可以使用 concurrently、npm-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.