Remix & Cloudflare Pages で KV を触る

作業ログです。

Remix のアプリを Cloudflare Pages にデプロイして KV にアクセスするまでをやってみました。

github.com

結果としては、id:leader22 さんの記事*1と同じところにハマりました。 こちらの記事も参考になるかと思います。

違いはTypeScript用に型定義を用意したくらいでしょうか。

create-remix

とりあえず、脳死create-remixしていきます。

> npx create-remix@latest`
> Just the basics
> Cloudflare Pages
> TypeScript
> Run npm install ? > n

yarn を使いたいのでnpm installは断っておきます。

さて、テンプレートが展開されて完成しました。よかったですね。

とはいえ、このままyarn devyarn buildを行うとNo matches found: "dev:*"のように怒られてしまいますので修正しておきます。

- "build": "run-s build:*",
+ "build": "run-s 'build:*'",
- "dev": "remix build && run-p dev:*",
+ "dev": "remix build && run-p 'dev:*'",

azu on Twitter: "No matches found: "dev:*"ってなるのなんでだろな。 npm-run-all -p dev:api dev:worker みたいな個別指定ならいけるな。 * がどこかで展開されてるのかな?"

KV access in Pages Functions

間違ったやり方

私が躓いた、Cloudflare Pages にとって間違っていた方法です。 「remix cloudflare kv」などで調べると、その多くが Cloudflare Workers での実装なのでそのまま写すと動いてくれません。

Remix | Data Loading

Remix のドキュメントでは KV の名前をそのまま呼べると書かれており、加えてそれ用の型定義を宣言しておくことをオススメされます。

例えば KV の名前がGOOD_KVNAMEだとすると、実装イメージは以下のような感じです。

// global.d.ts
declare const GOOD_KVNAME: KVNamespace

// app/routes/index.tsx
export const loader: LoaderFunction = async () => {
  const cached = await GOOD_KVNAME.get<Cache>('cache-key', 'json')
  ...
}

RemixをCloudflare Workersで動かす & KVでデータをキャッシュする

しかし、これらは Cloudflare Pages の環境では動きません。ではどうしたら良いのでしょうか?

正しいやり方

まずはローカルで実行するために、package.jsonにあるdevスクリプトに手を加えます。

https://developers.cloudflare.com/pages/platform/functions/#develop-and-preview-locally

ドキュメントに従って、静的アセットのディレクトリを指定した後ろにKV_NAMESPACEを渡します。

package.json
- "dev:wrangler": "cross-env NODE_ENV=development wrangler pages dev ./public",
+ "dev:wrangler": "cross-env NODE_ENV=development wrangler pages dev ./public -k GOOD_KVNAME",

ローカルでの準備が終わったので、yarn devで開発環境を立ち上げてみます。すると、以下のようなエラーが返ってきます。

You must use the 2nd `env` parameter passed to exported handlers/Durable Object constructors, or `context.env` with Pages Functions.

どうやら、Pages Functions から KVNamespace を指定するには context.env から取得する必要があるようです。

プロジェクトルートにある、テンプレートから生成されたserver.jsを見てみると以下のような実装になっています。 この実装から、ルーティングファイルにあるloaderに渡ってくるリクエストのcontextcontext.envの値が入っているのが分かります。

// server.js
const handleRequest = createPagesFunctionHandler({
  build,
  mode: process.env.NODE_ENV,
  getLoadContext: context => context.env,
})

つまり、ルーティングファイルのloaderに渡ってくる context から、以下のように呼び出すことが出来ます。

const loader: LoaderFunction = async ({context}) => {
  const kv = context.GOOD_KVNAME as KVNamespace
  ...
}

とはいえ、context: anyを無理やりKVNamespaceへキャストするのもムズムズするので型定義を用意します。

// global.d.ts
import type {AppData, DataFunctionArgs} from '@remix-run/cloudflare'

interface LoaderFunctionArgs extends Omit<DataFunctionArgs, 'context'> {
  context: {
    GOOD_NAMESPACE: KVNamespace
  }
}

type LoaderFunctionResult = Promise<Response> | Response | Promise<AppData> | AppData

declare global {
  type LoaderFunction = (args: LoaderFunctionArgs) => LoaderFunctionResult
}

型定義を用意したことで、キャストの必要がなくなり以下のような実装になりました。

// app/routes/index.tsx
import {useLoaderData} from '@remix-run/react'

type Cache = {
  a: number
}

type LoaderData = {
  pageCache: Cache
}

export const loader: LoaderFunction = async ({context}): Promise<LoaderData> => {
  const kv = context.GOOD_KVNAME
  const cached = await kv.get<Cache>('cache-key', 'json')
  const updateCache = {...cached, a: (cached?.a ?? 0) + 1}
  await kv.set('cache-key', updateCache)
  return updateCache
}

以上でとりあえずは動くようになりました。

やっと開発が始められます。やったね。