12k
All articles

Hono ミドルウェアを自作する

型付きコンテキスト変数、リクエスト計測、早期終了、パス指定での適用まで、Honoのmiddleware作成を解説。next()の順序も整理します。

OpenReplay Team
OpenReplay Team
Hono ミドルウェアを自作する

Hono のミドルウェアは (c, next) を受け取る async 関数です。終わり方は 2 通りしかありません。next() を await して何も返さない(リクエストを次へ渡す)か、Response を返してそこでリクエストを終わらせるかです。

logger() や cors() はファクトリとして呼び出されるため、自作するまでこのシグネチャは目に触れません。最初の 1 本はたいてい問題なく動きます。つまずくのはその一歩先、ハンドラーがミドルウェアの設定した値を読もうとしたときに、TypeScript がその型を教えてくれない場面です。

本記事ではシグネチャから積み上げてミドルウェアを組み立てていきます。2 つのフェーズとその実行順序、両フェーズを必要とするリクエストタイマー、ミドルウェアを別ファイルへ切り出しても型が保たれる型付きコンテキスト変数、早期リターン、そしてパススコープです。すでにルートが動作していることを前提としており、その部分は getting started with Hono で扱っています。Express から移ってきた方には、framework comparison と porting notes が本記事の出発点より手前の内容をカバーしています。サンプルは npm で公開されている Hono 4.13.x 系を対象としています。

要点

  • Hono のミドルウェアは (c, next) です。await next() より前のコードは往路で実行され、後ろのコードは復路で実行されます。
  • ミドルウェアは往路では登録順、復路では登録の逆順で実行されます。つまり最初に登録したものが最初にリクエストを見て、最後にレスポンスを見ます。
  • hono/factory の createMiddleware() に Variables ジェネリクスを渡せば、ミドルウェアを別ファイルへ切り出しても c と next の型が保たれます。
  • next() を呼ばずに Response を返すとリクエストは終了し、それ以降に登録されたものは一切実行されません。
  • Hono はハンドラーとミドルウェアのエラーを捕捉して onError() または 500 へ回すため、next() が throw することはありません。

シグネチャと、ミドルウェアの 2 つの終わり方

ミドルウェアはコンテキストと next 関数を受け取り、Hono のミドルウェアガイドは終わり方を 2 つだけ認めています。next() を await して何も返さずチェーンの下流へ制御を渡すか、自前の Response を返してその場でリクエストを止めるかです。もう 1 つのプリミティブはハンドラーで、こちらは必ず Response を生成します。1 つのリクエストが到達するハンドラーは常に 1 つだけです。

import { Hono } from 'hono'

const app = new Hono()

app.use(async (c, next) => {
  console.log(`[${c.req.method}] ${c.req.url}`)
  await next()
})

app.get('/', (c) => c.text('Hello!'))

export default app

await を忘れても関数の意味は変わりませんが、順序の保証は失われます。必ず await next() と書いてください。

オニオン構造: Hono のミドルウェアはどの順序で実行されるのか

await next() より前に置いたコードは往路で実行され、後ろに置いたコードは復路で実行されます。だからこそ 1 つの関数で、ハンドラーの前にタイマーを開始し、その後に経過時間をレスポンスヘッダーへ書き込めるのです。Hono のコンセプトドキュメントはこの構造をオニオン(玉ねぎ)として描いています。各層がハンドラーを包み込み、その両側で 1 回ずつ処理の順番を得ます。ミドルウェアは往路では登録順、復路では登録の逆順に実行されるため、最初に登録したミドルウェアが最初にリクエストを見て、最後にレスポンスを見ることになります。

import { Hono } from 'hono'

const app = new Hono()

app.use(async (_, next) => {
  console.log('timing start')
  await next()
  console.log('timing end')
})
app.use(async (_, next) => {
  console.log('  auth start')
  await next()
  console.log('  auth end')
})
app.use(async (_, next) => {
  console.log('    flags start')
  await next()
  console.log('    flags end')
})

app.get('/', (c) => {
  console.log('      handler')
  return c.text('Hello!')
})

export default app

