hono-remix-adapterを試して、Cloudflare Pagesにデプロイしてみた

hono-remix-adapterを試して、Cloudflare Pagesにデプロイしてみた

Clock Icon2024.09.23

はじめに

こんにちは、コンサルティング部の神野です。

最近Hono開発者のWada Yusuke氏がXでhono-remix-adapterを発表していました!
https://x.com/yusukebe/status/1833053741488169133

スクリーンショットにある設定を入れるだけでReactをベースとしたフルスタックフレームワークRemixとHonoを簡単に組み合わせて使用できそうなので気になって試してみました!
フロントエンドにHonoのフレームワークを活用してバックエンドのAPIも実行できるのは嬉しいですよね!!Honoの強みであるフロントエンド・バックエンド連携を容易にするRPC機能も試していきたいと思います。
またデプロイ先にCloudflare Pagesをサポートしているので、デプロイも実施していきます。

作成するアプリケーションのイメージ

下記スクリーンショットみたいな簡単なTodoアプリケーションを作成します。
Todoの取得や更新はバックエンドのサーバーを通して行うこととします。
最終的にはCloudflare Pagesにデプロイするところまでをゴールとします。

image-20240922222157881

作成したアプリケーションのレポジトリ

今回作成したアプリケーションのレポジトリは下記に格納しております。
全体を参照したいなど必要に応じてご参照いただけますと幸いです。

https://github.com/yuu551/hono-remix-cloudflare-pages

準備

今回はCloudflare Pagesを使ってデプロイを行うため、もしデプロイまで進めたい方は事前にアカウントを作成する必要があります!
クレジットカードの情報なども不要で、メールアドレスだけで登録可能なのでお手軽です!

https://dash.cloudflare.com/sign-up

実装

事前準備

今回はNode.jsおよびパッケージマネージャーとしてnpmを使って進めていくので下記を事前にインストールしています。

  • Node.js・・・v20.16.0
  • npm・・・10.8.1

まずは早速Remixを作成していきます。下記コマンドを実行してテンプレートを作成できます。

Remixテンプレート作成コマンド
npm create cloudflare@latest -- my-remix-app --framework=remix

作成後は使用するライブラリをインストールします。

必要なライブラリをインストール
cd my-remix-app
npm install hono hono-remix-adapter @hono/zod-validator

shadcn/uiセットアップ

今回はUIライブラリのshadcn/uiを使用して画面を作成します。
公式ドキュメントのインストール手順に従ってセットアップを行います。
セットアップが完了したら今回使用するコンポーネントをインストールしていきます。

必要なコンポーネントをインストール
npx shadcn@latest add badge card button checkbox

honoセットアップ

vite.config.jsにビルド時のHono用のプラグインを設定します。サーバー処理側のエントリポイントを設定します。

vite.config.js
import {
  vitePlugin as remix,
  cloudflareDevProxyVitePlugin as remixCloudflareDevProxy,
} from "@remix-run/dev";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
+ import adapter from "@hono/vite-dev-server/cloudflare"
+ import serverAdapter from "hono-remix-adapter/vite"

export default defineConfig({
  plugins: [
    remixCloudflareDevProxy(),
    remix({
      future: {
        v3_fetcherPersist: true,
        v3_relativeSplatPath: true,
        v3_throwAbortReason: true,
      },
    }),
+   serverAdapter({
+     adapter,
+     entry:"./server/index.ts"
+   }),
    tsconfigPaths(),
  ],
});

環境変数の設定

.envファイルを作成し下記値を設定します。これはクライアントからバックエンド側にリクエストを送信する際のURLを設定しています。Cloudflare Pagesにデプロイする際は別途設定します。

.env
VITE_API_URL=http://localhost:5173/

バックエンド実装

次にHonoでバックエンド側の処理を実装していきます。serverフォルダにindex.tsファイルを作成して、バックエンド側の処理を記載します。今回はダミーのTodo情報を返却及び更新する簡易的な処理を実装します。

  • GETでダミーのTodo情報を一覧で取得し、PUTでTodoが完了したかどうかのcompletedを更新
  • CORSミドルウェアを適用
  • Zodを使用したバリデーションスキーマを定義し、リクエストデータの型安全性を確保
server/index.ts
import { Hono } from "hono";
import { cors } from "hono/cors";
import { z } from "zod";
import { zValidator } from "@hono/zod-validator";

const app = new Hono();

// カスタムZodスキーマ for YYYY-MM-DD形式の日付
const dateSchema = z.string().refine(
  (val) => {
    return /^\d{4}-\d{2}-\d{2}$/.test(val) && !isNaN(Date.parse(val));
  },
  {
    message: "Invalid date format. Use YYYY-MM-DD",
  }
);

