🔥

Next.jsとHono RPCで安全・爆速開発

2024/06/25に公開

AV検索データベース(以下AVベース)というサイトを作っています。

https://www.avbase.net/

AVベースはNext.js(Pages Router)のモノリスで作っています。画面から呼び出すAPIには Next.js の API Routes を使っています。API RoutesはHTTPメソッドを自分でハンドルする必要があったり、エラーハンドリングを各ファイルごとに行う必要があったりと、そのまま使うにはあまり便利ではありません。

そこでAPI RoutesでHonoを使うことにしました。Honoは高速かつ様々なランタイムで動作することで人気ですが、型推論を利用した RPC 機能も搭載しています。RPCによってサーバー・クライアント間の型が接続されたことで、画像のような快適な開発が可能になりました


左がサーバー側、右がクライアント側のコード。サーバー側のリファクタリングがクライアントにも反映される様子(リクエスト・レスポンスともに message というフィールドを text に置き換えています)

Hono RPC については作者のyusukebeさんの記事がありますし、Honoは公式のドキュメントも充実しています。
十分な情報は用意されているのですが、一つ一つのサンプルはとても小さいアプリケーションなので、自分の書きたいやり方で実現するにはいくつかの情報を組み合わせる必要がありました。
この記事ではRPC機能をふくめた、Next.js + Honoのプラクティスを紹介します。

この記事で紹介していること

この記事では公式ドキュメントより少し大きいアプリケーションのプラクティスを、RPC以外の部分も含めて紹介します

  1. Next.jsの API routes で Hono を使う
    • Pages/App ルーター、 nodejs/edge ランタイム
  2. ルーティングの定義とハンドラーの定義を別々のファイルで行う
    • Factory Helper を使って型をつける
  3. Hono のスキーマ定義を zod で行う
  4. Hono の middleware を定義して使う
    • Env で型を指定する
  5. Hono RPC を使う

くどいようですが、全ての情報は公式ドキュメントにあります。各章の最初に、対応する公式ドキュメントのリンクを貼っておきます。また、タイトル詐欺になってしまいますが実は 2 以降は Next.js かどうかは関係ないです。逆にいえば、Next.js以外の人でも役に立つかもしれません

実装する

Next.js の API Routes で Hono を使う

Next.jsのAPI Routesで Hono を使うケースはHono側で公式にサポートされています。Honoがadapterを用意してくれているのでそれを使えばいいわけです。ここでちょっとややこしいのは、Next.jsでAPIを実装するには以下の4パターンの方法があることです

  1. Pages Router / nodejs runtime
  2. Pages Router / edge runtime
  3. App Router / nodejs runtime
  4. App Router / edge runtime

どのケースでも、ちゃんと対応する adapter を使うことで Hono を API Routes にマウントすることができます。2,3,4に関しては、 hono/vercel を使います。Node.js runtimeのときには @hono/node-server/vercel を使え と書いてありますが、App Routerでは hono/vercel でないと動きませんでした[1]

そして1.の環境ではドキュメント通り @hono/node-server/vercel を使います。注意したいのは config = { bodyParser: false } の export を忘れないことです。これをつけないとGETは成功するがPOST(などbodyを解釈するメソッド)は失敗する、という状態になります。[2] [3]

ともあれ、適切な adapter さえあれば難しいことはありません。公式ドキュメントの通りにやってみましょう。既存のNext.jsのアプリケーションがある人はその /api 以下のお好きな場所に、ない人は npx create-next-app@latest などで作成して以下のファイルを作成します。

pages/api/[...route].ts
import { Hono } from 'hono'
import { handle } from '@hono/node-server/vercel'
import type { PageConfig } from 'next'

export const config: PageConfig = { api: { bodyParser: false } };

const app = new Hono().basePath('/api');

app.get('/hello', (c) => {
  return c.json({ message: 'Hello from Hono!' });
});

export default handle(app);

もしくは $ npm create hono@latest my-app で nextjs テンプレートを選択して作成するのも楽です。その場合は Pages Router の edge ランタイムになっているので、それ以外のパターンが良い場合は適切なアダプターを選んで書き換えてください。

Next.js のサーバーを動かして http://localhost:3000/api/hello にアクセスし、動くことを確認しておきましょう

