stin's Blog

Remix を Cloudflare Pages にデプロイするまでやってみる


Remix に興味を持ったのでドキュメントを読み漁ってだいたい読み切ったので、練習がてら Node.js 向け Remix テンプレートを修正して Cloudflare Pages にデプロイする。

create-remix という npx コマンドでさっとテンプレートを作る。プロジェクト名は remix-on-cloudflare-pages とした。パッケージ管理ツールは npm を普段遣いしているので、CLI に提案されるままに npm install を実行した。

npx create-remix@latest

   dir   Where should we create your new project?
         remix-on-cloudflare-pages

  Using basic template See https://remix.run/guides/templates for more
  Template copied

   git   Initialize a new git repository?
         Yes

  deps   Install dependencies with npm?
         Yes

  Dependencies installed

  Git initialized

  done   That's it!

         Enter your project directory using cd ./remix-on-cloudflare-pages
         Check out README.md for development and deploy instructions.

         Join the community at https://rmx.as/discord

できたディレクトリ構造(node_modules は省略)

.
├── app
│   ├── routes
│   │   └── _index.tsx
│   ├── entry.client.tsx
│   ├── entry.server.tsx
│   └── root.tsx
├── public
│   └── favicon.ico
├── README.md
├── package-lock.json
├── package.json
├── tsconfig.json
└── vite.config.ts

package.json を見てみると、@remix-run/node というパッケージの存在を確認できる。デフォルトのテンプレートは Node.js ランタイムを想定している。これを Cloudflare Pages ランタイムで実行できるように修正してデプロイするまでやる。

他で C3(create-cloudflare CLI) で作った Remix プロジェクトを見ながら差分を埋めていく感じで進める。

起動してみる

まずはそのまま Node.js で起動してみる。

npm run dev

vite の dev サーバーなので port=5173 で起動するが、僕はいつまで経ってもこの port 番号が記憶できなくて嫌いなので 3000 を指定する。

"dev": "remix vite:dev --port 3000"

デフォルトテンプレートは公式ドキュメントへのリンクが置いてあるだけの簡素なものになっている。

create-remix のデフォルトテンプレートから作成した直後に起動したサイトのスクショ。Welcome to Remix というタイトルと、公式ドキュメントへのリンクが3つ表示されている。

依存の修正

とにもかくにも wrangler を入れる。wrangler は Cloudflare Pages or Workers を開発する際にほぼ必ず使う CLI。

npm install wrangler -D

Remix のサーバーを Pages Functions で動かすことになるので、そのランタイム型定義も入れる(Pages Functions の実態は Workers)。

npm install @cloudflare/workers-types -D

Node.js ランタイムじゃなくなるので @remix-run/node は不要。

npm uninstall @remix-run/node

Remix はそれ自体にサーバー機能を持っておらず、Express などの他の Web フレームワークに乗っかるように設計されている。

しかし Next.js のようにスタンドアローン(?)の React フレームワークとして使いたい場合もある。そんなときに @remix-run/serve を使うらしい。今回は Cloudflare Functions で serve するので、これも不要。

npm uninstall @remix-run/serve

Cloudflare で Remix を起動するアダプターを追加する。Cloudflare Pages 用と Cloudflare Workers 用があるが、 Cloudflare Pages でのデプロイを目標にしているので @remix-run/cloudflare-pages をインストールする。Cloudflare 共通の型定義ファイルもインストール。

npm i @remix-run/cloudflare @remix-run/cloudflare-pages

コードの修正

tsconfig.json

もともと Node.js ランタイムの型定義が types に指定されている。

"types": ["@remix-run/node", "vite/client"],

tsconfig.json の types で Cloudflare Workers ランタイムの型定義と、remix の Cloudflare アダプター時の型定義を読み込ませる。順番に意味があるかは不明。

  "types": [
      "@remix-run/cloudflare",
      "vite/client",
      "@cloudflare/workers-types/2023-07-01"
    ],

vite.config.ts

Remix の開発サーバーを起動するとき、Node.js ランタイムではなく Cloudflare ランタイムをシミュレートするプラグインを追加する。