リクエスト 1 件で次の出力が得られます。

timing start
  auth start
    flags start
      handler
    flags end
  auth end
timing end

インデントはログ文字列に埋め込んだもので、console.log が生成しているわけではありません。ネスト構造こそが要点なので、あえてそう書いています。あるミドルウェアの復路部分は、それより前に登録されたすべてのものの復路部分に包まれています。

両フェーズにまたがって書くリクエストタイマー

Hono には hono/timing から Server-Timing ヘッダーを出力する Server-Timing ミドルウェアが同梱されており、そのフォーマットで用が足りるならこれが正解です。それでも手書きのタイマーは 2 フェーズ実行を最も明快に示してくれます。報告する値がどちらか一方のフェーズだけでは存在しないからです。

import { createMiddleware } from 'hono/factory'

export const responseTime = createMiddleware(async (c, next) => {
  const start = Date.now()
  await next()
  c.header('X-Response-Time', `${Date.now() - start}ms`)
})

await next() の後にはすでにレスポンスが存在するため、c.header() は準備中のヘッダーではなく、そのレスポンスのヘッダーへ直接書き込みます。同じミドルウェアのページには Cloudflare Workers 向けの注意書きがあります。Workers 上のタイマーは実際の経過時間ではなく最後の I/O に紐づくため、数値が実態を誤って示すことがあります。

型付きの値をどうやってチェーンの下流へ渡すか

c.set() で書き込んだものは 1 つのリクエストに属し、そのリクエストとともに消えます。したがって値を次のリクエストへ持ち越したり、リクエスト間で共有したりはできません。下流のコードは c.get('key') または c.var.key で読み戻します(どちらも Context API のページに記載があります)。インラインで set した場合、値は下流に型情報なしで届きます。

app.use(async (c, next) => {
  c.set('requestId', crypto.randomUUID())
  await next()
})

app.get('/', (c) => c.json({ id: c.var.requestId })) // no type information

hono/factory の createMiddleware() は、ミドルウェアを別ファイルへ切り出しても c と next の型をそのまま保ちます。さらに Variables ジェネリクスを渡せば、そのミドルウェアが設定する値すべてが、後続のハンドラーにとって型安全になります。

// middleware/request-id.ts
import { createMiddleware } from 'hono/factory'

export const requestId = createMiddleware<{
  Variables: { requestId: string }
}>(async (c, next) => {
  c.set('requestId', crypto.randomUUID())
  await next()
})
// index.ts
import { Hono } from 'hono'
import { requestId } from './middleware/request-id'

const app = new Hono().use(requestId).get('/', (c) => {
  return c.json({ id: c.var.requestId }) // string
})

export default app

コンテキスト変数に型を付ける仕組みは 3 つあります。既定では真ん中の行を選んでください。

仕組み型が見える範囲コスト
new Hono<{ Variables }>()その app インスタンス上のすべてのルート結合型を手作業で維持する必要があり、宣言箇所ごとに重複する
createMiddleware<{ Variables }>()そのミドルウェアの下流のルートミドルウェアごとにジェネリクス 1 つ
ContextVariableMap の拡張アプリケーション内のすべてのコンテキストミドルウェアが動いていないルートにも型が付く

ContextVariableMap を拡張すると、値を設定するミドルウェアがそのルートで実際に動いたかどうかに関係なく、アプリケーション内のすべてのコンテキストに型が付きます。動いていない場所では、実行時に値が存在しないのに c.get() は型が付いているように見えてしまいます。Context API ドキュメントの警告は、ミドルウェアを使うルートと使わないルートの組み合わせでこの点を示しています。型はミドルウェアにスコープさせましょう。

next() を呼ばずに処理を打ち切るには

Response を返して next() をスキップすれば、リクエストはそこで終わります。以降のミドルウェアもハンドラーも実行されません。

import { createMiddleware } from 'hono/factory'

export const apiKeyAuth = createMiddleware(async (c, next) => {
  if (c.req.header('X-API-Key') !== 'expected-key') {
    return c.text('Unauthorized', 401)
  }
  await next()
})

