基本(ルーティング)

Hono のルーティングは Express 風。app.get/post/put/delete/patch でメソッドと path を指定。 path にはパラメータワイルドカード正規表現が使える。

HTTP メソッド

import { Hono } from "hono"
const app = new Hono()

app.get("/users", (c) => c.json([]))
app.post("/users", async (c) => {
  const body = await c.req.json()
  return c.json({ created: body }, 201)
})
app.put("/users/:id", (c) => c.json({}))
app.patch("/users/:id", (c) => c.json({}))
app.delete("/users/:id", (c) => c.body(null, 204))

// すべてのメソッド
app.all("/health", (c) => c.text("ok"))

// 任意のメソッドを指定
app.on(["PURGE", "OPTIONS"], "/cache", (c) => c.text(""))

export default app

パスパラメータ

// 単一
app.get("/users/:id", (c) => {
  const id = c.req.param("id")    // string
  return c.json({ id })
})

// 複数
app.get("/posts/:postId/comments/:commentId", (c) => {
  const { postId, commentId } = c.req.param()
  return c.json({ postId, commentId })
})

// オプショナル
app.get("/api/:version?", (c) => {
  const v = c.req.param("version") ?? "v1"
  return c.json({ version: v })
})

正規表現でパラメータを制限

// 数字のみ
app.get("/users/:id{[0-9]+}", (c) => { ... })

// UUID
app.get("/users/:id{[0-9a-f-]{36}}", (c) => { ... })

ワイルドカード

// 任意の path
app.get("/files/*", (c) => {
  const path = c.req.path           // /files/a/b/c
  return c.text(path)
})

クエリパラメータ

app.get("/search", (c) => {
  const q = c.req.query("q")           // 単一
  const tags = c.req.queries("tag")    // 複数(?tag=a&tag=b)
  return c.json({ q, tags })
})

// 全部まとめて
const allQuery = c.req.query()         // Record<string, string>

レスポンスヘルパー

メソッド用途
c.text(body, status?)プレーンテキスト
c.json(obj, status?)JSON
c.html(str)HTML
c.body(buf, status?, headers?)任意のボディ
c.redirect(url, status?)リダイレクト(既定 302)
c.notFound()404
c.newResponse(body, init)低レベル
app.get("/", (c) => c.text("Hello"))
app.get("/api/data", (c) => c.json({ ok: true }, 200))
app.post("/login", (c) => c.redirect("/dashboard"))
app.get("/admin", (c) => c.body("Forbidden", 403))

ステータスコード・ヘッダー

app.get("/api/data", (c) => {
  c.status(200)
  c.header("X-Custom", "value")
  c.header("Cache-Control", "max-age=60")
  return c.json({ ok: true })
})

// または直接 headers を渡す
return c.json({ ok: true }, 200, {
  "X-Custom": "value",
})

グルーピング

app.route()サブアプリを結合できる。コードを分けるのに便利。

// users.ts
import { Hono } from "hono"
const users = new Hono()
users.get("/", (c) => c.json([]))
users.get("/:id", (c) => c.json({ id: c.req.param("id") }))
export default users

// app.ts
import users from "./users"
const app = new Hono()
app.route("/users", users)        // /users と /users/:id

ベースパス

const app = new Hono().basePath("/api/v1")

app.get("/users", ...)            // /api/v1/users

順序とマッチング

Hono のルートは登録順に評価される。具体的なパスをに書く。

// 良い順序
app.get("/users/me", meHandler)
app.get("/users/:id", userHandler)   // 先に /users/me がマッチ

// 悪い順序
app.get("/users/:id", userHandler)   // /users/me も :id にマッチしてしまう
app.get("/users/me", meHandler)      // 到達しない

ハンドラのチェーン(Express 風)

1ルートに複数のハンドラを並べられる。前のハンドラで next() を呼ぶと次へ。

app.get(
  "/admin",
  async (c, next) => {                  // 1: 認証チェック
    if (!c.req.header("Authorization")) {
      return c.text("Unauthorized", 401)
    }
    await next()
  },
  (c) => c.text("Welcome admin"),       // 2: 本処理
)

ミドルウェアの注入

詳しくはミドルウェア。基本は app.use():

import { logger } from "hono/logger"
import { cors } from "hono/cors"

app.use(logger())
app.use("/api/*", cors())     // 特定 path のみ

404 / エラーハンドラ

app.notFound((c) => c.json({ error: "Not Found" }, 404))

app.onError((err, c) => {
  console.error(err)
  return c.json({ error: err.message }, 500)
})

// 任意の場所で投げて onError で受ける
import { HTTPException } from "hono/http-exception"
app.get("/forbidden", () => {
  throw new HTTPException(403, { message: "Forbidden" })
})

JSX レスポンス(HTML を返す)

Hono はJSX を組み込みでサポート。React 互換の構文で SSR できる。

tsconfig.json:

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "hono/jsx"
  }
}
import { Hono } from "hono"
const app = new Hono()

const Layout = (props: { children: any }) => (
  <html>
    <head><title>Hono</title></head>
    <body>{props.children}</body>
  </html>
)

app.get("/", (c) => c.html(
  <Layout>
    <h1>Hello, JSX!</h1>
  </Layout>
))

ストリーミングレスポンス

import { stream, streamText, streamSSE } from "hono/streaming"

app.get("/stream", (c) => {
  return streamText(c, async (stream) => {
    for (const chunk of ["Hello", " ", "World"]) {
      await stream.write(chunk)
      await stream.sleep(500)
    }
  })
})

// Server-Sent Events
app.get("/sse", (c) => {
  return streamSSE(c, async (stream) => {
    let id = 0
    while (true) {
      await stream.writeSSE({ id: String(id++), data: `tick ${Date.now()}` })
      await stream.sleep(1000)
    }
  })
})

静的ファイル配信

ランタイムごとにアダプタが違う。Cloudflare では Workers Sites または Pages、Node.js は serveStatic:

// Node.js
import { serveStatic } from "@hono/node-server/serve-static"
app.use("/static/*", serveStatic({ root: "./public" }))

// Cloudflare Pages
import { serveStatic } from "hono/cloudflare-pages"
app.get("/*", serveStatic())

テスト

Hono はfetch を直接受け取るのでテストが簡単:

import { app } from "./app"

const res = await app.request("/users/1")
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ id: "1" })