islands(honoxにおけるclient実装)
2024年11月5日

islands(honoxにおけるclient実装)

24,920文字(読了まで約63分)
headerimage

これまで、Hono を用いてブログサイトの実装を行ってきましたが、基本的にはサーバサイドでの処理(いわゆる SSR)のみで実装をしてきました。

一方で、ブログサイトを公開するにあたっては、どのブログ記事が読まれているんだろう?などを知りたくなってきますし、単にアクセス分析するのではなく、読者がコメントしたり、いいねしたりといったリアクションをできるようにしたいと思いました。

いままで通りの実装スタイルでは、MPA(マルチページアプリケーション。ユーザ操作に応じて、ページ全体を読込直しする挙動のサイト)にならざるを得ず、「いいね」ボタンを押すだけで全体読み込み直しというのはなんともユーザ体験が低いように感じたため、クライアント側の処理実装を試みようと思います。

コメント機能を実装するにあたっては、他にもいろいろな技術要素を取り入れる必要がありますので、本記事では以下のような内容に触れます(大ボリュームです)。

  • HonoX の islands
    • React ライクな Hono/jsx
  • データベース と ORM
    • SQLite ベースの cloudflare D1
    • drizzle

  • 「いいね」ボタンなどを実装するにあたり、操作者を識別できるユーザ ID を発行する

    • ログイン操作を要すような機密性を求める機能は実装しない。あくまで簡易的なユーザ ID とする
    • 発行したユーザ ID をサーバ側では保持しない(ログイン操作までしないので)。ユーザ ID は クライアント側で cookie に保持する
      • サイト訪問初回に表示する cookie 利用の同意ダイアログを実装する
      • この辺のことについては、個人情報保護法制等を十分に学習する必要があります。
      • (個人情報にあたらないまでも)個人データとしての取扱や、GCPR での取扱を踏まえたものとしています。
    • 勝手に任意の文字列をユーザ ID として利用できないよう、cookie に保存する値はサーバ側で管理する秘密鍵を用いて暗号化した jwt とする
  • ユーザインタラクションが必要な部分のみを islands のクライアント側処理として実装する

    no image

    https://zenn.dev/yusukebe/articles/724940fa3f2450


  • クライアント上のいいねボタン押下時に、(全体を再読み込みせずに)非同期的に呼び出しする API は、Hono の Web サーバ上に実装する

  • お金のかからない DB を利用する(今のところ、個人の趣味の範囲なので)

  • DB を Typescript から型安全に利用したい

    • DB スキーマ(テーブル構造等の定義)をベースに、マイグレーション(DB テーブル等の生成)や Typescript プログラムから型安全に利用できる Drizzle を使う
    • 現状、prismaの方が普及しているようだが、スキーマの定義が Typescript ではなく、独特の記述?な感じがした・・・。使い方を見ているとDrizzleの方がしっくりくる。最近伸びているようだし。
      no image

      https://zenn.dev/daichi2mori/articles/20240515-cf-d1-hono-drizzle


graph TB subgraph dbserver[DBサーバ(Cloudflare D1)] direction TB DB[(DB)] end subgraph webserver[Webサーバ(Cloudflare Pages/Workers)] direction TB Web[Web Route<br>SSR and response] RPC[RPC Route] ORM[[Drizzle ORM]] end subgraph client[端末のWebブラウザ] direction TB request[サイトへアクセス] schtml[SSRされたhtml <br> (without js)] subgraph islands[islandsとして実装] cchtml[client component <br>(hydrate and CSR)] end end request--①Request-->Web Web--③htmlを応答-->schtml schtml--④islands部分をCSR-->cchtml cchtml--⑤ユーザ操作<br>(ボタン押下等)-->RPC RPC--⑦応答内容を反映-->cchtml Web-.②データ取得等.->ORM RPC-.⑥データ更新等.->ORM ORM<-.->DB

参考サイト

  • D1 データベースの作成
npx wrangler d1 create honox-blog
  • D1 のデータベース ID(cloudflare の画面上に表示されます)を wrangler.toml に設定
./app/wrangler.toml
name = "cf-pages-honox"
compatibility_date = "2024-04-01"
compatibility_flags = [ "nodejs_compat" ]
pages_build_output_dir = "./dist"
 
