🔥

Hono x Next.js でキャッシュしないRPCクライアントを使いたい

2024/06/30に公開

こんにちは!
最近個人的に推している開発環境は Hono x Next.js です。Next.js Route Handlers の処理を Hono で構成します。

https://nextjs.org/docs/app/building-your-application/routing/route-handlers

構築方法は、Hono 公式ドキュメントか、

https://hono.dev/docs/getting-started/vercel

Tsuboi 氏の Zenn 記事が参考になります。

https://zenn.dev/chot/articles/e109287414eb8c

遭遇した問題

Server Component 上で Hono RPC を使うと、ビルド時にリクエストしてしまいエラーに遭遇しました。

bunx next build
  ▲ Next.js 14.2.4

   Creating an optimized production build ...
 ✓ Compiled successfully
.
. (省略)
.
 ✓ Collecting page data
   Generating static pages (0/7)  [    ]TypeError: fetch failed
    at node:internal/deps/undici/undici:12502:13
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async A (/Users/yoshikouki/src/github.com/yoshikouki/honon/.next/server/app/page.js:14:37391) {
  digest: '3496390840',
  [cause]: AggregateError [ECONNREFUSED]:
      at internalConnectMultiple (node:net:1117:18)
      at afterConnectMultiple (node:net:1684:7)
      at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
    code: 'ECONNREFUSED',
    [errors]: [ [Error], [Error] ]
  }
}
TypeError: fetch failed
    at node:internal/deps/undici/undici:12502:13
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async A (/Users/yoshikouki/src/github.com/yoshikouki/honon/.next/server/app/page.js:14:37391) {
  digest: '3496390840',
  [cause]: AggregateError [ECONNREFUSED]:
      at internalConnectMultiple (node:net:1117:18)
      at afterConnectMultiple (node:net:1684:7)
      at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
    code: 'ECONNREFUSED',
    [errors]: [ [Error], [Error] ]
  }
}
TypeError: fetch failed
    at node:internal/deps/undici/undici:12502:13
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async A (/Users/yoshikouki/src/github.com/yoshikouki/honon/.next/server/app/page.js:14:37391) {
  digest: '3496390840',
  [cause]: AggregateError [ECONNREFUSED]:
      at internalConnectMultiple (node:net:1117:18)
      at afterConnectMultiple (node:net:1684:7)
      at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
    code: 'ECONNREFUSED',
    [errors]: [ [Error], [Error] ]
  }
}

Error occurred prerendering page "/". Read more: https://nextjs.org/docs/messages/prerender-error

TypeError: fetch failed
    at node:internal/deps/undici/undici:12502:13
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async A (/Users/yoshikouki/src/github.com/yoshikouki/honon/.next/server/app/page.js:14:37391)
 ✓ Generating static pages (7/7)

> Export encountered errors on following paths:
	/page: /

このエラーは Next.js 15 RC や、 RC で PPR と ReactCompiler を有効にしていても同様に発生します。

事前にビルドできる分はしておく Next.js の最適化ではありますが、ケースによっては余計です。また、個人的には、事前ビルドやキャッシュは自分でコントロールしておきたいです。特にキャッシュ事故が怖い。

起因

エラーの起因は Hono RPC が発行する fetch を Next.js がキャッシュ対象と判定しているためです。

キャッシュをオプトアウトするには、Next.js 公式ドキュメントで7つの方法が挙げられています。

  • The cache: 'no-store' is added to fetch requests.
  • The revalidate: 0 option is added to individual fetch requests.
  • The fetch request is inside a Router Handler that uses the POST method.
  • The fetch request comes after the usage of headers or cookies.
  • The const dynamic = 'force-dynamic' route segment option is used.
  • The fetchCache route segment option is configured to skip cache by default.
  • The fetch request uses Authorization or Cookie headers and there's an uncached request above it in the component tree.

https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#opting-out-of-data-caching

Next.js のキャッシュを無効にする Hono RPC を作る

上記を参考に、キャッシュ無効な Hono RPC とキャッシュ可能なものを用意します。

src/server/client.ts
import { hc } from "hono/client";
import type { AppType } from ".";

const apiUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:8888/api";

export const cacheableClient = hc<AppType>(apiUrl);
export const client = hc<AppType>(apiUrl, {
  fetch: (input: RequestInfo | URL, requestInit?: RequestInit) =>
    fetch(input, {
      cache: "no-cache",
      ...requestInit,
    }),
});

後はこれを ServerComponent で呼び出すだけです

src/app/page.tsx
import { client } from "@/server/client";

export const RootPage = async () => {
  const res = await client.time.$get();
  const { message } = await res.json();

  return (
    <main className="flex flex-col items-center justify-center gap-10 py-40">
      <div className="text-2xl tabular-nums">S: {message}</div>
    </>
  );
};

Server Component の表示例

おわりに

この記事では、Hono RPC を Server Component で使用する際にキャッシュ無効にする方法について述べました。
ここからは雑感を書き連ねます。

そもそもこの実装では、Next.js から自分自身へ HTTP リクエストしているためオーバーヘッドがあります。また、利用するエンドポイントが Cookie で保護されていた場合などには別の問題が発生するでしょう。

理想は Hono RPC のインターフェースと型だけを利用したいのですが、現状上手く行っていません。「クライアントへAPIを公開しつつ、Server Component からも型・検証付きのインターフェースとして利用できる」みたい状況を夢見ています。

しかし、そもそも RPC がそのような目的を持っているわけではないため、Hono が MVC2 モデルでいう Controller レイヤーとその呼び出しという風に捉えると、別のレイヤーで実現すべきなのかもしれません (例を挙げると、Hono Routing から呼び出す Service レイヤーのようなものを導入し、Server Component からはそちらを叩くなど)

ここら辺は実際にアプリケーションを作って行く中でより良い形を見つけて行こうと思います。

Discussion