uttk's site/articles/Hono RPC を活用するためにやったこと

Hono RPC を活用するためにやったこと

この記事について

Hono には RPC という、ソースコードから TypeScript の型を生成し、バックエンドとクライアントを型安全にできる機能があります。

RPC - Hono

The RPC feature allows sharing of the API specifications between the server and the client.

hono.dev

今回は、そんな Hono RPC を上手く使うために筆者がやってきたことを共有したいと思います。

Monorepo で構成する

最初はモノリス ( 一枚岩 ) で構築していましたが、ソースコードが増加するつれて TypeScript の入力補完などのエディタ機能が著しく遅くなってしまう問題が発生しました。

この問題に対処するべく、構成を frontend/backend/ で分けて Monorepo で管理するようにし、backend から型情報を export するようにしました。イメージとしては以下のような感じです 👇

backend/src/index.ts
import { serve } from '@hono/node-server'
import { Hono } from 'hono'

const app = new Hono()

const appRoute = app.get('/', (c) => {
  return c.text('Hello Hono!')
})

const port = 3000
console.log(`Server is running on http://localhost:${port}`)

serve({
  fetch: app.fetch,
  port
})

// ここで RPC で使う型を export して置く
export type AppRouteType = typeof appRoute;
frontend/src/index.ts
// @app/backend でバックエンドでexportした値にアクセスする
import type { AppRouteType } from "@app/backend"; 
import { hc } from "hono/client";

const client = hc<AppRouteType>("http://localhost:3000")

// ...

このようにすることで、backend 側を ESModule として扱えるので型ビルドなどが容易に行いやすくなります。

私の環境では、pnpm workspace で Monorepo を構築しているので、具体的な設定は以下のような感じになります 👇

./backend/package.json
// ※ 関係ある部分だけ記述しています
{
  "private": true,
  "sideEffects": false,
  "name": "@app/backend",
  "main": "./dist/index.js",
  "type": "module",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsup ./src/index.ts --dts --format esm",
  },
}
./frontend/package.json
// ※ 関係ある部分だけ記述しています
{
  "name": "@app/frontend",
  "dependencies": {
    "@app/backend": "workspace:^",
  }
}

上記のような設定にすることで、pnpm build --filter=@app/backend でバックエンドの型を生成でき、開発サーバーを立てる前やフロントエンドのソースコードを触る前に型を生成しておけば、爆速で Hono RPC を利用することができます。

※ 基本的な Monorepo 構成については前に記事を書きましたのでそちらを参照してください 👇

もはや pnpm と Turborepo で Monorepo 環境作れるから

pnpm と Turborepo を使った必要最低限の Monorepo 環境構築の解説記事です

zenn.dev

Turborepo を使って型生成を最小限に抑えつつ自動化する

前節では pnpm workspace を使うことで事前にバックエンドの型生成をできるようにしましたが、build コマンドを手動でやらねばならず不便でした。

そこで、Turborepo を使うことで開発サーバーを立てる前に build コマンドを実行できるようにして、無駄な工程を省くようにしました 👇

./turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": [],
      "inputs": ["src/**"],
      "outputs": ["dist/**" ]
    },
    "dev": {
      "dependsOn": ["@app/backend#build"],
      "cache": false,
      "persistent": true
    }
  }
}

Turborepo では パッケージ名#コマンド名 で特定のパッケージのコマンドを指定できるので、"dependsOn" に指定することで、turbo run dev を実行すると、各パッケージの dev コマンドが実行される前にバックエンドの build コマンドが実行されるようになります。