[[d1_databases]]
binding = "DB"
database_name = "honox-blog"
database_id = xxxxx-xxxxx-xxxxxxx-xxxxxx"・・・IDを設定
migrations_dir = "drizzle"・・・マイグレーション時に利用するsqlを格納するフォルダを用意して設定する
  • drizzle を導入
bun add drizzle-orm
bun add -D drizzle-kit
  • スキーマファイルを配置するディレクトリを作成(ここでは./db)し、SQL 生成やマイグレーション実行のスクリプトを package.json に登録しておく
./package.json
  "scripts": {
    "drz:gen": "drizzle-kit generate", ・・・SQL生成
    "drz:migl": "wrangler d1 migrations apply honox-blog --local",・・・マイグレーション(ローカル)
    "drz:migr": "wrangler d1 migrations apply honox-blog --remote"・・・マイグレーション(cloudflare上)
  },
  • スキーマファイルを作成したら、npm run drz:genで SQL を生成した後、マイグレーションを実行します
    • "npm run drz:migl"でローカル(開発端末)上の SQLite にテーブル等が生成されます
    • "npm run drz:migr"で Cloudflare D1 上の SQLite にテーブル等が生成されます

ここまで言っておきながらなんなのですが、コメント投稿フォームについては、MPA の構成で実装してみます。 (単に、そもそも私が MPA について何も分かっていないので・・・)

  • コメント表示及び投稿フォームの画面コンポーネントを作成(いずれも islands ではなく、SSR のコンポーネントとして作成)
    • コメント表示コンポーネントの処理では、DB からコメント一覧を取得して表示
    • 投稿フォームコンポーネントでは、ボタン押下で Web サーバの コメント投稿 API(RPC) を呼び出す
  • コメント投稿 API(RPC)では、クライアントから送信されたコメント情報を DB に保存する
    • 応答として、元のブログ記事へリダイレクトさせる応答を返し、投稿されたコメントを反映したブログ記事ページを再表示させる

graph TB subgraph dbserver[DBサーバ(Cloudflare D1)] direction TB DB[(DB)] end subgraph webserver[Webサーバ(Cloudflare Pages/Workers)] direction TB Web[ブログ記事Route<br>(コメント表示含む)] RPC[フォーム投稿API<br>(RPC Route)] ORM[[Drizzle ORM]] end subgraph client[端末のWebブラウザ] direction TB request[ブログ記事へ<br>アクセス] schtml[ブログ記事<br>(SSRされたhtml)] end request--①ブログ記事を<br>リクエスト-->Web Web--③コメントを含む<br>ブログ記事のhtmlを返す-->schtml schtml--④(コメント入力のうえ)<br>コメント投稿ボタン押下-->RPC RPC--⑥同じページに<br>リダイレクトさせる応答-->schtml schtml--⑦あとは①〜③の流れで<br>コメント反映後のブログ記事を表示-->Web Web-.②当該記事の<br>コメントを取得.->ORM RPC-.⑤コメントを保存.->ORM ORM<-.->DB

  • 画面用コンポーネントを以下の用に実装しました。
    • このブログ記事に表示できるよう、./app/routes/posts/_renderer.tsx中から return するようにします。
    • DB へのアクセスは getComments 内に実装しています(後述)
./app/components/posts/Comment.tsx
import { css } from "hono/css";
import { FC } from "hono/jsx";
import { getComments } from "@/app/lib/dbAccess";
import { useTheme } from "@/app/components/context/useTheme";
 
type CommentsProps = { postPath: string };
 
