こんにちは、うしろのこです。直近1年ではVueから離れて、maja と呼ばれる組織管理基盤の新規プロダクトの開発をしていました。
プロダクトの話はこちら(maja)↓
今回は、0->1における技術選定や開発中の工夫、結果どうだったかなどを書きます。
技術選定
初めに、前提条件は以下のような感じでした。
- メンバーはReactの経験が豊富、フロントを触るのは多くて3,4人くらい
- 常にユーザー認証された状態で操作されるため、FE用のmiddleware的な層があるとうれしい
- toBアプリケーション
せっかくなので使ったことのないものを使ってみよう、ということで、すでにWAFでの導入が進んでいたCloudflareの技術の採用をFEでも検討しました。少し触った感じではdeploy体験がよく、ローカル開発環境であるwranglerの出来も申し分なかったため、Cloudflare Workersの利用を決めました。
次にWebフレームワークの部分ですが、Reactをベースにしたフルスタックなものであればなんでも良く、開発開始当初(2023/05辺り)Workers統合が最もスムーズだったRemixを選択しました。Next.jsも考えましたが、アプリケーションの特性上ほぼキャッシュを効かせたい部分がない(常に最新の情報を表示する必要がある)こと、他で進んでいる0->1のアプリケーションで採用されていたこと、当時のWorkers/Pagesと合わせた際の開発体験の悪さ等から除外しました(以下の記事も参考にしました)。
Remixの魅力の一つとして、Session管理をCloudflare KVで行うことができ、APIが整備されている点があります。今回のアプリケーションでは STORES アカウント による認証/認可の処理をRemix Serverで行ったため、この点が非常に重宝しました。認可フローをGo BEから剥がせるため、Go側ではドメインモデルをいじくることに注力できていました。一方で、新規にアプリケーションを作る際に都度この仕組みが必要になるため、ゆくゆくは中央集権型のセッション管理の仕組みを作り、そこへ移譲していく可能性もあります。
この内容は上記記事でも触れています。
GraphQLの統合
本アプリケーションではGraphQLでBEとの通信を行います。当初はurqlやrelay等GraphQL Clientとなりえるものの調査を行いましたが、Remixが提供しているForm APIを生かすため、基本的にはRemix Actionによるリクエストを行う方針にしました。つまり、ClientからのGraphQL Operationは行わないことになりました。前述しましたが、積極的なキャッシュ戦略はとらないため、問題にならないという判断です。
インメモリキャッシュによる最適化をしないので、それ用のclient hookを提供しているライブラリを使う必要もなく、actionを叩いた際にRemix Server -> BEへAPI Callするための最低限のライブラリとして graphql-request
を採用しました。
graphql-request
は標準のErrorを拡張した、GraphQLフレンドリーなClientErrorインスタンスにラップしてくれるため、GraphQL標準のGraphQLResponse型に則ってエラーの取り回しができます。また、TypedDocumentNodeに対応しているので、GraphQL Code Generatorのclient-presetを利用できます。
Form ValidationとGraphQLの統合
Formが中心になるアプリケーションなため、バリデーションライブラリの導入を検討しました。GraphQL Schema Firstな開発をするために、GraphQL Schemaからバリデーション用のSchemaが生成できることがベストでした。
そこで、GraphQL Code Generatorのプラグインである graphql-codegen-typescript-validation-schema
を採用しました。
これで、GraphQL Schema上に @constraint(required: true)
のようにアノテーションすることでZod Schemaを生成できます。codegen.tsは以下のようになっています(現在はvalibotもサポートされているので、新規で使うならこちらも選択肢に入れたい所)。
import type { CodegenConfig } from "@graphql-codegen/cli"; const config: CodegenConfig = { schema: "__generated.schema.graphql", ignoreNoDocuments: true, documents: ["app/**/*.{ts,tsx}"], generates: { "./app/gql/": { preset: "client", plugins: [], config: { strictScalars: true, scalars: { Date: "string", Email: "string", UUID: "string", URL: "string", }, }, }, "./app/gql/zodSchema.ts": { plugins: ["typescript-validation-schema"], config: { schema: "zod", importFrom: "./graphql", enumsAsConst: true, scalarSchemas: { Date: "z.string()", Email: "z.string()", UUID: "z.string()", URL: "z.string()", }, directives: { constraint: { startsWith: [ "startsWith", "$1", "$1で始まる文字列を入力してください", ], maxLength: ["max", "$1", "$1文字以下で入力してください"], format: { date: ["datetime", "有効な日付で入力してください"], email: ["email", "有効な形式で入力してください"], url: ["url", "有効な形式で入力してください"], }, pattern: ["regex", "/$1/", "有効な形式で入力してください"], required: ["nonempty", "必須です"], }, }, }, }, }, }; export default config;
以下のようなGraphQL Schemaから、Zod Schemaが自動生成されます。
input UserInput { """姓""" familyName: String! @constraint(required: true, maxLength: 20, pattern: "^[ぁ-んァ-ヶー一-龠々〆ヶa-zA-Z]+$") """名""" givenName: String! @constraint(required: true, maxLength: 20, pattern: "^[ぁ-んァ-ヶー一-龠々〆ヶa-zA-Z]+$") """姓カナ""" familyNameKana: String! @constraint(required: true, maxLength: 20, pattern: "^[ァ-ヴー]*$") """名カナ""" givenNameKana: String! @constraint(required: true, maxLength: 20, pattern: "^[ァ-ヴー]*$") """生年月日""" birthdate: Date! @constraint(required: true, format: "date") }
export function UserInputSchema(): z.ZodObject<Properties<UserInput>> { return z.object({ familyName: z.string().nonempty("必須です").max(20, "20文字以下で入力してください").regex(/^[ぁ-んァ-ヶー一-龠々〆ヶa-zA-Z]+$/, "有効な形式で入力してください"), familyNameKana: z.string().nonempty("必須です").max(20, "20文字以下で入力してください").regex(/^[ァ-ヴー]*$/, "有効な形式で入力してください"), givenName: z.string().nonempty("必須です").max(20, "20文字以下で入力してください").regex(/^[ぁ-んァ-ヶー一-龠々〆ヶa-zA-Z]+$/, "有効な形式で入力してください"), givenNameKana: z.string().nonempty("必須です").max(20, "20文字以下で入力してください").regex(/^[ァ-ヴー]*$/, "有効な形式で入力してください"), birthdate: z.string().nonempty("必須です").datetime("有効な日付で入力してください") }) }
これによりGraphQL Schema Firstを維持しつつ、BEと齟齬のないバリデーションを行えるようになりました。一部Schemaの拡張がしたい場合も .merge
や .refine
を用いてその場で定義を追加できます。
このバリデーションスキーマは主にactionやblurイベントでのバリデーションに用います。majaではGo BEでもバリデーションを行っており、BEから返ってくるエラー構造とZodのエラー構造に差異があるため、その吸収もこの層で行なっています。これにより、クライアント上で取り回すエラーの構造がGraphQL標準のものに統一されます。
以下はactionでのバリデーションの例です。
export const action = async ({ request }: ActionFunctionArgs) => { try { const formData = generateFormData(await request.formData()); // formDataをいい感じにオブジェクトに正規化するやつ const parseResult = UserInputSchema().safeParse( input: formData, ); if (!parseResult.success) { return json( { errorType: "ClientError", error: zodErrorToClientError(parseResult), // zodのエラー構造をGraphQLのエラー構造にマッピングするやつ }, { status: 400 }, ); } const input = parseResult.data; const client = await initializeClient(request); return await client .request( graphql(/* GraphQL */ ` mutation UserData( $input:UserDataInput! ) { userData(input: $input) { user { id, } } } `), { input, }, ) .then(({ userData }) => { if (!userData) { return json( { errorType: "standard", error: new Error("登録できませんでした"), }, { status: 400 }, ); } return redirect(`/path/to/redirect`); }) .catch((error) => { // BEでバリデーションエラーになった場合ここにくる if (error instanceof ClientError && isClientError(error)) { return json( { errorType: "ClientError", error, }, { status: 400 }, ); } }); } catch (error) { // GraphQL の error ではないものは Sentry に送る return handleUnexpectedErrorResponse({ error, request }); } };
認証
詳細は割愛しますが、今回は STORES アカウント を用いたprivate_key_jwt認証を行っています。
panva/jose
はCloudflare Workersでも動作する署名用のライブラリで、JWKの読み込みや署名等ができます。クライアントアサーションを STORES アカウント に投げることで、トークンリクエストを行います。
OAuthのコールバックはRemixのloaderで受け、署名付きトークンリクエストやstate,nonceの検証などを行った後に、取得したid token等をセッションに保持します。state,nonceも、ログインフローで STORES アカウント へ最初のリダイレクトをする際にセッションへ書き込みを行っておきます。actionによるAPI通信時にセッションからトークンを取り出し、Bearerに付与しています。
以下はgraphql-requestの初期化関数です。各actionで都度呼び出します。
import { GraphQLClient } from "graphql-request"; import { commitSession, getSession } from "./session.server"; import { refreshWithToken } from "./openid.server"; export const initializeClient = async (request: Request) => { const client = new GraphQLClient("...", { fetch: fetch, }); const session = await getSession(request.headers.get("Cookie")); const refreshToken = session.get("refreshToken"); const tokenExpiresAt = session.get("tokenExpiresAt"); if (refreshToken && tokenExpiresAt && isTokenExpired(tokenExpiresAt)) { // なんかリフレッシュの処理がある } const accessToken = session.get("accessToken"); if (accessToken) { client.setHeader("Authorization", `Bearer ${accessToken}`); } else { client.setHeader("Authorization", ""); } return client; };
ユニットテスト
ユニットテストは実物のloader,actionの実行を伴った、ユーザーイベント駆動のシナリオテストをしています。@testing-library/react
でユーザーイベント駆動のブラックボックステストの環境を担保し、@remix-run/testing
の createRemixStub
を用いることでメモリ上でルーティングをしながらテストが行えます。
またテストランナーには vitest
を用いています。
以下はその例の一部です。
import { createRemixStub } from "@remix-run/testing"; import { prepareAuthedSession, mocks, } from "tests/helpers"; import type * as remixruncloudflare from "@remix-run/cloudflare"; import type * as gqlclientserver from "~/gql-client.server"; import Route, { action, loader } from "./route"; import { render, screen } from "@testing-library/react"; import type { XXXQuery } from "~/gql/graphql"; // 自動生成されたQueryの型 const routePath = "/path/to/route" vi.mock("@remix-run/cloudflare", async (importOriginal) => { const actual = await importOriginal<typeof remixruncloudflare>(); return { ...actual, redirect: () => mocks.redirect(), }; }); const clientMock = mocks.initializeClient; let requestMock = clientMock.mockResolvedValue(undefined); vi.mock("../../gql-client.server", async () => { const actual = await vi.importActual<typeof gqlclientserver>( "../../gql-client.server", ); return { ...actual, initializeClient: () => ({ request: requestMock }), }; }); const prepare = async ({ path = routePath, loader, loaderArgs, action, actionArgs, ErrorBoundary = () => <div>404</div>, }: { path?: string; loader?: (args: any) => any; loaderArgs?: remixruncloudflare.LoaderFunctionArgs; action?: (args: any) => any; actionArgs?: remixruncloudflare.ActionFunctionArgs; ErrorBoundary?: () => any; }) => { const RemixStub = createRemixStub([ { path, Component: Route, ErrorBoundary, loader: (args) => loader && loader({ ...args, ...loaderArgs, }), action: (args) => action && action({ ...args, ...actionArgs, }), }, ]); const querySubmitButton = () => { return screen.queryByRole("button", { name: "完了" }); }; return { RemixStub, querySubmitButton, }; }; describe(routePath, async () => { afterEach(() => { vi.restoreAllMocks(); }); describe("loader", async () => { it("ログインしていない場合はエラー", async () => { requestMock.mockResolvedValueOnce({ ... } satisfies XXXQuery); // GraphQL Queryのレスポンスのモック const { RemixStub } = await prepare({ loader, loaderArgs: { request: new Request( `http://localhost${routePath}`, ), params: { id: "456", }, context: {}, }, }); render( <RemixStub initialEntries={[routePath]} />, ); expect(await screen.findByText("404")).toBeInTheDocument(); }); it("ログインしている場合、結果が表示できる", async () => { requestMock.mockResolvedValueOnce({ ... } satisfies XXXQuery); const { cookie } = await prepareAuthedSession(); const { RemixStub } = await prepare({ loader, loaderArgs: { request: new Request( routePath, { headers: { cookie }, }, ), params: { id: "456", }, context: {}, }, }); render( <RemixStub initialEntries={[routePath]} />, ); expect(await screen.findByText("STORES Test")).toBeInTheDocument(); }); }); describe("action", async () => { it("正しくPOSTすると次の画面にリダイレクトされる", async () => { requestMock.mockResolvedValueOnce({ ... } satisfies XXXQuery); const { cookie } = await prepareAuthedSession(); const { RemixStub, querySubmitButton, } = await prepare({ loader, action, loaderArgs: { request: new Request( `http://localhost${routePath}`, { headers: { cookie }, }, ), params: { id: "111", }, context: {}, }, }); render( <RemixStub initialEntries={[routePath]} />, ); await waitFor(async () => { await userEvent.selectOptions(await screen.findByLabelText("法人呼称"), [ "〇〇株式会社", ]); }); await inputText("市区町村カナ", "シブヤク"); await inputText("町名カナ", "ヒガシ"); await inputText("建物名・部屋番号", "STORESビル1号室"); await inputText("建物名・部屋番号カナ", "ストアズビルイチゴウシツ"); await waitFor(async () => { await userEvent.click(querySubmitButton()!); }); expect(mocks.redirect).toHaveBeenCalled(); }); }); }); async function inputText(placeHolder: string, value: string) { await waitFor(async () => { const input = screen.getByLabelText(placeHolder); await userEvent.click(input); await userEvent.keyboard(value); }); }
ディレクトリ設計
特に独自で考えたものはなく、Remixの Organization Convention
と呼ばれるルールに従っています。基本的には app/routes/xxx/route.tsx
を作り、それがそのままパスになります。 xxx/
の配下には hooks.ts
や type.ts
といったそのルートに関連するコードを置いていくイメージです。ユニットテストも route.test.tsx
を同階層に置いています。
app- - xxx - route.tsx - route.test.tsx - hooks.tsx - type.tsx - FeatureComponent.tsx - yyy - route.tsx - route.test.tsx - hooks.tsx - type.tsx - FeatureComponent.tsx
その他ハマり所
env
Cloudflare Workersのenvは癖があり、ハマりどころです。普通にググるとloader/actionのcontextから取得するようなドキュメントが出てきますが、wrangler環境では以下のように設定すると process.env
のような使い方が可能です。
ルートに .dev.vars を置く
WEB_HOST = "https://example.com"
もしくは wrangler.tomlに記述する(publicなenvの場合)
[env.vars] WEB_HOST = "https://example.com"
envの型を定義する
// env.d.ts declare const WEB_HOST: string;
loader,action等のサーバーで実行されるファイルで参照する
// login.ts export const loader = async ({ request }: LoaderFunctionArgs) => { const callbackUrl = new URL("/auth_callback", WEB_HOST); return redirect(callbackUrl.toString()); };
ローカル以外でsecretを設定する場合はCoudflareのコンソールか、wrangler secret put
コマンドで設定できます。
loaderでの認証処理
Remixのloaderは親子関係にある(Layout-> children)場合に、childrenとなるルートへアクセスしても並列に実行されます。つまり、Layoutで未ログインを弾いたとしても、childrenではログインチェックされずに表示できてしまいます。そのため、こういった処理を親にまとめることが現時点ではできません。(Single Fetchという仕組みで解消されます→https://remix.run/docs/en/main/guides/single-fetch#enabling-single-fetch)
そのためmajaではレイアウトを利用せず、すべてのルートのloaderでログインチェックの処理を入れています。またスタイルコンテナの共通化は単にレイアウトコンポーネントのchildrenにコンテンツを渡すようにしています。この辺りの難しさは、Single Fetch時代の前後で大きく変わることが期待されます。
デプロイ
GitHub Actionsでデプロイしています。Cloudflare公式のactionがあるので、これを用いて各環境へデプロイします。
Sentry統合
Sentryの統合は一癖あり、可能であれば最近betaになった公式のSentry Integrationを用いるのが良いかもしれません。
自前でやる場合、 @sentry/remix
を用いて設定します。投げられたError は最終的に root.tsx
のErrorBoundaryでキャッチされるので、そこで captureRemixErrorBoundaryError
を用いて通知しています…が、今のところうまくキャプチャされていません。上手いやり方知ってる人、待ってます。
また、throwせずに(エラー画面を表示せずに)キャプチャしたい場合は toucan-js
を用いてその場でエラー通知をしています。worker-sentry
というCloudflare公式のモジュールもありますが、これはtoucan-jsをラップしたものであり、ほぼメンテされていないため利用しない方が良いと判断しました(Sentry Integrationの方に注力してそう)。
CSPの対応
Remixでは全てのリクエストは entry.server.ts
に定義したhandleRequest
、非HTMLの場合 handleDataRequest
を通ります。そのため、リクエスト共通で設定したいheaderはここで弄ると良いです。
export default async function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, remixContext: EntryContext, ) { const body = await renderToReadableStream( <RemixServer context={remixContext} url={request.url} />, { signal: request.signal, onError(error: unknown) { console.error(error); responseStatusCode = 500; }, }, ); if (isbot(request.headers.get("user-agent"))) { await body.allReady; } responseHeaders.set("Content-Type", "text/html"); responseHeaders.set( "Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload", ); responseHeaders.set("X-Content-Type-Options", "nosniff"); responseHeaders.set("X-Frame-Options", "DENY"); responseHeaders.set( "Content-Security-Policy", // なんやかんや ); responseHeaders.set("Referrer-Policy", "strict-origin-when-cross-origin"); responseHeaders.set("X-Permitted-Cross-Domain-Policies", "none"); return new Response(body, { headers: responseHeaders, status: responseStatusCode, }); }
終わりに
新規プロダクトということで、メンバー全員が経験のないRemixとCloudflare(とGraphQL)を用いた開発にチャレンジしました。RemixとCloudflareの統合難易度が思った以上に低く、とても快適に開発できました。またWorkersの運用コストが非常に低く、月/数百円で動作しているのも嬉しい点です。 まだまだ手探りの状態で、本当にこれで良いのか?という疑問は常に絶えません。
やりたいこと、やり残していることが山積みの、やりがいのあるプロダクトだと思います。STORES では一緒にフロントエンドから価値を創出するメンバーを常に募集しています。今回の記事で少しでも興味を持っていただけたら幸いです。