また、Turborepo にはキャッシュ機能が備わっているので backend/src/** 内のファイルを変更しない限り build コマンドは実行されなくなり二回目以降は高速になります。

ただ Turborepo は Monorepo ツールなため、導入するのが難しいプロジェクトもあるかと思います。そのような場合は wireit という Turborepo のキャッシュ部分だけを抜き出したようなツールがあるので、そちらを試してみると良いかと思います 👇

google/wireit

Wireit upgrades your npm/pnpm/yarn scripts to make them smarter and more efficient.

github.com

hono/client を ESModule として提供する

これまでは Hono RPC を使うためだけにフロントエンド側で hono/client を import していました。

しかし、そうすると frontend/backend/ のそれぞれで hono を dependencies に含める必要がありバージョン管理が大変でした。

なので、バックエンド側で hono/client を生成するための関数を export するようにしました 👇

./backend/src/index.ts
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { hc } from "hono/client";

const app = new Hono()

const appRoute = app.get('/', (c) => {
  return c.text('Hello Hono!')
})

const port = 3000
console.log(`Server is running on http://localhost:${port}`)

serve({
  fetch: app.fetch,
  port
})

export type AppRouteType = typeof appRoute;

type ClientType = typeof hc<AppRouteType>;

// ここで hono/client を生成する関数を export する
export const createClient = (
  ...args: Parameters<ClientType>
): ReturnType<ClientType> => {
  return hc<AppRouteType>(...args);
}

そして、フロントエンド側で hono/client を import する代わりに定義した createClient() を import します 👇

./frontend/src/index.ts
import { createClient } from "@app/backend"; 

// hono/client を使わずにクライアントを生成できる!
const client = createClient("http://localhost:3000")

// ...

このようにすることで、hono のバージョン管理がバックエンド ( backend/ ) のみで済むので管理が簡単になりました。

Fetcher をカスタマイズする

hono/client では、リクエスト処理をカスタマイズできます 👇

RPC ( Custom fetch method ) - Hono

Web framework built on Web Standards for Cloudflare Workers, Fastly Compute, Deno, Bun, Vercel, Node.js, and others. Fast, but not only fast.

hono.dev

これを利用して、私の環境では 400 以上のステータスコードを受け取った時は throw するようにしています 👇

./frontend/src/index.ts
import { createClient } from "@app/backend"; 

const myFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
  const res = await fetch(input, init);

  // statusCode が 204 のときに res.json() を実行するとエラーになるため
  if (res.status === 204) return Response.json({});

  const jsonData = await res.json();

  if (res.status >= 400) {
    throw Error(jsonData.message ?? "エラーです");
  }

  return Response.json(jsonData);
};

const client = createClient("http://localhost:3000", { fetch: myFetch })

// ...

このようにすることで、swr などのライブラリと併用しやすくなるため大変便利です。

ただし、ここの部分は設計方針や使うライブラリなどによって変わると思いますので、ぜひご自身でイイ感じにカスタマイズしてください。

Response を throw できるようにする

Hono で API を実装する時エラーレスポンスを返す時があると思いますが、そのときそのまま return してしまうと、Hono RPC を使用した時にエラーレスポンスの型が付与されてしまいます 👇

./backend/src/index.ts
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { hc, type InferResponseType } from "hono/client";

const app = new Hono()

const appRoute = app.get('/', (c) => {
  if (c.req.query("error")) return c.json({ error_message: "Error!" })
  return c.json({ message: "OK" })
})

const client = hc<AppRouteType>("http://localhost:3000");

type IndexApiResponse = InferResponseType<typeof client.index.$get>
//   ^? { error_message: string; } | { message: string; }

これを防ぐためにはエラーレスポンスの時に throw を使う必要がありますが、デフォルトの Hono ではレスポンスを throw することはできません。

しかし、ミドルウェアを使うことでレスポンスを throw できるようにできるので、私はそれで対応しています 👇

./backend/src/index.ts
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { hc, type InferResponseType } from "hono/client";

/**
 * throw された Response をそのままクライアントにレスポンスとして返すミドルウェア
 */
export const catchResonseMiddleware: MiddlewareHandler = async (_, next) => {
  try {
    await next();
  } catch (err) {
    if (err instanceof Response) {
      return err;
    }
  }
};

const app = new Hono();

// ここで作ったミドルウェアを適用する
app.use(catchResonseMiddleware);

const appRoute = app.get('/', (c) => {
  if (c.req.query("error")) throw c.json({ error_message: "Error!" })
  return c.json({ message: "OK" })
})

const client = hc<AppRouteType>("");

type IndexApiResponse = InferResponseType<typeof client.index.$get>
//   ^? { message: string; } 👈 { error_message: string } を排除できる! 

あとがき

以上で私が Hono RPC を使う上でやってきた活用方法を共有しました。

まだまだ使いきれてないのでもっといい方法とかあるかと思いますが、現状でもイイ感じに使えているので小規模の開発なら参考になるかもしれません。

注意として Hono RPC の型生成コストは依然として高いです。

この記事ではなるべく型生成を減らす方向で対処していますが、ビルド時間は長いままなので根本的な解決には至っていません。

そのため、微妙に開発体験が悪くなる時が多々あります。その点には十分にご注意ください🙏

はい、ここまで読んでくれてありがとうございました!
これが誰かの参考になれば幸いです。

それではまた 👋