HonoでRPCモードを触ってみた
たまたま見かけたHonoのRPCモードについて、TypeScriptの型を使ってサーバーとクライアントの仕様を共有できることがわかりました。APIの定義やレスポンスの型情報を共有することで、クライアントアプリ側でも参照しやすくなる点がポイントです。RPCを活用する際は、メソッドチェーンでAPI Routeを作成する必要があります。RPCモードを使用すると、API側で定義した型情報をクライアント側でも利用できるため、便利な機能と言えるでしょう。
目次
たまたまXで見かけたポストが気になったので、RPCモードで何ができるかを調べてみました。
HonoのRPCモードは?
作者の記事によると、「TypeScriptの型でサーバーとクライアントの仕様を共有する」ことだそうです。サーバー側で実装したAPIの引数やレスポンスの型定義などを、クライアントアプリ側でも参照しやすくなるということでしょうか。
このあたりの理解を深めるためにも、実際にコードを書いてみましょう。
HonoでAPI Routeを作る
まずはAPI Routeを作っていきましょう。今回はファイル名をapp/routes/api/index.tsxとしました。ここにAPI用のHonoを実装していきましょう。
import { Hono } from "hono";
const app = new Hono()
.get('/', async c => {
    console.log("called")
    return c.json({
        message: 'hello rpc'
    })
})
export default app
APIを追加していきましょう。ここでapp.get()のように足していくと、型情報が意図した通りに取れないことがありました。RPCモードを使う時は、メソッドチェーンで足していく必要があるみたいです。
import { Hono } from "hono";
const app = new Hono()
.get('/', async c => {
    console.log("called")
    return c.json({
        message: 'hello rpc'
    })
})
.get('/rpc', async c => {
    return c.json({
        message: 'rpc'
    })
})
export default appアプリを起動すると、追加したAPIが認識されていることがわかります。
GET  /
GET  /api/
GET  /api/rpc
GET  /*ちなみに、ネストしたルートもちゃんと動作します。
import { Hono } from "hono";
const adminApp = new Hono()
.get('/user', c => {
    return c.json({
        id: 1
    })
})
const app = new Hono()
.get('/', async c => {
    console.log("called")
    return c.json({
        message: 'hello rpc'
    })
})
.get('/rpc', async c => {
    return c.json({
        message: 'rpc'
    })
})
.route('/admin', adminApp)
export default appHono Clientを作る
続いてクライアント側で利用するAPIクライアントを作りましょう。といってもやることはシンプルで、hc関数をexportするだけです。
const app = new Hono()
.get('/', async c => {
    console.log("called")
    return c.json({
        message: 'hello rpc'
    })
})
.get('/rpc', async c => {
    return c.json({
        message: 'rpc'
    })
})
.route('/admin', adminApp)
export default app
export let client = hc<typeof app>('http://localhost:5173/api')Clientを使ってAPIを呼び出し
いよいよRPCを使った実装を勧めましょう。フロントエンドアプリ側で作成したAPIを呼び出す場合、通常はfetchを利用してこのように実装します。
const data = await fetch('http://localhost:5173/api/rpc').then(data => data.json()).catch(console.log)
一方HonoのRPCモードでは、先ほどexportしたclientを利用します。
const data2 = await client.rpc.$get().then(data => data.json()).catch(console.log)
exportする際にAPIのホスト名などを指定しているため、実装が非常にシンプルになっています。
比較のため、どちらのAPIも呼び出すコンポーネントを作ってみました。
import { client } from "../routes/api"
export default async function Test() {
    const data = await fetch('http://localhost:5173/api/rpc').then(data => data.json()).catch(console.log)
    const data2 = await client.rpc.$get().then(data => data.json()).catch(console.log)
    return (
        <>
        <pre><code>{JSON.stringify(data,null,2)}</code></pre>
        <pre><code>{JSON.stringify(data2,null,2)}</code></pre></>
    )
}どちらも表示される内容は同じです。同じAPIを呼び出しているので当然ですね。ただ、RPCモードで実装した場合のみ、レスポンスデータでAPI側で定義した型情報を利用できることがわかります。
RPCはクライアントコンポーネントでも
この機能はクライアント側の処理でも実行できます。useEffectを使った実装に変えてみましょう。
import { useEffect, useState } from "hono/jsx"
import { client } from "../routes/api"
export default async function Test() {
    const [data, setData] = useState('')
    const [data2, setData2] = useState('')
    useEffect(() => {
        console.log('1')
        client.rpc.$get().then(data => data.json())
        .then(data => {
            setData(data.message) 
        fetch('/api/hello').then(data => data.json())
        .then(data => {
            setData2(data.message)
        })
    },[])
    return (
        <>
        <pre><code>{JSON.stringify(data2,null,2)}</code></pre>
        <pre><code>{JSON.stringify(data,null,2)}</code></pre></>
    )
}これも同じデータが表示され、同じく型サポートがRPCモードのみつかることがわかります。
まとめ: Honoでフルスタックアプリを作るなら、RPCはほぼ必須?
ポイントはAPI側のHono Appをclinetとしてexportして、それを参照することでしょうか。そのため、APIとウェブアプリが別のリポジトリで管理されているケースなどでは、組み込みや変更の反映が少し煩雑になる可能性がありそうです。
ただしアプリケーション一式をモノレポまたはAll Honoで作る場合、この機能は非常に強力です。API側で実装した内容にあわせてクライアント側の型情報も変わるため、意図しないレスポンスの変更による不具合発生リスクなどが減らせます。