spinny:~/writing $ vim hono-framework-guide.md
1~2Pendant des années, choisir un framework web JavaScript impliquait un compromis : Express était universel mais lent et lié à Node, Fastify était rapide mais réservé à Node, Next.js était complet mais lourd. Quand sont apparus les runtimes edge - Cloudflare Workers, Deno Deploy, Bun, Vercel Edge - ces frameworks ont montré leurs limites : dépendances incompatibles, bundles énormes, APIs liées aux `req`/`res` de Node.3~4[Hono](https://hono.dev) (japonais pour "flamme" 🔥) est la réponse moderne. Un framework de moins de 14 Ko, entièrement bâti sur les Web Standards (`Request`, `Response`, `fetch`), qui tourne partout où existe un runtime JavaScript. Le même code se déploie sur Cloudflare Workers, Bun, Deno, Node.js, Vercel, Netlify et AWS Lambda - sans modification.5~6## Pourquoi Hono7~8Hono fait trois choses mieux que tout le monde :9~101. **Performance.** Le `RegExpRouter` compile tous les patterns de route en une seule regex, évitant les boucles linéaires des routeurs traditionnels. Les benchmarks dépassent 400 000 ops/s, plaçant Hono parmi les routeurs les plus rapides de l'écosystème JavaScript.112. **Portabilité.** Web Standards signifie zéro dépendance à Node. Le même `app.fetch` est exporté par défaut dans un Cloudflare Worker, passé à `Bun.serve`, monté dans un serveur Deno ou adapté avec `@hono/node-server`.123. **DX TypeScript-first.** Paramètres de chemin typés en literal, client RPC type-safe de bout en bout, validateurs qui infèrent les types d'entrée et de sortie. L'autocomplétion frôle la télépathie.13~14```mermaid15graph LR16 Client[Client] -->|Request| App[app.fetch]17 App --> MW1[Middleware 1]18 MW1 --> MW2[Middleware 2]19 MW2 --> Router[RegExpRouter]20 Router --> Handler[Route Handler]21 Handler --> Context[c.json / c.text]22 Context -->|Response| Client23 App -.->|deploy| CF[Cloudflare Workers]24 App -.->|deploy| Bun[Bun]25 App -.->|deploy| Deno[Deno]26 App -.->|deploy| Node[Node.js]27 App -.->|deploy| Vercel[Vercel]28```29~30## Démarrer31~32Le moyen le plus rapide est le starter officiel, qui scaffolde le projet pour le runtime choisi.33~34```bash35npm create hono@latest my-api36cd my-api37npm install38npm run dev39```40~41Le starter demande quel template utiliser : `cloudflare-workers`, `bun`, `deno`, `nodejs`, `vercel`, `aws-lambda`, `nextjs`, et plus. Il choisit les bonnes configs et scripts de déploiement. Pour tester à la volée, on peut aussi partir d'un seul fichier :42~43```typescript44// src/index.ts45import { Hono } from 'hono'46~47const app = new Hono()48~49app.get('/', (c) => c.text('Hello Hono!'))50~51export default app52```53~54Sur Cloudflare Workers, ça suffit. Sur Bun : `Bun.serve({ fetch: app.fetch, port: 3000 })`. Sur Node : `serve({ fetch: app.fetch })` depuis `@hono/node-server`. La surface est identique.55~56## Routing57~58Les routes se déclarent avec les méthodes des verbes HTTP et supportent paramètres, wildcards et regex.59~60```typescript61import { Hono } from 'hono'62~63const app = new Hono()64~65app.get('/', (c) => c.text('Home'))66app.get('/posts/:id', (c) => {67 const id = c.req.param('id') // string, typé68 return c.json({ id })69})70app.get('/posts/:id/comments/:commentId', (c) => {71 const { id, commentId } = c.req.param()72 return c.json({ id, commentId })73})74app.get('/files/*', (c) => c.text('Wildcard'))75app.post('/posts', async (c) => {76 const body = await c.req.json()77 return c.json({ created: body }, 201)78})79```80~81Les paramètres sont inférés en types literal : TypeScript sait que `c.req.param('id')` retourne un `string` uniquement si tu as déclaré `:id` dans le pattern. Une faute de frappe est une erreur de compilation.82~83### Grouper les routes84~85`app.route()` permet de composer des sous-applications comme des modules, chacune avec son préfixe.86~87```typescript88// routes/posts.ts89import { Hono } from 'hono'90~91const posts = new Hono()92posts.get('/', (c) => c.json({ posts: [] }))93posts.get('/:id', (c) => c.json({ id: c.req.param('id') }))94export default posts95~96// src/index.ts97import { Hono } from 'hono'98import posts from './routes/posts'99~100const app = new Hono()101app.route('/posts', posts)102```103~104Les routes imbriquées héritent du base path et du type de l'app racine, donc le client RPC voit toute la structure.105~106## L'objet Context107~108Chaque handler reçoit un `c` - le Context de la requête courante. C'est la seule API à apprendre pour lire les entrées et produire les sorties.109~110```typescript111app.post('/echo', async (c) => {112 // Lecture113 const userAgent = c.req.header('User-Agent')114 const page = c.req.query('page')115 const body = await c.req.json()116 const env = c.env // bindings (KV, D1, secrets sur Cloudflare)117~118 // Variables partagées entre middleware et handler119 c.set('requestId', crypto.randomUUID())120 const id = c.get('requestId')121~122 // Réponse123 c.header('X-Request-Id', id)124 c.status(200)125 return c.json({ userAgent, page, body, id })126})127```128~129Les méthodes de réponse principales sont `c.text`, `c.json`, `c.html`, `c.body` (raw) et `c.redirect`. Toutes acceptent un code de statut en deuxième argument.130~131## Middleware : le modèle en oignon132~133Les middleware sont des fonctions `(c, next) => ...` qui peuvent exécuter du code avant et après le handler. Composés, ils forment le classique modèle en oignon : le premier middleware enregistré est le premier à démarrer et le dernier à terminer.134~135```typescript136import { Hono } from 'hono'137import { logger } from 'hono/logger'138import { cors } from 'hono/cors'139import { secureHeaders } from 'hono/secure-headers'140~141const app = new Hono()142~143app.use('*', logger())144app.use('*', secureHeaders())145app.use('/api/*', cors({ origin: 'https://spinny.dev' }))146~147app.use('*', async (c, next) => {148 const start = performance.now()149 await next()150 c.header('X-Response-Time', `${performance.now() - start}ms`)151})152```153~154`await next()` cède le contrôle au middleware suivant. Tout ce qui suit s'exécute en phase de réponse, après que le handler a produit un résultat. Retourner une `Response` avant `next()` court-circuite la chaîne.155~156### Middleware intégrés157~158Hono livre un riche ensemble de middleware production-ready, importables depuis `hono/...` :159~160| Middleware | Rôle |161|-----------|---------|162| `logger` | Logs structurés méthode, chemin, statut, durée |163| `cors` | CORS configurable par origin, méthodes, headers |164| `csrf` | Protection CSRF basée sur l'origin |165| `secureHeaders` | Définit CSP, HSTS, X-Frame-Options |166| `bearerAuth` / `basicAuth` | Auth Bearer/Basic prête à l'emploi |167| `jwt` | Vérification/signature JWT avec `jose` |168| `etag` | Génère ETag et gère le 304 |169| `cache` | Cache via Web Cache API |170| `compress` | gzip/deflate sur la réponse |171| `bodyLimit` | Rejette les bodies au-dessus d'un seuil |172| `timing` | Header Server-Timing pour le profiling |173~174### Middleware custom type-safe175~176Pour étendre le `Context` avec des variables typées, utilise `createMiddleware` :177~178```typescript179import { createMiddleware } from 'hono/factory'180~181type AuthVars = { userId: string; role: 'user' | 'admin' }182~183export const requireAuth = createMiddleware<{ Variables: AuthVars }>(184 async (c, next) => {185 const token = c.req.header('Authorization')?.replace('Bearer ', '')186 if (!token) return c.json({ error: 'Unauthorized' }, 401)187~188 const payload = await verifyJwt(token)189 c.set('userId', payload.sub)190 c.set('role', payload.role)191 await next()192 }193)194~195// Utilisation196app.get('/me', requireAuth, (c) => {197 const userId = c.var.userId // string, typé198 return c.json({ userId })199})200```201~202En aval du middleware, `c.var.userId` est typé sans aucun cast. La propagation se fait dans toute la chaîne.203~204## Validation avec Zod205~206`@hono/zod-validator` branche Zod dans le cycle de la requête. Tu définis un schéma, l'appliques à la route et obtiens des entrées déjà validées et typées.207~208```typescript209import { Hono } from 'hono'210import { zValidator } from '@hono/zod-validator'211import { z } from 'zod'212~213const createPost = z.object({214 title: z.string().min(1).max(200),215 body: z.string().min(1),216 tags: z.array(z.string()).default([]),217})218~219app.post(220 '/posts',221 zValidator('json', createPost),222 (c) => {223 const data = c.req.valid('json') // typé selon le schéma224 return c.json({ ok: true, post: data }, 201)225 }226)227```228~229Si le body échoue à la validation, Hono répond 400 avec l'erreur Zod sans même appeler le handler. Tu peux aussi valider `query`, `param`, `header`, `cookie` et `form`.230~231## RPC : client type-safe de bout en bout232~233La fonctionnalité qui distingue vraiment Hono est le **mode RPC**. Tu exportes le type de ton app depuis le serveur, le client `hc` l'importe et tu obtiens une autocomplétion complète - chemins, query, body, headers, réponse - sans codegen ni OpenAPI.234~235```typescript236// server.ts237import { Hono } from 'hono'238import { zValidator } from '@hono/zod-validator'239import { z } from 'zod'240~241const app = new Hono()242 .get('/posts/:id', (c) =>243 c.json({ id: c.req.param('id'), title: 'Hello' })244 )245 .post(246 '/posts',247 zValidator('json', z.object({ title: z.string(), body: z.string() })),248 (c) => c.json({ ok: true }, 201)249 )250~251export type AppType = typeof app252export default app253```254~255```typescript256// client.ts257import { hc } from 'hono/client'258import type { AppType } from './server'259~260const client = hc<AppType>('https://api.spinny.dev')261~262const res = await client.posts[':id'].$get({ param: { id: '42' } })263if (res.ok) {264 const data = await res.json() // { id: string, title: string }265 console.log(data.title)266}267~268const created = await client.posts.$post({269 json: { title: 'Salut', body: 'Hono est une flamme' }, // validé270})271```272~273Renomme une route côté serveur et le TypeScript du client casse immédiatement en CI. Même avantage que tRPC, mais sur HTTP standard, sans middleware spécifique et avec un bundle minuscule.274~275### Discrimination par code de statut276~277Si tu retournes des statuts différents, le client les discrimine automatiquement.278~279```typescript280.get('/posts/:id', (c) => {281 const post = findPost(c.req.param('id'))282 if (!post) return c.json({ error: 'not found' }, 404)283 return c.json({ post }, 200)284})285```286~287```typescript288const res = await client.posts[':id'].$get({ param: { id } })289if (res.status === 404) {290 const { error } = await res.json() // { error: string }291}292if (res.status === 200) {293 const { post } = await res.json() // { post: Post }294}295```296~297## Routeurs et performance298~299Hono propose cinq routeurs avec différents trade-offs. Le défaut est le `SmartRouter`, qui au démarrage mesure quel routeur sert le mieux tes routes et s'y fixe.300~301| Routeur | Points forts | Quand l'utiliser |302|---------|--------------|------------------|303| `RegExpRouter` | Vitesse maximale, regex compilée | Défaut pour la plupart des APIs |304| `TrieRouter` | Supporte tous les patterns | Patterns complexes non gérés par RegExp |305| `SmartRouter` | Choisit le meilleur automatiquement | Défaut recommandé |306| `LinearRouter` | Enregistrement ultra-rapide | Workers one-shot, cold starts critiques |307| `PatternRouter` | Bundle minimal (<15 Ko) | Contraintes de taille extrêmes |308~309Pour des workers stateless avec cold starts fréquents, `LinearRouter` évite le coût de compilation initial et est 33x plus rapide que `find-my-way` quand on mesure enregistrement plus matching.310~311```typescript312import { Hono } from 'hono'313import { LinearRouter } from 'hono/router/linear-router'314~315const app = new Hono({ router: new LinearRouter() })316```317~318## Déploiement multi-runtime319~320Le même `export default app` change de cible en ne touchant qu'au fichier d'entrée.321~322### Cloudflare Workers323~324```typescript325// src/index.ts326import { Hono } from 'hono'327~328type Bindings = { MY_KV: KVNamespace; DB: D1Database }329const app = new Hono<{ Bindings: Bindings }>()330~331app.get('/cache/:key', async (c) => {332 const value = await c.env.MY_KV.get(c.req.param('key'))333 return c.json({ value })334})335~336export default app337```338~339Déploiement : `npx wrangler deploy`. Bindings KV/D1/R2/Queues natifs.340~341### Bun342~343```typescript344import { Hono } from 'hono'345const app = new Hono()346app.get('/', (c) => c.text('Bun + Hono'))347~348Bun.serve({ fetch: app.fetch, port: 3000 })349```350~351### Node.js352~353```typescript354import { serve } from '@hono/node-server'355import { Hono } from 'hono'356~357const app = new Hono()358app.get('/', (c) => c.text('Node + Hono'))359~360serve({ fetch: app.fetch, port: 3000 })361```362~363### Deno364~365```typescript366import { Hono } from 'jsr:@hono/hono'367const app = new Hono()368app.get('/', (c) => c.text('Deno + Hono'))369Deno.serve(app.fetch)370```371~372### Vercel373~374```typescript375// api/[[...route]].ts376import { Hono } from 'hono'377import { handle } from 'hono/vercel'378~379const app = new Hono().basePath('/api')380app.get('/hello', (c) => c.json({ msg: 'Hello from Vercel' }))381~382export const GET = handle(app)383export const POST = handle(app)384```385~386Même code métier, même routing, même middleware. Seul l'adaptateur change.387~388## Exemple concret : API REST avec auth et BD389~390Mettons les pièces ensemble. Une API blog sur Cloudflare Workers + D1, avec auth JWT, validation et RPC.391~392```typescript393import { Hono } from 'hono'394import { jwt } from 'hono/jwt'395import { logger } from 'hono/logger'396import { cors } from 'hono/cors'397import { zValidator } from '@hono/zod-validator'398import { z } from 'zod'399~400type Bindings = { DB: D1Database; JWT_SECRET: string }401type Variables = { jwtPayload: { sub: string } }402~403const app = new Hono<{ Bindings: Bindings; Variables: Variables }>()404~405app.use('*', logger())406app.use('/api/*', cors({ origin: 'https://spinny.dev', credentials: true }))407~408const auth = (c: any, next: any) =>409 jwt({ secret: c.env.JWT_SECRET })(c, next)410~411const api = app.basePath('/api')412~413api.get('/posts', async (c) => {414 const { results } = await c.env.DB415 .prepare('SELECT id, title, created_at FROM posts ORDER BY created_at DESC LIMIT 50')416 .all()417 return c.json({ posts: results })418})419~420api.post(421 '/posts',422 auth,423 zValidator('json', z.object({424 title: z.string().min(1).max(200),425 body: z.string().min(1),426 })),427 async (c) => {428 const { title, body } = c.req.valid('json')429 const userId = c.var.jwtPayload.sub430 const result = await c.env.DB431 .prepare('INSERT INTO posts (title, body, author_id) VALUES (?, ?, ?) RETURNING id')432 .bind(title, body, userId)433 .first<{ id: number }>()434 return c.json({ id: result?.id }, 201)435 }436)437~438api.onError((err, c) => {439 console.error(err)440 return c.json({ error: 'Internal error' }, 500)441})442~443export type AppType = typeof api444export default app445```446~447Un client React la consomme avec une type-safety complète :448~449```typescript450import { hc } from 'hono/client'451import type { AppType } from '../api/src/index'452~453const api = hc<AppType>(import.meta.env.VITE_API_URL)454~455const res = await api.posts.$post({456 json: { title: 'Hono en 2026', body: '...' },457}, {458 headers: { Authorization: `Bearer ${token}` },459})460```461~462## Tests463~464`app.request()` permet de tester les routes sans démarrer un serveur HTTP. C'est le même chemin qu'en production, exécuté en mémoire.465~466```typescript467import { describe, it, expect } from 'vitest'468import app from '../src/index'469~470describe('GET /api/posts', () => {471 it('renvoie la liste des posts', async () => {472 const res = await app.request('/api/posts')473 expect(res.status).toBe(200)474 const body = await res.json()475 expect(body.posts).toBeInstanceOf(Array)476 })477})478```479~480Sur Cloudflare Workers, `@cloudflare/vitest-pool-workers` fait tourner les mêmes tests dans un vrai Worker avec des bindings mock - réalisme maximal, zéro déploiement.481~482## Bonnes pratiques483~484### 1. Chaîner les définitions de route485~486```typescript487const app = new Hono()488 .get('/posts', handler1)489 .post('/posts', handler2)490 .get('/posts/:id', handler3)491```492~493Le chaînage maintient le type de `app` à jour à chaque route, ce qui est essentiel pour le client RPC. Les définir séparément avec `app.get(...)` sur des lignes distinctes casse l'inférence.494~495### 2. Exporter le type, pas l'implémentation496~497Le client doit importer `AppType`, pas l'app. `import type` garantit que le build du frontend ne contient pas le code backend.498~499### 3. Un routeur par domaine500~501Une sous-app pour `posts`, une pour `users`, une pour `webhooks`. Compose-les avec `app.route()` et chacune possède ses middleware. La structure scale sans méga-fichiers.502~503### 4. Validation en bord, toujours504~505Toute entrée externe (body, query, header) doit passer par `zValidator`. Ne fais pas confiance aux données en aval : même un cast TypeScript sans validation runtime est un bug en attente.506~507### 5. Mise à profit des bindings, pas des clients globaux508~509Sur Cloudflare, accède à KV/D1/R2 via `c.env`. Pas de singletons globaux, pas de connexions persistant entre Workers. Le modèle stateless est une fonctionnalité, pas une limitation.510~511### 6. Mesurer avant d'optimiser le routeur512~513Le `SmartRouter` par défaut convient à 95 % des cas. Change de routeur seulement après avoir profilé et constaté un goulot d'étranglement réel.514~515## Conclusion516~517Hono est devenu en 2026 le standard de facto pour construire des APIs prêtes pour le edge en TypeScript. La combinaison de Web Standards, performance, type safety et portabilité résout précisément les problèmes qui freinaient les frameworks traditionnels : lock-in au runtime, bundles lourds, type system fragile entre client et serveur.518~519Tu l'utiliseras pour des microservices sur Cloudflare Workers, pour remplacer Express dans un monolithe Node, pour une fonction sur Vercel ou pour une API en Bun. Le même savoir se transfère partout - et c'est ce qui en fait un investissement solide pour les années à venir.520~521> **Checklist pour démarrer :**522>523> - [x] `npm create hono@latest` et choisis ton template de runtime524> - [x] Définis les routes avec le chaînage (`.get(...).post(...)`)525> - [x] Ajoute `logger`, `cors`, `secureHeaders` comme middleware globaux526> - [x] Valide chaque entrée avec `@hono/zod-validator`527> - [x] Exporte `AppType` et consomme l'API avec le client `hc` type-safe528> - [x] Écris les tests avec `app.request()` - sans serveur HTTP529> - [x] Déploie avec `wrangler deploy` (CF), `vercel deploy` ou le bundler de ton runtime530~
NORMAL · hono-framework-guide.md [readonly]530 lines · :q to close