Skip to content

远程过程调用

🌐 RPC

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

🌐 The RPC feature allows sharing of the API specifications between the server and the client.

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

🌐 First, export the typeof your Hono app (commonly called AppType)—or just the routes you want available to the client—from your server code.

通过将 AppType 作为通用参数,Hono 客户端可以推断出由验证器指定的输入类型,以及由返回 c.json() 的处理器发出的输出类型。

🌐 By accepting AppType as a generic parameter, the Hono Client can infer both the input type(s) specified by the Validator, and the output type(s) emitted by handlers returning c.json().

NOTE

为了使 RPC 类型在单一代码库中正常工作,需要在客户端和服务器的 tsconfig.json 文件的 compilerOptions 中设置 "strict": true阅读更多

服务器

🌐 Server

在服务器端你所需要做的就是编写一个验证器并创建一个变量 route。下面的示例使用了 Zod 验证器

🌐 All you need to do on the server side is to write a validator and create a variable route. The following example uses 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 规范。

🌐 Then, export the type to share the API spec with the Client.

ts
export type AppType = typeof route

客户端

🌐 Client

在客户端,首先导入 hcAppType

🌐 On the Client side, import hc and AppType first.

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

hc 是一个用于创建客户端的函数。将 AppType 作为泛型传入,并将服务器 URL 作为参数指定。

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

调用 client.{path}.{method} 并将你希望发送到服务器的数据作为参数传入。

🌐 Call client.{path}.{method} and pass the data you wish to send to the server as an argument.

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

res 与 "fetch" 响应兼容。你可以使用 res.json() 从服务器获取数据。

🌐 The res is compatible with the "fetch" Response. You can retrieve data from the server with res.json().

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

Cookies

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

🌐 To make the client send cookies with every request, add { 'init': { 'credentials": 'include' } } to the options when you're creating the client.

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

状态代码

🌐 Status code

如果你在 c.json() 中明确指定状态码,例如 200404,它将被添加为传递给客户端的类型。

🌐 If you explicitly specify the status code, such as 200 or 404, in c.json(), it will be added as a type for passing to the client.

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) // Specify 404
    }

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

export type AppType = typeof app

你可以通过状态代码获取数据。

🌐 You can get the data by the status code.

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
>

全球响应

🌐 Global Response

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

🌐 Hono RPC client doesn't automatically infer response types from global error handlers like app.onError() or global middleware. You can use the ApplyGlobalResponse type helper to merge global error response types into all routes.

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

现在客户端了解了成功和错误的响应:

🌐 Now the client knows about both success and error responses:

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

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

🌐 You can also define multiple global error status codes at once:

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

未找到

🌐 Not Found

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

🌐 If you want to use a client, you should not use c.notFound() for the Not Found response. The data that the client gets from the server cannot be inferred correctly.

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 is unknown

请使用 c.json() 并指定未找到响应的状态码。

🌐 Please use c.json() and specify the status code for the Not Found Response.

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) // Specify 404
    }

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

或者,你可以使用模块增强来扩展 NotFoundResponse 接口。这允许 c.notFound() 返回一个类型化的响应:

🌐 Alternatively, you can use module augmentation to extend NotFoundResponse interface. This allows c.notFound() to return a 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 响应类型了。

🌐 Now the client can correctly infer the 404 response type.

路径参数

🌐 Path parameters

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

🌐 You can also handle routes that include path parameters or query values.

ts
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 传递,即使底层值是不同类型。

🌐 Both path parameters and query values must be passed as string, even if the underlying value is of a different type.

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

🌐 Specify the string you want to include in the path with param, and any query values with query.

ts
const res = await client.posts[':id'].$get({
  param: {
    id: '123',
  },
  query: {
    page: '1', // `string`, converted by the validator to `number`
  },
})

多个参数

🌐 Multiple parameters

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

🌐 Handle routes with multiple parameters.

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

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

🌐 Add multiple [''] to specify params in path.

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

包含斜线

🌐 Include slashes

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

ts
// 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 对参数进行编码,以确保正确路由。

Headers

你可以将标头附加到请求中。

🌐 You can append the headers to the request.

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

要向所有请求添加通用头,请将其作为参数传递给 hc 函数。

🌐 To add a common header to all requests, specify it as an argument to the hc function.

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

init 选项

🌐 init option

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

🌐 You can pass the fetch's RequestInit object to the request as the init option. Below is an example of aborting a Request.

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: {
      // Request body
    },
  },
  {
    // RequestInit object
    init: {
      signal: abortController.signal,
    },
  }
)

// ...

abortController.abort()

INFO

init 定义的 RequestInit 对象优先级最高。它可以用来覆盖由其他选项如 body | method | headers 设置的内容。

$url()

你可以使用 $url() 获取一个 URL 对象来访问该端点。

🌐 You can get a URL object for accessing the endpoint by using $url().

WARNING

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

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

ts
// ❌ 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()
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`

Typed URL

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

🌐 You can pass the base URL as the second type parameter to hc to get more precise URL types:

ts
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 等库的类型安全键时,这非常有用。

🌐 This is useful when you want to use the URL as a type-safe key for libraries like 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`

你也可以传递查询参数:

🌐 You can also pass query parameters:

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

文件上传

🌐 File Uploads

你可以使用表单正文上传文件:

🌐 You can upload files using a form body:

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 方法

🌐 Custom fetch method

你可以设置自定义的 fetch 方法。

🌐 You can set the custom fetch method.

在以下 Cloudflare Worker 示例脚本中,Service Bindings 的 fetch 方法被使用,而不是默认的 fetch

🌐 In the following example script for Cloudflare Worker, the Service Bindings' fetch method is used instead of the default 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),
})

