初めての Hono
0. この記事のゴール
- Hono ってなに? を最小例で理解する
- 実プロジェクトの
apps/api/src/配下 6 ファイルがそれぞれ何の役割かを言えるようになる -
GET /api/v1/health1 本のリクエストが、どの順序でミドルウェアを通り、どこでハンドラが動き、どこでレスポンスが組み立てられるかを実行順序で説明できる - HTTP サーバを起動せずに Hono アプリをテストする 3 段階(ハンドラ単体 / ミドルウェア契約 / 実 HTTP 統合)の使い分けを理解する
1. Hono の特徴
| 観点 | 特徴 |
|---|---|
| ランタイム非依存 | Web 標準(Request / Response)の上に乗っており、Node / Bun / Deno / Cloudflare Workers / AWS Lambda で同じコードが動く |
| 軽量・高速 | コアが小さく依存も少ない。コールドスタートやバンドルサイズが効くサーバレス環境と相性が良い |
| TypeScript-first | ルートチェーンで型が伝播する。RPC クライアント (hc) を導入すればフロントエンドへ型付き API を供給できる |
| 公式ミドルウェアが充実 |
cors / logger / jwt / compress / etag / secure-headers などを公式提供 |
| Express からの移行が容易 | API 設計が Express に近く、メンタルモデルの差が小さい |
| アダプタで配備先を選べる | Node・Bun・Deno・Cloudflare Workers・AWS Lambda・Vercel・Netlify 等への配備アダプタが公式に存在 |
| Web 標準準拠でテストが速い | HTTP サーバを起動せず app.request(req) で Request → Response を直接取れる。ユニットテストが軽快に書ける |
このプロジェクトでは Phase 0 を @hono/node-server で long-running 起動し、Phase 1 で hono/aws-lambda の handle(app) を別エントリで export して Lambda へ載せ替える前提です。「ランタイム非依存」がそのまま採用理由になっています。
2. Hono ってなに?
Hono は 「Web 標準 Request / Response をハンドラに渡す軽量フレームワーク」 です。Express の精神的後継ですが、Express と違ってランタイムに縛られません。
最小例(公式 Getting Started より):
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hello Node.js!'))
serve(app)
-
new Hono()でアプリインスタンス -
app.get(path, handler)でルート登録 - ハンドラは
Context(c)を受け取り、c.json()/c.text()等でResponseを返す -
serve()は Node 専用アダプタ(@hono/node-server)。他ランタイムでは別のアダプタを使う
これだけ覚えれば、あとは「ミドルウェアの差し込み方」と「ルートを分割する方法」を順に覚えていくだけです。本記事はこの 2 つを実コードで追います。
3. apps/api のディレクトリ
apps/api/
└── src/
├── index.ts # Node サーバ起動エントリ(@hono/node-server)
├── app.ts # Hono 本体(middleware + onError + route 合成)
├── env.ts # 環境変数を Zod で起動時検証 / fail-fast
├── app.test.ts # 共通ミドルウェアの契約テスト
├── integration.test.ts # serve() を実起動して fetch する統合テスト
├── lib/
│ ├── logger.ts # pino で構造化 JSON ログ
│ └── meta.ts # 全レスポンス共通 meta(requestId / timestamp)
├── routes/
│ ├── health.ts # GET /api/v1/health(DB / Secrets 死活確認)
│ ├── health.test.ts # vi.mock で DB を差し替えた routes 単体テスト
│ ├── users.ts # 別記事で扱う
│ └── users.test.ts
└── db/
└── client.ts # Kysely + pg.Pool(DB 章は別記事)
DB のスキーマ・マイグレーションは workspace の packages/db に分離されています。apps/api 側は import type { DB } from '@okigin/db' で型だけ受け取り、SQL は Kysely 経由で発行します。
3.1 ファイル間の依存
ポイント:
-
logger.tsだけ何にも依存しない —env.tsよりも前に呼べる(env パース失敗時もログは出る) -
app.tsはindex.tsに依存しない — テスト側は Node サーバを起動せずapp.request('/...')を叩ける
4. apps/api を構成するファイルを順に読む
4.1 index.ts — Node サーバ起動
import { serve } from '@hono/node-server'
import { app } from './app'
import { db } from './db/client'
import { env } from './env'
import { logger } from './lib/logger'
const server = serve({ fetch: app.fetch, port: env.PORT }, (info) => {
logger.info({ url: `http://localhost:${info.port}` }, 'api listening')
})
const shutdown = () => {
setTimeout(() => process.exit(1), 10_000).unref()
server.close(() => {
void db.destroy().finally(() => process.exit(0))
})
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
-
serve()にfetch: app.fetchを渡している。Hono アプリは(req: Request) => Response | Promise<Response>という Web 標準シグネチャを露出しており、@hono/node-serverがそれを Node のhttp.Serverにブリッジする -
SIGINT/SIGTERMで HTTP サーバを閉じてから Kysely (db.destroy()) を閉じる 順序。受付中のリクエストを取りこぼさずに DB 接続を解放できる -
setTimeout(..., 10_000).unref()はdb.destroy()がハングした場合の安全装置。.unref()のおかげで正常終了時はタイマー自体がプロセスを引き止めない -
index.tsはapp.tsを import するだけ。Hono の設定そのものは持たないので、テストはこのファイルを経由せずapp.tsを直接読める
本ファイルは Phase 0 のローカル開発専用です。Phase 1 では
hono/aws-lambdaのhandle(app)を export するlambda.tsを別途追加し、Lambda 関数はそちらを参照します。app.tsはそのまま使い回せる、というのが「アダプタで配備先を選べる」の実例です。
4.2 app.ts — Hono 本体
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger as honoLogger } from 'hono/logger'
import { env } from './env'
import { logger, serializeError } from './lib/logger'
import { responseMeta } from './lib/meta'
import { healthRoute } from './routes/health'
import { usersRoute } from './routes/users'
export const app = new Hono()
// hono/logger は既定で console.log を使うため、出力先を自前ロガーへ差し替える。
app.use(
'*',
honoLogger((message) => logger.info(message)),
)
// CORS は Allow-Methods / Allow-Headers / Max-Age / Allow-Credentials を明示。
app.use(
'*',
cors({
origin: env.CORS_ALLOWED_ORIGINS,
allowMethods: ['GET', 'POST', 'OPTIONS'],
allowHeaders: ['Authorization', 'Content-Type', 'Idempotency-Key', 'X-Request-Id'],
maxAge: 600,
credentials: true,
}),
)
// 例外時のグローバル handler。内部詳細をクライアントへ漏らさない。
app.onError((err, c) => {
const meta = responseMeta(c)
logger.error({ requestId: meta.requestId, err: serializeError(err) }, 'unhandled error')
return c.json(
{
error: { code: 'INTERNAL_ERROR' as const, message: 'Internal server error' },
meta,
},
500,
)
})
const _routes = app.route('/api/v1/health', healthRoute).route('/api/v1/users', usersRoute)
export type AppType = typeof _routes
| 行 | やっていること | なぜそうしたか |
|---|---|---|
app.use('*', honoLogger(...)) |
公式ロガー middleware を入口で噛ます |
* でパス全マッチ。コールバックを差し替えて console.log をバイパスし、pino の構造化ログに流す |
app.use('*', cors({...})) |
CORS ヘッダ付与 | 必要な値を明示。デフォルト(全メソッド許可・リクエストヘッダ反射・Max-Age 未設定)に頼ると、本番で意図せず広く開いてしまう |
app.onError((err, c) => ...) |
例外時のグローバル handler | スタックトレースをクライアントへ漏らさず、共通エラー形状(error.code + meta)に正規化 |
app.route('/api/v1/health', healthRoute).route(...) |
サブルートをチェーンでマウント | パスはプレフィックス付きで合成され、型情報も伝播する(後述) |
export type AppType = typeof _routes |
RPC 用に型を export | フロントエンドから hc<AppType>('...') で型付き呼び出しを行うときの布石 |
app.route()はapp自体を mutate するため、本来チェーンの戻り値を保持しなくても動きます。それでも_routesを残しているのは、typeof _routesから 「どんなパスにどんなレスポンスが返ってくるか」が型として積み上がるからです。これが Hono の RPC(hc<AppType>)が動く原理です。
4.3 env.ts — Zod で起動時検証 / fail-fast
import { z } from 'zod'
import { logger } from './lib/logger'
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().min(1),
CORS_ALLOWED_ORIGINS: z
.string()
.min(1)
.transform((s) => s.split(',').map((o) => o.trim()).filter(Boolean))
.refine((arr) => arr.length > 0),
DB_POOL_MAX: z.coerce.number().int().positive().default(10),
DB_POOL_IDLE_TIMEOUT_MS: z.coerce.number().int().nonnegative().default(30_000),
DB_POOL_CONNECTION_TIMEOUT_MS: z.coerce.number().int().nonnegative().default(5_000),
COGNITO_ISSUER: z.string().url(),
COGNITO_JWKS_URI: z.string().url(),
COGNITO_CLIENT_ID: z.string().min(1),
})
const parsed = EnvSchema.safeParse(process.env)
if (!parsed.success) {
logger.error({ fieldErrors: parsed.error.flatten().fieldErrors }, 'Invalid environment variables')
process.exit(1)
}
export const env = parsed.data
-
safeParseで投げずに、構造化ロガーでfieldErrorsを出してからexit(1)。Lambda 移行後も CloudWatch でフィールド検索できる -
DATABASE_URL/CORS_ALLOWED_ORIGINS/COGNITO_*に default を持たせない。本番で env を渡し忘れた際に「localhost が許可された状態」で silent に立ち上がるのを防ぐ -
CORS_ALLOWED_ORIGINSは CSV 文字列を.transform()でstring[]に変換 →hono/corsのoriginオプションに直接渡せる形にしている -
DB_POOL_*は Phase 1 で Lambda 化したときにDB_POOL_MAX=1〜3まで絞るための入口。値の型・既定値・上下限を env レイヤで縛っておくと、後段のdb/client.ts側で守りを書かなくて済む
4.4 lib/logger.ts — pino で構造化ログ
import pino from 'pino'
const level = process.env.LOG_LEVEL ?? 'info'
export const logger = pino({
base: null,
level,
formatters: { level: (label) => ({ level: label }) },
timestamp: pino.stdTimeFunctions.isoTime,
messageKey: 'message',
})
export function serializeError(err: unknown): Record<string, unknown> {
if (err instanceof Error) return pino.stdSerializers.err(err)
return { value: String(err) }
}
-
base: null— pino 既定のpid/hostnameを抑制。Lambda / Local どちらでも意味を持たず、CloudWatch のフィールド数を増やすだけのため -
level: (label) => ({ level: label })—30ではなく"info"で書き出し、CloudWatch Logs Insights のfilter level = "info"が自然に書ける -
messageKey: 'message'— pino 既定のmsgをmessageに rename。集約システム側の慣習に合わせる -
process.env.LOG_LEVELを直接参照 —env.tsの Zod 検証より前にロガーを使えるよう、env 非依存に保つ(env パース失敗時もこのロガーでエラーを吐ける) -
serializeErrorはpino.stdSerializers.err互換。throw 'string'のような非Error値が来てもクラッシュしない
以前は
process.stdout.writeで自前 JSON Lines を書いていましたが、レビューで「pino に寄せたほうがエコシステムが効く」と指摘を受けて移行しました。Hono のhonoLoggerの出力差し替えはこの pino インスタンスに対して行います。
4.5 lib/meta.ts — レスポンス共通 meta
import type { Context } from 'hono'
import type { Meta } from '@okigin/shared'
const RAW_REQUEST_ID = /^[A-Za-z0-9._\-:]{1,128}$/
export function responseMeta(c: Context): Meta {
const supplied = c.req.header('x-request-id')
return {
requestId: supplied && RAW_REQUEST_ID.test(supplied) ? supplied : crypto.randomUUID(),
timestamp: new Date().toISOString(),
}
}
全レスポンスに meta.requestId / meta.timestamp を載せる規約。クライアントが X-Request-Id を発行していればそれを採用、なければサーバで採番。
ハマりやすいポイント — クライアントが送ってきた X-Request-Id を無検証で echo するとログ注入 / ログ肥大化の入口になります。1〜128 文字の [A-Za-z0-9._\-:](UUID と一般的なトレース ID 表記の最小積集合)以外は受け取らず、サーバ採番にフォールバックしています。
4.6 routes/health.ts — チェーンルート
import { Hono } from 'hono'
import { checkDbConnection } from '../db/client'
import { responseMeta } from '../lib/meta'
export const healthRoute = new Hono().get('/', async (c) => {
const dbOk = await checkDbConnection()
// TODO(Phase 1): Secrets Manager 参照で Cognito 認証情報の取得可否を実チェック
const secretsOk = true
const allOk = dbOk && secretsOk
return c.json(
{
data: {
status: allOk ? ('healthy' as const) : ('unhealthy' as const),
checks: {
db: dbOk ? ('ok' as const) : ('fail' as const),
secrets: secretsOk ? ('ok' as const) : ('fail' as const),
},
},
meta: responseMeta(c),
},
allOk ? 200 : 503,
)
})
-
new Hono().get('/', handler)を メソッドチェーンしたままexportしている。これが Hono の型推論の核:チェーンの戻り値型に「どんなルートが追加されたか」が積み上がっていく - 親側(
app.ts)でapp.route('/api/v1/health', healthRoute)するとパスがプレフィックス付きで合成され、最終的にGET /api/v1/healthが完成する -
as constを成功 / 失敗の両分岐に付けているのは、共有パッケージの Zod スキーマ(HealthResponseSchema)と型互換にするため - ヘルスチェックの例外仕様:通常 5xx は
errorエンベロープに統一するが、ヘルスは「どのチェックが落ちたか」を示す必要があるので 503 でもdata.checks形状のまま返す
5. リクエスト → レスポンスの流れ
GET /api/v1/health を例にすると、実行順序は以下になります(コード上の登場順序とは別物)。
例外が発生した場合:
ポイントは「onError は throw されたときだけ走る」「正常系のレスポンスには介在しない」「ログには meta.requestId と同じ ID が乗るので、クライアント側の障害報告と照合できる」の 3 点です。
6. テスト戦略(3 段階)
「ネットワーク介さず速く回る単体テスト」と「実 HTTP + 実 DB でフルパスを通す統合テスト」を 同居 させています。
① ハンドラ単体(routes/health.test.ts)
vi.mock('../db/client', () => ({
checkDbConnection: vi.fn().mockResolvedValue(true),
}))
const { app } = await import('../app')
const res = await app.request('/api/v1/health')
expect(res.status).toBe(200)
const parsed = HealthResponseSchema.parse(await res.json())
expect(parsed.data.status).toBe('healthy')
-
app.request()で HTTP サーバを起動せずRequestを直接食わせる → 速い・依存少ない -
vi.mock('../db/client')でpool = new Pool(...)の副作用ごと差し替え → 実 DB が要らない - レスポンスは
HealthResponseSchema.parse()で 共有 Zod スキーマに通す → サーバとクライアントが同じスキーマを import するため契約のドリフトが起きない
② ミドルウェア契約(app.test.ts)
onError の発火を確かめるためだけのテストルート /__test__/boom をテストファイル側で動的に追加し、hono/cors のヘッダや X-Request-Id の echo / 採番ロジックを検証しています。hono/cors のメジャー更新で挙動が変わったらこのテストで即気づける保険です。
③ 実 HTTP + 実 DB(integration.test.ts)
await sql`SELECT 1`.execute(db) // 実 DB が落ちてたらここで先に失敗させる
server = serve({ fetch: app.fetch, port: 0 }, (info) => {
baseUrl = `http://127.0.0.1:${info.port}`
})
const res = await fetch(`${baseUrl}/api/v1/health`)
-
port: 0で OS に空きポートを採番させ、並列実行 / 再実行時のポート衝突を避ける -
serve→fetch→ Hono → Kysely → PostgreSQL のフルパスを実際に通す - ハンドラ単体テストでは検出できない 「
index.ts起動経路の壊れ」(例: 環境変数の渡し漏れ、serve()引数の型崩れ)を CI で先取りする
「① で速さを稼ぎ、③ で本気を確かめる」の二段構えが、Hono の app.fetch 公開と port: 0 の組み合わせで自然に書けます。
7. つまずきポイントと解いた問題
7.1 hono/logger が console.log を踏む
症状: コーディングルール「console.log を直接書かない」と衝突。
解決: honoLogger((message) => logger.info(message)) で出力関数を差し替え、pino に流す。これは公式に推奨される拡張ポイント。
7.2 db.destroy() がハングして SIGTERM 後に終わらない
症状: ネットワーク断のままシャットダウンすると Kysely のプール解放が返ってこない。
解決: setTimeout(() => process.exit(1), 10_000).unref() を shutdown() の頭で仕掛ける。.unref() のおかげで正常終了時はタイマーがプロセスを引き止めない。Lambda 移行後は不要になる(ライフサイクルがランタイム側)。
7.3 クライアントが送ってきた X-Request-Id をそのまま echo した
症状: 改行入りや長すぎる ID が混入し、ログが汚染される。
解決: lib/meta.ts で正規表現 ^[A-Za-z0-9._\-:]{1,128}$ をかけ、外れたらサーバ側で crypto.randomUUID() を採番。
Discussion