🎊

Remix 2.7で安定版となったCloudflare PagesのVite対応の実現方法を読み解く

2024/02/25に公開

Remix 2.7がリリースされました。この2.7からは今までunstableであったVite対応が正式版として採用されたバージョンとして登場しました。
この2.7以前はunstableであったものNode.jsのランタイムでは動作するものが提供されていましたが、Cloudflare Pagesでの動作するものは提供されていませんでした。しかし、2.7のリリースと同時にCloudfalre Pagesで動作するものがリリースされたということで何が変わって、どう対応しているのかというのを調べた結果を纏めておきます。

https://github.com/remix-run/remix/blob/main/CHANGELOG.md#v270

2.7.0のリリース後にいくつかのバグが修正されているので、2.7.0ではなく移行のバグ修正版を使用することをオススメします。
また、Vite版への移行は公式にドキュメントがあるので合わせて読むと理解が深まると思います。

https://remix.run/docs/en/main/future/vite#migrating

初期package.jsonの違い

まずは、従来版とVite版との初期パッケージの違いを比べます。

インストールコマンドの違い

テンプレートが分かれているので、従来版とVite版は異なるので気をつけてください。

npx create-remix@latest --template remix-run/remix/templates/cloudflare-pages
npx create-remix@latest --template remix-run/remix/templates/vite-cloudflare

npm scriptおよびnpm packagesの違い

実際に上記のコマンドから生成される package.json は以下ように異なります。

インストールされるパッケージにはもちろんViteなどが追加されています。パッケージの違いはわかりますが、npm scriptも異なっているのがわかると思います。

  • deploy postinstall および typegen のnpm scriptの追加
  • dev の追加および start の変更

このように変わっており、それぞれ変わった内容を説明していきます。

typegen および postinstall とは

まずは増えたnpm scriptである typegenpostinstall を解説していきます。
postinstallnpm install コマンドなどの実行後に自動で起動されるコマンドを定義できるものです。なので、Vite版は npm install に相当するコマンドを実行することで postintall に定義されているスクリプトが実行されます。肝心の postinstall の中身はというと単に typegen のnpm scriptを読んでいるだけです。なので本体は typegen のnpm scriptです。

追記 postinstall は消えました。

https://github.com/remix-run/remix/pull/8808

typegen の正体

typegen が何をやっているかという wrangler types というコマンドを叩いています。これはWranglerのドキュメントを読めば何をやっているかわかります。

https://developers.cloudflare.com/workers/wrangler/commands/#types

要はCloudflareの開発環境やデプロイなどで使用する wrangler.toml ファイルからアプリケーションに必要な型ファイルである worker-configuration.d.ts を生成してくれます。

具体的には以下のような wrangler.toml ファイルがあった場合に

wrangler.toml
vars = { API_HOST = "example.com" }

kv_namespaces = [
  { id = "MY_KV", binding="MY_KV" }
]

d1_databases = [
  { binding = "DB", database_name = "TestDB", database_id = "test-db-id" }
]

このような worker-configuration.d.ts という型ファイルを生成してくれます。

worker-configuration.d.ts
// Generated by Wrangler on Wed Feb 21 2024 20:28:46 GMT+0900 (日本標準時)
interface Env {
	MY_KV: KVNamespace;
	API_HOST: "example.com";
	DB: D1Database;
}

この worker-configuration.d.ts 型ファイルは後にRemixで使用するので非常に大事です。なぜならばこれは従来版である remix.env.d.ts にあたるファイルになるからです。Vite版では remix.env.d.ts は使用しないのでmigrateのドキュメントに書いている通り必ず生成するようにしましょう。

型生成の注意点

自動で型ファイルを生成してくれるので便利は便利なんですが、ちょっとだけ注意点があります。
それは何かというと wrangler.toml の記載方法です。先程の例で vars の書き方は2通りあり、その片方を書いたのですがもう片方が以下の書き方です。

wrangler.toml
# vars = { API_HOST = "example.com" } を変更
[vars]
API_HOST = "example.com"

kv_namespaces = [
  { id = "MY_KV", binding="MY_KV" }
]

d1_databases = [
  { binding = "DB", database_name = "TestDB", database_id = "test-db-id" }
]

これも正しい記述なんですが、この状態で typegen のnpm scriptを動かすと以下のようような worker-configuration.d.ts が出来ます。

worker-configuration.d.ts
// Generated by Wrangler on Sun Feb 25 2024 16:54:13 GMT+0900 (日本標準時)
interface Env {
	API_HOST: "example.com";
	kv_namespaces: [{"id":"MY_KV","binding":"MY_KV"}];
	d1_databases: [{"binding":"DB","database_name":"TestDB","database_id":"test-db-id"}];
}