ルーティングの定義とハンドラーの定義を別々のファイルで行う

これでめでたし!なのですが、もう一捻りしてみましょう。
サンプルで定義されているエンドポイントは一つですが、実際にはもっと多く、サービスによっては数十〜のエンドポイントが定義されることになります。サンプルの書き方のままでは、エンドポイントが増えたときにファイルが肥大化していってしまいます。

import { handlerA } from '...'
import { handlerB } from '...'
import { handlerC } from '...'

app.
  .get('/routeA', handlerA)
  .get('/routeB', handlerB)
  .get('/routeC', handlerC)
  ...

上のようにルーティングが一覧できる形で書きたいですよね? 私は書きたいです。このとき問題になるのが、handlerの型です。ルーティング定義の第二引数に直接書けば c は Context 型に推論してくれてなんの問題もないのですが、個別に定義すると自分で型をつけなくてはいけません。

app.get('/routeA', async (c) => {
  // get などのルーティングに直接ハンドラーを書けば、適切な型(Context)が推論されている
  c.req...
});

const handler1 = async (c: Context /* 自分でアノテーションする必要がある */) => {
  ...
}
app.get('/routeB', handlerB);

これは思った以上に大変です。上のように素の Context であればいいのですが、後述するバリデーション middleware などを使うととたんに複雑になります。
hono ではこういうケースで使える Factory Helper が用意されているので、それを使いましょう

ここではシンプルに上の例を Factory Helper を使って書き直してみます。

import { createFactory } from 'hono/factory'

const factory = createFactory()

const handlers = factory.createHandlers(async (c) => {
  return c.jons({ message: 'hello' }) // -> ちゃんと c が Context に推論されている
})

// hanlders は配列(正確にはタプル?)になっているので、spreadする
app.get('/routeA', ...handlers);

どうでしょうか。あとはハンドラーを別ファイルに移して読み込むようにすれば、ルーティング定義をすっきりと羅列させられるようになります。

Hono のスキーマ定義を zod で行う

さて次は zod を使ってスキーマ定義 & バリデーションをしていきましょう。
Zod Validator Middleware を使うととても簡単に書くことができます。ほんとうに簡単なのであまり書くことがないですが、 createHandlers と一緒につかった例を載せておきます

ここでは上の例に加えて、path paramから数値のIDを取り出し、body param (application/json)から message という文字列を取り出してみます。

/server/controllers.ts
import { zValidator } from '@hono/zod-validator';
import { createFactory } from 'hono/factory';
import { z } from 'zod';

const factory = createFactory()

export const getRouteAHandler = factory.createHandlers(
  zValidator('param', z.object({ id: z.string().transform(Number) })), // ※1
  zValidator('json', z.object({ message: z.string() })),
  async (c) => {
    const { id } = c.req.valid('param');
    const { message } = c.req.valid('json');
    ...
    return c.json({ id, message })
  }
)
/pages/api/[...route].ts
...
import { getRouteAHander } from '@/server/controller';
...
app.get('/routeA/:id', ...getRouteAHandler); // ※2
...

どうでしょう。zod で定義したpathパラメータとbodyパラメータを c.req.valid() を使って型付きで取り出すことができています。zodの話にはなりますが、※1にあるようにIDが数値であることがわかっている場合は .transform を使って変換までやることができます。バリデーション失敗時の挙動は、zValidatorの第3引数で変えることができます。詳しくは Zod Validation Middleware のドキュメントをみてみてください

Hono の middleware を定義して使う

ここまででスキーマ・バリデーション付きのAPI定義をすることはできました。実際にアプリケーションを開発する際には、エンドポイントを横断して行いたい処理(例えばユーザー認証など)を middleware で行うと思います。
この章では Hono の middleware を定義し、middleware で Context にセットした値をハンドラーから参照する方法を紹介します。

まず、middlewareを定義します。ここでは仮の実装としてsomeSession(c: Context): Promise<Session | undefined> があるものとします。someSession は Context から何らかの方法でなんらかの Session 情報(有効なセッションがなければ undefined)を取得して返すものとします。急にボヤッとした感じになりますが、この辺りはこの記事の本題ではないのでご了承ください

middlewareの定義には Factory Helper の createMiddleware を使います。

