12k
All articles

Écrire ses propres middlewares Hono

Construisez des middleware Hono avec variables de contexte typées, timing de requête, sorties anticipées et ciblage par chemin. Comprenez lordre de next().

OpenReplay Team
OpenReplay Team
Écrire ses propres middlewares Hono

Un middleware Hono est une fonction asynchrone qui prend (c, next). Il peut se terminer de deux manières : soit il attend next() et ne retourne rien, ce qui transmet la requête plus loin, soit il retourne une Response et la requête s’arrête là.

logger() et cors() sont appelés comme des factories, si bien que cette signature reste hors de vue jusqu’au jour où vous écrivez la vôtre. Le premier middleware que vous écrivez fonctionne généralement du premier coup. Les ennuis commencent à l’étape suivante, quand un handler lit une valeur définie par le middleware et que TypeScript est incapable de vous en indiquer le type.

Cet article construit un middleware en partant de la signature : les deux phases et leur ordre d’exécution, un chronomètre de requête qui nécessite les deux phases, des variables de contexte typées qui conservent leurs types une fois le middleware déplacé dans son propre fichier, la sortie anticipée et le cantonnement par chemin. Il suppose que vous avez déjà des routes en fonctionnement, comme décrit dans getting started with Hono. Si vous venez d’Express, la comparaison des frameworks et les notes de portage couvrent le terrain en amont de cet article. Les exemples ciblent la branche Hono 4.13.x publiée sur npm.

Points clés

  • Un middleware Hono, c’est (c, next) : le code placé avant await next() s’exécute à l’aller, celui placé après s’exécute au retour.
  • Les middlewares s’exécutent dans l’ordre d’enregistrement à l’aller et dans l’ordre inverse au retour : le premier enregistré est donc le premier à voir la requête et le dernier à voir la réponse.
  • createMiddleware() de hono/factory, accompagné d’un générique Variables, conserve le typage de c et de next lorsque le middleware est déplacé dans son propre fichier.
  • Retourner une Response sans appeler next() met fin à la requête : rien de ce qui est enregistré après ne s’exécute.
  • Hono intercepte les erreurs des handlers et des middlewares et les achemine vers onError() ou vers une 500 : next() ne lève donc jamais d’exception.

La signature et les deux façons dont un middleware se termine

Un middleware prend le contexte et la fonction next, et le guide des middlewares Hono ne lui autorise que deux issues. Il peut passer le contrôle plus loin dans la chaîne en attendant next() et en ne retournant rien du tout, ou il peut arrêter la requête sur place en renvoyant sa propre Response. L’autre primitive est le handler, qui produit toujours une Response, et une même requête n’en atteint jamais qu’un seul.

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

Oubliez le await et la fonction conserve sa signification mais perd sa garantie d’ordonnancement : écrivez donc toujours await next().

L’oignon : dans quel ordre les middlewares Hono s’exécutent-ils ?

Le code placé avant await next() s’exécute à l’aller, et le code placé après s’exécute au retour, ce qui explique qu’une seule fonction puisse démarrer un chronomètre avant le handler et écrire la durée écoulée dans un en-tête de réponse après lui. La documentation des concepts de Hono représente cette organisation comme un oignon, chaque couche enveloppant le handler et prenant son tour de chaque côté de celui-ci. Les middlewares s’exécutent dans l’ordre d’enregistrement à l’aller et dans l’ordre inverse au retour : le premier middleware que vous enregistrez est donc le premier à voir la requête et le dernier à voir la réponse.

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

Une requête produit :

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

L’indentation est intégrée aux chaînes de log, elle n’est pas produite par console.log. Elle est là parce que l’imbrication est justement le sujet : la moitié sortante de chaque middleware est enveloppée par la moitié sortante de tout ce qui a été enregistré avant lui.

Un chronomètre de requête, écrit à cheval sur les deux phases

