🐳

HonoX+CloudflarePages+D1+Prisma+Auth.js+GoogleOIDC+SWR+TailwindCSS事始め

2024/11/09に公開

下記の技術スタックでアプリケーションを開発しようとしていくつか詰まったので、スムーズに開発を開始できるようにここにやり方をまとめる。

  • HonoX
    • Webフレームワーク
  • Auth.js
    • 認証ライブラリ
  • CloudflarePages
    • エッジプラットフォーム
  • D1
    • データベース
  • Prisma
    • ORM
  • GoogleOIDC
    • 認証プロバイダー
  • SWR
    • データフェッチライブラリ
  • Tailwind CSS
    • CSS framework

事前準備

  • Cloudflareのアカウントを作成しておく
  • GoogleOAuthのプロジェクトを作成しておく
  • Githubの空リポジトリを作成しておく

HonoXプロジェクトの作成

HonoXプロジェクトの作成はテンプレートを使うこともできますが、それほど必要なファイルは多くないので手動で作成します。また、パッチバージョンが上がって動かなくなることが度々あったので利用するパッケージはすべてバージョン固定します。

> npm install -E hono@4.6.8 honox@0.1.26
> npm install -E -D vite@5.4.10
tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "skipLibCheck": true,
    "strict": true,
    "lib": [
      "ESNext",
      "DOM"
    ],
    "types": [
      "vite/client"
    ],
    "jsx": "react-jsx",
    "jsxImportSource": "hono/jsx"
  }
}
vite.config.ts
import { defineConfig } from "vite";
import honox from "honox/vite";

export default defineConfig({
  plugins: [honox()],
});
app/server.ts
import { createApp } from "honox/server";
import { showRoutes } from "hono/dev";

const app = createApp();

showRoutes(app);

export default app;

ContextRendererはbiomeのerrorが消えなかったのでignoreしてます。

app/global.d.ts
import type {} from "hono";

type Head = {
  title?: string;
};

declare module "hono" {
  interface ContextRenderer {
    // biome-ignore lint/style/useShorthandFunctionType:
    (
      content: string | Promise<string>,
      head?: Head,
    ): Response | Promise<Response>;
  }
}

app/routes/_renderer.tsx
import { jsxRenderer } from "hono/jsx-renderer";
import { Script } from "honox/server";
import { Style } from "hono/css";

export default jsxRenderer(({ children, title }) => {
	return (
		<html lang="en">
			<head>
				<meta charset="UTF-8" />
				<meta name="viewport" content="width=device-width, initial-scale=1.0" />
				<meta name="description" content="sample site" />
				<title>{title}</title>
				<Script src="/app/client.ts" />
				<Style />
			</head>
			<body>{children}</body>
		</html>
	);
});
app/routes/_404.tsx
import type { NotFoundHandler } from "hono";

const handler: NotFoundHandler = (c) => {
	return c.render(<h1>Sorry, Not Found...</h1>);
};

export default handler;
app/routes/index.tsx
import { createRoute } from "honox/factory";

export default createRoute((c) => {
	const name = c.req.query("name") ?? "no name";
	return c.render(
		<div class="w-full">
			<h1>Hello, {name}!</h1>
		</div>,
		{ title: name },
	);
});
app/routes/_error.tsx
import type { ErrorHandler } from "hono";

const handler: ErrorHandler = (e, c) => {
	return c.render(<h1>Error! {e.message}</h1>);
};

export default handler;
app/client.ts
import { createClient } from "honox/client";

createClient();
.gitignore
# prod
dist/

