-
Notifications
You must be signed in to change notification settings - Fork 258
feat(examples): add Prisma + Hyperdrive example #609
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| # Prisma + Hyperdrive on Cloudflare Workers | ||
|
|
||
| A full-stack CRUD example using [Prisma](https://www.prisma.io/) with [Hyperdrive](https://developers.cloudflare.com/hyperdrive/) for accelerated PostgreSQL access on Cloudflare Workers via vinext. | ||
|
|
||
| Demonstrates: | ||
| - Per-request Prisma client (avoids [alternating connection failures](https://github.com/cloudflare/vinext/issues/537)) | ||
| - Hyperdrive connection pooling | ||
| - Server component data fetching | ||
| - Route handler CRUD API | ||
|
|
||
| ## Prerequisites | ||
|
|
||
| - A PostgreSQL database (Neon, Supabase, or any provider) | ||
| - A Cloudflare account with Hyperdrive enabled | ||
|
|
||
| ## Setup | ||
|
|
||
| 1. Install dependencies: | ||
|
|
||
| ```bash | ||
| pnpm install | ||
| ``` | ||
|
|
||
| 2. Set your database URL: | ||
|
|
||
| ```bash | ||
| echo 'DATABASE_URL="postgresql://user:pass@host:5432/db"' > .env | ||
| ``` | ||
|
|
||
| 3. Create the database table: | ||
|
|
||
| ```bash | ||
| pnpm db:push | ||
| ``` | ||
|
|
||
| 4. Generate the Prisma client: | ||
|
|
||
| ```bash | ||
| pnpm db:generate | ||
| ``` | ||
|
|
||
| 5. Create a Hyperdrive config: | ||
|
|
||
| ```bash | ||
| npx wrangler hyperdrive create my-db \ | ||
| --connection-string="postgresql://user:pass@host:5432/db" | ||
| ``` | ||
|
|
||
| 6. Copy the Hyperdrive ID into `wrangler.jsonc`: | ||
|
|
||
| ```jsonc | ||
| "hyperdrive": [{ "binding": "HYPERDRIVE", "id": "<paste-id-here>" }] | ||
| ``` | ||
|
|
||
| ## Development | ||
|
|
||
| ```bash | ||
| pnpm dev | ||
| ``` | ||
|
|
||
| Open http://localhost:5173 — the app fetches items from your database via Hyperdrive. | ||
|
|
||
| ## Deploy | ||
|
|
||
| ```bash | ||
| pnpm build | ||
| npx wrangler deploy | ||
| ``` | ||
|
|
||
| ## How it works | ||
|
|
||
| ### The per-request client pattern | ||
|
|
||
| Cloudflare Workers reuse isolates across requests, but each request has its own I/O context. A global `PrismaClient` singleton causes alternating failures because the connection pool from one request becomes invalid in the next. | ||
|
|
||
| `lib/db.ts` solves this with a short-lived cache: | ||
|
|
||
| ```ts | ||
| let cached: PrismaClient | null = null; | ||
| let cachedAt = 0; | ||
|
|
||
| export function getPrisma(): PrismaClient { | ||
| const now = Date.now(); | ||
| if (cached && now - cachedAt < 50) return cached; | ||
| const pool = new Pool({ connectionString: env.HYPERDRIVE.connectionString }); | ||
| cached = new PrismaClient({ adapter: new PrismaPg(pool) }); | ||
| cachedAt = now; | ||
| return cached; | ||
| } | ||
| ``` | ||
|
|
||
| Within a single request (~50ms), `getPrisma()` returns the same client. Across requests, a fresh client is created. Zero alternating failures. | ||
|
|
||
| ### Hyperdrive | ||
|
|
||
| Hyperdrive pools and caches PostgreSQL connections at Cloudflare's edge. The connection string comes from the `HYPERDRIVE` binding in `wrangler.jsonc`, accessed via `import { env } from "cloudflare:workers"`. | ||
|
|
||
| ### Route handlers use standard Request | ||
|
|
||
| vinext route handlers receive the Web standard `Request` object (not `NextRequest`). Use `new URL(request.url)` for search params: | ||
|
|
||
| ```ts | ||
| const { searchParams } = new URL(request.url); | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { getPrisma } from "@/lib/db"; | ||
|
|
||
| export async function GET() { | ||
| const prisma = getPrisma(); | ||
| const items = await prisma.item.findMany({ orderBy: { createdAt: "desc" } }); | ||
| return Response.json(items); | ||
| } | ||
|
|
||
| export async function POST(request: Request) { | ||
| const { searchParams } = new URL(request.url); | ||
| const contentType = request.headers.get("content-type") ?? ""; | ||
|
|
||
| let title: string | null = null; | ||
|
|
||
| if (contentType.includes("application/json")) { | ||
| const body = await request.json(); | ||
| title = body.title; | ||
| } else { | ||
| // Form submission | ||
| const formData = await request.formData(); | ||
| title = formData.get("title") as string; | ||
| } | ||
|
|
||
| if (!title?.trim()) { | ||
| return Response.json({ error: "title is required" }, { status: 400 }); | ||
| } | ||
|
|
||
| const prisma = getPrisma(); | ||
| const item = await prisma.item.create({ data: { title: title.trim() } }); | ||
|
|
||
| // Redirect back to home on form submit, return JSON for API calls | ||
| if (!contentType.includes("application/json")) { | ||
| return new Response(null, { status: 303, headers: { Location: "/" } }); | ||
| } | ||
| return Response.json(item, { status: 201 }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| export const metadata = { | ||
| title: "Prisma + Hyperdrive on vinext", | ||
| description: "Full-stack example with Prisma, Hyperdrive, and Cloudflare Workers", | ||
| }; | ||
|
|
||
| export default function RootLayout({ children }: { children: React.ReactNode }) { | ||
| return ( | ||
| <html lang="en"> | ||
| <body style={{ fontFamily: "system-ui, sans-serif", maxWidth: 600, margin: "40px auto", padding: "0 16px" }}> | ||
| {children} | ||
| </body> | ||
| </html> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { getPrisma } from "@/lib/db"; | ||
|
|
||
| export default async function Home() { | ||
| const prisma = getPrisma(); | ||
| const items = await prisma.item.findMany({ orderBy: { createdAt: "desc" } }); | ||
|
|
||
| return ( | ||
| <main> | ||
| <h1>Items ({items.length})</h1> | ||
| <form action="/api/items" method="POST"> | ||
| <input name="title" placeholder="New item..." required style={{ padding: 8, marginRight: 8 }} /> | ||
| <button type="submit" style={{ padding: "8px 16px" }}>Add</button> | ||
| </form> | ||
| <ul style={{ listStyle: "none", padding: 0 }}> | ||
| {items.map((item) => ( | ||
| <li key={item.id} style={{ padding: "8px 0", borderBottom: "1px solid #eee" }}> | ||
| <span style={{ textDecoration: item.completed ? "line-through" : "none" }}> | ||
| {item.title} | ||
| </span> | ||
| <span style={{ color: "#999", fontSize: 12, marginLeft: 8 }}> | ||
| {item.createdAt.toLocaleDateString()} | ||
| </span> | ||
| </li> | ||
| ))} | ||
| {items.length === 0 && <li style={{ color: "#999" }}>No items yet. Add one above.</li>} | ||
| </ul> | ||
| </main> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| /** | ||
| * Per-request Prisma client for Cloudflare Workers. | ||
| * | ||
| * Workers reuse isolates across requests, but each request has its own | ||
| * I/O context. A global PrismaClient singleton causes alternating | ||
| * connection failures (success, fail, success, fail...) because the | ||
| * connection from Request A is invalid in Request B. | ||
| * | ||
| * Solution: create a fresh PrismaClient per request using a simple | ||
| * module-scoped cache with a short TTL. Within a single request, | ||
| * multiple calls to getPrisma() return the same client (fast). | ||
| * Across requests, the TTL ensures a fresh client is created. | ||
| * | ||
| * Once vinext merges per-request store support (#608), this can be | ||
| * simplified to use getRequestStore() instead of the TTL pattern. | ||
| */ | ||
| import { PrismaClient } from "@prisma/client"; | ||
| import { PrismaPg } from "@prisma/adapter-pg"; | ||
| import { Pool } from "pg"; | ||
| import { env } from "cloudflare:workers"; | ||
|
|
||
| let cached: PrismaClient | null = null; | ||
| let cachedAt = 0; | ||
|
|
||
| /** | ||
| * Get a PrismaClient scoped to the current request. | ||
| * | ||
| * Uses Hyperdrive for connection pooling — the connection string comes | ||
| * from the HYPERDRIVE binding defined in wrangler.jsonc. | ||
| */ | ||
| export function getPrisma(): PrismaClient { | ||
| const now = Date.now(); | ||
| // 50ms TTL: within a single request, reuse the client. | ||
| // Across requests (>50ms apart), create a fresh one. | ||
| if (cached && now - cachedAt < 50) { | ||
| return cached; | ||
| } | ||
| const pool = new Pool({ connectionString: env.HYPERDRIVE.connectionString }); | ||
| const adapter = new PrismaPg(pool); | ||
| cached = new PrismaClient({ adapter }); | ||
| cachedAt = now; | ||
| return cached; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| { | ||
| "name": "vinext-prisma-hyperdrive", | ||
| "type": "module", | ||
| "private": true, | ||
| "scripts": { | ||
| "dev": "vp dev", | ||
| "build": "vp build", | ||
| "preview": "vp preview", | ||
| "db:generate": "prisma generate", | ||
| "db:migrate": "prisma migrate dev", | ||
| "db:push": "prisma db push" | ||
| }, | ||
| "dependencies": { | ||
| "@vitejs/plugin-react": "catalog:", | ||
| "react": "catalog:", | ||
| "react-dom": "catalog:", | ||
| "vite": "catalog:", | ||
| "vinext": "workspace:*", | ||
| "@vitejs/plugin-rsc": "catalog:", | ||
| "react-server-dom-webpack": "catalog:", | ||
| "@cloudflare/vite-plugin": "catalog:", | ||
| "wrangler": "catalog:", | ||
| "@prisma/client": "^7.0.0", | ||
| "@prisma/adapter-pg": "^7.0.0", | ||
| "pg": "^8.13.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@cloudflare/workers-types": "catalog:", | ||
| "vite-plus": "catalog:", | ||
| "prisma": "^7.0.0", | ||
| "@types/pg": "^8.11.0" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| generator client { | ||
| provider = "prisma-client-js" | ||
| previewFeatures = ["driverAdapters"] | ||
| } | ||
|
|
||
| datasource db { | ||
| provider = "postgresql" | ||
| url = env("DATABASE_URL") | ||
| } | ||
|
|
||
| model Item { | ||
| id String @id @default(uuid()) | ||
| title String | ||
| completed Boolean @default(false) | ||
| createdAt DateTime @default(now()) | ||
| updatedAt DateTime @updatedAt | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| { | ||
| "compilerOptions": { | ||
| "target": "ESNext", | ||
| "module": "ESNext", | ||
| "moduleResolution": "bundler", | ||
| "jsx": "react-jsx", | ||
| "strict": true, | ||
| "esModuleInterop": true, | ||
| "skipLibCheck": true, | ||
| "paths": { | ||
| "@/*": ["./*"] | ||
| } | ||
| }, | ||
| "include": ["**/*.ts", "**/*.tsx"], | ||
| "exclude": ["node_modules"] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { defineConfig } from "vite"; | ||
| import vinext from "vinext"; | ||
| import { cloudflare } from "@cloudflare/vite-plugin"; | ||
|
|
||
| export default defineConfig({ | ||
| plugins: [ | ||
| vinext(), | ||
| cloudflare({ | ||
| viteEnvironment: { | ||
| name: "rsc", | ||
| childEnvironments: ["ssr"], | ||
| }, | ||
| }), | ||
| ], | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| /** | ||
| * Cloudflare Worker entry point for vinext App Router. | ||
| * | ||
| * Uses the default vinext handler — no custom logic needed. | ||
| * Hyperdrive binding is configured in wrangler.jsonc and | ||
| * accessed via `import { env } from "cloudflare:workers"`. | ||
| */ | ||
|
|
||
| // @ts-expect-error -- virtual module resolved by vinext at build time | ||
| export { default } from "virtual:vinext-app-handler"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| { | ||
| "$schema": "node_modules/wrangler/config-schema.json", | ||
| "name": "prisma-hyperdrive", | ||
| "compatibility_date": "2026-03-01", | ||
| "compatibility_flags": ["nodejs_compat"], | ||
| "main": "./worker/index.ts", | ||
| "preview_urls": true, | ||
| "assets": { | ||
| "not_found_handling": "none", | ||
| "binding": "ASSETS" | ||
| }, | ||
| "images": { | ||
| "binding": "IMAGES" | ||
| }, | ||
| // Hyperdrive accelerates PostgreSQL connections via connection pooling. | ||
| // Create one with: wrangler hyperdrive create my-db --connection-string="postgres://..." | ||
| "hyperdrive": [ | ||
| { | ||
| "binding": "HYPERDRIVE", | ||
| "id": "<your-hyperdrive-id>" | ||
| } | ||
| ] | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This time-based cache is not actually request-scoped: two different requests that arrive within 50ms (including concurrent requests in one Worker isolate) will receive the same
PrismaClient. Since this example’s rationale is avoiding cross-request connection reuse, this condition can reintroduce the same class of failures under load and makes behavior nondeterministic by request timing.Useful? React with 👍 / 👎.