Open11

Remix 2.7で対応されたCloudflare PagesのVite版についての違いを調査

chimamechimame

従来版とVite版のパッケージの違い

公式からVite版のテンプレートも出ているので、従来版との違いを見ていく

Vite版

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

まずは初期状態でインストールされるパッケージの違いを見てみるとこんな感じ(右がVite版)

基本的には増えてる

  • deploy postinstall および typegen のnpm scriptの追加
  • miniflare node-fetch vite および vite-tsconfig-paths のパッケージの追加

結構違う。特に気になるのは追加されたnpm script系と miniflarenode-fetch のパッケージ追加。

chimamechimame

typegen および postinstall スクリプト

他も気になるが一旦この typegen のジェネレートは何をしてるかというのを見ていく(ちなみに増えた postinstall はnpm install後に typegen を呼び出してるだけのもの)。中身自体は wrangler types というコマンドを叩いているだけ。じゃあこいつの正体はというとCloudflareのドキュメントに書いてある。

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

どうやらこいつはCloudflare Workersなどで使用する設定ファイルに wrangler.toml というファイルがあるが、その中に環境変数だったり接続するCloudflare D1やKVといった情報を保持するが、wrangler.toml に書いた設定を worker-configuration.d.ts というファイルに型出力するというものらしい。

なのでこんな風に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
// Generated by Wrangler on Wed Feb 21 2024 20:28:46 GMT+0900 (日本標準時)
interface Env {
	MY_KV: KVNamespace;
	API_HOST: "example.com";
	DB: D1Database;
}

それで Env という型がglobalに定義されて使うことができる。実際に使っているファイルは

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

type Cloudflare = Omit<PlatformProxy<Env>, "dispose">;

declare module "@remix-run/cloudflare" {
  interface AppLoadContext {
    cloudflare: Cloudflare;
  }
}

要は今まで、remix.env.d.ts 使って AppLoadContext の型を拡張していたのがCloudflareに依存するもはwrangler.toml に書けば、型を自動生成して取り込んでくれるということらしい。

