React Router 7 + Cloudflare WorkersのアプリにHonoを導入した話
React Router 7 + Supabase + Cloudflare Workers のアプリにHonoを導入したので、その動機とか変更内容とかをまとめます。
Honoを導入した理由
middlewareを使いたかったからです。
前提として、React Router 7のようなSSRアプリケーションでは、supabaseのセッション情報はCookieに保存します。
また、supabaseクライアントは、利用時に必要に応じてアクセストークンを自動更新してくれます。これによって、supabaseクライアントのセッション情報を、適切に Set-Cookie
で書き出している限り、開発者はアクセストークンの期限切れを気にする必要がなくなるわけです。
一方で、React Router 7では、ネストされたルートの loader
は非同期で並列実行されるため、認証チェックは親ルートだけでなく、ネストされる全ルートで等しく実行する必要があります。
これらのことから、以下のような実装が必要となります。
- 認証が必要な全ルートの
loader
やaction
で、認証チェック (supabase.auth.getUser()
) を呼び出す必要がある - 認証チェックにより更新されたアクセストークンを次回リクエストで使うために、
全ルートのloader
やaction
の戻り値に、更新後の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を使えるようにしました。
Hono導入のためにやったこと
以下の記事を参考に、ほぼ似たようなことをやりました。
違いがあるとこだけ抜粋します。
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;
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)
)
);
},
},
});
}
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>;
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