🔥

Remix on CloudflarePages + Prisma + Supabase で銀の弾丸を目指す 20240828

2024/08/28に公開
2

自分が思う最強の(かつ貧者の)構成を目指したログ。流行りの技術選定ってやつしたかった。

結論だけ言うと、まだ綺麗ではないが現実的に動く。動かし方を理解してないと事故る、かも。

この記事は自分がたどり着いた結論を順を追って記述するが、自分にとって自明な場所の差分を記録してないので、コードをなぞるより変更意図を追って各々自分で組み立てる、ということを推奨する。

動いてるリポジトリはここ。ただこの記事の説明を読まないと、その意図が伝わらない。

https://github.com/mizchi/remix-supabase-prisma-example/tree/main

追記

このスタックの意図

  • Remix on cloudflare-pages
    • コストとパフォーマンスを両立できる、2024年現在最も銀の弾丸に近いフロントエンド構成
    • これに勝てるのは remix を qwik に置き換えたときぐらい
    • 今後 RSC 周りで苦労したくないなら next-on-pages も選択肢になるが、 cloudflare context に接続するためにはフルビルドからの再起動が必要で、開発体験が悪い
  • DB に Supabase postgres を採用
    • スモールスタートのプロジェクトの場合、理想的には(カタログスペックを実現した) cloudflare-d1 が理想的だが、現状は未知数。ビジネス的にその不確実性は容認しがたい。俺も怖い。
      • d1 + prisma client wasm も検討したが、ユーザーコードを圧迫する
    • 今回は Supabase を使ったが Managed Postgres であるという一点で採用している。Firebase 代替とかそのへんの話はどうでもいいが
      • Supabase Auth だけ使うかも。
      • Supabase 、意外と流行ってるので、継続性という面でも安心感は出てきた
    • DBaaS を使わずに自前で管理できるなら Supabase に拘る必要はない。Postgres なら移行先は無限にある。
  • コネクションプールにPrisma Accelerate を使うか、TCPコネクションをSupabase Pool Mode に直接貼るかは選択
    • Prisma Accelerate
      • 無料枠は 60000クエリ/month しかないのに注意。おもちゃ。
      • 金を払う場合、大雑把に秒間2.4クエリまでなら $18/month で収まる。
      • コストが気になったり1円も払いたくない場合、頑張れば cloudflare workers でセルフホストできる(できそう)。セキュリティ的にも必要な人は多いかも。
      • リージョン指定してサーバーが立つので、地理分散は失われる。
    • Supabase PoolMode に直接 TCP Connection を張る
      • Supabase の Pool Mode(port 6543) 自体が Connection Pool を持っている
      • 駄目だったら Prisma Accelarate か Cloudflare Hyperdrive にする

大まかな手順

  • create-cloudflare で remix + cloudflare-pages の雛形を作る
    • npm create cloudflare@latest my-remix-app -- --framework=remix
  • supabase でアカウントを作って、 無料プランのDBを作成
  • Prisma で Supabase の Postgres を migrate する
    • pnpm prisma migrate dev --name init あたりを適当に実行
    • prisma の使い方自体は略。ググって
  • Plan A: Prisma Accelarate
    • prisma/client/edge で Prisma Accelerate に接続する(無料)
    • 無料といったが月間60000クエリまで。有料枠に手を出すかコネクションプールをセルフホストする
    • Prisma Accelerate でコネクションプールのサービスを作り、作成したデータベースに向けておく
  • Plan B: TCP Connection
    • @prisma/adatper-pg-worker から直接 TCP コネクションを張る。
    • が、色々ハックが必要だったので、悩ましくなった
  • remix の loader から prisma/client を叩いて解決
  • It works.

Prisma Accelerate を使うパターンと、Supabase の Pool Mode に TCP Connection で直接接続するパターンがある。両方とも動くが、前者はコードが綺麗で無駄が多く、後者はコードがややこしいが理論上はここがゴール。

Step by Step

小さなプロジェクトを作って動作確認する。

まずはなるべくノイズがない構成から試したかったので、 ローカルに完結した remix on node と prisma で動作確認する。

