Skip to content

RPC

RPC 功能允许在服务器和客户端之间共享 API 规范。

首先,从服务器代码中导出 Hono 应用的 typeof(通常称为 AppType)——或者只是你想要客户端可用的路由。

通过将 AppType 接受为泛型参数,Hono Client 可以推断 Validator 指定的输入类型,以及处理程序返回 c.json() 发出的输出类型。

NOTE

为了使 RPC 类型在 monorepo 中正常工作,在客户端和服务端的 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 作为泛型传递,并将服务器 URL 指定为参数。

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

Cookies

要使客户端在每个请求中发送 cookies,在创建客户端时将 { 'init': { 'credentials": 'include' } } 添加到选项中。

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

// 此请求现在将包含你可能设置的任何 cookies
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
>

全局响应

Hono RPC 客户端不会自动从全局错误处理程序(如 app.onError())或全局中间件推断响应类型。你可以使用 ApplyGlobalResponse 类型辅助工具将全局错误响应类型合并到所有路由中。

ts
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')

现在客户端知道了成功和错误响应:

ts
const res = await client.api.users.$get()

if (res.ok) {
  const data = await res.json() // { users: string[] }
}

// InferResponseType 包含全局错误类型
type ResType = InferResponseType<typeof client.api.users.$get>
// { users: string[] } | { error: string }

你也可以一次性定义多个全局错误状态码:

ts
type AppWithErrors = ApplyGlobalResponse<
  typeof app,
  {
    401: { json: { error: string; message: string } }
    500: { json: { error: string; message: string } }
  }
>

未找到

如果你想使用客户端,不应该使用 c.notFound() 作为 Not Found 响应。客户端从服务器获取的数据无法被正确推断。

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 是未知的

请使用 c.json() 并为 Not Found 响应指定状态码。

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 = await getPost(id)

    if (!post) {
      return c.json({ error: 'not found' }, 404) // 指定 404
    }

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

或者,你可以使用模块扩充来扩展 NotFoundResponse 接口。这允许 c.notFound() 返回 typed response:

ts
// 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 响应类型。

路径参数

你也可以处理包含路径参数或查询值的路由。

ts
const route = app.get(
  '/posts/:id',
  zValidator(
    'query',
    z.object({
      page: z.coerce.number().optional(), // 强制转换为数字
    })
  ),
  (c) => {
    // ...
    return c.json({
      title: 'Night',
      body: 'Time to sleep',
    })
  }
)

路径参数和查询值 必须 作为 string 传递,即使底层值是不同类型的。

使用 param 指定你想包含在路径中的字符串,使用 query 指定任何查询值。

ts
const res = await client.posts[':id'].$get({
  param: {
    id: '123',
  },
  query: {
    page: '1', // `string`,由验证器转换为 `number`
  },
})

多个参数

处理具有多个参数的路由。

ts
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',
    })
  }
)

添加多个 [''] 以指定路径中的参数。

ts
const res = await client.posts[':postId'][':authorId'].$get({
  param: {
    postId: '123',
    authorId: '456',
  },
  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 对参数进行编码以确保正确路由。

请求头

你可以将 headers 附加到请求中。

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

要为所有请求添加公共 header,请将其作为参数指定给 hc 函数。

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

init 选项

你可以将 fetch 的 RequestInit 对象作为 init 选项传递给请求。下面是中止请求的示例。

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

你必须传入绝对 URL 才能使其工作。传入相对 URL / 将导致以下错误。

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`

类型化 URL

你可以将基础 URL 作为第二个类型参数传递给 hc 以获得更精确的 URL 类型:

ts
const client = hc<typeof route, 'http://localhost:8787'>(
  'http://localhost:8787/'
)

const url = client.api.posts.$url()
// url 是带有精确类型信息的 TypedURL
// 包括协议、主机和路径

当你想将 URL 用作 SWR 等库的类型安全键时,这很有用。

$path()

$path() 类似于 $url(),但返回的是路径字符串而不是 URL 对象。与 $url() 不同,它不包含基础 URL 源,因此无论您传递给 hc 的基础 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 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`

您也可以传递查询参数:

ts
const path = client.api.posts.$path({
  query: {
    page: '1',
    limit: '10',
  },
})
console.log(path) // `/api/posts?page=1&limit=10`

文件上传

您可以使用表单主体上传文件:

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

自定义 fetch 方法

您可以设置自定义 fetch 方法。

在以下 Cloudflare Worker 示例脚本中,使用了 Service Bindings 的 fetch 方法,而不是默认的 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),
})

自定义查询序列化器

您可以使用 buildSearchParams 选项自定义查询参数的序列化方式。当您需要数组的括号表示法或其他自定义格式时,这很有用:

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

推断

使用 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 的响应。

ts
import { parseResponse, DetailedError } from 'hono/client'

// result 包含解析后的响应主体(根据 Content-Type 自动解析)
const result = await parseResponse(client.hello.$get()).catch(
  (e: DetailedError) => {
    console.error(e)
  }
)
// 如果响应不正常,parseResponse 会自动抛出错误

使用 SWR

您也可以使用 React Hook 库,例如 SWR

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 每次使用应用时都会执行这项耗时的任务。如果您有很多路由,这会显著减慢您的 IDE 速度。

不过,我们有一些技巧可以缓解这个问题。

Hono 版本不匹配

如果您的后端与前端分离并位于不同的目录中,您需要确保 Hono 版本匹配。如果您在后端使用一个 Hono 版本,在前端使用另一个版本,将会遇到诸如 "Type instantiation is excessively deep and possibly infinite"(类型实例化过深且可能无限)之类的问题。

TypeScript 项目引用

就像 Hono 版本不匹配 的情况一样,如果您的后端和前端是分开的,也会遇到问题。如果您想在前端访问后端的代码(例如 AppType),您需要使用 项目引用。TypeScript 的项目引用允许一个 TypeScript 代码库访问和使用另一个 TypeScript 代码库中的代码。(来源:Hono RPC 和 TypeScript 项目引用)

在使用前编译您的代码(推荐)

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)
)

仅指定单个类型参数就能在性能上产生差异,但如果您有很多路由,这可能会花费您大量时间和精力。

将您的应用和客户端拆分为多个文件

正如 在大型应用中使用 RPC 中所述,您可以将应用拆分为多个应用。您也可以为每个应用创建客户端:

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 就不需要一次性为所有路由实例化类型。