🔥

React Router 7 + Cloudflare WorkersのアプリにHonoを導入した話

2025/01/22に公開

React Router 7 + Supabase + Cloudflare Workers のアプリにHonoを導入したので、その動機とか変更内容とかをまとめます。

https://zenn.dev/tmiyajima/articles/ac3dfeed1cb92f

Honoを導入した理由

middlewareを使いたかったからです。

前提として、React Router 7のようなSSRアプリケーションでは、supabaseのセッション情報はCookieに保存します。

https://supabase.com/docs/guides/auth/server-side/creating-a-client?queryGroups=framework&framework=remix

また、supabaseクライアントは、利用時に必要に応じてアクセストークンを自動更新してくれます。これによって、supabaseクライアントのセッション情報を、適切に Set-Cookie で書き出している限り、開発者はアクセストークンの期限切れを気にする必要がなくなるわけです。

一方で、React Router 7では、ネストされたルートの loader は非同期で並列実行されるため、認証チェックは親ルートだけでなく、ネストされる全ルートで等しく実行する必要があります。

https://remix.run/docs/en/main/guides/faq#how-can-i-have-a-parent-route-loader-validate-the-user-and-protect-all-child-routes

これらのことから、以下のような実装が必要となります。

  • 認証が必要な全ルートの loaderaction で、認証チェック (supabase.auth.getUser()) を呼び出す必要がある
  • 認証チェックにより更新されたアクセストークンを次回リクエストで使うために、
    全ルートの loaderaction の戻り値に、更新後のsession情報を Set-Cookie として書き出す必要がある
イメージ
export async function loader({ request, context }: Route.LoaderArgs) {
  // レスポンスヘッダー
  const headers = new Headers()

  // supabaseクライアント作成
  const supabase = createServerClient(context.cloudflare.env.SUPABASE_URL, context.cloudflare.env.SUPABASE_ANON_KEY, {
    cookies: {
      getAll() {
        return parseCookieHeader(request.headers.get('Cookie') ?? '')
      },
      setAll(cookiesToSet) {
        // セッションの更新結果はレスポンスヘッダーに書き出す
        cookiesToSet.forEach(({ name, value, options }) =>
          headers.append(
            "Set-Cookie",
            serializeCookieHeader(name, value, options)
          )
        );
      },
    },
  });

  // 認証チェック
  const authCheckResult = await supabase.auth.getUser();
  if (authCheckResult.error) {
    throw redirect("/login");
  }

  // なんか他のチェック
  if (someCheckError) {
    throw redirect("..", { headers }); // 忘れずにレスポンスヘッダーを設定する!
  }

  // 処理結果
  return data({ someData }, { headers }); // 忘れずにレスポンスヘッダーを設定する!
}

supabaseクライアントの作成や認証チェックは、ユーティリティ関数とか作ってなんとかするとしても、レスポンスヘッダーを入れ忘れないようにするのがかなりハードル高いと感じます。 action でいろいろチェックを追加すると、そのすべての箇所で入れていかないといけないので、絶対忘れる自信がある。

ちなみに、このレスポンスヘッダーの設定漏れは、アクセストークン更新の次のリクエストでセッション切れが起こる (時間をおいて操作すると一回目のリクエストは成功するけど、その次でエラーが起きる) という、非常に分かりづらい(かつ開発時に気づきにくい)バグを招きます。

こういうのは普通はmiddlewareでやるだろう!ということなんですが、残念ながらReact Router 7にはまだmiddlewareの仕組みがないため、
巷で評判のHonoを導入して、middlewareを使えるようにしました。

https://hono.dev/

Hono導入のためにやったこと

以下の記事を参考に、ほぼ似たようなことをやりました。

https://zenn.dev/achamaro/articles/b50dac4edf9883

違いがあるとこだけ抜粋します。

server/index.ts
import { Hono, HonoRequest } from "hono";
import { redirect } from "react-router";
import { createSupabaseClient } from "./supabase/client.ts";
import type { HonoEnv } from "./types";
import { singleFetchRedirect } from "./utils"; // 内容は元記事参照

const app = new Hono<HonoEnv>();