これはRemixが狙っている型ファイル形式ではなくなるので、記述には気をつけてください。

Cloudflare Pagesにおける wrangler.toml の罠

非常に大きな罠が潜んでいます。RemixのVite版の対応であるCloudflare PagesのREADMEには以下の記述があります。

> [!WARNING]  
> Cloudflare does _not_ use `wrangler.toml` to configure deployment bindings.
> You **MUST** [configure deployment bindings manually in the Cloudflare dashboard][bindings].

形生成するんだからデプロイの時も反映してくれるんじゃないの?というとなぜか wrangler pages deploy では wrangler.toml の内容が反映されないという仕様になっています。なのでまずはCloudflareのコンソールで設定してねって書いてます。

余談の余談ですが、そのうち反映してくれるように改修されるのを祈りましょう。

https://twitter.com/yusukebe/status/1760273331218166104

一応対応予定となったようです。

2つの開発環境立ち上げ方法

2.7のVite版では2つの方法が存在します。それが変更された devstart のnpm scriptです。従来版の dev のnpm scriptは以下ように記載されおります。

package.json
  "scripts": {
    ...
    "dev": "remix dev --manual -c \"npm run start\"",
    ...
    "start": "wrangler pages dev --compatibility-date=2023-06-21 ./public",
    ...
  },

なので dev を起動すればそのまま start まで起動されるということです。(従来版は remix dev でビルドもされます)ではVite版のどうなっているかというと

package.json
  "scripts": {
    ...
    "dev": "remix vite:dev",
    ...
    "start": "wrangler pages dev ./build/client",
    ...
  },

devstart は連携されていません。別々のコマンドとして動作します。 dev に関しては何やらViteで動作するようなコマンドが定義されています。 start に関しては従来と同様にビルドしたファイルで wrangler で起動するコマンドが定義されています。
なのでここで変わったことは start は従来版とほぼ同等の wrangler を使用した開発環境の起動で dev は新たに追加されたViteでの開発環境の起動コマンドということです。

remix vite:dev の実態

新たに追加された remix vite:dev とは一体何者なのかというのを追っていきます。cliのコマンドから追っていくと説明が長くなるので要点だけを書きます。
Vite版ということでViteの設定である vite.config.ts を見ると以下のようなCloudflareのための記述である cloudflareDevProxyVitePlugin というものに目が行きます。

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

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

こいつのコードの中に vite:dev いわゆる開発環境のためのVite Serverの実態にたどり着きます。

https://github.com/remix-run/remix/blob/df0a668d416014f19313419dc7701ddcbe4ee312/packages/remix-dev/vite/cloudflare-proxy-plugin.ts#L59-L85

この configureServer という中で getPlatformProxy という wranlger のAPIを呼び出しています。これが remix vite:dev の実態と言っても過言ではありません。

https://developers.cloudflare.com/workers/wrangler/api/#getplatformproxy

これは簡単にいうと wrangler がプロキシとなり、実際はVite Serverが動くという構成になるものです。ではなぜ wrangler がプロキシとなる必要があるかというと、この getPlatformProxy から返ってくる中にCloudflare独自のサービス(例えばD1,R2,KVなどなど)を Vite Server側で動作させることがAPIを返してくれるというのが大きな特徴です。開発環境でRemixのコードの中でD1へのアクセスプログラムを書いて、Vite Serverで動作するのはこの getPlatformProxy でプロキシしたCloudflareの環境APIをVite Server上のRemixが動かしているからということになります。

remix vite:dev の注意点

ここまで読んで以下の疑問点を持った人は鋭いです。

workerdの環境で動作確認したい

そうです。あくまで wrangler がCloudflareのAPIを提供してVite ServerつまりはNodeサーバで動作していることと変わりません。なので wranglerd 環境ではないので何もしなくてもNodeのAPIに依存したコードが動いてしまいます。
そのためにも最終的に動作を確認するためには start のnpm scriptが残っていると理由の1つだと思っています。 start 側は wrangler で開発環境を上げるのでworkerd環境での動作確認になります。

「じゃあ start だけでいいのでは?」

確かにそうとも思います。しかし start は別問題もあり常用するにはちょっと気をつける必要があります。

https://github.com/remix-run/remix/issues/7466

このようにVite版がリリースされたばかりなのでちょっと課題もありますが、これを改善する動きは既に始まっているので気長に待つかOSSなので協力するのがよいと思います。

Vite版でのデプロイまでのビルドプロセス

開発環境の起動方法も確かにViteによって変わりましたが、本丸はビルドです。Vite版というのだからもちろんビルドはViteで行うことになります。ちなみに従来版はesbuildでビルドを行います。

remix vite:build の実態