npx create-remix@latest
cd <path>
pnpm install
pnpm add prisma @prisma/client @prisma/cli -D
pnpm prisma init
# prisma/schema.prisma を編集

Supabase でプロジェクトを作成

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 側の管理画面でテーブルが生えていたら成功。

Remix から Prisma に接続

簡単に PrismaClient を作成して繋いでみる。まだ普通の Remix on Node である点に注意。

app/routes/_index.tsx

import type { LoaderFunction, MetaFunction } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

export const meta: MetaFunction = () => {
  return [
    { title: "New Remix App" },
    { name: "description", content: "Welcome to Remix!" },
  ];
};

import { PrismaClient } from "@prisma/client";
const client = new PrismaClient();

export const loader: LoaderFunction = async () => {
  const users = await client.post.findMany();
  return { message: "Hello, world!", users };
};

export default function Index() {
  const data = useLoaderData<typeof loader>();
  return (
    <div className="font-sans p-4">
      <h1 className="text-3xl">Welcome to Remix</h1>
      Hi
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
}

client.post.findMany() が返ってきていれば成功。何もなくて寂しいときは適当にシードデータを入れておく。

PlanA: Prisma Accelerate

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 = new PrismaClient().$extends(withAccelerate());

export const 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.184ms
query: 152.725ms
query: 64.99ms
query: 82.178ms
query: 61.365ms
query: 39.94ms
query: 60.554ms

スピンアップとキャッシュ有無で速度が変わってそうではある。要件次第だが、自分は許容範囲。

TODO: Prisma Accelerate から Supabase の接続は明示的なアクセス許可を出すべきだと思うのだが、どうなっているのか。あとで確認する。

Cloudflare Pages にデプロイしてからDBアクセス

(自分の試した手順で書いている。 Plan B: TCP Connection でいきたい人は、そのセットアップが済んでから読むといいかも)

まだローカル環境なので、CDN Edge から実行してみる。

.env 周りの環境誤差を吸収するのが面倒なので、一旦ハードコードして動作確認する。

// app/routes/_index.tsx
import { PrismaClient } from "@prisma/client/edge";
import { withAccelerate } from "@prisma/extension-accelerate";
const client = new PrismaClient({
  datasources: {
    db: {
      // url: process.env.DATABASE_URL,
      url: "prisma://accelerate.prisma-data.net/?[your-api-key]",
    },
  },
}).$extends(withAccelerate());

vite のビルド設定を cloudflare pages 用に変更

vite.config.mjs
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import {
  vitePlugin as remix,
  cloudflareDevProxyVitePlugin as remixCloudflareDevProxy,
} from "@remix-run/dev";
export default defineConfig({
  plugins: [
    remixCloudflareDevProxy(),
    remix({
      future: {
        v3_fetcherPersist: true,
        v3_relativeSplatPath: true,
        v3_throwAbortReason: true,
      },
    }),
    tsconfigPaths(),
  ],
});

ソースコード中の @remix-run/node@remix-run/cloudflare に変更していく。

app/entry.server.tsx を次のように変更

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,
  loadContext: AppLoadContext
) {
  const body = await renderToReadableStream(
    <RemixServer context={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");
  return new Response(body, {
    headers: responseHeaders,
    status: responseStatusCode,
  });
}

ビルドして実行して確認

pnpm build
pnpm 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 { type PlatformProxy } from "wrangler";
import { type AppLoadContext } from "@remix-run/cloudflare";
import { PrismaClient } from "@prisma/client/edge";
import { withAccelerate } from "@prisma/extension-accelerate";

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

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

type GetLoadContext = (args: {
  request: Request;
  context: {
    cloudflare: Cloudflare;
  };
}) => Promise<AppLoadContext>;

export const getLoadContext: GetLoadContext = async ({ context }) => {
  const db = new PrismaClient({
    datasources: {
      db: {
        url: context.cloudflare.env.DATABASE_URL,
      },
    },
  }).$extends(withAccelerate());
  return {
    cloudflare: context.cloudflare,
    // TODO: FIX TYPE
    // 本当はここで $extends(...) の推論された型を使いたいのだが
    // 一旦面倒なので Prisma Client の型をそのまま返している
    db: db as unknown as PrismaClient,
  };
};

wrangler に認識してもらうため DATABASE_URL を書いてるファイル名を .env から .dev.vars に変更

functions/[[path]].js

cloudflare pages 上ですべての request を受ける規約のファイル。ここにビルド成果物を叩き込む。

import { createPagesFunctionHandler } from "@remix-run/cloudflare-pages";
import * as build from "../build/server";
import { getLoadContext } from "load-context";
export const onRequest = createPagesFunctionHandler({ build, getLoadContext });

remix vite:dev 起動時に cloudflare に接続するために、 vite.config.ts に load-context を与える

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

export default defineConfig({
  plugins: [
    remixCloudflareDevProxy({ getLoadContext }),
    remix({
      future: {
        v3_fetcherPersist: true,
        v3_relativeSplatPath: true,
        v3_throwAbortReason: true,
      },
    }),
    tsconfigPaths(),
  ],
});

ローカルの pnpm prisma migrate は Prisma Accelerate ではなく Supabase のURLに向くようにする。

これらを起動する。

Migration

.env.dev.vars 両方を管理したくないので、 dotenv-cli で .dev.vars を env として読み込んで prisma cli を起動する。

pnpm dotenv -e .dev.vars -- pnpm prisma migrate dev

https://www.prisma.io/docs/orm/more/development-environment/environment-variables/managing-env-files-and-setting-variables

Plan B: TCP Connection

最近の 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 { type PlatformProxy } from "wrangler";
import { type AppLoadContext } from "@remix-run/cloudflare";

import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import PG from "pg";

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

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

type GetLoadContext = (args: {
  request: Request;
  context: {
    cloudflare: Cloudflare;
  };
}) => Promise<AppLoadContext>;

export const getLoadContext: GetLoadContext = async ({ context }) => {
  const pool = new PG.Pool({
    connectionString: context.cloudflare.env.DATABASE_URL,
  });
  const adapter = new PrismaPg(pool);
  const db = new PrismaClient({ adapter });
  return {
    cloudflare: context.cloudflare,
    db,
  };
};

事前に DATABASE_URL を Prisma Accelerate のエンドポイントから、Supabase に再設定しておいた。

これは動く。が、Cloudflare Workers で動くが、 --node-compat があるとき限定で、 Cloudflare Pages はこれがない。

これに対応した @prisma/adapter-pg-worker で動かす。

import { type PlatformProxy } from "wrangler";
import { type AppLoadContext } 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";

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

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

type GetLoadContext = (args: {
  request: Request;
  context: {
    cloudflare: Cloudflare;
  };
}) => Promise<AppLoadContext>;

export const getLoadContext: GetLoadContext = async ({ context }) => {
  const pool = new Pool({
    connectionString: context.cloudflare.env.DATABASE_URL,
  });
  const adapter = new PrismaPg(pool);
  const db = new PrismaClient({ 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.ts
functions/
  [[path]].ts <- 起動時に書き換える

動く。。。動くには動くし、実際に快適だが。。。しかしダサいし、ボイラープレートが増えているのも気持ち悪い。
なんとかする方法ないか?という Issue を建てておいた。

https://github.com/prisma/prisma/issues/25099

手元の計測だと、ホットスタートでおよそ 70~120ms ぐらい。(コールドスタートはログを取りそこねた)

結論

何度も書いてるが、やや汚いけど理想構成で動く。理解しないと事故るかも。

すでにプロダクションレディーだとは思うが、認知負荷が低い状態に整理されるまで、あと半年ぐらいはかかるかも。

残タスク

  • 今のままだと何もなさすぎるので form ぐらいは作りたい
  • 今だと常に本番に向いてるので Supabase をローカル起動できるようにする
  • 認証に Supabase Auth を使う?
    • Prisma のスキーマとの整合性を合わせる必要があり、丁寧にやる必要がある

Discussion

mizchimizchi

そういう意図です。記事中に書いた通り Postgres の DBaaS としてしか扱ってないので、Supabase の専用機能は触れてません。ただし、auth 周りだけは Supabase Auth を検証するだけしてみようと思ってます。