🤞

HonoXについて

2024/02/19に公開

2月9日、予告していた通りHono v4をリリースしました。

https://github.com/honojs/hono/releases/tag/v4.0.0

そのHono v4のリリースと同時に、Honoを使ったメタフレームワーク「HonoX」を公開しました。

https://github.com/honojs/honox

今回はHonoXのいくつかの特徴について書いてみたいと思います。これは使い方というより作者目線の思想みたいなものです。

メタフレームワーク

HonoXとは一言で言うと「HonoとViteを組み合わせたメタフレームワーク」です。HonoX自体が機能を提供しないのが肝です。

もう少しだけ具体的に言います。HonoXで扱うのは「Honoのインスタンス」そのものです。つまりあなたがHonoXでアプリを作るということは「Honoのアプリを作る」ことになります。その証拠にエントリーポイントになるapp/server.ts内で出てくるappはHonoのインスタンスなので、hono/devにあるヘルパー関数showRoutes()がそのまま使えます。

app/server.ts
import { showRoutes } from 'hono/dev'
import { createApp } from 'honox/server'

const app = createApp()

showRoutes(app)

export default app

Viteについて

これを透過的に実現するのがViteです。

「Viteとは?」となりますが、ここでは簡単に以下の3つを提供してくれる便利なツールだと考えてください。

  1. モジュールのロード
  2. 開発サーバー
  3. ビルド

HonoXではhonox/viteからViteのプラグインを提供しています。それをvite.config.tsでロードすることになります。最低限は以下です。

vite.config.ts
import honox from 'honox/vite'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: honox(),
})

例えばAstroはViteを使っていますが、Astroの設定ファイルはastro.config.tsvite.config.tsではありません。まぁラップした感じですね。HonoXではvite.config.tsなので、それと比べるとHonoXはViteをより「素」で使っていることになります。実装をより少なくしたかったのと、よりViteのエコシステムをわかりやすく使えるようにしたかったという意図があります。

ファイルベースルーティング

ファイルベースルーティングはファイルを置いた場所によってWebアプリのルーティングが決定する仕組みです。HonoXの場合、app/routes以下に.tsもしくは.tsxファイルを置きます。

  • app/routes/index.tsx => /
  • app/routes/about.tsx => /about
  • app/routes/posts/index.tsx => /posts/
  • app/routes/posts/[:id].tsx => /posts/:id

という具合です。

Honoベースのルート

ファイルベースルーティングといっても、中のルートファイルではHonoベースのシンタックス、オブジェクトが扱えるので、覚えることは少なくて済みます。

各ルートファイルは3種類のものを返すことができます。

1. (Handler|Middleware)[]

これが一番バランスの取れたAPIです。Honoのハンドラもしくはミドルウェアの配列をdefault exportで返せばそのレスポンスがそのままユーザーに返却されます。honox/factoryから提供されているヘルパーを使う方法を推奨しています。以下のように書けます。

app/routes/index.tsx
import { createRoute } from 'honox/factory'

export default createRoute((c) => {
  return c.render(<h1>Hello!</h1>)
})

READMEにも書いてある一般的な使い方です。ただ申した通り、ハンドラの配列でいいので、こうも書けてしまいます。

app/routes/index.ts
export default [
  () => {
    return new Response('Hello!')
  },
]

これがHonoXの面白いところです。

まず、返却するのはResponseオブジェクトを返せばそれがそのままレンダリングさせるということです。上記だとtext/plainHelloが表示されます。つまり、return c.render(<h1>Hello!</h1>)としているのはただ、Responseオブジェクトを返しているだけなんです。ですので、c.render()に限らずResponseオブジェクトさせ返せばいいので、自分でカスタムのヘルパーを作れるかもしれないし、そもそもc.render()が後述する通りカスタム可能です。このAPIがHonoXをシンプルでかつ柔軟にしています。

次に注目すべきは「配列」であることです。例えば、以下のようなコードを書けます。

app/routes/index.ts
export default [middleware1, middleware2, handler]

ヘルパーを使うとこうです。

app/routes/index.ts
export default createRoute(middleware1, middleware2, handler)

で、これはが内部的にはこう解釈されます。

app.get(middleware1, middleware2, handler)

つまりHonoでやってこと同じことです。ですので、例えばZod Validatorを挟もうと思えば、いつもどおりハンドラの前に書けばいいのです。

app/routes/index.tsx
export default createRoute(
  zValidator(
    'query',
    z.object({
      q: z.string()
    })
  ),
  (c) => {
    const { q } = c.req.valid('query')
    return c.render(<p>Your query is {q}</p>)
  }
)

このように、書き心地を限りなくHonoに近づけています。つまり、Honoを知っていればHonoXのアプリを書けるし、Hono自体も覚えることが少ない。三段論法でHonoXは使いやすいと思います。

2. app

上記「1」のAPIがHonoと「似ている」と書きましたが、Honoの書き方と「全く同じ」書き方ができます。

