📑

Hotwire/Honoなウェブアプリのアーキテクチャ

2024/04/03に公開

React Notes: MarkdownエディタのUIを作る

「React Notes」というReact Server Components(RSC)が発表された時期にReactチーム[1]やVercel[2]が公開していたブログ投稿デモサイトがあって、それをHotwireとHono/JSXで作ってみることでRSCなしに似たようなUXが作れるっていうのを示せるのではと思って、今クローンを作ってみています

現在はテキストエリアにMarkdownを入力するとプレビューをしてくれて、保存→更新の画面遷移がひととうりできるという部分のUIだけ先に試しに書いてみて以下にデプロイしました

https://hotwire-hono.pages.dev/

ソースコードがここにあります

https://github.com/laiso/honox-examples/tree/main/projects/notes-hotwire

  • SSRな部分をHono/JSXのテンプレート処理系に寄せて
  • クライアントサーバー通信と画面更新のコードはHotwire/Turboで簡略化
  • イベントハンドラな部分はHotwire/Stimulusの補助を受けてVanilla JSで書く

という、アーキテクチャなのでサーバーとして動くCloudflare Workersのプログラムからハイドレーション関連のコードをなくして軽量にできました

これをCloudflare Pagesにデプロイできるので、Next.jsやRemix等とはまた違った特性が出せるのではないかと考えています

従来HotwireはRuby on Railsに統合して使っていたんですけど、バージョンアップにより単にJavaScriptライブラリとして色んな環境から使いやすくなりました

それに加えてHono/JSXがサーバーサイドのテンプレート処理系としてよくできているし、React Server/DOMを真似したAPIが生えてて(JSXとは・・)泥くさい部分をカバーしてくれたりで使い勝手がいいです

もちろんRSCと比べてHotwireの方が優れているというわけでもなくて、クライアントサイドのチャンクを段階的に読み込んだり、コンポーネント単位で非同期にストリーミングしてキャッシュを効かせたりといった戦略はRSC(Next.js)の方が得意だろうし、Hotwireのidキーを軸にDOMが自動で書き換えられていくのがReactと比べて複雑なDXを引き起すトレードオフもありそうだな思います

用語の整理

Hono

新しめのサーバーサイドJavaScriptフレームワーク。Express, Fastify, NestJSなどと同じレイヤー

Node.js以降のJavaScriptランタイム(Deno, Bun, Clodflare Workers等)で快適に動くので注目されている。

Hono/JSX

Hono内部で実装されているテンプレートエンジンが発展したJSX処理系。HonoでAPIサーバーだけでなくWebページも構築するニーズが出てきて作られた

https://blog.taaas.jp/hono/hono-jsx/

Honox

Honoに入ってない実験的な機能が詰ったフルスタックなWebフレームワーク。

Next.js、RemixやDeno Freshなどと同じレイヤー

Viteを起点に配置したファイルから複数のHonoのルーターをインスタンス化して統合してくれたり、Hono/JSXでSSRしたコンポーネントを選択的ハイドレーション(Islandアーキテクチャ)してonClickを動くようにしたり、任意の個所にファイル配置したり関数渡したりしてカスタマイズしたりするプラガブルな設計になってる

Hotwire

Turbo/Stimulus/Stradaというライブラリたちを総称した名前

https://hotwired.dev/

Ruby on Railsの開発元が作っていてRailsでアプリケーションを作ると自然と導入されてくるのでプログラミング初学者の人がよくハマっているのを見かける(ただベテランもハマってる)

Hotwire/Turbo

サーバーにリクエストを送信して返ってきた結果で画面を更新するのをHTML内DSLでやってくれるJavaScriptライブラリ。Htmxなどと同じレイヤー。

ソースコードをTypeScriptからJavaScriptに書き換えたらなぜか炎上したことで有名。ビルドツールのvercel/turboとは全然関係ない

Turbo Drive, Turbo Frame, Turbo Streamという下位概念を持つけどシンプルに「Drive=全体(turbolinks)、Frame=特定の個所(独自iframe)、Stream=複数の個所(pushも可)」と覚えてる

Hotwire/Stimulus

DOMにVanilla JSをアタッチしてクライアントサイドの振舞いだけを書けるようにするライブラリ

jQuery, Alpine.jsなどと同じレイヤー(個人的にはWeb Components系のライブラリも含めたいのですが@lit-labs/ssrなど最近はリッチな機能を持つものも登場している)

Hotwire/Strada

