跳转到正文

RPC ​

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

首先,在服务端代码中导出 Hono 应用的 typeof 类型(通常命名为 AppType),也可以只导出希望提供给客户端的路由类型。

Hono 客户端接受 AppType 作为泛型参数,从而推断验证器指定的输入类型,以及返回 c.json() 的处理程序产生的输出类型。

NOTE

要让 RPC 类型在 monorepo 中正确工作,请在客户端和服务端的 tsconfig.json 文件的 compilerOptions 中都设置 "strict": true。了解更多。

服务端 ​

服务端只需编写验证器并创建 route 变量。以下示例使用 Zod 验证器。

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

TIP

Standard Schema 验证器也适用,因此可以使用 Valibot 等任意支持 Standard Schema 的库。

然后导出类型,与客户端共享 API 规范。

ts
export type AppType = typeof route

客户端 ​

在客户端,首先导入 hc 和 AppType。

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

Cookie ​

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

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

状态码 ​

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

    return c.json({ post }, 200) // Specify 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 includes the global error type
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() 返回未找到响应,否则无法正确推断客户端从服务端获取的数据。

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() 并指定未找到响应的状态码。

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() 返回带类型的响应:

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(), // coerce to convert to number
    })
  ),
  (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`, converted by the validator to `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

// 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 对参数编码,确保路由正确匹配。

请求头 ​

可以为请求添加请求头。

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

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`

带类型的 URL ​

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

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 等库中的类型安全键,这会很有用。

$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
// 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 示例脚本中,使用服务绑定的 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
  },
})

类型推断 ​

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

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 代码库访问和使用另一个代码库中的代码。(来源:Hono RPC 与 TypeScript 项目引用)。

tsc 可以在编译时执行类型实例化等繁重任务!这样,tsserver 就不必在每次使用时实例化所有类型参数,可以显著提高 IDE 速度。

将客户端连同服务端应用一起编译,可以获得最佳性能。在项目中加入以下代码:

ts
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,获取类型已预先计算好的客户端。

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

只指定一个类型参数就能改善性能,但路由较多时,这可能耗费大量时间和精力。

将应用和客户端拆分到多个文件 ​

如在大型应用中使用 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 就无需一次实例化所有路由的类型。

返回 Promise 链的处理程序 ​

直接返回 .then() 链的处理程序会丢失响应类型,客户端因此推断为 unknown:

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

ts
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() 添加类型注解也可以:

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

基于 MIT 许可证发布。