RPC
RPC機能は、サーバーとクライアント間でAPI仕様を共有することを可能にします。
Validatorで指定された入力タイプと、`json()` が出力する出力タイプの型をエクスポートできます。そして、Hono Clientはそれをインポートできるようになります。
注意
モノレポでRPCタイプが正しく動作するためには、クライアントとサーバーの両方の tsconfig.json ファイルの `compilerOptions` に ` "strict": true` を設定してください。詳細はこちら。
サーバー
サーバー側で行う必要があるのは、バリデーターを作成し、変数 `route` を作成することだけです。次の例では、Zod Validatorを使用しています。
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仕様を共有するために、型をエクスポートします。
export type AppType = typeof route
クライアント
クライアント側では、最初に `hc` と `AppType` をインポートします。
import { AppType } from '.'
import { hc } from 'hono/client'
`hc` はクライアントを作成するための関数です。`AppType` をジェネリクスとして渡し、サーバーのURLを引数として指定します。
const client = hc<AppType>('https://#:8787/')
`client.{path}.{method}` を呼び出し、サーバーに送信したいデータを引数として渡します。
const res = await client.posts.$post({
form: {
title: 'Hello',
body: 'Hono is a cool project',
},
})
`res` は "fetch" Responseと互換性があります。`res.json()` でサーバーからデータを取得できます。
if (res.ok) {
const data = await res.json()
console.log(data.message)
}
ファイルアップロード
現在、クライアントはファイルのアップロードをサポートしていません。
ステータスコード
`c.json()` で `200` や `404` などのステータスコードを明示的に指定した場合、クライアントに渡すための型として追加されます。
// 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
ステータスコードによってデータを取得できます。
// client.ts
const client = hc<AppType>('https://#: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
>
見つかりません
クライアントを使用する場合、Not Foundレスポンスに `c.notFound()` を使用しないでください。クライアントがサーバーから取得するデータが正しく推論できません。
// 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
Not Foundレスポンスには、`c.json()` を使用してステータスコードを指定してください。
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
}
)
パス パラメータ
パス パラメータを含むルートも処理できます。
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` を使用して、パスに含めたい文字列を指定します。
const res = await client.posts[':id'].$get({
param: {
id: '123',
},
query: {},
})
ヘッダー
リクエストにヘッダーを追加できます。
const res = await client.search.$get(
{
//...
},
{
headers: {
'X-Custom-Header': 'Here is Hono Client',
'X-User-Agent': 'hc',
},
}
)
すべてのリクエストに共通のヘッダーを追加するには、`hc` 関数の引数として指定します。
const client = hc<AppType>('/api', {
headers: {
Authorization: 'Bearer TOKEN',
},
})
`init` オプション
fetch の `RequestInit` オブジェクトを `init` オプションとしてリクエストに渡すことができます。以下は、リクエストを中止する例です。
import { hc } from 'hono/client'
const client = hc<AppType>('https://#: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 `/` を渡すと、次のエラーが発生します。
キャッチされていない TypeError: 'URL' の構築に失敗しました: 無効な URL
// ❌ Will throw error
const client = hc<AppType>('/')
client.api.post.$url()
// ✅ Will work as expected
const client = hc<AppType>('https://#:8787/')
client.api.post.$url()
const route = app
.get('/api/posts', (c) => c.json({ posts }))
.get('/api/posts/:id', (c) => c.json({ post }))
const client = hc<typeof route>('https://#: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 の次のサンプルスクリプトでは、デフォルトの `fetch` の代わりに、Service Bindings の `fetch` メソッドが使用されます。
# wrangler.toml
services = [
{ binding = "AUTH", service = "auth-service" },
]
// src/client.ts
const client = hc<CreateProfileType>('/', {
fetch: c.env.AUTH.fetch.bind(c.env.AUTH),
})
推論
`InferRequestType` と `InferResponseType` を使用して、リクエストされるオブジェクトの型と返されるオブジェクトの型を知ることができます。
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の使用
SWRのようなReact Hookライブラリも使用できます。
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の使用
大規模アプリケーションの構築で言及されている例のような大規模アプリケーションの場合、型の推論に注意する必要があります。これを行う簡単な方法は、型が常に推論されるようにハンドラーをチェーンすることです。
// 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
// 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
その後、通常どおりサブルーターをインポートし、ハンドラーもチェーンされていることを確認します。これはこの場合アプリのトップレベルであるため、エクスポートする型になります。
// 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つは、アプリの型を推論するために大量の型のインスタンス化が実行されることです。
たとえば、アプリに次のようなルートがあるとします。
// app.ts
export const app = new Hono().get('foo/:id', (c) =>
c.json({ ok: true }, 200)
)
Honoは次のように型を推論します。
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がはるかに高速になります。
サーバーアプリを含むクライアントをコンパイルすると、最高のパフォーマンスが得られます。プロジェクトに次のコードを追加します。
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` を使用して、すでに計算された型を持つクライアントを取得できます。
const client = hcWithType('https://#:8787/')
const res = await client.posts.$post({
form: {
title: 'Hello',
body: 'Hono is a cool project',
},
})
プロジェクトがモノレポの場合、このソリューションは適しています。`turborepo`のようなツールを使用すると、サーバープロジェクトとクライアントプロジェクトを簡単に分離し、それらの間の依存関係の管理をより適切に統合できます。これは動作する例です。
クライアントとサーバーが単一のプロジェクトにある場合、`tsc` のプロジェクト参照が良いオプションです。
`concurrently` や `npm-run-all` のようなツールを使用して、ビルドプロセスを手動で調整することもできます。
型引数を手動で指定する
これは少し面倒ですが、型のインスタンス化を回避するために型引数を手動で指定できます。
const app = new Hono().get<'foo/:id'>('foo/:id', (c) =>
c.json({ ok: true }, 200)
)
単一の型引数を指定するだけでパフォーマンスに違いが生じますが、ルートが多数ある場合は多くの時間と労力がかかる場合があります。
アプリとクライアントを複数のファイルに分割する
大規模アプリケーションでのRPCの使用で説明したように、アプリを複数のアプリに分割できます。アプリごとにクライアントを作成することもできます。
// 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` はすべてのルートの型を一度にインスタンス化する必要がなくなります。