自分が思う最強の(かつ貧者の)構成を目指したログ。流行りの技術選定ってやつしたかった。
結論だけ言うと、まだ綺麗ではないが現実的に動く。動かし方を理解してないと事故る、かも。
この記事は自分がたどり着いた結論を順を追って記述するが、自分にとって自明な場所の差分を記録してないので、コードをなぞるより変更意図を追って各々自分で組み立てる、ということを推奨する。
動いてるリポジトリはここ。ただこの記事の説明を読まないと、その意図が伝わらない。
https://github.com/mizchi/remix-supabase-prisma-example/tree/main
DATABASE_URL
で Connection Pool を有効にするのに?pgbouncer=true
を追加https://supabase.com/partners/integrations/prismanpm create cloudflare@latest my-remix-app -- --framework=remix
pnpm prisma migrate dev --name init
あたりを適当に実行@prisma/adatper-pg-worker
から直接 TCP コネクションを張る。Prisma Accelerate を使うパターンと、Supabase の Pool Mode に TCP Connection で直接接続するパターンがある。両方とも動くが、前者はコードが綺麗で無駄が多く、後者はコードがややこしいが理論上はここがゴール。
小さなプロジェクトを作って動作確認する。
まずはなるべくノイズがない構成から試したかったので、 ローカルに完結した remix on node と prisma で動作確認する。
npx create-remix@latestcd<path>pnpminstallpnpmadd prisma @prisma/client @prisma/cli-Dpnpm prisma init# prisma/schema.prisma を編集
Project Settings > CONFIGURATION > Database > Connection string
からDB接続情報を取得
Supabase で Transaction Mode のポート 6543のURLが提示されるが、prisma migrate の実行時は Session Mode でないといけないらしいので、DIRECT_URL 側は ポートを 5432 にする
Pool mode is permanently set to Transaction on port 6543
You can use Session mode by connecting to the pooler on port 5432 instead
https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pooler
こんな感じの文字列を.env
に設定する。PASSWORD は自前の。
DIRECT_URL="postgresql://postgres.[your-db-id]:[PASSWORD]@aws-0-ap-northeast-1.pooler.supabase.com:5432/postgres"DATABASE_URL="postgresql://postgres.[your-db-id]:[PASSWORD]@aws-0-ap-northeast-1.pooler.supabase.com:6543/postgres?pgbouncer=true"
prisma/schema.prisma
generator client { provider = "prisma-client-js" previewFeatures = ["driverAdapters"]}datasource db { provider = "postgresql" url = env("DATABASE_URL") directUrl = env("DIRECT_URL")}model Post { id String @id @default(cuid()) title String content String}
DIRECT_URL
を指定すると、prisma migrate
はそちらを使うようになる。
この状態で、 prisma migrate を実行。
pnpm prisma migrate dev --name init
Supabase 側の管理画面でテーブルが生えていたら成功。
簡単に PrismaClient を作成して繋いでみる。まだ普通の Remix on Node である点に注意。
app/routes/_index.tsx
importtype{LoaderFunction,MetaFunction}from"@remix-run/node";import{ useLoaderData}from"@remix-run/react";exportconst meta:MetaFunction=()=>{return[{ title:"New Remix App"},{ name:"description", content:"Welcome to Remix!"},];};import{PrismaClient}from"@prisma/client";const client=newPrismaClient();exportconst loader:LoaderFunction=async()=>{const users=await client.post.findMany();return{ message:"Hello, world!", users};};exportdefaultfunctionIndex(){const data=useLoaderData<typeof loader>();return(<divclassName="font-sans p-4"><h1className="text-3xl">Welcome to Remix</h1> Hi<pre>{JSON.stringify(data,null,2)}</pre></div>);}
client.post.findMany()
が返ってきていれば成功。何もなくて寂しいときは適当にシードデータを入れておく。
Plan B の TCP Connection より枯れていて資料も多いが、工夫しないとお金がかかる構成。
最終的に CDN Edge Worker にデプロイする場合、 prisma のclient を Prisma Engine(クエリ実行エンジン) を含まない形でビルドする必要がある。要は@prisma/client
が使えず@prisma/client/edge
での実行が可能なモデルにする必要がある。
一般に、リモートにあるDBに接続する場合、Edge Worker のようなサーバーレス実行モデルだと、都度DBにコネクションを貼りすぐに破棄するのはキャッシュ管理やメモリの面で不利なので DBにアクセスするコネクションプールをプロセスの外側で管理させるのが有用だと知られている。(同様のモデルの環境に AWS Aurora Serverless がある)
Prisma Accelerate はそのホスティングサービスみたいなもの。
https://www.prisma.io/data-platform/accelerate
(過去に Prisma DataProxy というものがあったが、これは Prisma Accelerate に置き換えられていくらしい)
Starter プランは60000クエリまで無料で、それ以降は 1000000query/month なので、秒間2.3クエリぐらいまで $18/month で済む。
コネクションプールを管理するというこれ自体の動作は単純なので、同等のものをセルフホストしてコストを浮かせてる人がいた。
https://zenn.dev/miravy/articles/c3787b3fc29546
https://github.com/node-libraries/prisma-accelerate-local
セルフホストできることは念頭におきつつ、一旦 Prisma Accelerate を使ってみる。
https://www.prisma.io/data-platform/accelerate
Accelerate のフリープランを選択肢し、サービスを作成。昔はリージョンが2つしかなかったらしいが、今は tokyo も選べる。
Prisma の接続先で、先程のDATABASE_URL
をここに入力すると、こういうエンドポイントができるはずなので、.env
に追記
DATABASE_URL="prisma://accelerate.prisma-data.net/?api_key=[your_api_key]"
この状態で prisma client を再生成pnpm prisma generate --no-engine
app/routes/_index.tsx
の loader 周りをこんな感じにしてみる。
// import { PrismaClient } from "@prisma/client";// const client = new PrismaClient();import{PrismaClient}from"@prisma/client/edge";import{ withAccelerate}from"@prisma/extension-accelerate";const client=newPrismaClient().$extends(withAccelerate());exportconst loader:LoaderFunction=async()=>{console.time("query");const users=await client.post.findMany();console.timeEnd("query");return{ message:"Hello, world!", users};};
(これは雑な実装で、後で client インスタンスを再利用可能な形で外に追い出す)
この時点で Remix (Local) -> Prisma Accelerate -> Supabase という経路になってる。
手元で雑にクエリ実行時間を計測してみる。
query: 463.184msquery: 152.725msquery: 64.99msquery: 82.178msquery: 61.365msquery: 39.94msquery: 60.554ms
スピンアップとキャッシュ有無で速度が変わってそうではある。要件次第だが、自分は許容範囲。
TODO: Prisma Accelerate から Supabase の接続は明示的なアクセス許可を出すべきだと思うのだが、どうなっているのか。あとで確認する。
(自分の試した手順で書いている。 Plan B: TCP Connection でいきたい人は、そのセットアップが済んでから読むといいかも)
まだローカル環境なので、CDN Edge から実行してみる。
.env
周りの環境誤差を吸収するのが面倒なので、一旦ハードコードして動作確認する。
// app/routes/_index.tsximport{PrismaClient}from"@prisma/client/edge";import{ withAccelerate}from"@prisma/extension-accelerate";const client=newPrismaClient({ datasources:{ db:{// url: process.env.DATABASE_URL, url:"prisma://accelerate.prisma-data.net/?[your-api-key]",},},}).$extends(withAccelerate());
vite のビルド設定を cloudflare pages 用に変更
import{ defineConfig}from"vite";importtsconfigPathsfrom"vite-tsconfig-paths";import{ vitePluginas remix, cloudflareDevProxyVitePluginas remixCloudflareDevProxy,}from"@remix-run/dev";exportdefaultdefineConfig({plugins:[remixCloudflareDevProxy(),remix({future:{v3_fetcherPersist:true,v3_relativeSplatPath:true,v3_throwAbortReason:true,},}),tsconfigPaths(),],});
ソースコード中の@remix-run/node
を@remix-run/cloudflare
に変更していく。
app/entry.server.tsx
を次のように変更
importtype{AppLoadContext,EntryContext}from"@remix-run/cloudflare";import{RemixServer}from"@remix-run/react";import{ isbot}from"isbot";import{ renderToReadableStream}from"react-dom/server";exportdefaultasyncfunctionhandleRequest( request:Request, responseStatusCode:number, responseHeaders:Headers, remixContext:EntryContext, loadContext:AppLoadContext){const body=awaitrenderToReadableStream(<RemixServercontext={remixContext}url={request.url}/>,{ signal: request.signal,onError(error:unknown){console.error(error); responseStatusCode=500;},});if(isbot(request.headers.get("user-agent")||"")){await body.allReady;} responseHeaders.set("Content-Type","text/html");returnnewResponse(body,{ headers: responseHeaders, status: responseStatusCode,});}
ビルドして実行して確認
pnpm buildpnpm wrangler pages dev ./build/client
http://localhost:8788 で動作確認。
wrangler.toml のname
を適当に書き換えてデプロイする。
pnpm wrangler pages deploy ./build/client
これで本番でも繋がった。勝利。
ここまでは動作確認を優先しており、実用に耐えうるセットアップではなかった。そのへんを整理する。
リクエスト単位で PrismaClient を初期化するのではなく、初回の起動時にコンテキストを注入する。また、ハードコードしていたDATABASE_URL
を Cloudflare のコンテキストから解決するようにする。
load-context.ts
import{typePlatformProxy}from"wrangler";import{typeAppLoadContext}from"@remix-run/cloudflare";import{ PrismaClient}from"@prisma/client/edge";import{ withAccelerate}from"@prisma/extension-accelerate";typeCloudflare= Omit<PlatformProxy<Env>,"dispose">;declaremodule"@remix-run/cloudflare"{interfaceAppLoadContext{ cloudflare: Cloudflare; db: PrismaClient;}}typeGetLoadContext=(args:{ request: Request; context:{ cloudflare: Cloudflare;};})=>Promise<AppLoadContext>;exportconst getLoadContext:GetLoadContext=async({ context})=>{const db=newPrismaClient({ datasources:{ db:{ url: context.cloudflare.env.DATABASE_URL,},},}).$extends(withAccelerate());return{ cloudflare: context.cloudflare,// TODO: FIX TYPE// 本当はここで $extends(...) の推論された型を使いたいのだが// 一旦面倒なので Prisma Client の型をそのまま返している db: dbasunknownas PrismaClient,};};
wrangler に認識してもらうためDATABASE_URL
を書いてるファイル名を.env
から.dev.vars
に変更
functions/[[path]].js
cloudflare pages 上ですべての request を受ける規約のファイル。ここにビルド成果物を叩き込む。
import{ createPagesFunctionHandler}from"@remix-run/cloudflare-pages";import*as buildfrom"../build/server";import{ getLoadContext}from"load-context";exportconst onRequest=createPagesFunctionHandler({ build, getLoadContext});
remix vite:dev
起動時に cloudflare に接続するために、 vite.config.ts に load-context を与える
import{ defineConfig}from"vite";import tsconfigPathsfrom"vite-tsconfig-paths";import{ vitePluginas remix, cloudflareDevProxyVitePluginas remixCloudflareDevProxy,}from"@remix-run/dev";import{ getLoadContext}from"./load-context";exportdefaultdefineConfig({ plugins:[remixCloudflareDevProxy({ getLoadContext}),remix({ future:{ v3_fetcherPersist:true, v3_relativeSplatPath:true, v3_throwAbortReason:true,},}),tsconfigPaths(),],});
ローカルのpnpm prisma migrate
は Prisma Accelerate ではなく Supabase のURLに向くようにする。
これらを起動する。
.env
と.dev.vars
両方を管理したくないので、 dotenv-cli で .dev.vars を env として読み込んで prisma cli を起動する。
pnpm dotenv -e .dev.vars -- pnpm prisma migrate dev
最近の Cloudflare は直接 TCP Connectionが貼れるので、直接 Supabase のDB に接続する。 そのために@prisma/adatper-pg
を使う。
https://www.prisma.io/blog/prisma-orm-support-for-edge-functions-is-now-in-preview
冷静に考えると分かるのだが、そもそも Pool Mode の supabase はそれ自体がコネクションプールを持っているので前段に Prisma Accelerate がいる必然性がない。つまり Prisma ランタイムを追い出してるいるだけの状態になっている。
なので、@prisma/adatper-pg
で直接 TCP Connection を貼る方式に切り替えることにチャレンジする。
(この節は結果的には動くが、ボイラープレートが複雑になったので、面倒な人は Prisma Accelerate か Hypedrive を検討することを推奨する)
https://developers.cloudflare.com/hyperdrive/
前置きはこのぐらいにして、@prisma/adatper-pg
を試す。
import{typePlatformProxy}from"wrangler";import{typeAppLoadContext}from"@remix-run/cloudflare";import{ PrismaClient}from"@prisma/client";import{ PrismaPg}from"@prisma/adapter-pg";importPGfrom"pg";typeCloudflare= Omit<PlatformProxy<Env>,"dispose">;declaremodule"@remix-run/cloudflare"{interfaceAppLoadContext{ cloudflare: Cloudflare; db: PrismaClient;}}typeGetLoadContext=(args:{ request: Request; context:{ cloudflare: Cloudflare;};})=>Promise<AppLoadContext>;exportconst getLoadContext:GetLoadContext=async({ context})=>{const pool=newPG.Pool({ connectionString: context.cloudflare.env.DATABASE_URL,});const adapter=newPrismaPg(pool);const db=newPrismaClient({ adapter});return{ cloudflare: context.cloudflare, db,};};
事前にDATABASE_URL
を Prisma Accelerate のエンドポイントから、Supabase に再設定しておいた。
これは動く。が、Cloudflare Workers で動くが、--node-compat
があるとき限定で、 Cloudflare Pages はこれがない。
これに対応した@prisma/adapter-pg-worker
で動かす。
import{typePlatformProxy}from"wrangler";import{typeAppLoadContext}from"@remix-run/cloudflare";import{ PrismaClient}from"@prisma/client";import{ PrismaPg}from"@prisma/adapter-pg-worker";import{ Pool}from"@prisma/pg-worker";// import PG from "pg";typeCloudflare= Omit<PlatformProxy<Env>,"dispose">;declaremodule"@remix-run/cloudflare"{interfaceAppLoadContext{ cloudflare: Cloudflare; db: PrismaClient;}}typeGetLoadContext=(args:{ request: Request; context:{ cloudflare: Cloudflare;};})=>Promise<AppLoadContext>;exportconst getLoadContext:GetLoadContext=async({ context})=>{const pool=newPool({ connectionString: context.cloudflare.env.DATABASE_URL,});const adapter=newPrismaPg(pool);const db=newPrismaClient({ adapter});return{ cloudflare: context.cloudflare, db,};};
これをremix dev:vite
で動かそうとしたところ、内部で workerd 環境のみに存在するcloudflare:*
のモジュールを参照しているので、node 環境の dev モードは動かない。 workerd で動かす必要がある。つまり、ビルド済みのアセットを workerd で動かすなら動く。
pnpm wrangler pages dev ./build/client
動いた。
悩ましい。一旦開発モードで adapter-pg を使い、プロダクションビルドでは adapter-pg-worker を使うようにすることを検討する。
結果から言うと、開発時は@prisma/adatper-pg
を使い、 本番では@prisma/adatper-pg-worker
する構成にした。以下のリポジトリでは、development と production で異なるエントリポイントのコードを用意して、実行前に差し替えるようにした。
https://github.com/mizchi/remix-supabase-prisma-example
functions-src/ dev.ts prod.tsfunctions/ [[path]].ts <- 起動時に書き換える
動く。。。動くには動くし、実際に快適だが。。。しかしダサいし、ボイラープレートが増えているのも気持ち悪い。
なんとかする方法ないか?という Issue を建てておいた。
https://github.com/prisma/prisma/issues/25099
手元の計測だと、ホットスタートでおよそ 70~120ms ぐらい。(コールドスタートはログを取りそこねた)
何度も書いてるが、やや汚いけど理想構成で動く。理解しないと事故るかも。
すでにプロダクションレディーだとは思うが、認知負荷が低い状態に整理されるまで、あと半年ぐらいはかかるかも。
バッジを受け取った著者にはZennから現金やAmazonギフトカードが還元されます。