本文へ移動

RPC ​

RPC 機能を使うと、サーバーとクライアントの間で API の仕様を共有できます。

まず、サーバーのコードから Hono アプリケーションの typeof の型(通常は AppType と呼びます)をエクスポートします。クライアントに公開したいルートの型だけでも構いません。

Hono クライアントは AppType を型引数として受け取り、バリデーターで指定された入力の型と、c.json() を返すハンドラーが生成する出力の型を推論できます。

NOTE

モノレポで 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
    )
  }
)

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

Not Found ​

クライアントを使う場合、Not Found レスポンスに 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() を使い、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) // 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 オプション ​

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: {
      // 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 の第 2 型引数として渡すと、より正確な 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

SWR などのライブラリで URL を型安全なキーとして使いたい場合に便利です。

$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 が遅くなります。主な原因の 1 つは、アプリケーションの型を推論するために大量の型のインスタンス化が行われることです。

たとえば、アプリケーションに次のようなルートがあるとします。

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

これは、1 つのルートに対する型のインスタンス化です。ユーザーが型引数を手動で書かなくてよい点は便利ですが、型のインスタンス化には時間がかかることが知られています。IDE の tsserver は、アプリケーションを使うたびにこの重い処理を実行します。ルートが多いと、IDE が大幅に遅くなる場合があります。

ただし、この問題を軽減する方法があります。

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)

コンパイル後は、hc の代わりに hcWithType を使い、型が計算済みのクライアントを取得できます。

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

モノレポのプロジェクトには、この方法が適しています。turborepo などのツールを使えば、サーバーとクライアントのプロジェクトを簡単に分離し、相互の依存関係を管理して連携を改善できます。動作する例もあります。

concurrently や npm-run-all などのツールで、ビルド処理を手動で調整することもできます。

型引数を手動で指定する ​

少し手間はかかりますが、型引数を手動で指定して型のインスタンス化を避けることができます。

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

型引数を 1 つ指定するだけでもパフォーマンスに違いが出ます。ただし、ルートが多い場合は多くの時間と労力が必要になるかもしれません。

アプリケーションとクライアントを複数のファイルに分割する ​

大規模なアプリケーションで 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 ライセンスで公開されています。