TurboのiOS/Android WebView向けSDKのTurbo NativeにStimulusのようにネイティブアプリ内部実装をくっつけられるライブラリ

Capacitor(Ionic)などと同じレイヤー

四天王のうち最弱らしく使われているのを見たことがない、けどHEYアプリで37signalsの人達は本番投入しているらしい

ソースコードとWORKAROUNDの解説

先にあげたnotes-hotwireを作るうえで工夫した点やWorkaroundを解説

  • Hono/JSX
  • Honox
  • Hotwire/Turbo Drive
  • Hotwire/Stimulus

が出てくる

Turbo Frame, Turbo Streamはまだ使ってないけど、たぶんそのうち使います

全体図

├── app
│   ├── client.ts
│   ├── components
│   │   ├── note-editor.tsx
│   │   └── note-viewer.tsx
│   ├── controllers
│   │   └── note_controller.ts
│   ├── global.d.ts
│   ├── islands
│   │   └── dummy.tsx
│   ├── routes
│   │   ├── _renderer.tsx
│   │   ├── index.tsx
│   │   └── note
│   │       ├── [id]
│   │       │   ├── edit.tsx
│   │       │   └── index.tsx
│   │       └── index.tsx
│   ├── server.ts
│   ├── styles
│   │   ├── note-preview.css
│   │   └── tailwind.css
│   └── types.ts
├── package.json
├── postcss.config.js
├── public
│   └── static
│       └── favicon.ico
├── tailwind.config.js
├── tsconfig.json
└── vite.config.ts

TailwindCSS

TailwindCSSを使えるようにviteを設定している

https://github.com/laiso/honox-examples/blob/9607fb22c924f2aa447e00984595f7f31cf97edb/projects/notes-hotwire/vite.config.ts

vite buildしてできたcssファイルを import styles from "../styles/tailwind.css?url"; で読み込ませる

https://github.com/laiso/honox-examples/blob/9607fb22c924f2aa447e00984595f7f31cf97edb/projects/notes-hotwire/app/routes/_renderer.tsx

<Script />についてはhono/cssで書いたスタイルを展開するもので、このプロジェクトではMarkdownのプレビューのスタイルを単一スコープで管理するために使っています

以下からcssファイルを適用しています

https://github.com/laiso/honox-examples/blob/9607fb22c924f2aa447e00984595f7f31cf97edb/projects/notes-hotwire/app/components/note-viewer.tsx#L2-L10

Honox: client.ts (WORKAROUND👷)

client.tsはクライアントサイドのエントリーポイントで、基本的にHono/JSXやReactのハイドレーションに使う想定になっている

なので動作させるためには現在「islandコンポーネントをロードしていること」という制約がある

今回はislandコンポーネント全無視でHotwireでHTML中心に書くので、各ページにおまじないを入れてる

https://github.com/laiso/honox-examples/blob/9607fb22c924f2aa447e00984595f7f31cf97edb/projects/notes-hotwire/app/routes/index.tsx

中身は空

https://github.com/laiso/honox-examples/blob/main/projects/notes-hotwire/app/islands/dummy.tsx

app/components/: Viewコンポーネント

app/routes/に記述したJSX(HTML)がある程度意味を持ったらコンポーネントに切り出してる。

https://github.com/laiso/honox-examples/blob/main/projects/notes-hotwire/app/components/note-editor.tsx

Reactコンポーネントで想像する非同期で状態管理を持つ部品ではなくて、実質サーバーサイドのpartialsテンプレート片として運用できる。Hono/JSXなのでツリー構造として組合せできるし、TypeScriptの補助を受けられるのでerbより気に入ってる

(※状態管理はサーバーサイドでやるか、Stimulusでクライアントサイドだけに押し込める)

app/controllers/: Stimulsコントローラー

Viewコンポーネントでdata-controller="note"という要素があるのはこのコードが接続される

https://github.com/laiso/honox-examples/blob/main/projects/notes-hotwire/app/controllers/note_controller.ts

Viewコンポーネントとコントローラーが多対一になっている部分が注目できると思う

│   ├── components
│   │   ├── note-editor.tsx
│   │   └── note-viewer.tsx
│   ├── controllers
│   │   └── note_controller.ts

Viewとしては2つのパターンを持ちつつも、ふるまいとしては同じ意味を持つ処理に紐づくというのをHTML要素を起点にStimulsが自動で解決してくれるのでこう書いた方が自然になる

コントローラーはエントリーポイントで以下のように登録している