export const Comments: FC<CommentsProps> = async ({ postPath }) => {
  const cssTheme = useTheme();
  // ここで当該記事に関するコメントを取得
  const results = await getComments(postPath);
 
  const cssRootContainer = css`
    padding-top: 0.5rem;
    padding-bottom: 0.5rem;
    padding-left: 16px;
    padding-right: 16px;
    margin: 5px;
    background-color: ${cssTheme.contentBgColor};
  `;
  const cssCommentBase = css`
    padding-top: 1rem;
    padding-bottom: 1rem;
  `;
  const cssComment = css`
    border-bottom: dotted 1px ${cssTheme.borderColor};
    ${cssCommentBase}
  `;
  const cssCommentLast = css`
    ${cssCommentBase}
  `;
  const cssCommentFormContainer = css`
    display: grid;
    justify-items: center;
    padding: 1rem;
  `;
  const cssCommentForm = css`
    display: flex;
    flex-direction: column;
    width: 100%;
  `;
  const cssCommentFormRow = css`
    display: flex;
    flex-direction: row;
    justify-content: center;
  `;
  const cssInputLabel = css`
    width: 20%;
  `;
  const cssInputName = css`
    width: 80%;
    color: ${cssTheme.fontColor};
    border: solid 1px;
    border-color: ${cssTheme.borderColor};
    background-color: ${cssTheme.bodyBgColor};
    margin: 1px;
  `;
  const cssInputMessage = css`
    width: 80%;
    color: ${cssTheme.fontColor};
    border: solid 1px;
    border-color: ${cssTheme.borderColor};
    background-color: ${cssTheme.bodyBgColor};
    margin: 1px;
    height: 10em;
    resize: none;
  `;
  const cssInputSubmit = css`
    width: 20%;
    margin: 1rem;
  `;
 
  return (
    <div class={cssRootContainer}>
      // コメント表示部分
      <div>
        <ol>
          // 取得したコメントを表示
          {results.map((result, idx, arr) => (
            <>
              <li class={idx === arr.length - 1 ? cssCommentLast : cssComment}>
                <div>
                  {result.name} [ {result.createdAt} ]
                </div>
                <div>{result.message}</div>
              </li>
            </>
          ))}
        </ol>
      </div>
      //コメント投稿フォーム部分
      <div class={cssCommentFormContainer}>
        //API`/form/comments`へpostアクセスする設定
        <form class={cssCommentForm} method="post" action="/form/comments">
          //APIからの応答でリダイレクトさせるため、表示中の記事のURLpathを隠し項目として保持(form送信時に渡す)
          <input type="hidden" name="postPath" value={postPath} />
          <div class={cssCommentFormRow}>
            <label class={cssInputLabel}>名前</label>
            <input
              type="text"
              name="name"
              class={cssInputName}
              required
            ></input>
          </div>
          <div class={cssCommentFormRow}>
            <label class={cssInputLabel}>コメント</label>
            <textarea
              id="message"
              class={cssInputMessage}
              name="message"
              required
            ></textarea>
          </div>
          <div class={cssCommentFormRow}>
            <input type="submit" value="投稿" class={cssInputSubmit}></input>
          </div>
        </form>
      </div>
    </div>
  );
};

  • コメント投稿 API ※コメント取得は SSR のみで実行のため、API の Route は作っていない
./app/routes/form/comments/index.ts
import { postComment } from "@/app/lib/dbAccess";
import { Hono } from "hono";
import { zCommentForm } from "@/app/types";
import { zValidator } from "@hono/zod-validator";
 
export const commentsRoute = new Hono().post(
  "/",
  /**
   * validation
   */
  zValidator("form", zCommentForm), ・・・ zodでバリデーションをしています
  /**
   * route logic
   */
  async (c) => {
    const commentForm = c.req.valid("form");
 
    await postComment({
      name: commentForm.name,
      message: commentForm.message,
      createdAt: new Date().toLocaleDateString(),
      postPath: commentForm.postPath,
    });
    // 元のブログ記事へリダイレクト
    return c.redirect(commentForm.postPath);
  }
);
./app/types.ts
import { z } from "zod";
 
export const zCommentForm = z.object({
  name: z.string().min(1, {
    message: "必須項目です。",
  }),
  message: z.string().min(1, {
    message: "必須項目です。",
  }),
  createdAt: z.string().optional(),
  postPath: z.string(),
});

上記 route を、server.ts に登録

./app/server.ts
import { showRoutes } from "hono/dev";
import { createHono } from "honox/factory";
import { createApp } from "honox/server";
import { themeRoute } from "./routes/theme";
import { embedClientThemeHandler } from "./hono-middleware/embedClientThemeHandler";
import { embedMermaid } from "./hono-middleware/embedMermaid";
import { commentsRoute } from "./routes/form/comments";
 
// createApp前にベースとなるappを用意し、middlewareを設定する
const base = createHono();
base
  .use("*", embedClientThemeHandler)
  .use("/posts/*", embedMermaid)
  .route("/theme", themeRoute)
  .route("/form/comments", commentsRoute);
 
const app = createApp({ app: base });
 
showRoutes(app);
 
export default app;

