Prisma driver adapter for Cloudflare D1をRemixに組み込む
数日前にPrismaがEdge Functionで動作するバージョン5.11をリリースしました。
これでJavaScriptランタイムの主要なORMやQuery BuilderがすべてEdge Functionに対応しました。
そこでエッジとなるCloudflare PagesをサポートしているRemixに組み込んでみたコードを書いたのでそれの導入から説明の軽い内容を書いておきます。
結論(2024/3/19時点)
- ビルドサイズがPrismaだけで1MB近く専有するので有料版のCloudflare Workersのみが動作します
- PrismaのmigrateはCloudflare D1への反映はサポートされていないのでPrismaが出力したDDLをCloudflare D1に向けて実行する必要があります
出来上がりのコード
導入と動くサンプルコードを作るまでインストールコマンドを除けば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を書きます。
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が作成されます。ローカルで使うにしても以下のような設定が必要になります。
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を使用してクライアントを生成するには以下のようなコードになります。
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
という以下のようなファイルを生成することが出来ます。
interface Env {
RemixPrismaD1: D1Database;
}
これをRemixは読み取って、D1を使うための型となります。実際には load-context.ts
というファイルを作成して以下のように context
に格納したりします。
+ 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に設定すればよいです。
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()],
});
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の loader
や action
から context
を介してPrsimaを使用することが出来ます。
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 })
}
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
からのデータを表示してやればいいだけです。
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さんが考えてくれたので、気になる方は見てみてください。
現状はまだEarly AccessなのでPrismaを使いたいって人は今後に期待しておいてください。
この記事を書いてる途中にDrizzleが結構使いやすくなってたのでKysely, Drizzle, Prismaの比較をやり直して記事にしてみる予定です。
Discussion
Prismaを導入したかったので参考にさせていただいています!
最近Cloudflareを触り始めたためそもそも何か認識が違うかもしれませんが、1点質問させてください🙇🏻♂️
このような記述があるのですが公式ドキュメントを見ると以下のような記述があります。
Cloudflare Workersはコールドスタートがほぼないこと(0ns cold start)が特徴だと思っていたんですが、1MBを超えた場合はさすがに影響が出てくる、みたいな意味合いで書いてくださったのでしょうか?
はい、さすがに0ではありません。
正確な数字を測ってないので、今度どこかのタイミングで測ろうと思いますが昔はもっとサイズ制限がきつい時期がありその時に中の人からサイズによってコールドスタートに影響が出てくると伺っております。
ありがとうございます〜。疑問解消できました!🙏🏻