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"
デフォルトテンプレートは公式ドキュメントへのリンクが置いてあるだけの簡素なものになっている。
依存の修正
とにもかくにも 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/server
の renderToPipeableStream
は Node.js で SSR された React ツリーをストリーミングでレスポンスする際に使用する関数。
default export している関数が主役で、リクエスト元がクローラーなどの bot かどうか判定して bot 用の処理とブラウザ用の処理を切り分けている。どちらも renderToPipeableStream
で RemixServer
をサーバーサイドレンダリングしている。
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 で配信したいディレクトリ)は 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 向けテンプレートに変えただけなのですが。。。
終わり!