./app/lib/dbAccess.ts
import { comments } from "@/db/schema";
import { and, count, eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/d1";
import { useRequestContext } from "hono/jsx-renderer";
import { InferInsertModel } from "drizzle-orm";
 
// Insert操作時に必要な型
type CommentModel = InferInsertModel<typeof comments>;
 
let db: ReturnType<typeof drizzle>;
 
const initDb = () => {
  if (db) return;
  const c = useRequestContext();
  db = drizzle(c.env.DB); // D1データベースへのアクセス
  return;
};
 
export const getComments = async (postPath: string) => {
  initDb();
  const result = await db
    .select()
    .from(comments)
    .where(eq(comments.postPath, postPath));
  return result;
};
 
export const postComment = async ({
  name,
  message,
  createdAt,
  postPath,
}: CommentModel) => {
  initDb();
  const result = await db.insert(comments).values({
    name: name,
    message: message,
    createdAt: createdAt,
    postPath: postPath,
  });
 
  return result;
};
  • DB スキーマ定義
    • 予め SQL 生成、マイグレーションを実行し、DB テーブル等を用意しておきます
./db/schema.ts
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
 
export const comments = sqliteTable("comments", {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
  postPath: text("post_path").notNull(),
  createdAt: text("created_at").notNull(),
  name: text("name").notNull(),
  message: text("message").notNull(),
});
 
// インデックス。post_pathで検索する想定なので張っています
export const idxCommentPostPath = index("idx_comment_post_path").on(
  comments.postPath
);

ここからは islands での実装を行います。

graph TB subgraph webserver[Webサーバ(Cloudflare Pages/Workers)] direction TB Web[ブログ記事Route<br>(Web Route)] RPC[ID発行API<br>(RPC Route)] end subgraph client[端末のWebブラウザ] direction TB request[ブログ記事へ<br>アクセス] schtml[ブログ記事(SSRされたhtml)] subgraph islands[islandsとして実装] cchtml[同意ダイアログ<br>(hydrate and CSR)] end end request--①ブログ記事を<br>リクエスト-->Web Web--②ブログ記事のhtmlを返す-->schtml schtml-->cchtml cchtml--③同意ボタン押下-->RPC RPC--④ID発行-->RPC RPC--⑤応答(cookieを設定)-->schtml

islands として実装します。後に実装するいいねボタンコンポーネントと cookie 利用同意状態の state を共有したいため、上位のコンポーネントを用意して構成します。

graph LR SSR[ブログ記事<br>(SSR)] subgraph islands CSRParent[ブログ記事のislands配置用<br>親コンポーネント] CSRAcceptCookie[Cookie同意<br>コンポーネント] CSRLike[いいねボタン<br>コンポーネント] end SSR--(サーバで取得した)<br>初期値を渡す-->CSRParent CSRParent<--cookie同意の<br>状態を共有-->CSRAcceptCookie CSRParent<--cookie同意の<br>状態を共有-->CSRLike

islands 配置用親コンポーネント

  • 同意したかどうかの state を定義
  • state と更新関数を、下位のコンポーネントに渡して共有
  • 後述するいいねボタンコンポーネントに対して、リクエスト時点のいいね数を返すため、上位の SSR のコンポーネントで DB へ照会した結果を受け取るように構成する(likesCount)
./app/islands/PostIslslandsContainer.tsx
import { FC, useState } from "hono/jsx";
import { css } from "hono/css";
import LikeButton from "@/app/islands/components/LikeButton";
import AcceptCookieDialog from "@/app/islands/components/AcceptCookieDialog";
 
type PostIslandsContainerProps = {
  likesCount: number;
  currentAcceptCookie: boolean | undefined;
};
 
// ブログ記事ページのislands配置用コンテナ
export const PostIslandsContainer: FC<PostIslandsContainerProps> = ({
  likesCount,
  currentAcceptCookie,
}) => {
  // cookie利用同意状態をstateとして定義し、コンポーネント間で共有
  const [acceptCookie, setAcceptCookie] = useState(currentAcceptCookie);
 
  const cssLayoutLike = css`
  (略)
  `;
  const cssLayoutAcceptCookie = css`
  (略)
  `;
 
  return (
    <div>
      <div class={cssLayoutAcceptCookie}>
        {/* cookie同意ダイアログ */}
        <AcceptCookieDialog
          acceptCookie={acceptCookie}
          setAcceptCookie={setAcceptCookie}
        />
      </div>
      <div class={cssLayoutLike}>
        {/* いいねボタン */}
        <LikeButton
          acceptCookie={acceptCookie}
          initialLikesCount={likesCount}
        />
      </div>
    </div>
  );
};

cookie 利用同意ダイアログコンポーネント

  • 同意(または拒否)ボタン押下時に、ID 発行 RPC を呼び出し、同意状態や ID(を含む jwt)を保持する cookie を設定します
  • 拒否した場合、sessionStorage に拒否したということを保存しています。この場合、別の記事へジャンプしたりしてもダイアログが再度表示されることはなくなりますが、ブラウザを開き直したり別タブで表示したときには、再度このダイアログが表示されるようにします。
./app/islands/components/AcceptCookieDialog.tsx
import { FC, useState } from "hono/jsx";
import { hc } from "hono/client";
import { css } from "hono/css";
import { IdAppType } from "@/app/routes/api/id";  ・・・RPCで提供する型を参照する
import { AcceptCookie, SetAcceptCookie } from "@/app/types";
import { isRunningInBrowser } from "@/app/islands/lib/utils";・・・クライアントで実行している状態を判定するユーティリティ関数(後述)
 
type AcceptCookieDialogProps = {
  acceptCookie: AcceptCookie;
  setAcceptCookie: SetAcceptCookie;
};
 
const AcceptCookieDialog: FC<AcceptCookieDialogProps> = ({
  acceptCookie,
  setAcceptCookie,
}) => {
  // RPC呼び出し用のクライアントを作成
  const client = hc<IdAppType>(`https://xxxxxxxxxxxx/api/id`);
 
  // cookie利用に同意していない場合に表示(一度拒否すると、セッション内では非表示にする)
  const cookieDenied = isRunningInBrowser()
    ? sessionStorage.getItem("cookieDenied") === "true"
    : false;
  const [show, setShow] = useState(
    !acceptCookie && !cookieDenied ? true : false
  );
 
  const cssRoot = css`
  (略)
  `;
  const cssDescription = css`
  (略)
  `;
 
  const accept = async () => {
    await acceptOrDeny(true);
  };
  const deny = async () => {
    await acceptOrDeny(false);
    sessionStorage.setItem("cookieDenied", "true");
  };
  const acceptOrDeny = async (accept: boolean) => {
    // ID発行APIを呼び出し
    const result = await client.index.$post({ json: { accept: accept } });・・・RPCの呼び出し
    setAcceptCookie(accept);
    setShow(false);
  };
 
  // show ? : FOUCを抑制
  return show ? (
    <div class={cssRoot}>
      <div class={cssDescription}>
        <p>
          このサイトではcookieを利用して、サイト訪問者の識別に利用します。
          cookieの利用に同意いただくことで、サイト訪問者は記事のいいね機能等をご利用いただけます。
          なお、サイト運営者はアクセス統計としてcookieの情報を利用する場合があります。
        </p>
      </div>
      <button onClick={accept}>同意する</button>
      <button onClick={deny}>拒否する</button>
    </div>
  ) : (
    // useEffectによる設定完了までは表示しない
    <></>
  );
};
 
export default AcceptCookieDialog;

./app/routes/api/id/index.ts
import { getTokenizeSecret } from "@/app/utils/env";
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { setCookie } from "hono/cookie";
import { sign } from "hono/jwt";
import { z } from "zod";
 
const zPostId = z.object({ accept: z.boolean() });
export const idRoute = new Hono().post(
  "/",
  /* validation */
  zValidator("json", zPostId),・・・zodでのvalidationを実装
 
  /* route logic */
  async (c) => {
    const postIdParam = c.req.valid("json");
 
    if (postIdParam.accept) {
      // ID to Token
      const secret = getTokenizeSecret(); ・・・jwt作成用の秘密鍵を取得(ローカルではtoml内に定義、cloudflareでは管理画面から設定)
      const token = await sign({ sub: crypto.randomUUID() }, secret);
 
      setCookie(c, "accept-cookie", "true");
      setCookie(c, "token", token);
      return c.json({ message: "new token" });
    } else {
      setCookie(c, "accept-cookie", "false");
      setCookie(c, "token", "");
      return c.json({ message: "token cleared" });
    }
  }
);
 
export type IdAppType = typeof idRoute;

上記 RPC の route をserver.tsに登録しておく

./app/server.ts
import { showRoutes } from "hono/dev";
import { createHono } from "honox/factory";
import { createApp } from "honox/server";
import { embedClientThemeHandler } from "./hono-middleware/embedClientThemeHandler";
import { embedMermaid } from "./hono-middleware/embedMermaid";
import { commentsRoute } from "./routes/form/comments";
import { idRoute } from "./routes/api/id";
 
// createApp前にベースとなるappを用意し、middlewareを設定する
const base = createHono();
base
  .use("*", embedClientThemeHandler)
  .use("/posts/*", embedMermaid)
  .route("/form/comments", commentsRoute)
  .route("/api/id", idRoute);
 
const app = createApp({ app: base });
 
showRoutes(app);
 
export default app;

graph TB subgraph dbserver[DBサーバ(Cloudflare D1)] direction TB DB[(DB)] end subgraph webserver[Webサーバ(Cloudflare Pages/Workers)] direction TB Web[ブログ記事Route<br>(Web Route)] RPC[いいねAPI<br>(RPC Route)] end subgraph client[端末のWebブラウザ] direction TB request[ブログ記事へ<br>アクセス] schtml[ブログ記事<br>(SSRされたhtml)] subgraph islands[islandsとして実装] cchtml[いいねボタン<br>(hydrate and CSR)] end end request--①ブログ記事を<br>リクエスト-->Web Web-.②当該記事の<br>いいね数を取得.->DB Web--③ブログ記事のhtmlを返す<br>いいねボタン含む-->schtml schtml-->cchtml cchtml--④いいねボタン押下-->RPC RPC-.⑤いいねを登録または削除.->DB RPC--⑥応答-->cchtml

  • cookie 同意済みの場合のみ活性化
  • 最初にいいね数を(RPC より)取得して表示
  • 閲覧ユーザが過去に当該記事で「いいね」していたかを取得
  • いいね未押下の場合、押すと、いいね数を+1、RPC で DB に登録
  • いいね押下済の場合、もう一度押すと、いいね数をー1、RPC で DB から削除
./app/islands/components/LikeButton.tsx
import { FC, useEffect, useState } from "hono/jsx";
import { hc } from "hono/client";
import { css } from "hono/css";
import { LikesAppType } from "@/app/routes/api/likes";
import { AcceptCookie } from "@/app/types";
 
const getPath = () => {
  const url = new URL(document.URL);
  return url.pathname;
};
 
type LikeButtonProps = {
  acceptCookie: AcceptCookie;
  initialLikesCount: number;
};
const LikeButton: FC<LikeButtonProps> = ({
  acceptCookie,
  initialLikesCount,
}) => {
  const cssTheme = useTheme();
  const envValue = useEnv();
  const [likeCount, setLikeCount] = useState(initialLikesCount);
  const [liked, setLiked] = useState(false);
  const [btnDisabled, setBtnDisabled] = useState(acceptCookie ? true : false);
 
  const client = hc<LikesAppType>(`https://xxxxxxxxxxxx/api/likes`);
  const cssRoot = css`
  (略)
  `;
  const cssLike = css`
  (略)
  `;
  const cssLikeCount = css`
  (略)
  `;
 
  useEffect(() => {
    (async () => {
      // like数を取得
      const countRes = await client.count.$get({
        query: { postPath: getPath() },
      });
      if (countRes.status === 200) {
        const result = await countRes.json();
        setLikeCount(result.count);
      } else {
        if (countRes.status === 401) {
          const result = await countRes.json();
        }
        setLikeCount(0);
      }
 
      // ユーザが表示中の記事にlikeしているかどうかを取得
      if (acceptCookie) {
        const res = await client[":postPath"].$get({
          param: {
            postPath: encodeURIComponent(getPath()),
          },
        });
        if (res.status === 200) {
          const result = await res.json();
          setLiked(result[0] ? true : false);
          setBtnDisabled(false);
        } else {
          setLiked(false);
        }
      }
    })();
  }, []);
 
  useEffect(() => {
    setBtnDisabled(acceptCookie ? false : true);
  }, [acceptCookie]);
 
  const like = async () => {
    setLikeCount(liked ? likeCount - 1 : likeCount + 1);
    setLiked(!liked);
    if (liked) {
      await client[":postPath"].$delete({
        param: {
          postPath: encodeURIComponent(getPath()),
        },
      });
    } else {
      await client[":postPath"].$post({
        param: {
          postPath: encodeURIComponent(getPath()),
        },
      });
    }
  };
 
  return (
    <button class={cssRoot} disabled={btnDisabled} onClick={like}>
      <div class={cssLike}>
        <p class={cssLikeCount}>{likeCount}</p>
      </div>
    </button>
  );
};
 
export default LikeButton;

./app/islands/routes/likes.tsx
import { Hono } from "hono";
import { getCookie } from "hono/cookie";
import { verify } from "hono/jwt";
import { zValidator } from "@hono/zod-validator";
import {
  deleteLike,
  getLike,
  getLikesCount,
  postLike,
} from "@/app/lib/dbAccess";
import {
  zDeleteLikeSchema,
  zGetLikeSchema,
  zGetLikesCountSchema,
  zPostLikeSchema,
} from "@/app/types";
import { getTokenizeSecret } from "@/app/utils/env";
 
/**
 * いいね機能API Route
 */
export const likesRoute = new Hono()
  /**
   * いいね数カウント取得API
   */
  .get(
    "/count",
    /* validation */
    zValidator("query", zGetLikesCountSchema),
    /* route logic */
    async (c) => {
      const query = c.req.valid("query");
      const likesCount = await getLikesCount(query.postPath);
      return c.json({ count: likesCount });
    }
  )
  /**
   * ユーザのいいね実施有無取得API
   */
  .get(
    "/:postPath",
    /* validation */
    zValidator("param", zGetLikeSchema),
    /* route logic */
    async (c) => {
      const param = c.req.valid("param");
 
      const token = getCookie(c, "token");
      if (!token) {
        return c.json({ message: "unauthorized" }, 401);
      }
      // decode token
      const secret = getTokenizeSecret();
      const id = await verify(token, secret);
 
      const result = await getLike({
        postPath: param.postPath,
        userId: id.sub as string,
      });
      return c.json(result, 200);
    }
  )
  /**
   * ユーザのいいね登録API
   */
  .post(
    "/:postPath",
    /* validation */
    zValidator("param", zPostLikeSchema),
    /* route logic */
    async (c) => {
      const param = c.req.valid("param");
      const token = getCookie(c, "token");
 
      if (!token) {
        return c.json({ message: "unauthorized" }, 401);
      }
 
      // decode token
      const secret = getTokenizeSecret();
      const id = await verify(token, secret);
 
      await postLike({
        userId: id.sub as string,
        postPath: param.postPath,
        createdAt: new Date().toLocaleDateString(),
      });
      return c.json({ message: "ok" });
    }
  )
  /**
   * ユーザのいいね削除API
   */
  .delete(
    "/:postPath",
    /* validation */
    zValidator("param", zDeleteLikeSchema),
    /* route logic */
    async (c) => {
      const param = c.req.valid("param");
      const token = getCookie(c, "token");
 
      if (!token) {
        return c.json({ message: "unauthorized" }, 401);
      }
 
      // decode token
      const secret = getTokenizeSecret();
      const id = await verify(token, secret);
 
      await deleteLike({
        postPath: param.postPath,
        userId: id.sub as string,
      });
      return c.json({ message: "ok" });
    }
  );
 
export type LikesAppType = typeof likesRoute;

useContext の利用 script タグの挿入

Hono のサーバサイドで扱う context と名称が同じため、ググラビリティが低い・・・

  • islands 内でクライアント側でしか使えないオブジェクトへアクセスするとエラーとなる
    • document など。
    • islands コンポーネントも、サーバーサイドで一旦実行される仕様であるため、エラーとなってしまう
    • 回避のため、islands の処理内でこれらのオブジェクトへアクセスする前に、サーバ側と判定される場合は処理をスキップする
    // 判定用のユーティリティ関数
    export const isRunningInBrowser = (): boolean => {
      return typeof window !== "undefined";
    };

  • Cloudflare D1 を使って、ユーザ情報やいいねボタン押下状態を保存できるブログサイトにしました。
  • 初期表示する値は SSR で取得し、islands へ送る。クライアント側で全体を読み込み直さず、SPA 的に処理したい部分は islands として実装しました
  • Drizzle は型安全なコーディングを実現可能であり、マイグレーションも含め一貫して Typescript での実装ができて、体験が良かったです。

以上です。

- コメント -

  1. testコメント [ 2024年11月16日 7:15 ]
    コメントです

このサイトではcookieを利用して、サイト訪問者の識別に利用します。 cookieの利用に同意いただくことで、サイト訪問者は記事のいいね機能等をご利用いただけます。 なお、サイト運営者はアクセス統計としてcookieの情報を利用する場合があります。