app/routes/index.ts
import { Hono } from 'hono'

const app = new Hono()

app.get(() => {
  return c.render(<h1>Hello!</h1>)
})

export default app

これをapp/routes/index.tsapp/routes/about.tsに置けば動くのです。面白いですね。

「1」やこれから紹介する「3」に比べると冗長になってしまうので、HTMLを返したいだけならそちらを使うのですが、APIを作るのに便利です。URL的に/api以下をAPIにして、同じアプリのフロントから叩く、でもいいし、HonoXを使って「APIだけ」のアプリを使ってもいいということです。

また、工夫次第でRPCモードも使えます。以下のように書けばAppTypeをIslandsのクライントと共有させることができます。

app/routes/index.ts
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'

const app = new Hono()

const schema = z.object({
  name: z.string()
})

const routes = app.get('/echo', zValidator('query', schema), (c) => {
  const { name } = c.req.valid('query')
  return c.json({ message: `Hello, ${name}! from API` })
})

export type AppType = typeof routes

export default app

「Hono」と全く同じシンタックスが使えるということは既存アプリからの乗り換えが簡単だし、その逆もまた然りとなります。

3. Function Component

これは他のフレームワークを触っていたら馴染みがあるでしょう。

app/routes/about.tsx
export default function About() {
  return <h1>I'm Hono!</h1>
}

JSXを返すだけです。内部的にはそれがそのままc.render()の引数になります。

これも興味深いのは引数にContextオブジェクトを受け取れることです。つまり、こう書けます。

app/routes/about.tsx
import type { Context } from 'hono'

export default function About(c: Context) {
  const name = c.req.query('name') ?? 'nanashi'
  return <h1>I'm {name}!</h1>
}

HonoのContextオブジェクトはその名の通りハンドラに必要な全ての情報を持っているので、これさえできれば、だいたいできます。

また、変数をexportしておくと、あとでRendererで使えます。

app/routes/index.tsx
export const title = 'about' // <=== これ

export default function About() {
  return <h1>I'm Hono!</h1>
}

と書くと、

app/routes/_renderer.tsx
import { jsxRenderer } from 'hono/jsx-renderer'

export default jsxRenderer(({ children, title }) => {
  return (
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <title>{title}</title>
      </head>
      <body>{children}</body>
    </html>
  )
})

このtitleaboutという値が渡ります。例えば、ページのメタデータをexportするようにしたら便利でしょう。

Bring Your Own Renderer

先ほどからc.render()というメソッドが出てきてます。HonoXではこのメソッドをよく使うことになるでしょう。それもそのはず、このc.render()、実はHonoXのために僕がしれっと追加したものです。

とはいえ、c.renderの実装はほとんど何もないです。Honoの内部を見るとよくわかります。setRenderer()で関数を登録でき、render()で引数をそれに渡しているだけです。

context.ts
render: Renderer = (...args) => this.renderer(...args)

// ...

setRenderer = (renderer: Renderer) => {
  this.renderer = renderer
}

型定義だけで機能を明示していて、デフォルトはこのようになっています。

context.ts
interface DefaultRenderer {
  (content: string | Promise<string>): Response | Promise<Response>
}

HonoXではhono/jsxをデフォルトで使うことを推奨しているので、この型定義がフィットします。

しかし、これはつまり、型さえ変えれば、hono/jsxではなくとも、例えば他のReactなどのUIライブラリも使えます。実際、@hono/react-rendererという実装もあって、その中の型定義はこうなっています。

interface ContextRenderer {
  (children: React.ReactElement, props?: Props): Response | Promise<Response>
}

この自分の好きなレンダラーを使えることを「Bring Your Own Renderer = BYOR」と呼んでいます。HonoXの実装がシンプルになって、かつ柔軟にしています。

Islandsアーキテクチャ

部分的にインタラクションを足すために、Islandsアーキテクチャを採用しています。これは同じくIslandsアーキテクチャを用いているDeno用のフレームワークFreshを参考にしました。

わかりやすい仕組みです。/app/islands以下に置いたコンポーネントは、それをimportすることで、クライアントにも配信され、インタラクティブになります。hono/jsxはClinet Componentsに対応したので、デフォルトでこのようにカウンターが書けます。

app/islands/counter.tsx
import { useState } from 'hono/jsx'

export default function Counter() {
  const [count, setCount] = useState(0)
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  )
}

それを、ルートファイル内でimportします。

app/routes/index.tsx
import { createRoute } from 'honox/factory'
import Counter from '../islands/counter'

export default createRoute((c) => {
  return c.render(
    <div>
      <h1>Hello, {name}!</h1>
      <Counter />
    </div>
  )
})

すると、サーバーサイドはインタラクションなしのUIをレンダリングします。クライントが読み込まれるとインタラクションが追加されるという仕組みです。

