👋

React Router 7プロジェクトを後からCloudflare Workers向けに変更する方法

2025/01/20に公開

タイトルのとおりです。
あんまり情報がなくてハマったので、やり方をメモがてら書いておきます。

基本的なやり方

https://github.com/remix-run/remix/tree/main/templates/cloudflare-workers
https://reactrouter.com/upgrading/remix

上記のRemixのCloudflare Workers向けのテンプレートから必要なファイルを持ってきて、React Router 7のRemixからのマイグレーション手順を見ながら各ファイルを修正する形です。

修正箇所

パッケージの追加・削除

node.js向けのパッケージをcloudflare向けのパッケージに入れ替えます。

# 追加
npm install -S @react-router/cloudflare
npm install -D @cloudflare/workers-types wrangler
# 削除
npm uninstall -S @react-router/node @react-router/serve

package.jsonの修正

wrangler を使ったコマンドに書き換えます。

package.json
 {
   "scripts": {
     "build": "react-router build",
     "dev": "react-router dev",
     "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
-    "start": "react-router-serve ./build/server/index.js",
+    "start": "wrangler dev",
+    "deploy": "wrangler deploy",
+    "typegen": "wrangler types",
     "typecheck": "react-router typegen && tsc"
   }
 }

tsconfig.jsonの修正

cloudflare向けのパッケージの型情報を読み込ませます。

tsconfig.json
 {
   "include": {
+    "worker-configuration.d.ts",
     "**/*.ts",
     "**/*.tsx",
     "**/.server/**/*.ts",
     "**/.server/**/*.tsx",
     "**/.client/**/*.ts",
     "**/.client/**/*.tsx",
     ".react-router/types/**/*"
   },
   "compilerOptions": {
     "lib": ["DOM", "DOM.Iterable", "ES2022"],
-    "types": ["@react-router/node", "vite/client"],
+    "types": [
+      "@react-router/cloudflare",
+      "@cloudflare/workers-types",
+      "vite/client"
+    ],
     "isolatedModules": true,
     "esModuleInterop": true,
     ...
   },
   ...
 }

load-context.tsの追加

Route.LoaderArgs とかに含まれる context: AppLoadContext に型をつけるためのファイルっぽい?

load-context.ts
import { type PlatformProxy } from "wrangler";

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

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) {
  return context;
}

vite.config.tsの修正

viteのプラグインを追加します。

vite.config.ts
 import { reactRouter } from "@react-router/dev/vite";
+import { cloudflareDevProxy } from "@react-router/dev/vite/cloudflare";
 import { defineConfig } from "vite";
 import tsconfigPaths from "vite-tsconfig-paths";
+import { getLoadContext } from "./load-context";

 export default defineConfig({
-  plugins: [reactRouter(), tsconfigPaths()],
+  plugins: [
+    cloudflareDevProxy({ getLoadContext }),
+    reactRouter(),
+    tsconfigPaths(),
+  ],
 });

server.tsの追加

Cloudflare workersのエントリーポイントになるファイルです。

server.ts
import { createRequestHandler, type ServerBuild } from "react-router";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore This file won’t exist if it hasn’t yet been built
import * as build from "./build/server"; // eslint-disable-line import/no-unresolved
import { getLoadContext } from "./load-context";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleRemixRequest = createRequestHandler(build as any as ServerBuild);

export default {
  async fetch(request, env, ctx) {
    try {
      const loadContext = getLoadContext({
        request,
        context: {
          cloudflare: {
            // This object matches the return value from Wrangler's
            // `getPlatformProxy` used during development via Remix's
            // `cloudflareDevProxyVitePlugin`:
            // https://developers.cloudflare.com/workers/wrangler/api/#getplatformproxy
            cf: request.cf,
            ctx: {
              waitUntil: ctx.waitUntil.bind(ctx),
              passThroughOnException: ctx.passThroughOnException.bind(ctx),
            },
            caches,
            env,
          },
        },
      });
      return await handleRemixRequest(request, loadContext);
    } catch (error) {
      console.log(error);
      return new Response("An unexpected error occurred", { status: 500 });
    }
  },
} satisfies ExportedHandler<Env>;

entry.server.tsの修正

以下の内容に丸っと置き換えます。
既存のファイルに修正を加えていた場合は、デグレないようにうまいことやる必要があります。

app/entry.server.tsx
/**
 * By default, Remix will handle generating the HTTP Response for you.
 * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
 * For more information, see https://remix.run/file-conventions/entry.server
 */

import { isbot } from "isbot";
import { renderToReadableStream } from "react-dom/server";
import type { AppLoadContext, EntryContext } from "react-router";
import { ServerRouter } from "react-router";

const ABORT_DELAY = 5000;

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext,
  // This is ignored so we can keep it in the template for visibility.  Feel
  // free to delete this parameter in your app if you're not using it!
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  loadContext: AppLoadContext
) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), ABORT_DELAY);

  const body = await renderToReadableStream(
    <ServerRouter context={remixContext} url={request.url} />,
    {
      signal: controller.signal,
      onError(error: unknown) {
        if (!controller.signal.aborted) {
          // Log streaming rendering errors from inside the shell
          console.error(error);
        }
        responseStatusCode = 500;
      },
    }
  );

  body.allReady.then(() => clearTimeout(timeoutId));

  if (isbot(request.headers.get("user-agent") || "")) {
    await body.allReady;
  }

  responseHeaders.set("Content-Type", "text/html");
  return new Response(body, {
    headers: responseHeaders,
    status: responseStatusCode,
  });
}

wrangler.tomlの追加

wrangler.toml
#:schema node_modules/wrangler/config-schema.json
name = "app-name"

main = "./server.ts"
workers_dev = true
# https://developers.cloudflare.com/workers/platform/compatibility-dates
compatibility_date = "2024-09-26"

[assets]
# https://developers.cloudflare.com/workers/static-assets/binding/
directory = "./build/client"

[build]
command = "npm run build"

worker-configuration.d.tsの追加

以下のコマンドで自動生成します。イマイチ役割がよくわかってない。。

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

// eslint-disable-next-line @typescript-eslint/no-empty-interface,@typescript-eslint/no-empty-object-type
interface Env {}

多分eslintのエラーが出ちゃうので、無視するように設定を追加しておきます。

.eslintignore
worker-configuration.d.ts

.envを.dev.varsに変更

環境変数の読み込み元が変わるため、リネームします。

mv .env .dev.vars

.dev.vars.gitignore に追加します。

.gitignore
 ...
 .env
+.dev.vars
 ...

環境変数の参照方法変更

process.env で環境変数を参照していた箇所を、 context.cloudflare.env に変更する必要があります。
process.env はグローバルにどこからでも参照できる一方、context.cloudflare.envloader または action の引数としてわたってくるところからしか参照できないため、場合によってはいろいろ作りを変える必要が出てきます。

app/routes/some-route.tsx
-export async function loader() {
+export async function loader({ context }: Route.LoaderArgs) {
   return {
-    someVar: process.env.SOME_VAR,
+    someVar: context.cloudflare.env.SOME_VAR,
   };
 }

動作確認

以下のコマンドで起動してみて、動作すればOKです。

npm run build
npm run start

Discussion