従来はRemixがesbuildでビルドするための様々なオプションを生成してビルドしていましたが、Vite版はどのようにやっているかということを書いていきます。これも remix vite:dev 同様に vite.config.ts から読み解けば見えてきます。

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

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

ここに vitePlugin というものをViteのプラグインに設定しています。これがビルドするための設定を書いた本体になっています。

https://github.com/remix-run/remix/blob/df0a668d416014f19313419dc7701ddcbe4ee312/packages/remix-dev/vite/plugin.ts#L530

こいつはRemixをビルドするためのViteのプラグイン設定を複数返します。その中で以下の4つがビルドに関する処理を知りたいなら読むべきものです。

  1. name: "remix": というViteのビルドのconfig設定を返すもの
  2. name: "remix-virtual-modules": というRemixのサーバサイドのコードだけをビルドするためにサーバサイドコードを再生成するもの
  3. name: "remix-dot-server": Remixの .server ファイルがクライアントのコードに混入するのをバリデーションするもの
  4. name: "remix-dot-client": Remixの .client ファイルがサーバのコードに混入するのをバリデーションするもの

なのでViteでビルドするためのオプションを仮にカスタマイズしたいならば配列の最初に返ってくる name: "remix" の返り値を変更するということも可能です。
今までesbuildのビルド設定が変更できずにモヤモヤしていた方はVite版に変えることでやろうと思えば好きにビルドを変更できるので試してみてください。

これでViteでビルドされて build/client/build/server/ にクライアント用コードとサーバ用コードが生成されるという仕組みです。

ちなみにサーバ用のビルドをするために app/routes にあるファイルを1つのファイルとしてビルドするために結構泥臭いことをしています。

https://github.com/remix-run/remix/blob/df0a668d416014f19313419dc7701ddcbe4ee312/packages/remix-dev/vite/plugin.ts#L704-L750

このようにVite版ではCloudflareに限らずビルド方法がこのように変わっています。

Cloudflare Pagesの利用方法

Viteでビルドした結果があるだけではCloudflare Pagesでは動作しません。サーバサイドの処理を行う場合はCloudflare PagesとCloudflare Workersを連携させるCloudflare Pages Functionsという関数が必要になります。

従来版はRemixでビルドすることでこのCloudflare Pages Functionsとなる functions/[[path]].js が生成されていました。Vite版ではまだここまで来ていません。

しかし、Vite版では初期から functions/[[path]].ts というファイルが提供されています。

functions/[[path]].ts
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 });

従来版ではビルドした結果のJavaScriptを配置する場所として使っていましたが、Vite版では初期からTypeScriptが提供されており、その中で build/server/index.js が読み込まれているのです。 wrangler (pages)functions/ というディレクトリがあればそれをCloudflare Pages Functionsとして認識するのですが、JavaScriptだけでなくTypeScriptにも対応されています。

https://developers.cloudflare.com/pages/functions/typescript/

なのでこうすることで wrangler pages devwrangler pages deploy 時にRemixのサーバサイドの処理をビルドしたJavaScriptを含んでCloudflare Pages Functionsとして生成するという方式に変更しているのです。

つまるところ、 start(wranlger pages dev) で動作するには remix vite:build を事前に行っておく必要があるということに注意してください。

まとめ

このようにRemixのVite版に対応したCloudflare PagesはRemixだけでなく、WranglerないしViteの3つの協力があって実現できていることがよくわかります。
多少の課題はありますがViteに移行することで更にRemixでの開発体験は向上していますので、ぜひ体験してみてください。

ここに書いてないVite版への移行のことなどはこっちに書いてますので興味があればこちらも参照ください。
https://zenn.dev/chimame/scraps/0fd8a16f9f3b9c

本題とは逸れるおまけ

従来版ではesbuildの設定を無理やり変えて node-globals-polyfillnode-module-polyfill を挿入することでCloudflare Pages FunctionsからTCPでDBに接続させることが出来ました。(非公式っちゃ非公式)

https://zenn.dev/chimame/scraps/de4dca06398cd2

じゃあVite版ではどうやるの?っていうと案としては2つ

  1. functions/[[path]].ts をアドバンスモードで動作させるJavaScriptにビルドしてデプロイする
  2. functions/[[path]].ts から生成されるJavaScriptになんとか node-globals-polyfillnode-module-polyfill を挿入する

アドバンスモードでやる

Cloudflare Pagers Functionsだけ別途ビルドすることが出来ます。その際に node_compat いわゆるpolyfillを公式的に組み込むことができます。
ちなみにこのアドバンスモードで出来ると知ったのは以下のPRでコメントされている内容を読んで知った。

https://github.com/cloudflare/workers-sdk/pull/2541

wrangler pages functions build functions --node-compat --outdir <output path>