Next.jsのように言えば、通常はServer Componentとして動作し、app/islands以下に置いてルートファイルでimportされるとClient Componentsとみなされるということです。

サーバー側からIslandsコンポーネントへ値やコンポーネントの受け渡しもできます。非同期コンポーネントとSuspenseに対応するので、例えば、サーバーで作った「2秒待ってDone!と表示する」コンポーネントをIslandのカウンターコンポーネントに渡すこともできます。

app/routes/index.tsx
import { Suspense } from 'hono/jsx'
import { createRoute } from 'honox/factory'
import Counter from '../islands/counter'

const AsyncComponent = async () => {
  await new Promise((resolve) => setTimeout(resolve, 2000))
  return <p>Done!</p>
}

export default createRoute((c) => {
  return c.render(
    <Counter>
      <Suspense fallback={<p>Loading...</p>}>
        <AsyncComponent />
      </Suspense>
    </Counter>
  )
})

Suspenseが動きつつ、カウンターも動作します。

SC

このIslandsアーキテクチャの機能はユーザーがオプトインするものになっていて、デフォルトでは発動しません。つまり、JavaScriptを全く必要としない。また、もし、Islandsのコンポーネントを追加したとしても、その部分だけのJavaScriptが配信されるので非常に効率的です。

Viteのエコシステム

特にラップもせずにViteを素で使っているので、Viteのエコシステムを扱えます。

@hono/vite-*

GitHubのhonojs orgにはhonojs/vite-pluginsというオリジナルのViteのPluginのためのリポジトリがあります。

https://github.com/honojs/vite-plugins

実はこのリポジトリもHonoXを想定して作ったものです。

ここにはHonoの開発に使えるViteのPluginがいくつかあります。

  • @hono/vite-dev-server
  • @hono/vite-cloudflare-pages
  • @hono/vite-ssg

@hono/vite-dev-serverhonoxパッケージに同封され、なくてはならない開発サーバです。そして他の2つも使うことができます。

@hono/vite-cloudflare-pages

@hono/vite-cloudflare-pagesはCloudflare Pagesへデプロイする時に使います。いわゆるSSRをするためのものです。

vite.config.ts
import honox from 'honox/vite'
import pages from '@hono/vite-cloudflare-pages'
import { defineConfig } from 'vite'

const entry = './app/server.ts'

export default defineConfig(() => {
  return {
    plugins: [honox(), pages({ entry })]
  }
})

こんな感じで設定を書いて

vite build

をするとdist/_worker.jsが作られます。Cloudflare Pagesだと_workers.jsがエントリポイントになるので、これをめがけて

wrangler pages deploy dist

とやればCloudflare Pagesへデプロイができます。

@hono/vite-ssg

v4で導入されたSSGヘルパーを使ったVite Pluginです。これを使うと上記プラグインと同じようにvite buildコマンドでビルド、この場合は静的ファイルの書き出しができます。

vite.config.ts
import honox from 'honox/vite'
import ssg from '@hono/vite-ssg'
import { defineConfig } from 'vite'

const entry = './app/server.ts'

export default defineConfig(() => {
  return {
    plugins: [honox(), ssg({ entry })]
  }
})

これもdist以下にファイルを生成するので、上記と同じコマンドでCloudflare Pagesへデプロイできます。

wrangler pages deploy dist

MDX

HonoXでは読み込むファイルの対象に.mdx拡張子のついたファイルも含めています。適切にViteの設定をしてあげることで、Markdown拡張であるMDXを動かすことができます。

vite.config.ts
import mdx from '@mdx-js/rollup'
import honox from 'honox/vite'
import remarkFrontmatter from 'remark-frontmatter'
import remarkMdxFrontmatter from 'remark-mdx-frontmatter'
import { defineConfig } from 'vite'

const entry = './app/server.ts'

export default defineConfig(() => {
  return {
    plugins: [
      honox(),
      mdx({
        jsxImportSource: 'hono/jsx',
        remarkPlugins: [remarkFrontmatter, remarkMdxFrontmatter],
      }),
    ],
  }
})

これがなかなか強力で、フロントマターにも簡単に対応します。

---
title: Hello World
---

## Hello World

Today is a good day!

と書けば、frontmatterという名前のレコードがRendererに渡って使うことができます。

app/routes/_renderer.ts
import { jsxRenderer } from 'hono/jsx-renderer'

export default jsxRenderer(({ children, frontmatter }) => {
  return (
    <html lang="en">
      <head>{<title>{frontmatter.title}</title>}</head>
      <body>{children}</body>
    </html>
  )
})

他にも、設定次第でTailwind CSSを導入できたりします。

まとめ

HonoXについて書いてみました。結果、具体的な使い方ではなく「HonoXの考え方」みたいになりました。といっても難しいことはなくなかなかよく考えられたAPIになっていて使いやすいと思います。

まだ未熟な点あると思うので、しばらくは暖かく見守ってもらえると嬉しいです!

https://github.com/honojs/honox

Discussion