import { vitePlugin as remix, cloudflareDevProxyVitePlugin } from "@remix-run/dev";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [cloudflareDevProxyVitePlugin(), remix(), tsconfigPaths()],
});

functions/[[path]].ts

Pages Functions でリクエストを受けてそれを Remix に流すような形になるので、functions/[[path]].ts にアダプターを設置する。Pages Functions では onRequest 関数を named-export するルールとなっている。[[path]].ts は catch-all のファイル名ルールで、すべてのリクエストが Remix に流されることになる(HTML はもちろん JS などの静的アセットも含む)。

import { createPagesFunctionHandler } from "@remix-run/cloudflare-pages";

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - the server build file is generated by `remix vite:build`
// eslint-disable-next-line import/no-unresolved
import * as build from "../build/server";

export const onRequest = createPagesFunctionHandler({ build });

Remix Build によって build/server/index.js ファイルが生成されるため、それを Pages Functions につなぐイメージ。ビルド前には存在しないファイルを import するので型エラーを潰す必要がある。ビルド後は存在するファイルになって型エラーが出なくなるため、@ts-expect-error ではダメ。eslint を黙らせてでも @ts-ignore を使用する。

app/entry.server.tsx

サーバーサイドのエントリーポイントらしい。HTTPリクエストを受け付けたときに必ず通過する処理で、react-dom/server のようなローレベルなAPIを触らないといけない。ファイル自体なくてもよしなに処理してくれるので削除しても良いが、せっかくなのでこのファイルも Cloudflare ランタイム向けに書き換える。

まず元の Node.js 向けコードを転載。