ただ、こいつCIとかでnpm packageをインストールする時に postinstall が動くと再作成しちゃうから正直いらない(´・ω・`)

ただ、このAppLoadContext をそのまま使うのではなく、何か初期化して追加するよな拡張を行う場合は load-context.ts に処理を追加することで可能になるそう。

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

ドキュメントを見ると

  1. load-context.ts の追記
  2. vite.config.ts の追記
  3. functions/[[path]].ts の追加

が必要になるのだが、ここで functions/[[path]].ts というのが出てくる。これはCloudflare PagesがCloudflare Workersを動作させるもの。

https://developers.cloudflare.com/pages/functions/get-started/

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

TypeScriptがサポートされており、これが wangler deploy 時にビルドされてCloudflare PagesのCloudflare Workersとして機能する。機能するために中ではこんな感じでリクエストを捌くためのRemix用のHandlerが定義されている。

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 });
chimamechimame

次に書いてるけど、従来は _routes.json とかあったから本当はそれを含めててあった方がいいのでは?と思ってる。

chimamechimame

deploy スクリプト

従来版のCloudflare PageのRemixは deploy スクリプトは提供されていませんでした。が、ビルドがそもそも public/build に出力されるし、 public ディレクトリには _routes.json など設定ファイルがあることから基本的には

wrangler pages deploy public

でデプロイするのは明白でした(最初から用意しておいてよとは思う)
今回はそのデプロイするための deploy スクリプトが用意されており、以下のようになっています。

{
  "scripts": {
    ...
    "deploy": "wrangler pages deploy ./build/client",
    ...
  },
}

要はViteでビルドするので、出力先がこっちになったよってことなんだと思います。 ただし従来版にあった _routes.json_headers などは出力されていません。この辺がほしかったら自分で作成しろってことでしょうか?ちなみにほしいなら従来通りに public/ に配置しておけばそのままビルドディレクトリに出力されます。

https://developers.cloudflare.com/pages/functions/routing/#create-a-_routesjson-file

https://developers.cloudflare.com/pages/configuration/headers/#headers

chimamechimame

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

Viteを対応したCloudflare PagesでのRemixの開発では2つの開発環境を立ち上げる方法が README.md にかかれている。それが以下のnpmスクリプト

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

今までは1つしか提供されていなかったが、従来のRemixはこの新しい2つの方法である start のスクリプト方式で実行されていた。Cloudflare Pages向けにビルドされたファイルを wrangler でコマンドで動作させるという方法のみだった。しかし、今回は dev スクリプトにあるように remix vie:dev というViteでの動作をさせる方法を新たに提供している。

ではViteで動作させるにはどうやっているかを調べるにはViteの設定ファイルである 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";
import { getLoadContext } from "./load-context";

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

どう見ても cloudflareDevProxyVitePlugin が何かやってるはず。なのでコードを追っていく。

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

正体は getPlatformProxy を呼んで wrangler をProxyモードで立ち上げて実態はVite serverが処理をするということになっている。

https://developers.cloudflare.com/workers/wrangler/commands/#dev-1

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

正体がわかったのはわかったのだが、なぜ2つ用意するかというのはあるが、考えられる原因は2つ

  1. ファイルを変更を検知してからビルド→開発サーバの再読み込みというのが RemixとWranglerの2つがあるので咬み合わせが非常に悪い
  2. wranglergetPlatformProxy ではすべてのwranglerコマンドの引数に対応できないので、wrangler コマンドを使う

まず1つ目だが、これはIssueにも上がってる。

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

実際どういうことが起きるというと、まず何かファイルの変更をするとそのファイル変更を検知するのだが

  • Remixが検知してビルドを行う
  • Wranglerが検知してサーバの再読み込みを行う

この2つが同時に動いてしまうのです。ではどうなるかというとビルドが完了していないのに、サーバの再読み込みが走って、下手をすればビルドファイルが未完成の状態でサーバが動いてしまってサーバがダウンしてしまうということが従来起こっていました。そのためファイルI/Oに依存しない方法としてこの新しいViteを使用した remix vite:dev での動作が安定するということで1つ提供されていると思います。

次に2つ目ですが、例えProxyモードであっても wrangler コマンドのすべてをカバーできるわけではないです。例えば動的に環境変数を展開して実行したい場合は

wrangler pages dev --binding NODE_ENV=${NODE_ENV}

的なcliで実行するわけですが、このようなことがProxyモードではできません。なので、柔軟に wrangler コマンドを使って動的にごにょごにょしたい場合は従来の方法を使うということで2つ目の方法が残っているのかな?と予想しています。

chimamechimame

Vite serverを使う場合のアクセスログを出す方法

Viteを使う場合にアクセスログの出力がないので自分で出すしかない。シンプルにやるならこんな感じ

vite.config.ts
import {
  vitePlugin as remix,
  cloudflareDevProxyVitePlugin as remixCloudflareDevProxy,
} from '@remix-run/dev'
import { defineConfig, type ViteDevServer } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'
import { getLoadContext } from './load-context'

function LogPlugin() {
  return {
    name: 'log-plugin',
    configureServer(server: ViteDevServer) {
      server.middlewares.use((req, res, next) => {
        console.log(`request ${req.method}: `, req.url)
        next()
      })
    },
  }
}

export default defineConfig({
  server: {
    host: true,
    port: 3000,
  },
  plugins: [
    LogPlugin(),
    remixCloudflareDevProxy({
      getLoadContext,
    }),
    remix(),
    tsconfigPaths(),
  ],
})

作った LogPlugin を最初に差し込んで、出力だけしたら次に渡す処理をしてるだけの簡単なもの。

chimamechimame

Cloudflare Pages FunctionsからTCPでPostgreSQLにつなぐ

私の中でこれがかなり大事なのでメモ。
従来版はesbuildでビルドしていたものを無理やりカスタマイズして、 node-globals-polyfill などを設定していたが、Viteではどのようにやるか。

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

まず、開発環境では node-globals-polyfill などの設定は何もしないでもTCPの接続が出来た。これはそもそもVitaがサーバとして動作しているせい(remix vite:dev)。実際のCloudflare Pages Functions環境は wrangler pages start で動かして動作するか確認する必要がある。

もちろん、どノーマルな状態ではTCPの接続というか pg が色々とNode.js APIに依存している fs やら stream が無いとエラーが出まくる。
そもそもどうやって動いてるかと言うと

  1. remix vite:buildbuild/server/index.js が出力される
  2. 出力された build/server/index.js を取り込んで Cloudflare Pages Functions用の関数である functions/[[path]].ts が動作する。

なので、そもそもの環境である「 build/server/index.js にpolyfillを含む」か「 functions/[[path]].ts を再度JavaScriptにビルドする際にpolyfillを含む」のどちらかの対応が必要になると思う。
時間切れなので、また時間を取って調査して追記する。

chimamechimame

CommonJSライブラリの扱い

ViteでビルドするのでCommonJSのライブラリは軒並みビルド出来ない。ちなみに自分が引っかかったライブラリは以下の2つ

  • react-timer-hook
  • react-dropzone

https://github.com/amrlabib/react-timer-hook

https://github.com/react-dropzone/react-dropzone

中身がどうなってたっけ?て見にいけば react-timer-hook はwebpackでビルドしてる。react-dropzone に関してはほぼメンテしてないといっても過言ではない。
ただ、この2つに関してはそこまでコード量も多くないのでGitHubからコードもらって、自分のプロジェクト無いでTypeScript化して取り込んだ。
大きいライブラリでCommonJSでしか提供してないのがあると結構地獄を見ると思う。

chimamechimame

Cloudflare Pages FunctionsからTCPでPostgreSQLにつなぐ(その2)

とりあえず動作したので一旦自分の環境の前提条件から書いておく。

【前提条件】

  • DBクライアントは pgKysely でクエリビルドして使用している
  • 接続のインスタンスはPages Functions(Workers)にリクエストが来た時に生成する

上記のことからRemixの context に拡張して保持するようにしている。なので loader-context.ts はこんな感じ

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,
    ),
  }
}

で、以前にも書いたがPages Functionsの本体となるCloudflare WorkersでTCPを接続するには pg とNode Polyfillが必要になる。なのでNode Polyfillを設定してやる必要があるが、Cloudflare Pages Functionsにデプロイされるまでの過程は

  1. Remixが remix vite:build して build/server/index.js を生成する
  2. functions/[[path]].ts が上記の build/server/index.js を読み込むコードがRemixから初期で提供されている
  3. wrangler pages deploy 時に functions/ というディレクトリがある場合はそれを自動的にビルドしてデプロイする

という流れになる。なのでDBの接続インスタンスを作るのが functions/[[path]].ts をビルドするタイミングなのでそこに差し込む必要がある。ところが wrangler pages deploy のビルドをカスタマイズするにはソースコードにパッチを当てる必要があるのでそれはやりたくない。なのでビルドステップが1ステップ増えるが以下のように対応した。

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

てな感じで、remix vite:build のあとで node ./build.js 的なesbuildを行うビルドを実施することでNode 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',
    outExtension: { '.js': '.js' },
    plugins: [
      NodeGlobalsPolyfills['default']({ buffer: true }),
      NodeModulesPolyfills['default'](),
    ],
  })
  console.log('Build complete')
} catch (e) {
  console.error(e)
  process.exit(1)
}

特に conditions の設定が重要で、これがないと pg が使用する pg-cloudflare がうまくバンドル出来ないので必ず指定すること。他は特にこれと言って大したことをしてないので割愛。