アドバンスモードと以下のドキュメントにありますが、URLのルーティング処理じゃ難しい場合にすべてのリクエストインターセプトしたいことができるモードだと思って大丈夫です。

https://developers.cloudflare.com/pages/functions/advanced-mode/

なので [[path]].ts はすべてのルーティングをワイルドカードで受けているのと似たような動きになります。ですが、このアドバンスモードでは少し困ったことが起きます。それは静的ファイルの配信パスすらも受けてしまうことです。 [[path]].ts 配置の場合はリクエストがあった場合にまずは静的ファイルの存在を確認して、静的ファイルがなかった場合に [[path]].ts にフォールバックします。なので静的ファイルのリクエストをCloudflare Pages FunctionsつまりCloudflare Workersの実行回数に含まれません。ですが、このアドバンスモードの場合はすべてのリクエストCloudflare Workersで受けてしまうので実行回数にカウントされます。ですのでこの方法を取る場合は静的ファイルの配信は別のサブドメインから配信する方法を取るなどの承知をして静的ファイルの配信でわざわざCloudflare Workersが動作しないような対応をすることが必要だと思います。

functions/[[path]].tsnode-globals-polyfillnode-module-polyfill を入れる

※まず前提に私の内容を読む前に公式のドキュメントのこれを読むと内容が理解出来ます。

https://remix.run/docs/en/main/future/vite#augmenting-load-context

これは私の環境化では以下のコードになってるので若干厄介でした。

  • 接続のインスタンスはPages Functions(Workers)にリクエストが来た時に生成する

要はこんな感じに context を拡張しています。

loader-context.ts
import { type AppLoadContext } from '@remix-run/cloudflare'
import { type PlatformProxy } from 'wrangler'
import { connection } from './app/lib/db'

type Cloudflare = Omit<PlatformProxy<Env>, 'dispose'>

declare module '@remix-run/cloudflare' {
  interface AppLoadContext {
    cloudflare: Cloudflare
    db: Awaited<ReturnType<typeof connection>>
  }
}

type GetLoadContext = (args: {
  request: Request
  context: {
    cloudflare: Cloudflare
  } // load context _before_ augmentation
}) => Promise<AppLoadContext>

// Shared implementation compatible with Vite, Wrangler, and Cloudflare Pages
export const getLoadContext: GetLoadContext = async ({ context }) => {
  return {
    ...context,
    db: await connection(
      context.cloudflare.env.DATABASE_URL,
    ),
  }
}

context を拡張して初期リクエスト時に設定するので loader-context.ts のビルドタイミングは functions/[[path]].ts をビルドするタイミングしかありません。

functions/[[path]].ts
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";
import { getLoadContext } from "../load-context";

export const onRequest = createPagesFunctionHandler({ build, getLoadContext });

ところが wrangler pages deploy のビルドをカスタマイズするにはソースコードにパッチを当てる必要があるのでそれはやりたくない。なのでビルドステップが1ステップ増えるが以下のように対応した。

  1. Remixが初期で提供している functions/[[path]].tsfunctionsBuild/[[path]].ts に配置し直す。(これは初回だけ)
  2. Remixが remix vite:build して build/server/index.js を生成する
  3. 自前で node-globals-polyfill などを差し込むのesbuildを用意して functionsBuild/[[path]].ts から functions/[[path]].js を出力する
  4. wrangler pages deploy 時に functions/ というディレクトリがあるので一緒にデプロイされる

てな感じで、 remix vite:build のあとで node ./build.js 的なesbuildを行うビルドを実施することで node-globals-polyfill などが差し込まれるので動くようになる。サンプルのビルドファイルは↓

build.js
import { build } from 'esbuild'
import NodeGlobalsPolyfills from '@esbuild-plugins/node-globals-polyfill'
import NodeModulesPolyfills from '@esbuild-plugins/node-modules-polyfill'

try {
  await build({
    bundle: true,
    format: 'esm',
    conditions: ['workerd', 'worker'], // ← これがかなり重要
    entryPoints: ['functionsBuild/[[path]].ts'],
    outdir: './functions',
    plugins: [
      NodeGlobalsPolyfills['default']({ buffer: true }),
      NodeModulesPolyfills['default'](),
    ],
  })
  console.log('Build complete')
} catch (e) {
  console.error(e)
  process.exit(1)
}

特に conditions の設定が重要で、これがないと pg が使用する pg-cloudflare がうまくバンドル出来ないので必ず指定すること。

もちろんそもそも build/server/index.js にViteのビルド設定をいじって node-globals-polyfill などが入れるならそれはそれでハッピーだと思う。こんな感じにしたらスマートじゃない?っていうのがあれば教えてください。

Discussion