HonoX+CloudflarePages+D1+Prisma+Auth.js+GoogleOIDC+SWR+TailwindCSS事始め
下記の技術スタックでアプリケーションを開発しようとしていくつか詰まったので、スムーズに開発を開始できるようにここにやり方をまとめる。
- 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
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"skipLibCheck": true,
"strict": true,
"lib": [
"ESNext",
"DOM"
],
"types": [
"vite/client"
],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
}
}
import { defineConfig } from "vite";
import honox from "honox/vite";
export default defineConfig({
plugins: [honox()],
});
import { createApp } from "honox/server";
import { showRoutes } from "hono/dev";
const app = createApp();
showRoutes(app);
export default app;
ContextRendererはbiomeのerrorが消えなかったのでignoreしてます。
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>;
}
}
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>
);
});
import type { NotFoundHandler } from "hono";
const handler: NotFoundHandler = (c) => {
return c.render(<h1>Sorry, Not Found...</h1>);
};
export default handler;
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 },
);
});
import type { ErrorHandler } from "hono";
const handler: ErrorHandler = (e, c) => {
return c.render(<h1>Error! {e.message}</h1>);
};
export default handler;
import { createClient } from "honox/client";
createClient();
# 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
{
+ "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
export default {
content: ['./app/**/*.tsx'],
theme: {
extend: {},
},
plugins: [],
}
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
@tailwind base;
@tailwind components;
@tailwind utilities;
import { defineConfig } from "vite";
import honox from "honox/vite";
export default defineConfig({
- plugins: [honox()],
+ plugins: [honox({ client: { input: ["/app/style.css"] } })],
});
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
@@ -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"
},
@@ -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/
@@ -9,6 +9,8 @@
"DOM"
],
"types": [
+ "./worker-configuration.d.ts",
+ "@cloudflare/workers-types/2023-07-01",
"vite/client"
],
"jsx": "react-jsx",
name = "{{プロジェクト名}}"
compatibility_date = "2024-04-01"
compatibility_flags = [ "nodejs_compat" ]
pages_build_output_dir = "./dist"
@@ -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_TOKEN
とCLOUDFLARE_ACCOUNT_ID
を取得し、GitHubのRepository secretsに追加しておきます。APIトークンにはCloudflare Pagasの編集権限を付けます。デプロイ時のビルドは速度を上げたいのでbunを使います。
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のプロジェクトに合わせてください)
- http://localhost:5173/auth/callback/google
- http://127.0.0.1:8788/auth/callback/google
- https://main.xxxx.pages.dev/auth/callback/google
- https://xxxx.pages.dev/auth/callback/google
また、この後クライアントIDとクライアントSECRETを使うので確認しておきます。
Auth.jsの導入
auth.jsは内部でreactを使っていますが、hono/jsxでも動いたのでエイリアスを張ります。@hono/react-compatでも行けそうですが、こちらのissueが解決していないので諦めました。
@@ -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
AUTH_SECRET="XXXXXXXXAUTHSECRETXXXXXXX"
AUTH_GOOGLE_ID="XXXXXXXXAUTHGOOGLEIDXXXXXXX"
AUTH_GOOGLE_SECRET="XXXXXXXXAUTHGOOGLESECRETXXXXXXX"
.dev.varsはgit管理されないので.dev.vars.exampleを作っておきます。
AUTH_SECRET="XXXXXXXXEXAMPLEXXXXXXX"
AUTH_GOOGLE_ID="XXXXXXXXEXAMPLEXXXXXXX"
AUTH_GOOGLE_SECRET="XXXXXXXXEXAMPLEXXXXXXX"
今回はすべてのページを認証必須にします。また、providerAccountIdをsessionのデータとして使えるように保存しておきます。
@@ -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;
+ });
+ },
+});
@@ -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 {{データベース名}}
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
@@ -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データを保存できるようにしておきます。
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の修正
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クライアントを使えるようにしておきます。
@@ -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(() => {
@@ -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モードで動かない。参考
@@ -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作成
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;
クライアント作成
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>
);
}
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>
);
}
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>
);
}
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" },
);
});
@@ -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