Honoフレームワーク実践記:Web標準で作られたWebフレームワークを使ってみて
- 課題: エッジ(Cloudflare Workers)で動く、軽量でコールドスタートが短いAPI層が必要。ただしOpenAPIやコード生成なしで端から端まで型安全にしたい。
- 解決策: Hono — Web標準(本物のRequest/Response)で作られ、複数ランタイムで動き、ミドルウェアがコアに同梱、サーバーからクライアントへ型を共有するRPCを備える。
- 結果: 数KBのバンドル、ミリ秒のコールドスタート、ルートからクライアントへ直接流れる型。代償は「楽観的」な型への規律と、使わない判断を持つこと。
短い答え: HonoはWeb標準で作られた軽量(数KB)なWebフレームワークで、ブラウザのfetch()と同じRequest/Responseを使う。だから同じコードがCloudflare Workers、Bun、Deno、Node、AWS Lambdaで動く。システムアーキテクトが注目すべき理由は「Expressより速い」からではなく、ランタイムへの縛りを外し、ルートからクライアントまで型をそのまま流せるからだ。
TL;DR(エグゼクティブサマリー)
- 課題: 当サイトのAPI層(と社内のいくつかのマイクロサービス)はCloudflare上で動いている。Workersに載せられる十分小さいバンドルとミリ秒のコールドスタートが必要で、なおかつOpenAPIを立ててコード生成する手間なしに端から端まで型安全にしたかった。
- 解決策: その層をHonoで書き直した。ミドルウェア(logger、CORS、JWT…)はコアに同梱で、5つのパッケージを
npm installする必要はない。RPCモードはexport type一行でサーバーからクライアントへ型を共有する。- 結果: 約15KB未満のバンドル、一桁ミリ秒のコールドスタート、ランタイム変更でもほぼコード修正なし。代償は、Honoの型が「楽観的」であること——ミドルウェアの順序を正しく保つのは利用者の規律に委ねられる。
再定義:システムアーキテクチャの視点でHonoとは
多くの紹介記事はHonoを「エッジ向けの速いExpress」と呼ぶ。分かりやすいが、本質的な話を台無しにしている。
核心的な違いは速度ではなく互換レイヤーだ。ExpressはNode独自のreq/resの上に書かれている——10年以上の歴史を持ち、Node内にしか存在しないAPIだ。HonoはWeb標準(WinterCG)のRequest/Responseの上に書かれている——ブラウザでfetch()を呼ぶときに使うのと同じオブジェクトだ。
アーキテクチャ上の帰結は大きい。Honoはランタイムの「共通言語」しか話さないので、同じハンドラがCloudflare Workers、Bun、Deno、Node、Vercel Edge、Fastly、Lambdaでほぼ無修正で動く。アーキテクチャを一つのベンダーに縛らない。システムの3〜5年の寿命を考えなければならない人間にとって、「ランタイムに縛られない」ことは飾りではなく資産だ。
なぜHonoを試したか(実際の文脈)
当サイトはCloudflare Pagesにデプロイしており、サーバーレス関数がいくつかある(例えばPageSpeed Insightsを呼ぶプロキシエンドポイント——APIキーをクライアントに漏らさないため)。関数の数が増えると、極めて現実的な問題が二つ現れた。
- コールドスタートとCPUバジェット。 WorkersはCPU時間で課金され、ハードな上限がある。重いNodeフレームワークを載せると、動かないか不快なコールドスタートを食う。ほぼ瞬時に立ち上がるものが必要だった。
- ネットワーク境界で型が切れる。 フロントが関数を呼ぶが、リクエスト/レスポンスの形は手作業で同期する必要がある。エンドポイントの形を一つ変えると呼び出し側を手で直すことになり、TypeScriptは助けてくれない。
Honoはこの両方を同時に解決するので、API層をHonoに移した。
実コード:ルーティングとミドルウェア
すぐに「軽い」と感じたのがこの部分だ。ミドルウェアは個別にインストールする山ではなく、コアに入っている。
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
const app = new Hono()
app.use('*', logger())
app.use('/api/*', cors())
app.get('/api/health', (c) => c.json({ ok: true }))
app.get('/api/posts/:id', (c) => {
const id = c.req.param('id') // string として推論済み
return c.json({ id })
})
export default app // Workersでは export default だけで動く
app.listen()もserver.jsもない。Cloudflare Workersではexport default appで十分だ——Hono自身がランタイムの期待するfetchハンドラの契約をそのまま話すからだ。ミドルウェアは登録順に実行される。最初に登録したミドルウェアの「next()の前」が最初に走り、その「next()の後」が最後に走る。認証・キャッシュ・エラーハンドラを積むときにこの理解が効いてくる。
最大の魅力:コード生成なしの型安全RPC
ここに残った理由がこれだ。バリデータ付きでルートを定義し、そのルートをexport typeする。
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const app = new Hono()
const route = app.post(
'/posts',
zValidator('json', z.object({ title: z.string(), body: z.string() })),
(c) => {
const data = c.req.valid('json') // Zodで検証・型付け済み
return c.json({ ok: true, id: 1 }, 201)
}
)
export type AppType = typeof route
クライアント側ではhc(Hono Client)を作り、AppTypeを渡す。
import { hc } from 'hono/client'
import type { AppType } from './server'
const client = hc<AppType>('https://api.example.com/')
const res = await client.posts.$post({
json: { title: 'Hono', body: 'Workersで試した' },
})
if (res.ok) {
const data = await res.json() // data.id は推論済み、手動キャスト不要
}
コード生成のステップも、再ビルドが必要な生成済みOpenAPIファイルも、追加のサーバーもない。型は通常のimportを通じてルート定義から呼び出し側へ直接流れる。サーバーでスキーマを変えれば、クライアントで即座にTypeScriptが赤くなる。これはtRPCがやることと同じだが、Honoは独自プロトコルではなく素のHTTP/fetchの上でそれを行う。
実践比較:Hono vs Express vs Elysia
以下の数値は公開ベンチマークと直接の経験から集めたもの——絶対値は環境次第だが、相対的な順位は安定している。
| 基準 | Express | Hono | Elysia |
|---|---|---|---|
| バンドル(minified) | 約572 KB | 約14〜15 KB | 約15 KB |
| 対応ランタイム | 主にNode | Workers, Deno, Bun, Node, Lambda, Vercel | Bunネイティブ |
| コアミドルウェア | 5つ以上を個別に | コアに同梱 | プラグイン方式 |
| クライアント型安全 | ほぼなし | RPC hc()、非常に良い |
Eden Treaty、最良 |
| スループット(Bun上) | 最低 | 高い | 最高 |
| エッジでのコールドスタート | 重い | 一桁ミリ秒 | 主眼ではない |
この表をアーキテクトとして読む:Elysiaは純粋なスループットと型の滑らかさで勝つが、Bunに強く縛られる。Honoは最もバランスの取れた位置にいる——速く、小さく、型も良く、そしてどこでも動く。Expressは伝統的なNodeモノリスには今も十分だが、エッジでは道具違いだ。
実践のハマりどころ:あまり事前に語られないこと
無料のフレームワークはない。私の時間を奪った三つを挙げる。
1. Honoの型は「楽観的」だ。 c.set()で設定した変数はアプリのグローバルスコープで型付けされるため、TypeScriptはその変数を設定するミドルウェアが一度も走っていないルートでもc.var.dbを読ませてしまう。IDEでは緑だが実行時にはundefinedになる。
app.use('/admin/*', async (c, next) => {
c.set('db', createDb(c.env))
await next()
})
app.get('/public', (c) => {
const db = c.var.db // TSエラーは出ないが、このルートは上のミドルウェアを通らない -> undefined
})
これはクリーンなDXのための意図的なトレードオフだが、規律を要求する。どのミドルウェアがどの変数を設定し、どのルートがそれを通るか——頭の中かフォルダ構造で自分で明確に保たねばならない。
2. ESMのみ。 Honoと@hono/node-serverはESM専用だ。プロジェクトにCommonJSが残っていると、実行時にモジュール解決エラーに当たる。古いコードベースに入れる前に知っておく価値がある。
3. HonoX(メタフレームワーク)はまだalpha。 Honoの上にNext風のフルスタックフレームワークを夢見るなら、HonoXがその方向だ——だがマイナーバージョンでも壊れうる。今日の本番では、安全なレシピはAPIにHono+別立てのフロントエンドで、HonoXは安定するまで追いかける。
ドキュメントの小さなコツで、私も正しいと確認したもの:ルートが多い場合は先にコンパイルしてからIDEを開くこと。さもないとRPCの型推論でエディタが重くなりうる。そしてRPCではc.notFound()を避け、ステータスコードを型に乗せるためc.json({...}, 404)を使う。
いつ使うべきで、いつ使わないべきか?
手短に言えば:HonoはエッジのAPI層やマイクロサービスに値し、tRPC/OpenAPIを背負わずに端から端まで型安全にしたいときに向く。逆に、問題なく動いている安定したExpressモノリスを、流行を追うためだけに移行するのはマイナス価値だ。
ただしこれは技術というよりコストとリスクの判断だ——特にExpressからHonoへは段階的に移行できないからだ。その部分は判断のための別記事に切り出した:Honoを本番で使うべきか?判断マトリクスと移行コスト。
よくある質問
HonoはあらゆるプロジェクトでExpressを置き換えられるか?
いいえ、そう考えるべきでもない。HonoはエッジのAPI層とマイクロサービスで力を発揮する——小さいバンドルとコールドスタートが現実の問題になる場所だ。Expressのミドルウェアエコシステムで問題なく動いているNodeモノリスには、作り直す技術的理由はない。きれいなベンチマークではなく、問題とランタイムで道具を選ぶこと。
HonoのRPCはtRPCと同じか?
体験は似ている:どちらもコード生成なしでサーバーからクライアントへ型を流す。違いは、tRPCがHTTPの上に独自プロトコルを構築するのに対し、Honoは素のHTTP/fetchを保つことだ——ルートは通常のRESTのままで、hc()クライアントはfetchに被せた薄い型レイヤーにすぎない。curlで普通のAPIとして叩けつつTypeScriptで型安全でいたいなら、Honoのモデルのほうが合う。
本記事はエッジで動くアーキテクチャのクラスタに属する:ハードウェア/AIの視点は運用におけるエッジAIとコンピュータビジョン、大規模データのアーキテクチャの視点はサプライチェーンのビッグデータ:1億件超のレコードを参照。エッジのAPI層やインフラの再構築を検討中なら、プロジェクトストーリーやお問い合わせもどうぞ。