# dev
.hono/
.yarn/
!.yarn/releases
.vscode/*
!.vscode/launch.json
!.vscode/*.code-snippets
.idea/workspace.xml
.idea/usage.statistics.xml
.idea/shelf

# deps
node_modules/

# env
.env
.env.production

# logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

# misc
.DS_Store
package.json
{
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build --mode client && vite build"
+  },
  "dependencies": {
    "hono": "4.6.8",
    "honox": "0.1.26"
  },
  "devDependencies": {
    "vite": "5.4.10"
  }
}

githubに入れ忘れていたら下記のようにして入れておく

> git init
> git add .
> git commit -m "First Commit!"
> git remote add origin git@github.com:{{ユーザ名}}/{{リポジトリ名}}.git
> git push -u origin main

Tailwind CSSの導入

> npm install -D -E autoprefixer@10.4.20 tailwindcss@3.4.14
tailwind.config.js
export default {
  content: ['./app/**/*.tsx'],
  theme: {
    extend: {},
  },
  plugins: [],
}
postcss.config.js
export default {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}
app/style.css
@tailwind base;
@tailwind components;
@tailwind utilities;
vite.config.ts
 import { defineConfig } from "vite";
 import honox from "honox/vite";

 export default defineConfig({
-  plugins: [honox()],
+  plugins: [honox({ client: { input: ["/app/style.css"] } })],
 });
app/routes/_renderer.tsx
 import { jsxRenderer } from "hono/jsx-renderer";
-import { Script } from "honox/server";
+import { Script, Link } from "honox/server";
 import { Style } from "hono/css";

 export default jsxRenderer(({ children }) => {
 	return (
 		<html lang="en">
 			<head>
 				<meta charset="UTF-8" />
 				<meta name="viewport" content="width=device-width, initial-scale=1.0" />
 				<Script src="/app/client.ts" />
+				<Link href="/app/style.css" rel="stylesheet" />
 				<Style />
 			</head>
 			<body>{children}</body>
 		</html>
 	);
 });

wranglerの導入

> npm install -E -D @cloudflare/workers-types@4.20241022.0 wrangler@3.84.1 @hono/vite-build@1.1.0 @hono/vite-dev-server@0.15.2
package.json
@@ -1,6 +1,9 @@
 {
   "type": "module",
   "scripts": {
+    "preview": "wrangler pages dev",
+    "deploy": "npm run build && wrangler pages deploy",
+    "wrangler": "wrangler",
     "dev": "vite",
     "build": "vite build --mode client && vite build"
   },
.gitignore
@@ -11,6 +11,7 @@ dist/
 .idea/workspace.xml
 .idea/usage.statistics.xml
 .idea/shelf
+.wrangler/

 # deps
 node_modules/
@@ -18,6 +19,7 @@ node_modules/
 # env
 .env
 .env.production
+.dev.vars

 # logs
 logs/
tsconfig.json
@@ -9,6 +9,8 @@
       "DOM"
     ],
     "types": [
+      "./worker-configuration.d.ts",
+      "@cloudflare/workers-types/2023-07-01",
       "vite/client"
     ],
     "jsx": "react-jsx",
wrangler.toml
name = "{{プロジェクト名}}"
compatibility_date = "2024-04-01"
compatibility_flags = [ "nodejs_compat" ]
pages_build_output_dir = "./dist"
vite.config.ts
@@ -1,6 +1,11 @@
 import { defineConfig } from "vite";
 import honox from "honox/vite";
+import build from "@hono/vite-build/cloudflare-pages";
+import adapter from "@hono/vite-dev-server/cloudflare";

 export default defineConfig({
-  plugins: [honox({ client: { input: ["/app/style.css"] } })],
+  plugins: [
+    honox({ client: { input: ["/app/style.css"] }, devServer: { adapter } }),
+    build(),
+  ],
 });

デプロイします。この時productionブランチを聞かれるのでmainブランチはプレビューとしたいのでproductionと入力しておきます。

> npm run deploy

自動デプロイ設定

CloudflareのダッシュボードでCLOUDFLARE_API_TOKENCLOUDFLARE_ACCOUNT_IDを取得し、GitHubのRepository secretsに追加しておきます。APIトークンにはCloudflare Pagasの編集権限を付けます。デプロイ時のビルドは速度を上げたいのでbunを使います。

https://dash.cloudflare.com/profile/api-tokens