/**
 * 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 { PassThrough } from "node:stream";

import type { AppLoadContext, EntryContext } from "@remix-run/node";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { isbot } from "isbot";
import { renderToPipeableStream } from "react-dom/server";

const ABORT_DELAY = 5_000;

export default 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,
) {
  return isbot(request.headers.get("user-agent") || "")
    ? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext)
    : handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext);
}

function handleBotRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext,
) {
  return new Promise((resolve, reject) => {
    let shellRendered = false;
    const { pipe, abort } = renderToPipeableStream(
      <RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
      {
        onAllReady() {
          shellRendered = true;
          const body = new PassThrough();
          const stream = createReadableStreamFromReadable(body);

          responseHeaders.set("Content-Type", "text/html");

          resolve(
            new Response(stream, {
              headers: responseHeaders,
              status: responseStatusCode,
            }),
          );

          pipe(body);
        },
        onShellError(error: unknown) {
          reject(error);
        },
        onError(error: unknown) {
          responseStatusCode = 500;
          // Log streaming rendering errors from inside the shell.  Don't log
          // errors encountered during initial shell rendering since they'll
          // reject and get logged in handleDocumentRequest.
          if (shellRendered) {
            console.error(error);
          }
        },
      },
    );

    setTimeout(abort, ABORT_DELAY);
  });
}

function handleBrowserRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext,
) {
  return new Promise((resolve, reject) => {
    let shellRendered = false;
    const { pipe, abort } = renderToPipeableStream(
      <RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
      {
        onShellReady() {
          shellRendered = true;
          const body = new PassThrough();
          const stream = createReadableStreamFromReadable(body);

          responseHeaders.set("Content-Type", "text/html");

          resolve(
            new Response(stream, {
              headers: responseHeaders,
              status: responseStatusCode,
            }),
          );

          pipe(body);
        },
        onShellError(error: unknown) {
          reject(error);
        },
        onError(error: unknown) {
          responseStatusCode = 500;
          // Log streaming rendering errors from inside the shell.  Don't log
          // errors encountered during initial shell rendering since they'll
          // reject and get logged in handleDocumentRequest.
          if (shellRendered) {
            console.error(error);
          }
        },
      },
    );

    setTimeout(abort, ABORT_DELAY);
  });
}

react-dom/serverrenderToPipeableStream は Node.js で SSR された React ツリーをストリーミングでレスポンスする際に使用する関数。

default export している関数が主役で、リクエスト元がクローラーなどの bot かどうか判定して bot 用の処理とブラウザ用の処理を切り分けている。どちらも renderToPipeableStreamRemixServer をサーバーサイドレンダリングしている。

bot 用の処理では onAllReady コールバックの中で resolve しているので、ストリーミングではなく完全にツリー構築を待ってからレスポンスしていると読める(雰囲気で読んでいる)。ツリー構築を待つなら renderToString とか renderToStaticMarkup とかじゃダメなのかな?と疑問に感じたが不明。

ブラウザ用処理では「シェル」の準備ができたらすぐにレスポンスをストリーミングで流し始めると読める。シェルってなんやねんと思って調べてみると、React ツリーのうち最初の Suspence より外側ある部分ツリーのことを指すらしい。

これをまるっと Cloudflare 用に書き換えたものが次。

/**
 * 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 type { AppLoadContext, EntryContext } from "@remix-run/cloudflare";
import { RemixServer } from "@remix-run/react";
import { isbot } from "isbot";
import { renderToReadableStream } from "react-dom/server";

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 body = await renderToReadableStream(
    <RemixServer context={remixContext} url={request.url} />,
    {
      signal: request.signal,
      onError(error: unknown) {
        // Log streaming rendering errors from inside the shell
        console.error(error);
        responseStatusCode = 500;
      },
    },
  );

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

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

react-dom/server から import している関数が renderToPipeableStream から renderToReadableStream に変わっている。こちらは Web 標準の Web Stream を生成する関数。Cloudflare Workers は Web 標準 API を提供しているのでこちらが使えるということのようだ。

Node.js 版と比較してとても短く読みやすくなっている。renderToReadableStream で生成する Stream インスタンスには allReady という Promise が生えている。対 bot の場合はそれが解決するまで待機してからレスポンスし、対ブラウザの場合は待たずにシェルだけレンダリングできたらレスポンスする。

package.json

ローカルでのプロダクションサーバーの起動に remix-serve が指定されているので、wrangler で起動させるように変更する。

"start": "wrangler pages dev build/client",

build/client ディレクトリを指定しているのは、Cloudflare Pages はあくまで静的サイト配信だから。Remix Build では HTML こそ生成されないが、JS や CSS は build/client に含むのでそれらをルートディレクトリとして指定するのだろう。結局すべてのリクエストを functions/[[path]].ts で捌くので、静的アセットであっても一旦 Remix が受け付けることになる。

Cloudflare Pages 版を起動してみる

ビルドして build/client を生成してからサーバースタートする。

npm run build && npm run start

http://localhost:8787 でサーバーが起動したら成功!

無事起動しました。

Cloudflare Pages でインターネットに公開する

GitHub にソースコードをプッシュして、ダッシュボードをポチポチするだけ。

Cloudflare Pages のビルドセットアップページのスクショ。フレームワークプリセットを「Remix」、ビルドコマンドを「npm run build」、ビルド出力ディレクトリを「build/client」に指定している。

ビルドの出力ディレクトリ(Cloudflare Pages で配信したいディレクトリ)は build/client なので、デフォルトではなく明示的に指定する。

CDN が有効になるようにカスタムドメインも繋いで公開した。

そのうちサイトを消す可能性はあるのでご了承ください。

キャッシュのステータスチェック

特に Cache-Control は変更していないが確認してみた。

HTML は Cloudflare デフォルトでキャッシュされないため、CF-Cache-Status: DYNAMIC となっていた。これはレスポンスヘッダーで如何様にもできると考えている。Next.js から浮気して Remix を使ってみたいと思ったのもこれで、Cloudflare CDN で明示的に Cache 制御したサイトを作りたかったため。

JS ファイルは Cloudflare デフォルトでキャッシュされるとなっている。実際キャッシュされていたのだが、max-age=14400 を過ぎてからずっと Cf-Cache-Status: EXPIRED を返すようになってしまった。

EXPIRED はオリジンサーバーから取得しているとのことなので、これでは CDN をかましている意味がないが、HIT に戻す方法もわからない…。誰かご存じの方がいれば教えて下さい。

終わりに

今回試したリポジトリはこちら。

と言っても Node.js 向けテンプレートを Cloudflare Pages 向けテンプレートに変えただけなのですが。。。

終わり!