Как написать собственное middleware для Hono
Создавайте middleware Hono с типизированными переменными контекста, таймингом запроса, ранним выходом и scope по пути. Разберитесь в порядке next().
Middleware в Hono — это асинхронная функция, принимающая (c, next). Она может завершиться одним из двух способов: дождаться next() и ничего не вернуть, передав запрос дальше, либо вернуть Response — и тогда обработка запроса на этом закончится.
logger() и cors() вызываются как фабрики, поэтому эта сигнатура остаётся вне поля зрения до тех пор, пока вы не начнёте писать своё middleware. Первое обычно работает с ходу. Проблемы начинаются на шаг позже, когда обработчик читает значение, установленное middleware, а TypeScript не может сказать, какого оно типа.
В этой статье middleware строится начиная с сигнатуры: две фазы и порядок их выполнения, таймер запроса, которому нужны обе фазы, типизированные переменные контекста, сохраняющие типы после выноса middleware в отдельный файл, ранний выход и ограничение по путям. Предполагается, что маршруты у вас уже работают — как описано в руководстве по началу работы с Hono. Если вы переходите с Express, сравнение фреймворков и заметки по портированию покрывают материал, с которого эта статья начинается. Примеры ориентированы на ветку Hono 4.13.x, опубликованную в npm.
Ключевые выводы
- Middleware в Hono — это
(c, next): код доawait next()выполняется на входе, код после него — на выходе. - Middleware выполняются в порядке регистрации на входе и в обратном порядке на выходе, поэтому зарегистрированное первым первым видит запрос и последним — ответ.
createMiddleware()изhono/factoryс дженерикомVariablesсохраняет типизациюcиnextпри выносе middleware в отдельный файл.- Возврат
Responseбез вызоваnext()завершает запрос: ничего из зарегистрированного после него не выполняется. - Hono перехватывает ошибки обработчиков и middleware и направляет их в
onError()или в ответ 500, поэтомуnext()никогда не выбрасывает исключение.
Сигнатура и два способа завершения middleware
Middleware принимает контекст и функцию next, и руководство по middleware в Hono допускает для него лишь два варианта завершения. Оно может передать управление дальше по цепочке, дождавшись next() и не вернув ничего, либо остановить запрос на месте, вернув собственный Response. Второй примитив — это обработчик, который всегда порождает Response, и один запрос достигает только одного из них.
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().
Луковица: в каком порядке выполняются middleware в Hono?
Код, размещённый до await next(), выполняется на входе, а код после него — на выходе; именно поэтому одна функция может запустить таймер до обработчика и записать прошедшее время в заголовок ответа после него. Документация по концепциям Hono изображает эту схему как луковицу, где каждый слой оборачивает обработчик и получает свою очередь с каждой его стороны. Middleware выполняются в порядке регистрации на входе и в обратном порядке на выходе, поэтому первое зарегистрированное middleware первым видит запрос и последним — ответ.
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
Один запрос даёт:
timing start
auth start
flags start
handler
flags end
auth end
timing end
Отступы заданы прямо в строках логов, а не создаются console.log. Они здесь потому, что вложенность и есть суть: внешняя (исходящая) половина каждого middleware обёрнута исходящей половиной всего, что зарегистрировано до него.
Таймер запроса, написанный на обеих фазах
В Hono есть встроенное middleware Server-Timing, которое отдаёт заголовки Server-Timing из hono/timing, и это правильный выбор, когда такой формат вам подходит. Но самописный таймер всё равно остаётся самой наглядной демонстрацией двухфазного выполнения, поскольку сообщаемое им значение не существует ни в одной из фаз по отдельности.
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() пишет непосредственно в заголовки этого ответа, а не в подготовленные заголовки. На той же странице документации по middleware есть оговорка для Cloudflare Workers: таймер там привязан к последней операции ввода-вывода, а не к реальному прошедшему времени, поэтому цифры могут вводить в заблуждение.
Как передавать типизированные значения дальше по цепочке?
Всё, что записано через c.set(), принадлежит одному запросу и исчезает вместе с ним, поэтому значение нельзя перенести в следующий запрос или разделить между запросами. Нижестоящий код читает его обратно через c.get('key') или c.var.key — оба способа описаны на странице Context API. Если устанавливать значение прямо в месте вызова, оно приходит дальше по цепочке нетипизированным:
app.use(async (c, next) => {
c.set('requestId', crypto.randomUUID())
await next()
})
app.get('/', (c) => c.json({ id: c.var.requestId })) // no type information
createMiddleware() из hono/factory сохраняет типы c и next при выносе middleware в отдельный файл, а передача ему дженерика Variables делает каждое устанавливаемое middleware значение типобезопасным для последующих обработчиков.
// 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
Существуют три механизма типизации переменных контекста. По умолчанию выбирайте средний вариант.
| Механизм | Где виден тип | Цена |
|---|---|---|
new Hono<{ Variables }>() | Все маршруты этого экземпляра приложения | Вручную поддерживаемый составной тип, дублируемый в каждом месте объявления |
createMiddleware<{ Variables }>() | Маршруты ниже этого middleware по цепочке | Один дженерик на middleware |
Расширение ContextVariableMap | Каждый контекст в приложении | Типизирует и те маршруты, на которых middleware никогда не выполнялось |
Расширьте ContextVariableMap — и тип попадёт в каждый контекст приложения независимо от того, выполнялось ли на этом маршруте middleware, устанавливающее значение. Там, где оно не выполнялось, c.get() по-прежнему выглядит типизированным, хотя значения в рантайме нет. Предупреждение в документации Context API иллюстрирует это парой маршрутов: одним, который использует middleware, и другим, который не использует. Вместо этого ограничивайте тип областью самого middleware.
Как завершить обработку досрочно, не вызывая next()?
Верните Response и пропустите next() — запрос закончится здесь: ни последующих middleware, ни обработчика.
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()
})
Всё, что зарегистрировано после этого middleware, не выполняется, включая исходящие половины middleware, которые обернули бы его. Исходящие половины middleware, зарегистрированных до него, по-прежнему выполняются, поэтому таймер, зарегистрированный первым, всё равно проставит заголовок на ответе 401.
Как ограничить middleware в Hono определённым путём?
Передайте шаблон пути первым аргументом в app.use(), чтобы ограничить middleware подходящими маршрутами, и помните, что порядок — это порядок регистрации.
app.use(responseTime) // every route
app.use('/api/*', apiKeyAuth) // only /api/**
app.get('/api/orders', (c) => c.json([]))
responseTime зарегистрирован первым, поэтому он оборачивает apiKeyAuth и измеряет в том числе отклонённые запросы. Поменяйте эти две строки местами — и таймер перестанет видеть ответы 401.
Две особенности поведения, на которых спотыкаются
Ошибка, выброшенная в любом месте цепочки — обработчиком или любым middleware, — перехватывается самим Hono: она уходит в app.onError(), если он у вас есть, а иначе возвращается как 500. Именно поэтому next() никогда не выбрасывает исключение и поэтому оборачивание его в try/catch ничего не даёт. Саму ошибку по-прежнему можно получить через c.error после await next(), если её нужно залогировать.
Типы накапливаются вдоль цепочки .use(), поэтому обработчик, стоящий после двух типизированных middleware, видит переменные обоих. Каждый .use() возвращает экземпляр, тип которого уже включает всё предшествующее, — именно поэтому в руководстве по middleware сказано, что большинству приложений никогда не требуется заранее выписывать составной тип Env.
Куда двигаться дальше
Собственное middleware — это одна форма функции с двумя половинами и одним правилом о том, как оно завершается. Добавляйте к нему дженерик Variables с первой же написанной строки, держите каждое middleware в собственном модуле — а остальное сделает порядок регистрации. Возьмите приведённый выше таймер, перенесите его в middleware/ и добавьте аргумент конфигурации, обернув createMiddleware() в обычную функцию, которая его возвращает: это и есть весь паттерн параметризованного middleware.
Часто задаваемые вопросы
Как запустить middleware только на одном маршруте, а не на всех?
Зарегистрируйте его вместе с методом маршрута, а не через голый app.use(). Hono позволяет подключать middleware через app.HTTP_METHOD, поэтому app.post('/posts/*', basicAuth()) применяется только к POST-запросам по этому пути, а передача middleware аргументом перед обработчиком, как в app.get('/echo', echoMiddleware, handler), ограничивает его этим единственным маршрутом, не затрагивая остальное приложение.
Применяется ли middleware к маршрутам, зарегистрированным до него?
Регистрируйте middleware выше тех обработчиков, которые оно должно оборачивать. Hono выполняет обработчики и middleware в порядке регистрации, и документация по маршрутизации формулирует правило прямо: всё, что должно выполняться перед обработчиком, нужно зарегистрировать раньше него. Маршрут, объявленный до вызова app.use(), сопоставляется первым, поэтому добавленное позже middleware не входит надёжным образом в цепочку этого маршрута.
В чём разница между Bindings и Variables в дженерике Env?
Bindings описывают ресурсы платформы и значения окружения, которые внедряет рантайм и которые читаются через c.env, — например, базу данных D1 или секрет в Cloudflare Workers. Variables описывают значения уровня запроса, которые ваш собственный код записывает через c.set() и читает через c.get() или c.var. Оба находятся в одном объекте Env, передаваемом как new Hono<{ Bindings: ...; Variables: ... }>().
Можно ли прочитать переменную контекста вне обработчика или middleware?
Да, с помощью встроенного middleware Context Storage. Зарегистрируйте app.use(contextStorage()) из 'hono/context-storage', затем вызывайте getContext() внутри любой функции, чтобы получить контекст текущего запроса, включая c.var и, в Cloudflare Workers, привязки в c.env. tryGetContext() работает так же, но возвращает undefined там, где getContext() выбросил бы исключение, что подходит для кода, который может выполняться вне запроса.
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