🌘

Prisma driver adapter for Cloudflare D1をRemixに組み込む

2024/03/18に公開
3

数日前にPrismaがEdge Functionで動作するバージョン5.11をリリースしました。

https://www.prisma.io/blog/prisma-orm-support-for-edge-functions-is-now-in-preview

https://github.com/prisma/prisma/releases/tag/5.11.0

これでJavaScriptランタイムの主要なORMやQuery BuilderがすべてEdge Functionに対応しました。

そこでエッジとなるCloudflare PagesをサポートしているRemixに組み込んでみたコードを書いたのでそれの導入から説明の軽い内容を書いておきます。

結論(2024/3/19時点)

  • ビルドサイズがPrismaだけで1MB近く専有するので有料版のCloudflare Workersのみが動作します
  • PrismaのmigrateはCloudflare D1への反映はサポートされていないのでPrismaが出力したDDLをCloudflare D1に向けて実行する必要があります

出来上がりのコード

https://github.com/chimame/remix-prisma-d1-on-cloudflare-pages

導入と動くサンプルコードを作るまでインストールコマンドを除けば3コミットしかないので軽く読めると思います。

Remixのセットアップ

Remixは2.7以降はViteに対応してるので、Vite版のインストールコマンドを叩いて作成します。

npx create-remix@latest --template remix-run/remix/templates/vite-cloudflare
corepack enable yarn
corepack use yarn
yarn install

私の場合は npm ではなく yarn を使ってます。

Prismaのセットアップ

Remixのセットアップが終わったら本題のPrismaを入れます。RemixではなくてもPrisma + D1ならばここは一緒になります。

yarn add @prisma/client @prisma/adapter-d1
yarn add -D prisma @cloudflare/workers-types wrangler

これが終われば初期化でschemaファイルを作成します。

yarn run prisma init --datasource-provider sqlite

Cloudflare D1はSQLiteベースなのでPrismaもそれに合わせます。作成されたschemaファイルでサンプルのmodelを書きます。

prisma/schema.prisma
generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["driverAdapters"]
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model User {
  id    Int  @id @default(autoincrement())
  email String
  name  String?
}

このschemaファイルの previewFeatures が現在は必要です。
あとはこの作ったschemaファイルに沿ったDDLを作成してもらうコマンドを打ちます。

yarn run prisma migrate dev --name add_user_model

これでローカルに SQLiteのDBが作成されて、テーブルが作成されます
Drizzleのmigrateとは異なり、PrismaはCloudflare D1自体へのmigrateはサポートされていません。なのでここから少し工夫が必要です。

Cloudflare D1にmigrate

Cloudflare D1の構築

まずはCloudflare D1を作成していないので作成します。

wrangler d1 create RemixPrismaD1 # 最後のRemixPrismaD1は好きな名前に

これを実行すると持っているCloudflareアカウント上にD1が作成されます。ローカルで使うにしても以下のような設定が必要になります。

wrangler.toml
d1_databases = [
  { binding = "RemixPrismaD1", database_name = "RemixPrismaD1", database_id = "test-db-id" }
]

Cloudflare D1のマイグレーション

Cloudflare D1のmigrateは実に単純で migrations/ というディレクトリにSQLファイルを格納していけばいいです。なので先程作ったPrismaが作成したDDLを migrations/ に移動させればいいです。
私の場合は zx で以下のような簡単なスクリプトを作りました。

#!/usr/bin/env zx

await $`mkdir -p ./migrations`

const packages = await glob(['prisma/migrations/*/migration.sql'])
for (let i = 0; i < packages.length; i++) {
  const migrationName = packages[i]
    .replace('prisma/migrations/', '')
    .split('/')[0]
  if (!fs.existsSync(`migrations/${migrationName}.sql`)) {
    await $`cp ${packages[i]} migrations/${migrationName}.sql`
  }
}

await $`yarn run wrangler d1 migrations apply RemixPrismaD1 --local`

Prismaは prisma/migrations/<migration name>/migration.sql というファイルを作ります。これをそのまま migrations/<migration name>.sql に移動させて最後に wrangler d1 migrations のコマンドを動作させるだけのスクリプトです。

余談ですが、 wrangler d1 migrations--local を付けてローカルにD1用のSQLiteにmigrateを実行していますが、 --local がなくても最新のwranglerはローカルに実行します。Cloudflareアカウントつまり実際のD1にmigrateするには --remote というオプションで実施するようになっています。( --local 無しで誤って実行してしまうのを防ぐ意図だと思います )

RemixからPrismaでCloudflare D1を参照する

PrismaからCloudflare D1への接続定義の作成

PrismaがD1用のadpterを使用してクライアントを生成するには以下のようなコードになります。

app/database/client.ts
import { PrismaClient } from '@prisma/client'
import { PrismaD1 } from '@prisma/adapter-d1'

export const connection = async (db: D1Database) => {
  const adapter = new PrismaD1(db)
  return new PrismaClient({ adapter })
}

なんのひねりもなく、ただ単に @prisma/adapter-d1 をadapterとして指定しているだけです。

Prismaを使用したCRUDの構築

最後にRemixでPrismaを使ったCloudflare D1へのCRUDを作ります。まずはCloudflare D1を使用するための型ファイルを生成します。

yarn run wrangler types # typegen スクリプトに定義していますが、説明上わざと書いてる

wranglerには wrangler.toml ファイルから型ファイルを生成するためのコマンドがあり、それを実行することで worker-configuration.d.ts という以下のようなファイルを生成することが出来ます。

worker-configuration.d.ts
interface Env {
	RemixPrismaD1: D1Database;
}