// Todoのスキーマ
const TodoSchema = z.object({
  title: z.string().min(1).max(100),
  completed: z.boolean(),
  dueDate: dateSchema.optional(),
});

// すべてのルートにCORS設定を適用
app.use(
  "*",
  cors({
    origin: "*",
    allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
    allowHeaders: ["Content-Type", "Authorization"],
    exposeHeaders: ["Content-Length"],
    maxAge: 600,
  })
);

// ダミーのTodo情報
const dummyTodos = [
  {
    id: 1,
    text: "買い物に行く",
    completed: false,
    dueDate: "2023-06-15",
    category: "日常",
  },
  {
    id: 2,
    text: "レポートを書く",
    completed: true,
    dueDate: "2023-06-10",
    category: "仕事",
  },
  {
    id: 3,
    text: "運動する",
    completed: false,
    dueDate: "2023-06-16",
    category: "健康",
  },
  {
    id: 4,
    text: "本を読む",
    completed: false,
    dueDate: "2023-06-20",
    category: "趣味",
  },
  {
    id: 5,
    text: "友達と電話する",
    completed: true,
    dueDate: "2023-06-12",
    category: "社交",
  },
];

const route = app
  .get("/api/todos", (c) => {
    return c.json(dummyTodos);
  })
  .put("/api/todos/:id", zValidator("json", TodoSchema), async (c) => {
    const id = c.req.param("id");
    const validatedData = c.req.valid("json");
    return c.json({ id: id, completed: !validatedData.completed });
  });

export default app;
// クライアント側で型情報を参照するためexport
export type AppType = typeof route;

画面実装

Todo一覧画面を作成していきます。特徴としては以下で特にバックエンドに型安全を担保しつつリクエストが送信できるRPC機能が魅力的です。

  • RPC機能を活用してフロントエンドからバックエンド側にリクエストを送信
    • バックエンドからimportした型定義を元にクライアントを作成しリクエストを送信することが可能
  • 一覧取得はサーバサイドレンダリング時に取得
  • チェックボックス押下時はクライアントからバックエンドからリクエストを送信し、更新を実施
app/routes/todos.tsx
import type { MetaFunction } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Checkbox } from "~/components/ui/checkbox";
import { Badge } from "~/components/ui/badge"; // shadcn/uiのBadgeコンポーネントを追加
import { PlusCircle } from "lucide-react"; // アイコンをインポート
import { AppType } from "server/index";
import { hc } from "hono/client";
import { useState } from "react";

export const meta: MetaFunction = () => {
  return [{ title: "Todo My App" }];
};

// HonoのRPC機能でClient作成
const client = hc<AppType>(import.meta.env.VITE_API_URL);

// 初回データフェッチ
export const loader = async () => {
  const res = await client.api.todos.$get();
  return res.json();
};
// カテゴリーに応じた色を定義
const categoryColors: { [key: string]: string } = {
  日常: "bg-blue-100 text-blue-800",
  仕事: "bg-purple-100 text-purple-800",
  健康: "bg-green-100 text-green-800",
  趣味: "bg-yellow-100 text-yellow-800",
  社交: "bg-pink-100 text-pink-800",
};

// TodoのCard部分
const TodoItem = ({
  todo,
}: {
  todo: Awaited<ReturnType<typeof loader>>[number];
}) => {
  const [isCompleted, setIsCompleted] = useState(todo.completed);
  const badgeColor =
    categoryColors[todo.category] || "bg-gray-100 text-gray-800";

  const handleCheckboxChange = async (checked: boolean) => {
    const result = await client.api.todos[":id"].$put({
      json: {
        title: todo.text,
        completed: isCompleted,
      },
      param: {
        id: todo.id.toString(),
      },
    });
    const updatedTodo = await result.json();
    setIsCompleted(updatedTodo.completed);
  };

  return (
    <Card className="mb-4 hover:shadow-md transition-shadow duration-200">
      <CardContent className="flex items-center p-4">
        <Checkbox
          id={`todo-${todo.id}`}
          checked={isCompleted}
          onCheckedChange={handleCheckboxChange}
          className="mr-4"
        />
        <div className="flex-grow">
          <label
            htmlFor={`todo-${todo.id}`}
            className={`text-lg font-medium leading-none ${
              isCompleted ? "line-through text-gray-400" : "text-gray-700"
            }`}
          >
            {todo.text}
          </label>
          <div className="mt-2 flex items-center space-x-2">
            <Badge className={`${badgeColor} font-semibold`}>
              {todo.category}
            </Badge>
            <span className="text-sm text-gray-500">期日: {todo.dueDate}</span>
          </div>
        </div>
      </CardContent>
    </Card>
  );
};

