跳至內容

RPC

RPC 功能允許在伺服器和客戶端之間共享 API 規格。

您可以導出由驗證器指定的輸入類型和 json() 發出的輸出類型。 Hono Client 將能夠導入它。

注意

為了使 RPC 類型在單體倉庫中正常工作,請在客戶端和伺服器的 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
    )
  }
)

然後,匯出類型以與客戶端共享 API 規格。

ts
export type AppType = typeof route

客戶端

在客戶端,首先導入 hcAppType

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

hc 是一個用於建立客戶端的函式。將 AppType 作為泛型傳遞,並將伺服器 URL 指定為引數。

ts
const client = hc<AppType>('https://127.0.0.1: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)
}

檔案上傳

目前,客戶端不支援檔案上傳。

狀態碼

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

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

export type AppType = typeof app

您可以通過狀態碼取得資料。

ts
// client.ts
const client = hc<AppType>('https://127.0.0.1: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
>

找不到

如果您想使用客戶端,則不應將 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: Post | undefined = await getPost(id)

    if (post === undefined) {
      return c.json({ error: 'not found' }, 404) // Specify 404
    }

    return c.json({ post }, 200) // Specify 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: {},
})

標頭

您可以將標頭附加到請求。

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 選項

您可以將 fetch 的 RequestInit 物件作為 init 選項傳遞給請求。以下是中止請求的範例。

ts
import { hc } from 'hono/client'

const client = hc<AppType>('https://127.0.0.1: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>('https://127.0.0.1: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>('https://127.0.0.1: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`

自訂 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>('/', {
  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>

使用 SWR

您也可以使用 React Hook 函式庫,例如 SWR

tsx
import useSWR from 'swr'
import { hc } from 'hono/client'
import type { InferRequestType } from 'hono/client'
import { 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 速度。

但是,我們有一些技巧可以減輕這個問題。

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('https://127.0.0.1:8787/')
const res = await client.posts.$post({
  form: {
    title: 'Hello',
    body: 'Hono is a cool project',
  },
})

如果您的專案是單體倉庫,則此解決方案非常適用。使用 turborepo 等工具,您可以輕鬆地將伺服器專案和客戶端專案分開,並在管理它們之間的相依性時獲得更好的整合。這是一個可運作的範例

如果您的客戶端和伺服器位於單一專案中,則 tsc專案參照是一個不錯的選擇。

您也可以使用 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 不需要一次實例化所有路由的類型。

根據 MIT 許可發布。