React Router v7の認証チェックをHonoのミドルウェアでやる
※ベースに使用する cloudflare
テンプレートが更新されたため 設定 の内容を修正しました。(2025/1/16)
はじめに
Cloudflare PagesにデプロイするRRv7アプリの認証チェックを functions/_middleware.ts
で行っていたのですが、ローカル開発時でもミドルウェアを動作させるために cloudflareDevProxy
をほんの少し改造したものを使用していました。
先日こんな投稿をみかけたので今回はサーバーをHonoで実装し functions/_middleware.ts
で実装していた認証チェックをHonoのミドルウェアに移行してみました。
できたもの
インストール
React Router v7
今回のデプロイ先はPagesですが、React Router v7のテンプレートにはWorkers向けのものしかなさそうなのでそこから始めます。
npx create-react-router@latest --template remix-run/react-router-templates/cloudflare cloudflare-pages-rr7-hono
Hono
Cloudflare環境をエミュレートするための @hono/vite-dev-server
も一緒にインストールします。
npm i hono-react-router-adapter hono @hono/vite-dev-server
設定
Honoサーバーを用意します。
import { Hono } from "hono";
const app = new Hono();
export default app;
Viteの設定ファイルを編集します。
cloudflareDevProxyをHonoのアダプターに置き換えるので、cloudflareDevProxyで設定されていたssrオプションを追加します。
+ import adapter from "@hono/vite-dev-server/cloudflare";
import { reactRouter } from "@react-router/dev/vite";
- import { cloudflareDevProxy } from "@react-router/dev/vite/cloudflare";
import autoprefixer from "autoprefixer";
+ import serverAdapter from "hono-react-router-adapter/vite";
import tailwindcss from "tailwindcss";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
- export default defineConfig(({ isSsrBuild }) => ({
+ export default defineConfig((_) => ({
- build: {
- rollupOptions: isSsrBuild
- ? {
- input: "./workers/app.ts",
- }
- : undefined,
- },
css: {
postcss: {
plugins: [tailwindcss, autoprefixer],
},
},
+ ssr: {
+ resolve: {
+ externalConditions: ["workerd", "worker"],
+ },
+ },
plugins: [
- cloudflareDevProxy({
- getLoadContext({ context }) {
- return { cloudflare: context.cloudflare };
- },
- }),
reactRouter(),
+ serverAdapter({
+ adapter,
+ entry: "server/index.ts",
+ }),
tsconfigPaths(),
],
}));
workers/app.ts
は使わないので削除します。
wrangler.toml
の内容も必要ないので空にしておきます。(ファイルは必要)
ここまでの設定でHonoサーバーでReact Router v7アプリが起動するようになります。
npm run dev
getLoadContext
Cloudflareでは環境変数を各リクエストごとに作成されるコンテキストから読み取る必要があるため、私の場合は環境変数を参照するようなものはコンテキストを拡張して実装しています。
例として、アプリケーションの動作環境を設定する環境変数 APP_ENV
を定義し、本番環境で動作しているかどうかの判定用に isProduction
というプロパティをコンテキストに追加します。
環境変数は .dev.vars
で定義します。
APP_ENV=development
.dev.vars
の編集が終わったら npx wrangler types
コマンドを実行して型ファイルを生成します。
このコマンドは wrangler.toml
や .dev.vars
の設定内容に基づいて型ファイルを生成してくれるので、これらのファイルの更新ごとに実行するようにします。
// Generated by Wrangler by running `wrangler types`
interface Env {
APP_ENV: string;
}
tsconfig
"include": [
".react-router/types/**/*",
"app/**/*",
"app/**/.server/**/*",
"app/**/.client/**/*",
"server/**/*",
"load-context.ts",
"worker-configuration.d.ts"
],
"include": [
"tailwind.config.ts",
"vite.config.ts",
"load-context.ts",
"worker-configuration.d.ts",
"app/cookie.server.ts"
],
次に load-context.ts
を実装します。
import type { Context } from "hono";
import type { AppLoadContext } from "react-router";
import type { PlatformProxy } from "wrangler";
type Cloudflare = Omit<PlatformProxy<Env>, "dispose">;
export interface HonoEnv {
Bindings: Env;
}
declare module "react-router" {
interface AppLoadContext {
cloudflare: Cloudflare;
hono: {
context: Context<HonoEnv>;
};
isProduction: boolean;
}
}
type GetLoadContext = (args: {
request: Request;
context: {
cloudflare: Cloudflare;
hono: { context: Context<HonoEnv> };
};
}) => AppLoadContext;
export const getLoadContext: GetLoadContext = ({ context }) => {
const {
cloudflare: { env },
} = context;
return {
...context,
isProduction: env.APP_ENV === "production",
};
};
npx wrangler types
コマンドで生成された Env
を PlatformProxy<Env>
や Context<{Bindings: Env}>
と指定することで cloudflare.env
や hono.context.env
で型が効くようになります。
実装した getContextLoader
を vite.config.ts
で設定します。
import adapter from "@hono/vite-dev-server/cloudflare";
import { reactRouter } from "@react-router/dev/vite";
import autoprefixer from "autoprefixer";
import serverAdapter from "hono-react-router-adapter/vite";
import tailwindcss from "tailwindcss";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
+ import { getLoadContext } from "./load-context";
export default defineConfig((_) => ({
// ...
plugins: [
reactRouter(),
serverAdapter({
adapter,
entry: "server/index.ts",
+ getLoadContext,
}),
tsconfigPaths(),
],
}));
認証
認証そのものは本題からはずれるので入力された名前をクッキーに保存し、クッキーに名前が存在すればログイン中とみなすような簡単なものにします。
クッキー
まずはクッキーを実装していきます。
シークレットは環境変数で定義するため引数で受け取るようにします。
import { createCookie } from "react-router";
export const SessionCookie = (secrets: string, secure: boolean) =>
createCookie("session", {
httpOnly: true,
secrets: secrets.split(","),
maxAge: 60, // すぐに切れるように有効期限を60秒に設定
path: "/",
sameSite: "lax",
secure,
});
このクッキーをコンテキストから使用できるように拡張します。
クッキーから取得した名前をHonoのコンテキストに設定するため HonoEnv["Variables"]
を設定します。hono.context.get/set
で型が効くようになります。
// ...
export interface HonoEnv {
Variables: {
userName?: string;
};
Bindings: Env;
}
declare module "react-router" {
interface AppLoadContext {
cloudflare: Cloudflare;
hono: {
context: Context<HonoEnv>;
};
isProduction: boolean;
sessionCookie: {
serialize: (value: string) => Promise<string>;
};
}
}
// ...
export const getLoadContext: GetLoadContext = ({ context }) => {
const {
cloudflare: { env },
} = context;
const isProduction = env.APP_ENV === "production";
return {
...context,
isProduction,
sessionCookie: {
serialize: async (value: string) => {
return await SessionCookie(
env.SESSION_COOKIE_SECRETS,
isProduction,
).serialize(value);
},
},
};
};
ログイン画面
フォームで入力された名前をキックキーのセットしつつ /private
にリダイレクトします。
import { Form, redirect } from "react-router";
import type { Route } from "./+types/login";
export async function action({
request,
context: { sessionCookie },
}: Route.ActionArgs) {
const formData = await request.formData();
return redirect("/private", {
headers: {
"Set-Cookie": await sessionCookie.serialize(
formData.get("username") as string,
),
},
}) as Response;
}
export default function Login() {
return (
<div className="p-5">
<Form method="post" className="flex w-fit flex-col gap-4">
<input
name="username"
type="text"
className="grow"
placeholder="ユーザー名"
required
/>
<button type="submit" className="btn btn-neutral">
ログイン
</button>
</Form>
</div>
);
}
執筆時点で redirect
が返す型でエラーが出るので型キャストで回避していますが、エラーが出なければ不要です。
ログイン後の画面
認証チェックが通るとHonoのコンテキストにユーザー名をセットするので、ここではコンテキストから取得したユーザー名を表示するようにします。
import { Form } from "react-router";
import type { Route } from "./+types/private";
export async function loader({ context }: Route.LoaderArgs) {
return context.hono.context.get("userName");
}
export default function Private({ loaderData }: Route.ComponentProps) {
return (
<div className="p-5">
<h1 className="mb-5 text-4xl">ダッシュボード</h1>
<p>ようこそ、{loaderData}さん</p>
<Form method="post" className="mt-5">
<button type="submit" className="btn btn-sm btn-neutral">
ログアウト
</button>
</Form>
</div>
);
}
認証チェックミドルウェア
認証チェックに失敗した場合はログイン画面へリダイレクトします。
React Router v7ではシングルフェッチによって複数のloaderリクエストがまとまってきます。このリクエストには turbo-stream
形式のレスポンスを返す必要があります。
turbo-stream
形式のレスポンスを作成するために、turbo-streamをインストールします。
npm i turbo-stream
リダイレクトレスポンスを turbo-stream
形式で返すための関数をReact Routerのリポジトリのコードを参考に実装します。
import { UNSAFE_SingleFetchRedirectSymbol } from "react-router";
import { encode } from "turbo-stream";
export function singleFetchRedirect(response: Response) {
const result = {
[UNSAFE_SingleFetchRedirectSymbol]: getSingleFetchRedirect(
response.status,
response.headers,
),
};
const body = encode(result, {
// https://github.com/remix-run/react-router/blob/5d96537148d768b304be3bea7237a12351127807/packages/react-router/lib/server-runtime/single-fetch.ts#L352C19-L352C74
plugins: [
(value) => {
if (
value &&
typeof value === "object" &&
UNSAFE_SingleFetchRedirectSymbol in value
) {
return [
"SingleFetchRedirect",
value[UNSAFE_SingleFetchRedirectSymbol],
];
}
},
],
});
const headers = new Headers(response.headers);
headers.set("Content-Type", "text/x-script");
headers.set("X-Remix-Response", "yes");
return new Response(body, {
status: 202,
headers,
});
}
// https://github.com/remix-run/react-router/blob/5d96537148d768b304be3bea7237a12351127807/packages/react-router/lib/server-runtime/single-fetch.ts#L256
function getSingleFetchRedirect(status: number, headers: Headers) {
const redirect2 = headers.get("Location");
return {
redirect: redirect2,
status,
revalidate:
// Technically X-Remix-Revalidate isn't needed here - that was an implementation
// detail of ?_data requests as our way to tell the front end to revalidate when
// we didn't have a response body to include that information in.
// With single fetch, we tell the front end via this revalidate boolean field.
// However, we're respecting it for now because it may be something folks have
// used in their own responses
// TODO(v3): Consider removing or making this official public API
headers.has("X-Remix-Revalidate") || headers.has("Set-Cookie"),
reload: headers.has("X-Remix-Reload-Document"),
replace: headers.has("X-Remix-Replace"),
};
}
Honoサーバーにミドルウェアを追加します。
ここでは3種類のリクエストに対応します。
- 直接画面に訪れた場合や、
<Link reloadDocument=true>
で画面遷移した場合 - React Routerのシングルフェッチリクエスト
- 通常のフェッチリクエスト
先頭のケースは Accept
ヘッダーに text/html
が含まれているかどうかで判定します。
シングルフェッチはパスの末尾に .data
が付きます。
import { Hono } from "hono";
import { redirect } from "react-router";
import { SessionCookie } from "../app/cookie.server";
import { singleFetchRedirect } from "../app/util/navigation/redirect";
import type { HonoEnv } from "../load-context";
const app = new Hono<HonoEnv>();
app.use(async (c, next) => {
const url = new URL(c.req.url);
// 認証が不要なページはスキップ
if (!url.pathname.startsWith("/private")) {
return next();
}
// クッキーからセッションデータを取得
const userName = await SessionCookie(
c.env.SESSION_COOKIE_SECRETS,
c.env.APP_ENV === "production",
).parse(c.req.header("Cookie") ?? "");
if (userName) {
c.set("userName", userName);
return next();
}
// 認証に失敗した場合はログイン画面へリダイレクト
const redirectResponse = redirect("/login");
// HTML要求ではない場合はfetchリクエストへのレスポンスを返す
if (!c.req.header("Accept")?.includes("text/html")) {
// シングルフェッチリクエストの場合はシングルフェッチのデータ形式で返す
if (url.pathname.endsWith(".data")) {
return singleFetchRedirect(redirectResponse);
}
return new Response("Unauthorized", { status: 401 });
}
return redirectResponse;
});
export default app;
確認用の画面
動作の確認用に各種リクエストが発生するリンク、ボタンをインデックスに置いておきます。
import { Link } from "react-router";
export default function Home() {
async function fetchDashboard() {
const res = await fetch("/private");
}
return (
<div className="p-5">
<h1 className="mb-5 text-4xl">トップ</h1>
<div className="grid grid-cols-1 gap-3">
<Link to="/private" className="underline">
ダッシュボード
</Link>
<Link to="/private" className="underline" reloadDocument>
ダッシュボード(reloadDocument)
</Link>
<button
type="button"
className="w-fit underline"
onClick={fetchDashboard}
>
ダッシュボード(fetch)
</button>
<Link to="/login" className="underline">
ログイン
</Link>
</div>
</div>
);
}
ルート定義
作成したルートモジュールをルート定義に追加します。
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/index.tsx"),
route("login", "routes/login.tsx"),
route("private", "routes/private.tsx"),
] satisfies RouteConfig;
Cloudflare Pagesにデプロイ
functions
Cloudflare Pagesへの全てのリクエストを受け付ける functions/[[path]].ts
を作成します。
../build/server
はビルドすることで作成されるためエディタ上のエラーは無視します。
// functions/[[path]].ts
import handle from "hono-react-router-adapter/cloudflare-pages";
import * as build from "../build/server";
import { getLoadContext } from "../load-context";
import server from "../server";
export const onRequest = handle(build, server, { getLoadContext });
型ファイル生成
npx wrangler types
コマンドで型ファイルを生成するには .dev.vars
が必要です。通常環境変数はgitの管理下に置かないため .dev.vars.example
ファイルを作成し値をダミーまたは空にしておきます。
そしてビルド直前に .dev.vars
にコピーして型ファイルを生成するようにビルドスクリプトを修正します。
// ...
"scripts": {
"build": "cp .dev.vars.example .dev.vars && npx wrangler types && react-router build",
// ...
},
// ...
アプリケーション作成
ここからはCloudflareダッシュボードにアクセスして操作します。
Gitに接続
Pagesタブを選択し「Gitに接続」ボタンを押下します。
連携後、リポジトリを選択してセットアップを開始します。
セットアップ
フレームワーク プリセットにはReact RouterがないのでRemixを選択します。
また、環境変数の設定もここで行います。
ビルドに使用するnodejsのバージョンを NODE_VERSION
で指定します。
「保存してデプロイ」ボタンを押下するとデプロイが始まり、完了するとサイトへアクセスできるようになります。
(サイトにアクセスできるようになるまで数分かかる場合もあります)
おしまい
Cloudflare Pagesはダッシュボードからポチポチするだけで自動デプロイが構成され、 main
ブランチを更新すると自動でデプロイされるようになるのでとても使いやすいです。
全ての設定をダッシュボードで行い、 wrangler.toml
の影響を受けないのも個人的には気に入ってます。
React Router v7にミドルウェアの仕組みが追加されるそうなのでそれも楽しみです。
Discussion