Hono fournit un middleware Server-Timing qui émet des en-têtes Server-Timing depuis hono/timing, et c’est le bon choix lorsque ce format vous convient. Un chronomètre écrit à la main reste néanmoins la démonstration la plus claire de l’exécution en deux phases, car la valeur qu’il rapporte n’existe dans aucune des deux phases prise isolément.

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`)
})

Après await next(), une réponse existe déjà : c.header() écrit donc directement dans les en-têtes de cette réponse plutôt que dans des en-têtes préparés. La même page de middleware comporte une mise en garde pour Cloudflare Workers : un chronomètre y est arrimé à la dernière opération d’E/S plutôt qu’au temps réellement écoulé, les chiffres peuvent donc induire en erreur.

Comment transmettre des valeurs typées le long de la chaîne ?

Tout ce qui est écrit avec c.set() appartient à une seule requête et disparaît avec elle : une valeur ne peut donc pas être reportée sur la requête suivante ni partagée entre plusieurs. Le code en aval la relit avec c.get('key') ou c.var.key, tous deux documentés sur la page de l’API Context. Définies en ligne, les valeurs arrivent en aval sans typage :

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() de hono/factory préserve intacts les types de c et de next lorsqu’un middleware est déplacé dans son propre fichier, et lui passer un générique Variables rend chaque valeur définie par le middleware type-safe pour les handlers qui suivent.

// 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

Il existe trois mécanismes pour typer les variables de contexte. Optez par défaut pour celui de la ligne du milieu.

MécanismeOù le type est visibleCoût
new Hono<{ Variables }>()Toutes les routes de cette instance d’applicationType combiné à maintenir à la main, dupliqué à chaque site de déclaration
createMiddleware<{ Variables }>()Les routes en aval de ce middlewareUn générique par middleware
Augmentation de ContextVariableMapTous les contextes de l’applicationType des routes sur lesquelles le middleware ne s’est jamais exécuté

Augmentez ContextVariableMap et le type se retrouve sur tous les contextes de l’application, que le middleware qui définit la valeur se soit exécuté ou non sur cette route. Là où il ne s’est pas exécuté, c.get() paraît toujours typé alors que la valeur est absente à l’exécution. L’avertissement dans la documentation de l’API Context illustre le propos avec deux routes, l’une qui utilise le middleware et l’autre non. Cantonnez plutôt le type au middleware.

Comment court-circuiter sans appeler next() ?

Retournez une Response et sautez next() : la requête s’arrête là, sans middleware ultérieur ni handler.

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()
})

Tout ce qui est enregistré après ce middleware ne s’exécute jamais, y compris la moitié sortante de tout middleware qui l’aurait enveloppé. Les moitiés sortantes des middlewares enregistrés avant lui s’exécutent quand même : un chronomètre enregistré en premier horodate donc bien la 401.

Comment cantonner un middleware Hono à un chemin ?

Passez un motif de chemin en premier argument d’app.use() pour restreindre un middleware aux routes correspondantes, et souvenez-vous que l’ordre est l’ordre d’enregistrement.

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

responseTime est enregistré en premier : il enveloppe donc apiKeyAuth et mesure aussi les requêtes rejetées. Inversez les deux lignes et le chronomètre cesse de voir les 401.

Deux comportements qui prennent les gens au dépourvu

Une erreur levée n’importe où dans la chaîne, par un handler ou par n’importe quel middleware, est interceptée par Hono lui-même : elle part vers app.onError() lorsque vous en avez défini un, et revient sinon sous forme de 500. C’est pourquoi next() ne lève jamais d’exception, et pourquoi l’entourer d’un try/catch ne vous apporte rien. L’erreur reste accessible sur c.error après await next() si vous souhaitez la journaliser.

Les types s’additionnent le long d’une chaîne de .use() : un handler placé après deux middlewares typés voit donc les variables des deux. Chaque .use() renvoie une instance dont le type inclut déjà ce qui l’a précédée, ce qui explique pourquoi le guide des middlewares indique que la plupart des applications n’ont jamais besoin d’écrire à l’avance un type Env combiné.

Pour aller plus loin

Un middleware personnalisé, c’est une forme de fonction unique, en deux moitiés, avec une seule règle sur sa façon de se terminer. Ajoutez-lui le générique Variables dès la première ligne que vous écrivez, gardez chaque middleware dans son propre module, et l’ordre d’enregistrement fera le reste. Reprenez le chronomètre ci-dessus, déplacez-le dans middleware/ et ajoutez-lui un argument de configuration en enveloppant createMiddleware() dans une simple fonction qui le retourne : voilà tout le patron d’un middleware paramétré.

FAQ

Comment exécuter un middleware sur une seule route plutôt que sur toutes ?

Enregistrez-le avec la méthode de route plutôt qu'avec un app.use() nu. Hono vous permet d'attacher un middleware via app.HTTP_METHOD : app.post('/posts/*', basicAuth()) ne s'applique donc qu'aux requêtes POST sur ce chemin, et passer le middleware en argument avant le handler, comme dans app.get('/echo', echoMiddleware, handler), le cantonne à cette unique route sans toucher au reste de l'application.

Un middleware s'applique-t-il aux routes enregistrées avant lui ?

Enregistrez le middleware au-dessus des handlers qu'il doit envelopper. Hono exécute les handlers et les middlewares dans l'ordre d'enregistrement, et la documentation du routage énonce la règle sans détour : tout ce que vous voulez exécuter avant un handler doit être enregistré avant lui. Une route déclarée avant l'appel à app.use() est mise en correspondance en premier, si bien que le middleware ajouté ensuite ne fait pas de façon fiable partie de la chaîne de cette route.

Quelle est la différence entre Bindings et Variables dans le générique Env ?

Les Bindings décrivent les ressources de plateforme et les valeurs d'environnement injectées par le runtime, lues via c.env, comme une base de données D1 ou un secret sur Cloudflare Workers. Les Variables décrivent les valeurs propres à chaque requête que votre propre code écrit avec c.set() et lit avec c.get() ou c.var. Les deux figurent sur le même objet Env passé sous la forme new Hono<{ Bindings: ...; Variables: ... }>().

Puis-je lire une variable de contexte en dehors d'un handler ou d'un middleware ?

Oui, avec le middleware Context Storage intégré. Enregistrez app.use(contextStorage()) depuis 'hono/context-storage', puis appelez getContext() dans n'importe quelle fonction pour atteindre le contexte de la requête en cours, y compris c.var et, sur Cloudflare Workers, les bindings sur c.env. tryGetContext() se comporte de la même façon mais renvoie undefined là où getContext() lèverait une exception, ce qui convient au code susceptible de s'exécuter en dehors d'une requête.

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.