これをRemixは読み取って、D1を使うための型となります。実際には load-context.ts というファイルを作成して以下のように context に格納したりします。

load-context.ts
+ import { type AppLoadContext } from '@remix-run/cloudflare'
import { type PlatformProxy } from "wrangler";
+ import { connection } from './app/database/client'

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>

+ export const getLoadContext: GetLoadContext = async ({ context }) => {
+   return {
+     ...context,
+     db: await connection(
+       context.cloudflare.env.RemixPrismaD1,
+     ),
+   }
+ }

これをViteのコンフィグとCloudflare Pages Functionsに設定すればよいです。

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(), remix(), tsconfigPaths()],
+  plugins: [remixCloudflareDevProxy({ getLoadContext }), remix(), tsconfigPaths()],
});
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 });
+ export const onRequest = createPagesFunctionHandler({ build, getLoadContext });

こうすることでRemixの loaderaction から context を介してPrsimaを使用することが出来ます。

loader.ts
import { type LoaderFunctionArgs, json } from "@remix-run/cloudflare";

export const loader = async ({ context }: LoaderFunctionArgs) => {
  const users = await context.db.user.findMany()

  return json({ users: users })
}
action.ts
import type { ActionFunctionArgs } from "react-router";

export const action = async ({ request, context }: ActionFunctionArgs) => {
  const formData = await request.formData();
  const name = formData.get("name");
  const email = formData.get("email");

  await context.db.user.create({
    data: {
      name: name as string,
      email: email as string,
    },
  });

  return null
}

後はこの loader からのデータを表示してやればいいだけです。

app/routes/users/route.tsx
import { Form, useLoaderData } from "@remix-run/react";
import { loader, action } from "./handlers";

export default function UserPage() {
  const { users } = useLoaderData<typeof loader>()

  return (
    <div>
      <Form method="post">
        <label htmlFor="name">Name</label>
        <input type="text" id="name" name="name" />
        <br />
        <label htmlFor="email">Email</label>
        <input type="email" id="email" name="email" required />
        <button type="submit">Add</button>
      </Form>
      <h1>Users</h1>
      <ul>
        {users.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  )
}

export { loader, action };

これで簡単なサンプルが完成です。どうしてもPrismaをEdgeで動かしたいって方は試してみてください。
実際に私もCloudflare Pagesにデプロイしてみましたが動くは動きました。ただし後述しますが、ビルドしたファイルサイズが1MBを超えるのでCloudflare Workersの有料版でしか動作しません

さいごに

PrismaがやっとEdge Functionsに対応してきましたが、残念ながらまだまだ使うには超えなければならないハードルがあります。それは容量です。PrismaはEngine部分をwasm化してEdge Functionsに対応してきましたが、Prismaの容量が1MBを超えます。

計測した結果

Remix + Prismaのバンドルサイズ

$ yarn run wrangler pages functions build --outfile=test/_worker.bundle --minify
✨ Compiled Worker successfully
$ gzip -c9 ./test/_worker.bundle > ./test/bundle.gz
$ du -sh test/*
2.8M    test/_worker.bundle
1.0M    test/bundle.gz

gzip前のサイズ

$ yarn run wrangler pages functions build --minify --outdir ./test2
✨ Compiled Worker successfully
$ du -sh test2/* 
1.9M    test2/5343c5664d9b411cd10438666dc2381e8c450cda-query_engine_bg.wasm
848K    test2/index.js

要はPrisma単体で2MB(gzipで770KB)近くファイルサイズになります。なのでCloudflare Workersの無料版では上限が1MBであるので、デプロイは出来ますが正常に反映されません。

他はと言えばはこれくらいです。

Cloudflare Pages FunctionsつまりはCloudflare Workersはサイズが大きくなればなるほどコールドスタートの時間に影響が出てきます。このPrismaのサイズはいくら有料版なら1MBの制限が無いとはいえなかなか辛いサイズです。

Service Bindingでサイズ制限を回避するということもmizchiさんが考えてくれたので、気になる方は見てみてください。
https://zenn.dev/mizchi/scraps/5788941c50f855

現状はまだEarly AccessなのでPrismaを使いたいって人は今後に期待しておいてください。
この記事を書いてる途中にDrizzleが結構使いやすくなってたのでKysely, Drizzle, Prismaの比較をやり直して記事にしてみる予定です。

Discussion

saneatsusaneatsu

Prismaを導入したかったので参考にさせていただいています!
最近Cloudflareを触り始めたためそもそも何か認識が違うかもしれませんが、1点質問させてください🙇🏻‍♂️

Cloudflare Pages FunctionsつまりはCloudflare Workersはサイズが大きくなればなるほどコールドスタートの時間に影響が出てきます。

このような記述があるのですが公式ドキュメントを見ると以下のような記述があります。

JavaScriptコードの実行にV8を使用すると、JavaScript Workersの起動時間が大幅に短縮され、ほとんどの場合「コールドスタート」の問題がなくなります。

Cloudflare Workersはコールドスタートがほぼないこと(0ns cold start)が特徴だと思っていたんですが、1MBを超えた場合はさすがに影響が出てくる、みたいな意味合いで書いてくださったのでしょうか?

chimamechimame

はい、さすがに0ではありません。
正確な数字を測ってないので、今度どこかのタイミングで測ろうと思いますが昔はもっとサイズ制限がきつい時期がありその時に中の人からサイズによってコールドスタートに影響が出てくると伺っております。

saneatsusaneatsu

ありがとうございます〜。疑問解消できました!🙏🏻