🔰

初めての Hono

に公開

0. この記事のゴール

  • Hono ってなに? を最小例で理解する
  • 実プロジェクトの apps/api/src/ 配下 6 ファイルがそれぞれ何の役割かを言えるようになる
  • GET /api/v1/health 1 本のリクエストが、どの順序でミドルウェアを通り、どこでハンドラが動き、どこでレスポンスが組み立てられるかを実行順序で説明できる
  • 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)RequestResponse を直接取れる。ユニットテストが軽快に書ける

このプロジェクトでは Phase 0 を @hono/node-server で long-running 起動し、Phase 1 で hono/aws-lambdahandle(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) でルート登録
  • ハンドラは Contextc)を受け取り、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.tsindex.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)
  1. serve()fetch: app.fetch を渡している。Hono アプリは (req: Request) => Response | Promise<Response> という Web 標準シグネチャを露出しており、@hono/node-server がそれを Node の http.Server にブリッジする
  2. SIGINT / SIGTERMHTTP サーバを閉じてから Kysely (db.destroy()) を閉じる 順序。受付中のリクエストを取りこぼさずに DB 接続を解放できる
  3. setTimeout(..., 10_000).unref()db.destroy() がハングした場合の安全装置.unref() のおかげで正常終了時はタイマー自体がプロセスを引き止めない
  4. index.tsapp.ts を import するだけ。Hono の設定そのものは持たないので、テストはこのファイルを経由せず app.ts を直接読める

本ファイルは Phase 0 のローカル開発専用です。Phase 1 では hono/aws-lambdahandle(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/corsorigin オプションに直接渡せる形にしている
  • 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 既定の msgmessage に rename。集約システム側の慣習に合わせる
  • process.env.LOG_LEVEL を直接参照env.ts の Zod 検証より前にロガーを使えるよう、env 非依存に保つ(env パース失敗時もこのロガーでエラーを吐ける)
  • serializeErrorpino.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,
  )
})
  1. new Hono().get('/', handler)メソッドチェーンしたまま export している。これが Hono の型推論の核:チェーンの戻り値型に「どんなルートが追加されたか」が積み上がっていく
  2. 親側(app.ts)で app.route('/api/v1/health', healthRoute) するとパスがプレフィックス付きで合成され、最終的に GET /api/v1/health が完成する
  3. as const を成功 / 失敗の両分岐に付けているのは、共有パッケージの Zod スキーマ(HealthResponseSchema)と型互換にするため
  4. ヘルスチェックの例外仕様:通常 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 に空きポートを採番させ、並列実行 / 再実行時のポート衝突を避ける
  • servefetch → Hono → Kysely → PostgreSQL のフルパスを実際に通す
  • ハンドラ単体テストでは検出できない index.ts 起動経路の壊れ」(例: 環境変数の渡し漏れ、serve() 引数の型崩れ)を CI で先取りする

「① で速さを稼ぎ、③ で本気を確かめる」の二段構えが、Hono の app.fetch 公開と port: 0 の組み合わせで自然に書けます。


7. つまずきポイントと解いた問題

7.1 hono/loggerconsole.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() を採番。


9. 参考リンク

MIZUKICHI Lab

Discussion