Zenn
🔥

Hono の RPC Client の仕組み

2024/12/20に公開

はじめに

この記事は Hono Advent Calendar 2024 の 20 日目の記事です。

今年は「Hono」のソースコードをすべて理解するというチャレンジを個人的にしてました。

Hono をご存じない方もいるかも知れないので簡単にご紹介すると、Hono は超軽量で高速なウェブフレームワークで、Node.js 環境でも Deno 環境でも、そしてブラウザでも動作するモダンな設計が特徴です。リポジトリはこちらから。

ちなみに、最近 Wikipedia も出来ました。
https://ja.wikipedia.org/wiki/Hono

遂に先日、全てを読み終えたので、その中でも特にブラックボックスであろう Hono の RPC Client の仕組みについてでも簡単に書こうと思います。

もし良ければ、いいね・共有お願いします!

Hono の RPC Client とは?

Hono の RPC Client とは hc のことです。
(RPCというのはここではサーバーとクライアント側でリクエスト/レスポンスの型を共有することを指します。)

ドキュメント: https://hono.dev/docs/guides/rpc

以下のように使用できます。

  • サーバー側
// backend.ts
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';

const app = new Hono()

const route = app.post(
  '/posts',
  zValidator(
    'form',
    z.object({
      title: z.string(),
      body: z.string(),
    })
  ),
  (c) => {
    // ...
    return c.json(
      {
        ok: true,
        message: 'Created!',
      },
      201
    )
  }
)

export type AppType = typeof route

後々分かりますが、この部分がとても重要です。

export type AppType = typeof route
  • クライアント側
// frontend.ts

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

const client = hc<AppType>('http://localhost:8787/')

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



ちゃんと型が付いているのが分かります。

これはどのように実現されているんでしょうか?

探索

先ほど述べた通り、この部分が非常に重要になってきます。

export type AppType = typeof route

この型はどうなっているのでしょうか?
ホバーして覗いてみます。

何やらAppTypeの中に、リクエストやレスポンスの型が含まれています。

この部分には、リクエストのパスとメソッドの情報が含まれています。

パス: /posts
メソッド: $post

そしてこの部分にはバリデータによって定義されたリクエストの型の情報が含まれています。

そしてこれはハンドラーのレスポンスの型の情報が含まれています。

ここで、なぜ typeof app とせず、 typeof route としているのかというと、TypeScript の制限が関係しています。
TypeScript では、途中で呼ばれた関数によって変更された型や narrowing (型の絞り込み) を追うことが出来ません。
https://github.com/honojs/hono/blob/main/src/types.ts#L150
その為、Hono では Generics (ジェネリクス) にルートの情報を積んでいくことで、リクエストやレスポンスの型を保存できます。

以下は Handler の型定義です。

https://github.com/honojs/hono/blob/main/src/types.ts#L70-L77
ここには Input と HandlerResponse という二つの型があります。
見ての通り、リクエストの型やレスポンスの型を Generics に保持しています。

https://github.com/honojs/hono/blob/main/src/types.ts#L43-L47
https://github.com/honojs/hono/blob/main/src/types.ts#L1896-L1908

これらはバリデータや、c.json 等により生成され、Handler の型を経由して Generics に積まれます。
これらを 先ほどの AppType のスキーマの形にして Generics に積んでいきます。

これらがクライアント側で組み立てられることで実現されます。

具体的には、
https://github.com/honojs/hono/blob/main/src/client/types.ts#L167-L173
この部分で Client の型が組み立てられています。
重要なのはこの部分
https://github.com/honojs/hono/blob/main/src/client/types.ts#L170
https://github.com/honojs/hono/blob/main/src/client/types.ts#L152-L164
名前から想像が付くかもしれませんが、
/api/posts/list => client.api.posts.list のようにパスをプロパティのチェーンで指定できる機能の型を生成しています。

TypeScript Playground

そして末端のパスに ClientRequest という型が生えます。
client.api.posts.list なら list の部分に生えます。

https://github.com/honojs/hono/blob/main/src/client/types.ts#L161-L163
https://github.com/honojs/hono/blob/main/src/client/types.ts#L29-L34

この型には $url$get, $post などのリクエストを送る関数が生えます。
この関数の型には、バリデータや c.json 等で生成されたリクエスト、レスポンスの型が渡され、型安全に通信を行えるという訳です。

内部的には、 JavaScript の Proxy という機能を用いて実装されています。
https://github.com/honojs/hono/blob/main/src/client/client.ts#L15-L31
https://github.com/honojs/hono/blob/main/src/client/client.ts#L148-L154

ここで、$post => POST のように変換され、
https://github.com/honojs/hono/blob/main/src/client/client.ts#L200
ここでheadersやbodyが生成されます。
そして、ここでリクエストが飛ぶというわけです。
https://github.com/honojs/hono/blob/main/src/client/client.ts#L201

そして、$get$post のレスポンスには、 ClientResponse という型が割り当てられています。

https://github.com/honojs/hono/blob/main/src/client/types.ts#L74-L101

見ての通り、通常の Response 型が上手くラップされています。
https://github.com/honojs/hono/blob/main/src/client/types.ts#L81-L85
Response.ok は、予想される status が成功であるか、失敗であるかを自動的に場合分けし、型を予想します。

https://github.com/honojs/hono/blob/main/src/client/types.ts#L86-L89

Response.status は予想されるステータスコードが割り当てられます。
他はそのままです。

https://github.com/honojs/hono/blob/main/src/client/types.ts#L92-L97

ここが特に重要です。
ここでレスポンスの Body の型を推測しています。

このように、上手く型が組み合わせられ、Hono の RPC Client は実現されています。

最後に

良いお年を!

app.get('/next-year', async (c, next) => {
  await next()
  return c.text('Happy new year 2025!')
})

Discussion

ログインするとコメントできます