server/middlewares/session.ts
export const sessionMiddleware = createMiddleware(async (c, next) => {
    const session = await someSession(c);
    c.set('session', session);
    await next()
})
pages/api/[...route].ts
import { sessionMiddleware } from '@/server/middlewares/session';

...

app.use('/', sessionMiddleware);
app
  .get('/routeA', ...getRouteAHandler)
  .get('/routeB', ...getRouteBHandler)
  ...

これですべてのルーティングで session 情報を Context に格納することができました。各ハンドラーで格納した情報を参照してみましょう。

server/controllers.ts
...
const factory = createFactory()

export const getRouteAHandler = factory.createHandlers(
  ...
  async (c) => {
    ...
    const session = c.var.session;
    ...
  }
)

...session が any になってしまいますね?このハンドラーは sessionMiddleware が use されていることを型情報として知らないですし、ここは推論は出来なさそうです。
createFactory のサンプルにあるように、c.set する値は型パラメータで指定してあげる必要があります。上のコードに少し付け足します

type MyEnv = {
  Variables: {
    session?: SomeSession;
  }
}

const factory = createFactory<MyEnv>() // <- 型パラメータを指定

const handlers = factory.createHandlers(
  ...
  async (c) => {
    ...
    const session = c.var.session; // <- SomeSession | undefined になる
    ...
  }
)

これで middleware でセットした値も、比較的型安全に取り出すことができるようになりました。これでサーバー側は完成です!

Hono RPC を使う

さて、ようやく最初のGIF画像にもどってこれました。hcをつかって快適なAPI呼び出しをしてみましょう。といっても、ここからは本当にドキュメントの通りにするだけです。

注意したいのは、サーバー側の定義です。

app.get(...);
app.get(...);
app.post(...);

export type AppType = typeof app;

このように定義してもAPIの実装としては動くのですが、 app にルーティング定義の型がついていないため RPC で使うことはできません。必ず以下のようにしてください

const route = app
  .get(...)
  .get(...)
  .post(...);

export type AppType = typeof route;

上のようにルーティング定義はまとめてチェインして行い、その返り値の型を export する必要があります。

import { AppType } from '@/pages/api/[...route]';
import { hc } from "hono/client";

const client = hc<AppType>('/');

const SomeComponent = () => {
  ...
  const res = await client.api.routeA[':id'].$get({
    param: { id: '1234' },
    json: { message: 'Hello, Hono RPC!!' },
  })
  ...
}

さて、ここで本日のハイライトです。VSCodeなどお使いのエディタのリファクタリング機能で、message といったフィールド名をリファクタリングしてみましょう。クライアント側のコードまで一緒に修正されれば成功です!

まとめ

気をつけポイントです

  • Pages Router の nodejs ランタイムを使うときは @hono/node-server/vercel アダプターを使い、bodyParser を false にする
  • App Router の nodejs ランタイムを使うときは hono/vercel を使う
  • ルーティング定義はチェインする
  • ルーティングとハンドラーを別々に定義したい時は、Factory Helperを使う
  • middlewareで c.set(...) するものには Env で型をつけてあげる

Next.js と Hono RPC を使って API/クライアントの実装をしました。サーバー・クライアントでの型の共有というと OpenAPI を使うのが一般的になってきていると思いますが、API定義を書くにしろ生成するにしろ1ステップが必要でした。RPC ではリアルタイムに型が共有されて、サーバー・クライアント間のリファクタリングも容易になり開発速度が一段階上がったと感じます。
クライアントからのAPI呼び出し部分の実装がストレスになっている人は導入してみてはいかがでしょうか 😸

脚注
  1. App Routerではreq/resがImcomingMessage/OutgoingMessage互換じゃないことが理由のように見えました ↩︎

  2. 泣き言ですが、ドキュメントにこの記述が追加されたのが2024/04でそれまでどうして良いかわからず、、これが解決できずに私は適当なadapterをでっち上げてつかっていました 😇 ↩︎

  3. AVベースでは Pages/Node.js をつかっています。本題とはそれますが edge ランタイムは Prisma や Elasticsearch のライブラリが動かず、App Routerはどうにもまだ不安定な印象があるためでした。いろいろ試したところそれぞれの環境での動かし方が調査できました 😅 ↩︎

Discussion