const Todo = () => {
  const todos = useLoaderData<typeof loader>();

  return (
    <div className="min-h-screen bg-gray-50">
      <header className="bg-white text-gray-800 p-4 shadow-sm">
        <div className="container mx-auto">
          <h1 className="text-2xl font-bold">My TODO App</h1>
        </div>
      </header>
      <main className="container mx-auto py-8 px-4">
        <div className="w-full max-w-4xl mx-auto">
          <div className="flex flex-row items-center justify-between mb-6">
            <h2 className="text-2xl font-bold text-gray-700">TODOリスト</h2>
            <Button className="bg-black text-white hover:bg-gray-800 transition-colors duration-200 rounded-xl px-4 py-2">
              <PlusCircle className="mr-2 h-4 w-4" />
              新しいTODOを追加
            </Button>
          </div>
          <div className="space-y-4">
            {todos.map((todo) => (
              <TodoItem key={todo.id} todo={todo} />
            ))}
          </div>
        </div>
      </main>
    </div>
  );
};
export default Todo;
補足:RPC機能について

ここでRPC機能を使ってフロント・バックエンドの連携を容易に実現しましたが、自動でコード補完や型安全が担保されるのは大変便利で開発体験は素晴らしいなと感じました!!
今後も積極的に使っていきたい機能です。
image-20240923003930931
参考:Honoを紹介した際の登壇資料抜粋

動作確認

実装が一通り完了したので、サーバーを立ち上げて動作確認してみます。
サーバーはhttp://localhost:5173で起動します。

サーバー起動コマンド
npm run dev

サーバーが立ち上がったところで早速画面にアクセスしてみます!
Todo画面:http://localhost:5173/todos
image-20240922225010959

問題なく画面が表示されていますね!
チェックボックスを押下しても問題なく反映されているので、API側サーバとの疎通は取得・更新ともに問題ないですね!
w3dgyon6s9zqjqegrz3y

ちなみにAPI部分にリクエスト送信すると、問題なくレスポンスが返却されます。

image-20240922191652950

これで実装は完了です!!RemixとHonoは序盤設定がいくつかあったものの思ったより簡単に統合できました!!バックエンド側でHonoを使ってAPIサーバの機能を持たせられるのは強いですね・・・!!

デプロイ

hono-remix-adapterはデプロイ先にCloudflare Pagesをサポートしているのでデプロイしてみます!
デプロイに当たっていくつか準備および設定を行います。

事前準備

functions/[[path]].tsファイルを作成し、下記コードを実装します。 このファイルは、Cloudflare Pagesがリクエストを処理する際の入口点となります。

functions/[[path]].ts
import handle from "hono-remix-adapter/cloudflare-pages";
import * as build from "../build/server";
import hono from "../server";
export const onRequest = handle(build, hono);

この設定で、Cloudflare Pagesは リクエストをHonoを通してRemixにルーティングし、適切なレスポンスを生成できるようになります。これによって、RemixアプリケーションとHonoのAPIを統合して、Cloudflare Pages上で動作させることが可能になります。

GithubへソースコードをPush

Cloudflare Pagesへデプロイする際にGithubのレポジトリと連携して自動でデプロイが可能なので、レポジトリを作成してPushしておきます。

デプロイ実施

Cloudflare Pagesにログインして、作業を進めていきます。Workers & Pagesタブの概要を押下し、Gitに接続ボタンを押下します。

d2dl7xsltasg1xdzyhbd

その後、自分がRemixのアプリケーションをPushしたレポジトリを連携します。

設定項目 設定値
Github アカウント 使用するGithub アカウント
レポジトリ RemixをPushしたレポジトリ

hj9sidwjckjnru5usv54

ビルドとデプロイのセットアップに伴って下記入力項目を設定します。

設定項目 設定値
プロジェクト名 任意のプロジェクト名(私はそのままレポジトリ名にしました)
プロダクション ブランチ 任意のブランチ
フレームワーク プリセット Remix
ビルド コマンド npm run build(デフォルト)
ビルド出力ディレクトリ build/client
環境変数 VITE_API_URL = https://<プロジェクト名>.dev/

ivkfyz2yo0d098p3arrt (1)

デプロイが完了するまでしばらく待って、下記画面が表示されて成功したら完了です!

image-20240922221558048

デプロイされたアプリケーションのURLにアクセスすると問題なく表示されていますね!
image-20240922193851917

これでデプロイも無事完了しました!簡単にデプロイできて素晴らしいですね!

おわりに

hono-remix-adapterを使って簡単にRemixとHonoを統合できて素晴らしいですね。軽量なアプリケーションならこれで問題なく事足りそうな印象でした。
現時点でのサポートはCloudflare Pagesのみですが、今後もアップデートがなされてサポートが増えた場合は引き続き試していきたいと思います!

最後までご覧いただきありがとうございました!!

この記事をシェアする

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.