🔥

Hono and React full stack dev

2024/04/30に公開

https://tsei.jp/articles/2024/04/01/note/
↑english ver

Hono は ultra-fast で軽量な Web 標準フレームワークです。
Hono 単体での SPA 開発は大変ですが、React と組み合わせることで Next や Remix のようなフルスタック機能を実現できます。
Hono はツリー構造のルーティングなど、React だけでは不可能な表現を可能にします。
また、小さなバンドルサイズや Node.js に依存しないため、Cloudflare のようなプラットフォームで低コストかつ簡単にデプロイ可能です。
いくつかのプロダクトで Hono をつかったときは Server-Sent Events や JWT 認証などを AWS Lambda に短期間でデプロイできました。

examples

articles

getting started

Hono 環境を構築するには、以下のコマンドを実行します。今回は file based rooting を使いたいため x-basic テンプレートを選択しましたが、他のテンプレートを選ぶことで、Cloudflare などでさらに速く全世界にデプロイすることができます。

$ npm create hono@latest

create-hono version 0.7.0
✔ Target directory … hono
✔ Which template do you want to use? › x-basic
cloned honojs/starter#main to $/glre/examples/hono
? Do you want to install project dependencies? no
🎉 Copied project files
Get started with: cd hono

ここまでの diff

https://github.com/tseijp/glre/pull/22/commits/db62518b8513890b5ea942ce706cca3780885ab5

setup

react や vite を使うために、honox-playground/projects/react を参考にしました。package.json の dependencies に react や tailwind などを追加します。

npm i @hono/react-renderer @types/react @types/react-dom autoprefixer postcss react@18 react-dom@18 tailwindcss
// package.json
  "@cloudflare/workers-types": "4",
+ "@hono/react-renderer": "latest",
  "@hono/vite-cloudflare-pages": "latest",
+ "@types/react": "18",
+ "@types/react-dom": "18",
+ "autoprefixer": "10",
+ "postcss": "8",
+ "react": "18",
+ "react-dom": "18",
+ "tailwindcss": "3",

tsconfig.json から "jsxImportSource": "hono/jsx"を削除し、Hono の default の設定の依存を減らします。(お好みですがいつも通り Next.js 同様の設定にしました。)

// tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "jsxImportSource": "react",
    "incremental": true,
  },
  "include": ["**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

React の SSR でエラーが発生しないように、vite.config.ts に ssr.external を追加します。(サーバーで実行できないパッケージをすべて指定する必要があります。)

// vite.config.ts
...
export default defineConfig(() => {
  return {
+   ssr: {
+     external: ['react', 'react-dom'],
+   },
    plugins: [honox(), pages()],
  }
});

support react

hono/jsx から React への移行のため、_renderer.tsx@hono/react-renderer を適応します。(react だけでなく、preact や solid のような任意の UI ライブラリも使うこともできます。)

// app/routes/_renderer.tsx
import { reactRenderer } from '@hono/react-renderer'

export default reactRenderer(({ children, title }) => {
  const src = import.meta.env.PROD
    ? '/static/client.js'
    : '/app/client.ts'
  return (
    <html lang="en">
      <head>
        <meta charSet="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <script type="module" src={src}></script>
      </head>
      <body>{children}</body>
    </html>
  )
})

React case

You can define a renderer using @hono/react-renderer. Install the modules first.

npm i @hono/react-renderer react react-dom hono
npm i -D @types/react @types/react-dom

Define the Props that the renderer will receive in global.d.ts.

// global.d.ts
import '@hono/react-renderer'

declare module '@hono/react-renderer' {
  interface Props {
    title?: string
  }
}

...

ドキュメント通り、app/client.tsx を変更して React hydration と rendering を有効にします。(現状 Type Error がたくさん出力されるので、今後 honox の コードを修正する必要があります。)

// app/client.tsx
import { createClient } from 'honox/client'

- createClient()

+ createClient({
+   hydrate: async (elem, root) => {
+     const { hydrateRoot } = await import('react-dom/client')
+     hydrateRoot(root, elem)
+   },
+   createElement: async (type, props) => {
+     const { createElement } = await import('react')
+     return createElement(type, props)
+   },
+ })

代わりに tailwind を採用しますので routes/index.ts から hono/css を取り除きます。

// app/routes/index.ts
- import { css } from 'hono/css'
import { createRoute } from 'honox/factory'
import Counter from '../islands/counter'

- const className = css`
-   font-family: sans-serif;
- `

export default createRoute((c) => {
  const name = c.req.query('name') ?? 'Hono'
  return c.render(
-   <div class={className}>
+   <div>
      <h1>Hello, {name}!</h1>
      <Counter />
    </div>,

counter.tsxhono/jsx から React への切り替えを行い、既存のデモを React を使用して実行可能にします。 yarn dev でサーバーを起動すると、これらの変更が確認できます。

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

ここまでの diff

https://github.com/tseijp/glre/pull/22/commits/4b46254b0e22b3d7a736d36047fe54814a0dbacf

support tailwind

Hono と組み合わせて Tailwind CSS を使用するために、tailwind.config.js, postcss.config.js, app/style.css を作成します。(Next.js 同様いつも通りですが、document にも方法が書いてあります)

Using Tailwind CSS

Given that HonoX is Vite-centric, if you wish to utilize Tailwind CSS, simply adhere to the official instructions.

Prepare tailwind.config.js and postcss.config.js:

// tailwind.config.js
export default {
  content: ['./app/**/*.tsx'],
  theme: {
    extend: {},
  },
  plugins: [],
}

// postcss.config.js
export default {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}

Write app/style.css:

/* app/style.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

...

_renderer.tsx を修正して tailwind css が読み込まれるようにします。

// app/routes/_renderer.tsx
import { reactRenderer } from '@hono/react-renderer'

export default reactRenderer(({ children, title }) => {
+ const href = import.meta.env.PROD
+   ? 'static/assets/style.css'
+   : '/app/style.css'
...
        <meta name='viewport' content='width=device-width, initial-scale=1.0' />
+       <link href={href} rel="stylesheet" />
        <script type="module" src={src}></script>
...

routes/index.ts についてhono/css を既に消したので、 試しに代わりの Tailwind クラスを追加してみます。

// app/routes/index.ts
export default createRoute((c) => {
  const name = c.req.query('name') ?? 'Hono'
  return c.render(
-   <div>
+   <div className="font-sans">

vite.config.js を修正し、プロダクションのビルド時に tailwind が build されるように設定を追加します。

// vite.config.js
export default defineConfig(({ mode }) => {
  if (mode === 'client') {
    return {
+     build: {
+       rollupOptions: {
+         input: ['/app/style.css'],
+         output: {
+           assetFileNames: 'static/assets/[name].[ext]'
+         }
+       }
+     },
      plugins: [client()],
    }
...

ここまでの diff

https://github.com/tseijp/glre/pull/22/commits/fc24c6cfe42aa4357a40eac4312b825c29047d07

add d1 sqlite

Build a Comments API · Cloudflare D1 docs の公式 docs と honox-playground/projects/cloudflare-bindings のコードを参考に実装できそうです。
wrangler のターミナルにログインするなどしてローカルでセットアップした後、以下のコマンドを実行します。
テキストが生成されますので、wrangler.toml として保存します。

npx wrangler d1 create xxx
# wrangler.toml
[[ d1_databases ]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "xxx"
database_id = "yyyyyyyyyyyyyyyyyyyyyyyy"

dump.sql で Cloudflare D1 のデータベーススキーマを定義し、テーブルと初期データ設定をします。
(本番環境の Cloudflare Pages / Worker から D1 をつかうには、 Cloudflare の Console から、 Settings/Function/D1 database bindings を選択し、Variable name property に DB , D1 database property に作成した database_name = xxx を指定してバインディングする必要があります。)

  • npx wrangler d1 execute xxx --local --file=./app/schemas/dump.sql
    (xxx は指定した名前で、--local を外すと Cloudflare に deploy されます。)
  • npx wrangler d1 execute xxx --local --command='SELECT * FROM creation'
    (テーブル内をチェックできます。)
/* app/schemas/dump.sql */
DROP TABLE IF EXISTS `creation`;
CREATE TABLE `creation` (
  `id` TEXT PRIMARY KEY,
  `title` TEXT DEFAULT NULL,
  `content` TEXT DEFAULT NULL,
  `created_at` TEXT DEFAULT (datetime('now')),
  `updated_at` TEXT DEFAULT (datetime('now'))
);
INSERT INTO `creation` (id, title, content) VALUES ('a_id', 'a_title', 'a_content');
INSERT INTO `creation` (id, title, content) VALUES ('b_id', 'b_title', 'b_content');
INSERT INTO `creation` (id, title, content) VALUES ('c_id', 'c_title', 'c_content');
┌──────┬─────────┬───────────┬─────────────────────┬─────────────────────┐
│ id   │ title   │ content   │ created_at          │ updated_at          │
├──────┼─────────┼───────────┼─────────────────────┼─────────────────────┤
│ a_id │ a_title │ a_content │ 2024-04-30 11:59:592024-04-30 11:59:59 │
├──────┼─────────┼───────────┼─────────────────────┼─────────────────────┤
│ b_id │ b_title │ b_content │ 2024-04-30 11:59:592024-04-30 11:59:59 │
├──────┼─────────┼───────────┼─────────────────────┼─────────────────────┤
│ c_id │ c_title │ c_content │ 2024-04-30 11:59:592024-04-30 11:59:59 │
└──────┴─────────┴───────────┴─────────────────────┴─────────────────────┘

vite.config.tsを修正して開発ビルド用に wrangler を設定します。
(server.watch.ignored.mf を追加しないと vite がクラッシュするバグがあったのですが、
修正 PR をだしたらすぐに merge されました 🎉)

// vite.config.ts
...
+ import { getPlatformProxy } from 'wrangler'

- export default defineConfig(({ mode }) => {
+ export default defineConfig(async ({ mode }) => {
  if (mode === 'client') {
    ...
  } else {
+   const { env, dispose } = await getPlatformProxy();
    return {
      ssr: {
        external: ['react', 'react-dom'],
      },
-     plugins: [honox(), pages()],
+     plugins: [
+       honox({
+         devServer: {
+           env,
+           plugins: [{ onServerClose: dispose }],
+         },
+       }),
+       pages(),
+     ],
  }

ここまでの diff

https://github.com/tseijp/glre/pull/22/commits/c6d34f0a1d2fa679f0fbe4700263e9ac90c0a7ed

make service

Query D1 from Hono · Cloudflare D1 docs の公式 docs と honox-examples/projects/blog at main · yusukebe/honox-examples のコードを参考に実装できそうです!
テキストを作成・更新・削除するための API として、routes/index.tsx, routes/new.tsx, routes/[userId]/[id]/index.tsx の 3 つの route を作成しました。

// app/routes/index.tsx
import { cors } from 'hono/cors'
import { createRoute } from 'honox/factory'
import App from '../islands/home'

export const GET = createRoute(cors(), async (c) => {
  const { results } = await c.env.DB.prepare(`select * from creation`).all()
  const creationItems = results
  return c.render(<App creationItems={creationItems} />)
})

// app/routes/new.tsx
import { z } from 'zod'
import { cors } from 'hono/cors'
import { createRoute } from 'honox/factory'
import { zValidator } from '@hono/zod-validator'
import App from '../islands/new'

export const GET = createRoute(cors(), async (c) => {
  return c.render(<App />)
})

const schema = z.object({
  title: z.string().min(1),
  content: z.string().min(1),
})

export const POST = createRoute(
  cors(),
  zValidator('json', schema, (result, c) => {
    if (!result.success) return c.render('Error')
  }),
  async (c) => {
    const { title, content } = c.req.valid('json')
    const id = crypto.randomUUID()
    const { success } = await c.env.DB.prepare(
      `INSERT INTO creation (id, title, content) VALUES (?, ?, ?)`
    )
      .bind(id, title, content)
      .run()
    if (success) {
      c.status(201)
      return c.json({ id })
    } else {
      c.status(500)
      return c.json({ message: 'Something went wrong' })
    }
  }
)

export type CreateAppType = typeof POST

// app/routes/[userId]/[id]/index.tsx
import { z } from 'zod'
import { createRoute } from 'honox/factory'
import { basicAuth } from 'hono/basic-auth'
import { zValidator } from '@hono/zod-validator'
import { cors } from 'hono/cors'
import App from '../../../islands/edit'

const AUTH = basicAuth({
  username: 'username',
  password: 'password',
})

export const GET = createRoute(cors(), AUTH, async (c) => {
  const { id } = c.req.param()
  const { results } = await c.env.DB.prepare(
    `select * from creation where id = ?`
  )
    .bind(id)
    .all()
  const item = results[0]

  return c.render(
    <App
      creationId={id}
      creationTitle={item.title}
      creationContent={item.content}
    />
  )
})

const schema = z.object({
  title: z.string().min(1),
  content: z.string().min(1),
})

export const PUT = createRoute(
  cors(),
  zValidator('json', schema, (result, c) => {
    if (!result.success) {
      return c.render('Error', {
        hasScript: true,
      })
    }
  }),
  async (c) => {
    const { id } = c.req.param()
    const { title, content } = c.req.valid('json')
    const { success } = await c.env.DB.prepare(
      `UPDATE creation SET title = ?, content = ? WHERE id = ?`
    )
      .bind(title, content, id)
      .run()
    if (success) {
      c.status(201)
      return c.json({ id })
    } else {
      c.status(500)
      return c.json({ message: 'Something went wrong' })
    }
  }
)

export const DELETE = createRoute(cors(), async (c) => {
  const { id } = c.req.param()
  const { success } = await c.env.DB.prepare(
    `DELETE FROM creation WHERE id = ?`
  )
    .bind(id)
    .run()
  if (success) {
    c.status(201)
    return c.json({ message: 'Deleted' })
  } else {
    c.status(500)
    return c.json({ message: 'Something went wrong' })
  }
})

vercel の v0.dev という AI で UI を生成してくれるサービスを使ってベースデザインを作りました。
v0.dev のプロンプトは chatgpt に教えてもらい、出力されたコードをいい感じにするといい感じになります!
あとは npm run deploy を実行しアプリケーションをデプロイして完了です!
Cloudflare のデプロイ速度が AWS のような他サービスが数分かかるのに対して爆速なのもすごく好きです!




https://play.glre.dev

play.glre.dev であそべます 💪

Discussion