Custom query serializer

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

🌐 You can customize how query parameters are serialized using the buildSearchParams option. This is useful when you need bracket notation for arrays or other custom formats:

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

推断

🌐 Infer

使用 InferRequestTypeInferResponseType 来了解要请求的对象类型以及要返回的对象类型。

🌐 Use InferRequestType and InferResponseType to know the type of object to be requested and the type of object to be returned.

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>

使用类型安全助手解析响应

🌐 Parsing a Response with type-safety helper

你可以使用 parseResponse() 辅助工具轻松地从 hc 解析响应,并保持类型安全。

🌐 You can use parseResponse() helper to easily parse a Response from hc with type-safety.

ts
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

🌐 Using SWR

你也可以使用像 SWR 这样的 React Hook 库。

🌐 You can also use a React Hook library such as 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

🌐 Using RPC with larger applications

在较大型应用的情况下,例如在 构建较大型应用 中提到的示例,你需要注意推断的类型。一个简单的方法是将处理程序串联起来,以便类型始终被推断出来。

🌐 In the case of a larger application, such as the example mentioned in Building a larger application, you need to be careful about the type of inference. A simple way to do this is to chain the handlers so that the types are always inferred.

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

然后,你可以像平常一样导入子路由,并确保也链接它们的处理程序,因为这是应用的顶层(在本例中),这是我们想要导出的类型。

🌐 You can then import the sub-routers as you usually would, and make sure you chain their handlers as well, since this is the top level of the app in this case, this is the type we'll want to export.

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 创建新客户端并像平常一样使用它。

🌐 You can now create a new client using the registered AppType and use it as you would normally.

已知问题

🌐 Known issues

IDE 性能

🌐 IDE performance

使用RPC时,路由越多,你的IDE运行就会越慢。造成这种情况的主要原因之一是为了推断应用的类型,需要执行大量的类型实例化。

🌐 When using RPC, the more routes you have, the slower your IDE will become. One of the main reasons for this is that massive amounts of type instantiations are executed to infer the type of your app.

例如,假设你的应用有这样的路由:

🌐 For example, suppose your app has a route like this:

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

Hono 将推断类型如下:

🌐 Hono will infer the type as follows:

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。

🌐 This is a type instantiation for a single route. While the user doesn't need to write these type arguments manually, which is a good thing, it's known that type instantiation takes much time. tsserver used in your IDE does this time consuming task every time you use the app. If you have a lot of routes, this can slow down your IDE significantly.

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

🌐 However, we have some tips to mitigate this issue.

Hono 版本不匹配

🌐 Hono version mismatch

如果你的后端与前端分开并位于不同的目录中,你需要确保 Hono 的版本匹配。如果你在后端使用一个 Hono 版本,而在前端使用另一个版本,你会遇到诸如“类型实例化过深且可能无限”的问题。

🌐 If your backend is separated from the frontend and lives in a different directory, you need to ensure that the Hono versions match. If you use one Hono version on the backend and another on the frontend, you'll run into issues such as "Type instantiation is excessively deep and possibly infinite".

TypeScript 项目参考

🌐 TypeScript project references

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

🌐 Like in the case of Hono version mismatch, you'll run into issues if your backend and frontend are separate. If you want to access code from the backend (AppType, for example) on the frontend, you need to use project references. TypeScript's project references allow one TypeScript codebase to access and use code from another TypeScript codebase. (source: Hono RPC And TypeScript Project References).

🌐 Compile your code before using it (recommended)

tsc 可以在编译时执行像类型实例化这样的繁重任务!然后,tsserver 每次使用时就不需要实例化所有类型参数了。这会让你的 IDE 快得多!

将客户端和服务器应用一起编译可以为你提供最佳性能。将以下代码放入你的项目中:

🌐 Compiling your client including the server app gives you the best performance. Put the following code in your project:

ts
import { app } from './app'
import { hc } from 'hono/client'

// this is a trick to calculate the type when compiling
export type Client = ReturnType<typeof hc<typeof app>>

export const hcWithType = (...args: Parameters<typeof hc>): Client =>
  hc<typeof app>(...args)

编译后,你可以使用 hcWithType 而不是 hc 来获取类型已计算好的客户端。

🌐 After compiling, you can use hcWithType instead of hc to get the client with the type already calculated.

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

如果你的项目是一个单体仓库,这个解决方案非常适合。使用像 turborepo 这样的工具,你可以轻松地将服务器项目和客户端项目分开,并更好地管理它们之间的依赖集成。这里有一个 可用示例

🌐 If your project is a monorepo, this solution does fit well. Using a tool like turborepo, you can easily separate the server project and the client project and get better integration managing dependencies between them. Here is a working example.

你也可以使用像 concurrentlynpm-run-all 这样的工具手动协调你的构建过程。

🌐 You can also coordinate your build process manually with tools like concurrently or npm-run-all.

手动指定类型参数

🌐 Specify type arguments manually

这有点麻烦,但你可以手动指定类型参数以避免类型实例化。

🌐 This is a bit cumbersome, but you can specify type arguments manually to avoid type instantiation.

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

仅指定一个类型参数会对性能产生影响,而如果你有很多路由,这可能会花费你很多时间和精力。

🌐 Specifying just a single type argument makes a difference in performance, while it may take you a lot of time and effort if you have a lot of routes.

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

🌐 Split your app and client into multiple files

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

🌐 As described in Using RPC with larger applications, you can split your app into multiple apps. You can also create a client for each app:

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

🌐 This way, tsserver doesn't need to instantiate types for all routes at once.

Hono 中文网 - 粤ICP备13048890号