// supabaseクライアントをコンテキストに設定
app.use(async (c, next) => {
  const supabase = createSupabaseClient(c.req.header("Cookie"), c.res.headers, c.env);
  c.set("supabase", supabase);
  return next();
});

// ログインユーザーをコンテキストに設定
app.use(async (c, next) => {
  const supabase = c.get("supabase");
  const { data } = await supabase.auth.getUser();

  c.set("loginUser", data?.user);

  return next();
});

// 認証チェック
app.use(async (c, next) => {
  const loginUser = c.get("loginUser");

  // 認証済みの場合、認証不要な場合はスキップ
  if (loginUser || !requireAuth(c.req)) {
    return next();
  }

  // ログインへリダイレクト
  const redirectResponse = redirect(loginPath);

  // HTML要求ではない場合はfetchリクエストへのレスポンスを返す
  if (!c.req.header("Accept")?.includes("text/html")) {
    // シングルフェッチリクエストの場合はシングルフェッチのデータ形式で返す
    const url = new URL(c.req.url);
    if (url.pathname.endsWith(".data")) {
      return singleFetchRedirect(redirectResponse);
    }

    return new Response("Unauthorized", { status: 401 });
  }

  return redirectResponse;
});

// 認証が必要なリクエストにはtrueを返すようにする
function requireAuth(req: HonoRequest): boolean {
  const url = new URL(req.url);

  return url.startWith("/admin");
}

export default app;
server/supabase/client.ts
import {
  createServerClient,
  parseCookieHeader,
  serializeCookieHeader,
} from "@supabase/ssr";
import { createClient } from "@supabase/supabase-js";
import type { Database } from "./types"; // npx supabase gen typesで作ったファイル

export function createSupabaseClient({
  cookie,
  responseHeaders,
  env,
}: {
  cookie: string | null | undefined;
  responseHeaders: Headers;
  env: Env;
}) {
  return createServerClient<Database>(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
    cookies: {
      getAll() {
        return parseCookieHeader(cookie ?? "");
      },
      setAll(cookiesToSet) {
        cookiesToSet.forEach(({ name, value, options }) =>
          responseHeaders.append(
            "Set-Cookie",
            serializeCookieHeader(name, value, options)
          )
        );
      },
    },
  });
}
server/types.ts
import type { User } from "@supabase/supabase-js";
import type { Context } from "hono";
import type { createSupabaseClient } from "./supabase/client";

export type HonoEnv = {
  Bindings: Env;
  Variables: {
    supabase: ReturnType<typeof createSupabaseClient>;
    loginUser?: User;
  };
};

export type HonoContext = Context<HonoEnv>;
load-context.ts
import type { PlatformProxy } from "wrangler";
import type { HonoContext } from "./server/types";

type GetLoadContextArgs = {
  request: Request;
  context: {
    cloudflare: Omit<PlatformProxy<Env>, "dispose" | "caches" | "cf"> & {
      caches: PlatformProxy<Env>["caches"] | CacheStorage;
      cf: Request["cf"];
    };
    hono: { context: HonoContext };
  };
};

declare module "react-router" {
  // eslint-disable-next-line @typescript-eslint/no-empty-interface
  interface AppLoadContext extends ReturnType<typeof getLoadContext> {
    // This will merge the result of `getLoadContext` into the `AppLoadContext`
  }
}

export function getLoadContext({ context }: GetLoadContextArgs) {
  // HonoのコンテキストをReact Routerのコンテキストに変換
  return {
    env: context.hono.context.env,
    ...context.hono.context.var,
  };
}

これで以下のことが実現できるようになりました。

  • supabaseクライアントをリクエスト単位で持ち回る
  • loader の処理前にアクセストークン更新済みの状態を作る (並列実行される loader 内でアクセストークンが多重に更新される心配がない)
  • loader action のレスポンスヘッダーを気にしなくても、自動でセッション維持される
  • 認証チェックの重複ロジックを一箇所に共通化

まとめ

  • 認証チェックはmiddlewareがないとしんどい
  • Honoは導入も楽だし、型もちゃんとついてて強い

Discussion