このミドルウェアより後に登録されたものはすべて実行されません。これを包んだはずのミドルウェアの復路部分も含めてです。一方で、これより前に登録されたミドルウェアの復路部分は実行されるので、最初に登録したタイマーは 401 にもきちんとヘッダーを付けます。

Hono のミドルウェアをパスにスコープするには

app.use() の第 1 引数にパスパターンを渡せば、マッチするルートにミドルウェアを限定できます。順序は登録順であることを忘れないでください。

app.use(responseTime)          // every route
app.use('/api/*', apiKeyAuth)  // only /api/**
app.get('/api/orders', (c) => c.json([]))

responseTime が先に登録されているため、これが apiKeyAuth を包み、拒否されたリクエストも計測します。この 2 行を入れ替えると、タイマーは 401 を見られなくなります。

見落としやすい 2 つの挙動

チェーン内のどこかで throw されたエラーは、ハンドラー由来でもミドルウェア由来でも Hono 自身が捕捉します。app.onError() を定義していればそこへ渡され、なければ 500 として返ります。だからこそ next() は throw せず、try/catch で囲んでも何の得もありません。ログに残したい場合は、await next() の後に c.error からエラーへアクセスできます。

型は .use() チェーンに沿って積み上がるため、型付きミドルウェア 2 つの後に置かれたハンドラーは両方の変数を見られます。.use() はそれぞれ、直前までの内容を型に含んだインスタンスを返します。ミドルウェアガイドが「ほとんどのアプリケーションでは結合した Env 型を前もって書き出す必要はない」と述べているのはこのためです。

次に進むために

カスタムミドルウェアとは、2 つの半分を持つ 1 つの関数の形と、終わり方に関する 1 つのルールだけです。書き始める最初の行から Variables ジェネリクスを付け、1 本ずつ独自のモジュールに収めれば、あとは登録順が仕事をしてくれます。上のタイマーを middleware/ へ移し、createMiddleware() をそれを返す素の関数で包んで設定引数を追加してみてください。パラメーター化されたミドルウェアのパターンは、それで全部です。

FAQ

すべてのルートではなく特定の 1 ルートだけでミドルウェアを実行するには?

素の app.use() ではなくルートメソッドで登録します。Hono は app.HTTP_METHOD 経由でミドルウェアを紐づけられるので、app.post('/posts/*', basicAuth()) はそのパスの POST リクエストにだけ適用されます。また app.get('/echo', echoMiddleware, handler) のようにハンドラーの前の引数としてミドルウェアを渡せば、アプリケーションの他の部分に触れずにその単一ルートへスコープできます。

ミドルウェアより前に登録されたルートにも適用されますか?

ミドルウェアは、それが包むべきハンドラーより上に登録してください。Hono はハンドラーとミドルウェアを登録順に実行し、ルーティングのドキュメントはこのルールを率直に述べています。ハンドラーより前に実行したいものは、そのハンドラーより先に登録しなければなりません。app.use() の呼び出しより前に宣言されたルートは先にマッチするため、後から追加したミドルウェアがそのルートのチェーンに含まれることは保証されません。

Env ジェネリクスの Bindings と Variables の違いは何ですか?

Bindings はランタイムが注入するプラットフォームのリソースや環境値を表し、c.env から読み取ります。Cloudflare Workers の D1 データベースやシークレットなどです。Variables は自分のコードが c.set() で書き、c.get() や c.var で読む、リクエストごとの値を表します。どちらも new Hono<{ Bindings: ...; Variables: ... }>() として渡す同じ Env オブジェクト上にあります。

ハンドラーやミドルウェアの外からコンテキスト変数を読めますか?

はい、組み込みの Context Storage ミドルウェアを使えば可能です。'hono/context-storage' から app.use(contextStorage()) を登録し、任意の関数の中で getContext() を呼べば現在のリクエストのコンテキストに到達できます。c.var のほか、Cloudflare Workers では c.env のバインディングも含まれます。tryGetContext() は同様に動作しますが、getContext() が throw する場面では undefined を返すため、リクエスト外で実行される可能性のあるコードに向いています。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.