🔥

React Router v7の認証チェックをHonoのミドルウェアでやる

2025/01/12に公開

※ベースに使用する cloudflare テンプレートが更新されたため 設定 の内容を修正しました。(2025/1/16)

はじめに

Cloudflare PagesにデプロイするRRv7アプリの認証チェックを functions/_middleware.ts で行っていたのですが、ローカル開発時でもミドルウェアを動作させるために cloudflareDevProxy をほんの少し改造したものを使用していました。

先日こんな投稿をみかけたので今回はサーバーをHonoで実装し functions/_middleware.ts で実装していた認証チェックをHonoのミドルウェアに移行してみました。

https://x.com/yusukebe/status/1875486595387945112

できたもの

https://github.com/achamaro/cloudflare-pages-rr7-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サーバーを用意します。

server/index.ts
import { Hono } from "hono";

const app = new Hono();

export default app;

Viteの設定ファイルを編集します。
cloudflareDevProxyをHonoのアダプターに置き換えるので、cloudflareDevProxyで設定されていたssrオプションを追加します。

vite.config.ts
+ 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 で定義します。

.dev.vars
APP_ENV=development

.dev.vars の編集が終わったら npx wrangler types コマンドを実行して型ファイルを生成します。
このコマンドは wrangler.toml.dev.vars の設定内容に基づいて型ファイルを生成してくれるので、これらのファイルの更新ごとに実行するようにします。

worker-configuration.d.ts
// Generated by Wrangler by running `wrangler types`

interface Env {
	APP_ENV: string;
}
tsconfig
tsconfig.cloudflare.ts
  "include": [
    ".react-router/types/**/*",
    "app/**/*",
    "app/**/.server/**/*",
    "app/**/.client/**/*",
    "server/**/*",
    "load-context.ts",
    "worker-configuration.d.ts"
  ],
tsconfig.node.ts
  "include": [
    "tailwind.config.ts",
    "vite.config.ts",
    "load-context.ts",
    "worker-configuration.d.ts",
    "app/cookie.server.ts"
  ],

次に load-context.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 コマンドで生成された EnvPlatformProxy<Env>Context<{Bindings: Env}> と指定することで cloudflare.envhono.context.env で型が効くようになります。

実装した getContextLoadervite.config.ts で設定します。

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(),
    ],
  }));

認証

認証そのものは本題からはずれるので入力された名前をクッキーに保存し、クッキーに名前が存在すればログイン中とみなすような簡単なものにします。

クッキー

まずはクッキーを実装していきます。
シークレットは環境変数で定義するため引数で受け取るようにします。

app/cookie.server.ts
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 で型が効くようになります。

load-context.ts
// ...

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 にリダイレクトします。

app/routes/login.tsx
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 が返す型でエラーが出るので型キャストで回避していますが、エラーが出なければ不要です。

https://github.com/remix-run/react-router/issues/12615

ログイン後の画面

認証チェックが通るとHonoのコンテキストにユーザー名をセットするので、ここではコンテキストから取得したユーザー名を表示するようにします。

private.tsx
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のリポジトリのコードを参考に実装します。

app/util/navigation/redirect.ts
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 が付きます。

server/index.ts
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;

確認用の画面

動作の確認用に各種リクエストが発生するリンク、ボタンをインデックスに置いておきます。

app/routes/index.tsx
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>
  );
}

ルート定義

作成したルートモジュールをルート定義に追加します。

app/routes.ts
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
// 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 にコピーして型ファイルを生成するようにビルドスクリプトを修正します。

package.json
  // ...

  "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