https://github.com/laiso/honox-examples/blob/main/projects/notes-hotwire/app/client.ts

(※import "@hotwired/turbo"はTurbo Drive, Frameを動作させるのに必要)

app/routes/: Honoxのファイルベースルーティング

Next.jsやRemixのように規約に従ったファイルを配置しておくと自動でルーテイングの定義に反映される

│   ├── routes
│   │   ├── _renderer.tsx
│   │   ├── index.tsx
│   │   └── note
│   │       ├── [id]
│   │       │   ├── edit.tsx
│   │       │   └── index.tsx
│   │       └── index.tsx

GET /

新規にNoteを投稿するformを出すページ

https://github.com/laiso/honox-examples/blob/9607fb22c924f2aa447e00984595f7f31cf97edb/projects/notes-hotwire/app/routes/index.tsx

POST /note

formのactionのPOST先

https://github.com/laiso/honox-examples/blob/9607fb22c924f2aa447e00984595f7f31cf97edb/projects/notes-hotwire/app/routes/note/index.tsx

status=303にするのはTurboが期待するレスポンスだから

After a stateful request from a form submission, Turbo Drive expects the server to return an HTTP 303 redirect response, which it will then follow and use to navigate and update the page without reloading. https://turbo.hotwired.dev/handbook/drive#redirecting-after-a-form-submission

https://github.com/laiso/honox-examples/blob/9607fb22c924f2aa447e00984595f7f31cf97edb/projects/notes-hotwire/app/components/note-editor.tsx

GET /note/:id

投稿されたNoteを閲覧するページ

DOMPurify.sanitize (WORKAROUND👷)

悪意のあるユーザーが<script>タグなどを保存して第三者に実行させようとするのを防ぐために、DOMPurify.sanitize(html)は本来サーバーサイド側でかけてからレスポンスに出力したい。しかしCloudflare Pages(Workers)でdompurifyやsanitize-htmlを動かすのができなくてしかたなくクライアントサイドで描画前にフィルタしている。

https://github.com/laiso/honox-examples/blob/main/projects/notes-hotwire/app/controllers/note_controller.ts#L14-L18

ここはサーバーサイドで処理を追加してなんとかしたい

GET /note/:id/edit

投稿したNoteを編集するページ

PUT /note/:id

編集をsubmitするとこのエンドポイントにデータが送信される。あとはPOSTの時と同じだがTurbo Driveでform#submitをする時にFetch APIでHTTPメソッドをPUTに置き換えるためにformmethod="put"を入れている

https://github.com/laiso/honox-examples/blob/main/projects/notes-hotwire/app/components/note-editor.tsx#L27C11-L34C20

他の方法としてはform自体はブラウザの機能をそのまま使い <input name="_method" type="hidden" value="put" />のパラメータをPOSTで送信してサーバーのリクエストミドルウェアのレイヤーで置き換えでハンドリングするというのもある

まとめ

本記事ではHotwire/Honoを使ったWebアプリのフルスタックなアーキテクチャに入門しました

ReactのRSCと比較して1点だけお勧めなポイントを上げるとすれば、サーバーサイドで実行するコードサイズを小さくできる余地があるのでFaaSやedge runtime系のライフサイクルと相性が良さそうな点でしょうか

Hotwire/Hono間でサーバーサイド/クライアントサイドと明確に境界ができるため、双方のバンドルサイズ最適化をコントロールしやすいです(巨大ライブラリを埋め込んだら別ですが……)

著者はCloudflare Pages(Workers)もFaaSの一種だと思っているので、本記事のアーキテクチャはCloudflareスタック向きであるとは言えそうです

また本記事の範囲はHtmxやAlpine.jsでも代用できると思います[3]

Cloudflare Workers and micro-frontends: made for one anotherにあるような水平分割可能なアーキテクチャにも応用できそうですね

とはいえRemixやSvelte, Solid, Qwikのメタフレームワークレイヤーなどとは比較していないのでまだ実験的な段階ですが……

脚注
  1. Introducing Zero-Bundle-Size React Server Components – React https://react.dev/blog/2020/12/21/data-fetching-with-react-server-components ↩︎

  2. vercel/server-components-notes-demo: Experimental demo of React Server Components with Next.js. Deployed serverlessly on Vercel. https://github.com/vercel/server-components-notes-demo ↩︎

  3. Hono + htmx + Cloudflareは新しいスタック https://zenn.dev/yusukebe/articles/e8ff26c8507799 ↩︎

Discussion