.github\workflows\deploy.yml
on: [push]

jobs:
  deploy:
    runs-on: ubuntu-latest
    name: Deploy
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - name: Install dependencies
        run: bun install
      - name: build
        run: bun run build
      - name: Deploy
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy

GoogleOAuthの準備

承認済みのリダイレクト URIに下記4つのURLを追加しておきます(xxxxはデプロイしたcloudflarepagesのプロジェクトに合わせてください)

また、この後クライアントIDとクライアントSECRETを使うので確認しておきます。

Auth.jsの導入

auth.jsは内部でreactを使っていますが、hono/jsxでも動いたのでエイリアスを張ります。@hono/react-compatでも行けそうですが、こちらのissueが解決していないので諦めました。

vite.config.ts
@@ -4,6 +4,9 @@ import build from "@hono/vite-build/cloudflare-pages";
 import adapter from "@hono/vite-dev-server/cloudflare";

 export default defineConfig({
+  resolve: {
+    alias: {
+      react: "hono/jsx/dom",
+      "react-dom": "hono/jsx/dom",
+    },
+  },
+  ssr: {
+    external: ["@auth/core"],
+  },
   plugins: [
     honox({ client: { input: ["/app/style.css"] }, devServer: { adapter } }),
     build(),
> npm install -E @hono/auth-js@1.0.7 @auth/core@0.37.2 
> npx auth secret --copy
> npx wrangler pages secret put AUTH_SECRET
> npx wrangler pages secret put AUTH_SECRET -e preview
> npx wrangler pages secret put AUTH_GOOGLE_ID
> npx wrangler pages secret put AUTH_GOOGLE_ID -e preview
> npx wrangler pages secret put AUTH_GOOGLE_SECRET
> npx wrangler pages secret put AUTH_GOOGLE_SECRET -e preview
.dev.vars
AUTH_SECRET="XXXXXXXXAUTHSECRETXXXXXXX"
AUTH_GOOGLE_ID="XXXXXXXXAUTHGOOGLEIDXXXXXXX"
AUTH_GOOGLE_SECRET="XXXXXXXXAUTHGOOGLESECRETXXXXXXX"

.dev.varsはgit管理されないので.dev.vars.exampleを作っておきます。

.dev.vars.example
AUTH_SECRET="XXXXXXXXEXAMPLEXXXXXXX"
AUTH_GOOGLE_ID="XXXXXXXXEXAMPLEXXXXXXX"
AUTH_GOOGLE_SECRET="XXXXXXXXEXAMPLEXXXXXXX"

今回はすべてのページを認証必須にします。また、providerAccountIdをsessionのデータとして使えるように保存しておきます。

app/server.ts
@@ -1,7 +1,43 @@
 import { createApp } from "honox/server";
 import { showRoutes } from "hono/dev";
+import { authHandler, initAuthConfig, verifyAuth } from "@hono/auth-js";
+import Google from "@auth/core/providers/google";
+import { HTTPException } from "hono/http-exception";

-const app = createApp();
+const app = createApp({
+  init(app) {
+    app
+      .use(
+        "*",
+        initAuthConfig(() => {
+          return {
+            basePath: "/auth",
+            providers: [Google],
+            callbacks: {
+              jwt(param) {
+                if (!param.token.id && param.account?.providerAccountId) {
+                  param.token.id = param.account?.providerAccountId;
+                }
+                return param.token;
+              },
+              session(param) {
+                param.session.user.id = param.token.id as string;
+                return param.session;
+              },
+            },
+          };
+        }),
+      )
+      .use("/auth/*", authHandler())
+      .use("*", verifyAuth())
+      .onError((err, c) => {
+        if (err instanceof HTTPException && err.status === 401) {
+          return c.redirect("/auth/signin");
+        }
+        throw err;
+      });
+  },
+});
app/routes/index.tsx
@@ -1,7 +1,13 @@
 import { createRoute } from "honox/factory";
+import { getAuthUser } from "@hono/auth-js";
+import { HTTPException } from "hono/http-exception";

-export default createRoute((c) => {
-       const name = c.req.query("name") ?? "no name";
+export default createRoute(async (c) => {
+       const user = await getAuthUser(c);
+       const name = user?.session.user?.name;
+       if (!name) {
+               throw new HTTPException(401, { message: "unauthrized" });
+       }
        return c.render(
                <div class="w-full">
                        <h1>Hello, {name}!</h1>

D1の導入

D1の作成

> npx wrangler d1 create {{データベース名}}
wrangler.toml
 name = "todoapp"
 compatibility_date = "2024-04-01"
 compatibility_flags = [ "nodejs_compat" ]
 pages_build_output_dir = "./dist"

+[[d1_databases]]
+binding = "DB" # i.e. available in your Worker on env.DB
+database_name = "{{データベース名}}"
+database_id = "{{データベースID}}"

worker-configuration.d.tsの作成

> npx wrangler types
app/global.d.ts
@@ -5,6 +5,9 @@ type Head = {
 };

 declare module "hono" {
+  interface Env {
+    Bindings: globalThis.Env;
+  }
   interface ContextRenderer {
     // biome-ignore lint/style/useShorthandFunctionType:
     (

prisma導入

> npm install -E @prisma/client@5.21.1 @prisma/adapter-d1@5.21.1
> npm install -E -D prisma@5.21.1

データベースのスキーマを定義します。今回はTodoアプリを作るのでTodoデータを保存できるようにしておきます。

prisma/schema.prisma
generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["driverAdapters"]
}
datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}
model Todo {
  id    Int     @id @default(autoincrement())
  user  String
  todo  String
}

migrationの作成

> npx wrangler d1 migrations create {{データベース名}} {{変更内容}}
> npx prisma migrate diff --from-empty --to-schema-datamodel ./prisma/schema.prisma --script --output migrations/{{連番}}_{{変更内容}}.sql

migrationの実行

> npx wrangler d1 migrations apply {{データベース名}} --local
> npx wrangler d1 migrations apply {{データベース名}} --remote

prisma clientの作成

> npx prisma generate

app/global.d.tsの修正

app/global.d.ts
 import type {} from "hono";
+import type { PrismaClient } from "@prisma/client";

 type Head = {
   title?: string;
@@ -6,6 +7,9 @@ type Head = {

 declare module "hono" {
   interface Env {
+    Variables: {
+      db: PrismaClient;
+    };
     Bindings: globalThis.Env;
   }
   interface ContextRenderer {

Contextからprismaクライアントを使えるようにしておきます。

app/server.ts
@@ -3,10 +3,18 @@ import { showRoutes } from "hono/dev";
 import { authHandler, initAuthConfig, verifyAuth } from "@hono/auth-js";
 import Google from "@auth/core/providers/google";
 import { HTTPException } from "hono/http-exception";
+import { PrismaD1 } from "@prisma/adapter-d1";
+import { PrismaClient } from "@prisma/client";

 const app = createApp({
   init(app) {
     app
+      .use(async (c, next) => {
+        const adapter = new PrismaD1(c.env.DB);
+        const prisma = new PrismaClient({ adapter });
+        c.set("db", prisma);
+        await next();
+      })
       .use(
         "*",
         initAuthConfig(() => {
vite.config.ts
@@ -5,7 +5,7 @@ import adapter from "@hono/vite-dev-server/cloudflare";

 export default defineConfig({
   ssr: {
-    external: ["@auth/core"],
+    external: ["@auth/core", "@prisma/client", "@prisma/adapter-d1"],
   },
   plugins: [
     honox({ client: { input: ["/app/style.css"] }, devServer: { adapter } }),

以降、prisma/schema.prismaを修正したときは下記を実行する

> npx wrangler d1 migrations create {{データベース名}} {{変更内容}}
> npx prisma migrate diff --from-local-d1 --to-schema-datamodel ./prisma/schema.prisma --script --output migrations/{{連番}}_{{変更内容}}.sql
> npx wrangler d1 migrations apply {{データベース名}} --local
> npx wrangler d1 migrations apply {{データベース名}} --remote
> npx prisma generate

swrの導入

> npm install -E swr@2.2.5

なぜか下記のようにaliasを追加しないとswrがdevモードで動かない。参考

vite.config.ts
@@ -6,6 +6,7 @@ import adapter from "@hono/vite-dev-server/cloudflare";
 export default defineConfig({
   resolve: {
     alias: {
+      "use-sync-external-store/shim/index.js": "hono/jsx/dom",
       react: "hono/jsx/dom",
       "react-dom": "hono/jsx/dom",
     },

サンプルアプリケーションの作成

ここまでで一通り準備は完了です。ここからはサンプルとしてtodoアプリを作ります。

zod-validator導入

> npm install -E @hono/zod-validator@0.4.1

API作成

app/routes/api/index.ts
import { Hono } from "hono";
import { z } from "zod";
import { zValidator } from "@hono/zod-validator";
import { getAuthUser } from "@hono/auth-js";
import { HTTPException } from "hono/http-exception";
import type { Context, Env } from "hono";

const app = new Hono<Env>();

const mustGetUserID = async (c: Context) => {
  const user = await getAuthUser(c);
  const userid = user?.session.user?.id;
  if (!userid) {
    throw new HTTPException(401, { message: "unauthrized" });
  }
  return userid;
};

const routes = app
  .get("/todo", async (c) => {
    const todos = await c.var.db.todo.findMany({
      where: { user: await mustGetUserID(c) },
    });
    return c.json(todos);
  })
  .post(
    "/todo",
    zValidator("json", z.object({ todo: z.string() })),
    async (c) => {
      const { todo } = c.req.valid("json");
      await c.var.db.todo.create({
        data: { todo: todo, user: await mustGetUserID(c) },
      });
      return c.json({ message: "Created!" }, 201);
    },
  )
  .delete(
    "/todo",
    zValidator("json", z.object({ id: z.number() })),
    async (c) => {
      const { id } = c.req.valid("json");
      await c.var.db.todo.delete({
        where: { user: await mustGetUserID(c), id: id },
      });
      return c.json({ message: "Deleted!" }, 201);
    },
  )
  .put(
    "/todo",
    zValidator("json", z.object({ id: z.number(), todo: z.string() })),
    async (c) => {
      const { id, todo } = c.req.valid("json");
      await c.var.db.todo.update({
        where: { user: await mustGetUserID(c), id: id },
        data: { todo: todo },
      });
      return c.json({ message: "Updated!" }, 201);
    },
  );

export type AppType = typeof routes;

export default app;

クライアント作成

app/islands/addtodo.tsx
import { useRef } from "hono/jsx";
import { hc } from "hono/client";
import type { AppType } from "../routes/api";
import { mutate } from "swr";

export default function AddTodo() {
	const textRef = useRef<HTMLInputElement>(null);
	const client = hc<AppType>("/api");
	return (
		<div class="flex">
			<input
				aria-label="addtodo"
				class="flex-auto bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2.5"
				ref={textRef}
				placeholder="New Task"
				type="text"
			/>
			<button
				class="py-2.5 px-5 m-1 mb-2 text-sm font-medium text-blue-900 focus:outline-none bg-white rounded-lg border-blue-200 hover:bg-blue-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-blue-100"
				type="button"
				onClick={async () => {
					if (!textRef.current?.value) return;
					await client.todo.$post({ json: { todo: textRef.current.value } });
					textRef.current.value = "";
					mutate("/api/todo");
				}}
			>
				Add
			</button>
		</div>
	);
}


app/islands/todo.tsx
import type { AppType } from "../routes/api";
import { hc } from "hono/client";
import { mutate } from "swr";
import { useState } from "hono/jsx";
import { useRef } from "hono/jsx";

type TodoProps = {
	id: number;
	todo: string;
};

export default function Todo(props: TodoProps) {
	const [isEdit, setEdit] = useState<boolean>(false);
	const [text, setText] = useState<string>(props.todo);
	const textRef = useRef<HTMLInputElement>(null);
	if (isEdit) {
		return (
			<div
				class="flex items-center  m-1 p-1 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700"
				key={props.id}
			>
				<input
					class="flex-auto bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2.5"
					type="text"
					ref={textRef}
					value={props.todo}
				/>
				<button
					type="button"
					onClick={async () => {
						if (!textRef?.current?.value) return;
						await hc<AppType>("/api").todo.$put({
							json: { id: props.id, todo: textRef.current.value },
						});
						mutate("/api/todo");
						setText(textRef.current.value);
						setEdit(false);
					}}
					class="py-2.5 px-5 m-1 mb-2 text-sm font-medium text-blue-900 focus:outline-none bg-white rounded-lg border-blue-200 hover:bg-blue-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-blue-100"
				>
					Update
				</button>
				<button
					class="py-2.5 px-5 m-1 mb-2 text-sm font-medium text-red-900 focus:outline-none bg-white rounded-lg hover:bg-red-100 hover:text-red-700 focus:z-10 focus:ring-4 focus:ring-red-100"
					type="button"
					onClick={() => {
						setEdit(false);
					}}
				>
					Cancel
				</button>
			</div>
		);
	}
	return (
		<div
			class="flex items-center p-1 m-1 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700"
			key={props.id}
		>
			<span class="flex-auto text-balance break-words min-w-0 ms-3 space-y-4 whitespace-nowrap">
				{text}
			</span>
			<button
				class="py-2.5 px-5 m-1 mb-2 text-sm font-medium text-blue-900 focus:outline-none bg-white rounded-lg border-blue-200 hover:bg-blue-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-blue-100"
				type="button"
				onClick={async () => {
					setEdit(true);
				}}
			>
				Edit
			</button>
			<button
				class="py-2.5 px-5 m-1 mb-2 text-sm font-medium text-red-900 focus:outline-none bg-white rounded-lg hover:bg-red-100 hover:text-red-700 focus:z-10 focus:ring-4 focus:ring-red-100"
				type="button"
				onClick={async () => {
					await mutate(
						"/api/todo",
						hc<AppType>("/api").todo.$delete({ json: { id: props.id } }),
						{
							optimisticData: (datas) =>
								datas.filter((data: { id: number }) => data.id !== props.id),
						},
					);
				}}
			>
				Delete
			</button>
		</div>
	);
}

app/islands/todolist.tsx
import useSWR from "swr";
import type { AppType } from "../routes/api";
import { hc } from "hono/client";
import Todo from "./todo";

export default function TodoList() {
	const { data, error, isLoading } = useSWR("/api/todo", async () => {
		return await hc<AppType>("/api")
			.todo.$get()
			.then((x) => {
				return x.json();
			});
	});
	if (error) return <div> failed to load </div>;
	if (isLoading) return <div> loading ... </div>;
	return (
		<div class="flex flex-col">
			{data?.map((x) => {
				return <Todo key={x.id} id={x.id} todo={x.todo} />;
			})}
		</div>
	);
}

app/routes/todoapp.tsx
import { createRoute } from "honox/factory";
import TodoList from "../islands/todolist";
import AddTodo from "../islands/addtodo";

export default createRoute(async (c) => {
	return c.render(
		<div class="container max-w-screen-md mx-auto">
			<div class="m-6">
				<AddTodo />
				<TodoList />
			</div>
		</div>,
		{ title: "ToDoApp" },
	);
});

app/routes/index.tsx
@@ -11,6 +11,7 @@ export default createRoute(async (c) => {
        return c.render(
                <div class="w-full">
                        <h1>Hello, {name}!</h1>
+                       <a href="/todoapp" class="font-medium text-blue-600 hover:underline">
+                               todoapp
+                       </a>
                </div>,
                { title: name },
        );

完成品

Discussion