1 Commits

Author SHA1 Message Date
Fatih Kadir Akın aa81ea1ac9 chore(build): add Cloudflare and Wrangler configurations 2026-03-19 10:26:05 +03:00
20 changed files with 4044 additions and 2427 deletions
+4
View File
@@ -46,5 +46,9 @@ next-env.d.ts
/src/generated/prisma /src/generated/prisma
/src/app/sponsors /src/app/sponsors
# Cloudflare / OpenNext
.open-next/
.wrangler/
# Sentry Config File # Sentry Config File
.env.sentry-build-plugin .env.sentry-build-plugin
+3 -40
View File
@@ -1,4 +1,3 @@
import { withSentryConfig } from "@sentry/nextjs";
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin"; import createNextIntlPlugin from "next-intl/plugin";
import createMDX from "@next/mdx"; import createMDX from "@next/mdx";
@@ -10,6 +9,7 @@ const withMDX = createMDX({
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"], pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
serverExternalPackages: ["@prisma/client", ".prisma/client", "sharp", "pg", "@prisma/adapter-pg"],
reactCompiler: true, reactCompiler: true,
// Configure webpack for raw imports // Configure webpack for raw imports
webpack: (config) => { webpack: (config) => {
@@ -19,8 +19,6 @@ const nextConfig: NextConfig = {
}); });
return config; return config;
}, },
// Enable standalone output for Docker
output: "standalone",
// Experimental features // Experimental features
experimental: { experimental: {
// Enable server actions // Enable server actions
@@ -30,6 +28,7 @@ const nextConfig: NextConfig = {
}, },
// Image optimization // Image optimization
images: { images: {
unoptimized: true,
remotePatterns: [ remotePatterns: [
{ {
protocol: "https", protocol: "https",
@@ -65,40 +64,4 @@ const nextConfig: NextConfig = {
}, },
}; };
export default withSentryConfig(withMDX(withNextIntl(nextConfig)), { export default withMDX(withNextIntl(nextConfig));
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: "promptschat",
project: "prompts-chat",
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: "/monitoring",
webpack: {
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
// See the following for more information:
// https://docs.sentry.io/product/crons/
// https://vercel.com/docs/cron-jobs
automaticVercelMonitors: true,
// Tree-shaking options for reducing bundle size
treeshake: {
// Automatically tree-shake Sentry logger statements to reduce bundle size
removeDebugLogging: true,
},
},
});
+3
View File
@@ -0,0 +1,3 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig();
+3803 -1566
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -8,6 +8,9 @@
"db:deploy": "prisma migrate deploy", "db:deploy": "prisma migrate deploy",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "bash scripts/cf-deploy.sh",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:push": "prisma db push", "db:push": "prisma db push",
@@ -37,7 +40,11 @@
"@mdx-js/react": "^3.1.1", "@mdx-js/react": "^3.1.1",
"@modelcontextprotocol/sdk": "^1.24.3", "@modelcontextprotocol/sdk": "^1.24.3",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@neondatabase/serverless": "^1.0.2",
"@next/mdx": "^16.1.1", "@next/mdx": "^16.1.1",
"@opennextjs/cloudflare": "^1.17.1",
"@prisma/adapter-neon": "^7.5.0",
"@prisma/adapter-pg": "^7.5.0",
"@prisma/client": "^6.19.0", "@prisma/client": "^6.19.0",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
@@ -66,11 +73,13 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lucide-react": "^0.556.0", "lucide-react": "^0.556.0",
"monaco-editor": "^0.55.1",
"next": "^16.0.10", "next": "^16.0.10",
"next-auth": "^5.0.0-beta.30", "next-auth": "^5.0.0-beta.30",
"next-intl": "^4.5.8", "next-intl": "^4.5.8",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"openai": "^6.10.0", "openai": "^6.10.0",
"pg": "^8.20.0",
"pino": "^10.1.0", "pino": "^10.1.0",
"prompts.chat": "^0.0.7", "prompts.chat": "^0.0.7",
"react": "19.2.0", "react": "19.2.0",
@@ -95,6 +104,7 @@
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.18.0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
@@ -111,7 +121,8 @@
"tsx": "^4.21.0", "tsx": "^4.21.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5", "typescript": "^5",
"vitest": "^2.1.8" "vitest": "^2.1.8",
"wrangler": "^4.75.0"
}, },
"engines": { "engines": {
"node": "24.x" "node": "24.x"
+2 -1
View File
@@ -1,5 +1,6 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
} }
datasource db { datasource db {
+123
View File
@@ -0,0 +1,123 @@
#!/bin/bash
set -e
echo "🚀 Cloudflare Workers Deploy Script"
echo "======================================"
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_ROOT"
SERVER_DIR=".open-next/server-functions/default"
# --- Step 1: Build with OpenNext ---
echo ""
echo "📦 Step 1: Building with OpenNext..."
CLOUDFLARE=1 npx opennextjs-cloudflare build
# --- Step 2: Post-build cleanup ---
echo ""
echo "📦 Step 2: Post-build patches..."
# --- Step 2b: Patch handler.mjs for Next.js 16 compat ---
# OpenNext's loadManifest throws on manifests it doesn't recognize.
# Next.js 16 adds new manifests (prefetch-hints, subresource-integrity, fallback-build).
# Patch: return {} for unknown manifests instead of throwing.
HANDLER="$SERVER_DIR/handler.mjs"
if grep -q 'Unexpected loadManifest' "$HANDLER"; then
sed -i '' 's|throw new Error(`Unexpected loadManifest(${path2}) call!`)|return{}|' "$HANDLER"
echo " ✅ Patched loadManifest to handle unknown Next.js 16 manifests"
fi
# --- Step 3: Stub heavy WASM/binary files to reduce bundle size ---
echo ""
echo "📦 Step 3: Stubbing heavy WASM files..."
# handler.mjs references files with absolute paths, causing wrangler's
# module collector to resolve them as: <server_dir>/<absolute_path>.
# We create stubs at both the normal and doubled path locations.
stub_wasm_file() {
local REL_PATH="$1"
local NORMAL="$SERVER_DIR/$REL_PATH"
local DOUBLED="$SERVER_DIR$PROJECT_ROOT/$SERVER_DIR/$REL_PATH"
for TARGET in "$NORMAL" "$DOUBLED"; do
mkdir -p "$(dirname "$TARGET")"
printf '\x00asm\x01\x00\x00\x00' > "$TARGET"
done
}
stub_empty_file() {
local REL_PATH="$1"
local NORMAL="$SERVER_DIR/$REL_PATH"
local DOUBLED="$SERVER_DIR$PROJECT_ROOT/$SERVER_DIR/$REL_PATH"
for TARGET in "$NORMAL" "$DOUBLED"; do
mkdir -p "$(dirname "$TARGET")"
: > "$TARGET"
done
}
# Stub @vercel/og WASM + fonts (saves ~1.5 MiB gzipped)
# Stub in .open-next bundle
stub_wasm_file "node_modules/next/dist/compiled/@vercel/og/resvg.wasm"
stub_empty_file "node_modules/next/dist/compiled/@vercel/og/Geist-Regular.ttf.bin"
stub_empty_file "node_modules/next/dist/compiled/@vercel/og/Geist-SemiBold.ttf.bin"
# Also stub in project root node_modules (wrangler resolves from here too)
REAL_RESVG="$PROJECT_ROOT/node_modules/next/dist/compiled/@vercel/og/resvg.wasm"
if [ -f "$REAL_RESVG" ] && [ "$(wc -c < "$REAL_RESVG")" -gt 100 ]; then
cp "$REAL_RESVG" "$REAL_RESVG.bak"
printf '\x00asm\x01\x00\x00\x00' > "$REAL_RESVG"
fi
echo " ✅ Stubbed @vercel/og WASM + fonts"
# Keep Prisma WASM query engine (needed for query compilation with driver adapters)
# Aggressively clean @prisma to fit within 10 MiB:
PRISMA_RUNTIME="$SERVER_DIR/node_modules/@prisma/client/runtime"
PRISMA_CLIENT="$SERVER_DIR/node_modules/@prisma/client"
if [ -d "$PRISMA_RUNTIME" ]; then
# Remove non-PostgreSQL engines
find "$PRISMA_RUNTIME" -name "*cockroachdb*" -delete 2>/dev/null
find "$PRISMA_RUNTIME" -name "*mysql*" -delete 2>/dev/null
find "$PRISMA_RUNTIME" -name "*sqlite*" -delete 2>/dev/null
find "$PRISMA_RUNTIME" -name "*sqlserver*" -delete 2>/dev/null
# Remove source maps (not needed at runtime)
find "$PRISMA_RUNTIME" -name "*.js.map" -delete 2>/dev/null
find "$PRISMA_RUNTIME" -name "*.mjs.map" -delete 2>/dev/null
# Remove duplicate .mjs versions of base64 WASM (keep only .js)
rm -f "$PRISMA_RUNTIME"/query_engine_bg.postgresql.wasm-base64.mjs 2>/dev/null
rm -f "$PRISMA_RUNTIME"/query_compiler_bg.postgresql.wasm-base64.mjs 2>/dev/null
# Remove query compiler WASM (only query engine is needed for driver adapters)
rm -f "$PRISMA_RUNTIME"/query_compiler_bg.postgresql.wasm-base64.js 2>/dev/null
# Remove unused runtimes (we only use wasm-engine-edge)
rm -f "$PRISMA_RUNTIME"/binary.js "$PRISMA_RUNTIME"/binary.mjs 2>/dev/null
rm -f "$PRISMA_RUNTIME"/library.js "$PRISMA_RUNTIME"/library.mjs 2>/dev/null
rm -f "$PRISMA_RUNTIME"/react-native.js 2>/dev/null
rm -f "$PRISMA_RUNTIME"/wasm-compiler-edge.js "$PRISMA_RUNTIME"/wasm-compiler-edge.mjs 2>/dev/null
rm -f "$PRISMA_RUNTIME"/edge-esm.js 2>/dev/null
rm -f "$PRISMA_RUNTIME"/index-browser.js "$PRISMA_RUNTIME"/index-browser.mjs 2>/dev/null
rm -f "$PRISMA_RUNTIME"/client.js "$PRISMA_RUNTIME"/client.mjs 2>/dev/null
# Remove generator-build (not needed at runtime)
rm -rf "$PRISMA_CLIENT/generator-build" 2>/dev/null
echo " ✅ Cleaned Prisma: kept only PostgreSQL WASM engine + wasm-engine-edge runtime"
fi
# --- Step 4: Check bundle size ---
echo ""
echo "📦 Step 4: Checking bundle size..."
npx wrangler deploy --dry-run --outdir /tmp/cf-dry-run 2>&1 | grep "Total Upload" || true
# --- Step 5: Deploy ---
echo ""
echo "📦 Step 5: Deploying to Cloudflare Workers..."
npx opennextjs-cloudflare deploy
# --- Step 6: Restore stubbed files in project root ---
REAL_RESVG="$PROJECT_ROOT/node_modules/next/dist/compiled/@vercel/og/resvg.wasm"
if [ -f "$REAL_RESVG.bak" ]; then
mv "$REAL_RESVG.bak" "$REAL_RESVG"
echo " ✅ Restored project root resvg.wasm"
fi
echo ""
echo "✅ Deploy complete!"
+4 -303
View File
@@ -1,304 +1,5 @@
import { ImageResponse } from "next/og"; // OG image generation disabled for Cloudflare Workers (resvg.wasm too large)
import { db } from "@/lib/db"; // Falls back to static og.png from the public directory
import { getConfig } from "@/lib/config"; export default function OGImage() {
return new Response(null, { status: 302, headers: { Location: "/og.png" } });
export const alt = "User Profile";
export const size = {
width: 1200,
height: 630,
};
export const contentType = "image/png";
const radiusMap: Record<string, number> = {
none: 0,
sm: 8,
md: 12,
lg: 16,
};
export default async function OGImage({ params }: { params: Promise<{ username: string }> }) {
const { username: rawUsername } = await params;
const config = await getConfig();
const radius = radiusMap[config.theme?.radius || "sm"] || 8;
// Only support /@username format
const decodedUsername = decodeURIComponent(rawUsername);
if (!decodedUsername.startsWith("@")) {
return new ImageResponse(
(
<div
style={{
height: "100%",
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#0a0a0a",
color: "#fff",
fontSize: 48,
fontWeight: 600,
}}
>
User Not Found
</div>
),
{ ...size }
);
}
const username = decodedUsername.slice(1);
const user = await db.user.findFirst({
where: { username: { equals: username, mode: "insensitive" } },
select: {
id: true,
name: true,
username: true,
avatar: true,
role: true,
createdAt: true,
_count: {
select: {
prompts: true,
},
},
},
});
if (!user) {
return new ImageResponse(
(
<div
style={{
height: "100%",
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#0a0a0a",
color: "#fff",
fontSize: 48,
fontWeight: 600,
}}
>
User Not Found
</div>
),
{ ...size }
);
}
// Get total upvotes received
const totalUpvotes = await db.promptVote.count({
where: {
prompt: {
authorId: user.id,
},
},
});
// Format join date
const joinDate = new Intl.DateTimeFormat("en-US", {
month: "short",
year: "numeric",
}).format(user.createdAt);
const primaryColor = config.theme?.colors?.primary || "#6366f1";
return new ImageResponse(
(
<div
style={{
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#ffffff",
padding: "48px 56px",
}}
>
{/* Top Bar - Branding */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 40,
}}
>
<span style={{ fontSize: 24, fontWeight: 600, color: primaryColor }}>
{config.branding.name}
</span>
{user.role === "ADMIN" && (
<div
style={{
display: "flex",
alignItems: "center",
backgroundColor: primaryColor + "20",
color: primaryColor,
padding: "8px 16px",
borderRadius: radius * 2,
fontSize: 18,
fontWeight: 600,
}}
>
Admin
</div>
)}
</div>
{/* Main Content */}
<div
style={{
display: "flex",
flex: 1,
alignItems: "center",
gap: 48,
}}
>
{/* Avatar */}
{user.avatar ? (
<img
src={user.avatar}
width={180}
height={180}
style={{
borderRadius: 90,
border: `4px solid ${primaryColor}30`,
}}
/>
) : (
<div
style={{
width: 180,
height: 180,
borderRadius: 90,
backgroundColor: "#f4f4f5",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#71717a",
fontSize: 72,
fontWeight: 600,
border: `4px solid ${primaryColor}30`,
}}
>
{(user.name || user.username).charAt(0).toUpperCase()}
</div>
)}
{/* User Info */}
<div
style={{
display: "flex",
flexDirection: "column",
flex: 1,
}}
>
{/* Name */}
<div
style={{
display: "flex",
fontSize: 56,
fontWeight: 700,
color: "#18181b",
lineHeight: 1.2,
letterSpacing: "-0.02em",
marginBottom: 8,
}}
>
{user.name || user.username}
</div>
{/* Username */}
<div
style={{
display: "flex",
fontSize: 28,
color: "#71717a",
marginBottom: 32,
}}
>
@{user.username}
</div>
{/* Stats */}
<div
style={{
display: "flex",
gap: 40,
}}
>
{/* Prompts */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
backgroundColor: "#f4f4f5",
padding: "14px 24px",
borderRadius: radius * 2,
}}
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#71717a" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
<span style={{ fontSize: 26, fontWeight: 600, color: "#18181b" }}>
{user._count.prompts}
</span>
<span style={{ fontSize: 22, color: "#71717a" }}>
prompts
</span>
</div>
{/* Upvotes */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
backgroundColor: primaryColor + "15",
padding: "14px 24px",
borderRadius: radius * 2,
}}
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke={primaryColor} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="m18 15-6-6-6 6" />
</svg>
<span style={{ fontSize: 26, fontWeight: 600, color: primaryColor }}>
{totalUpvotes}
</span>
<span style={{ fontSize: 22, color: "#71717a" }}>
upvotes
</span>
</div>
{/* Joined */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
backgroundColor: "#f4f4f5",
padding: "14px 24px",
borderRadius: radius * 2,
}}
>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#71717a" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="18" height="18" x="3" y="4" rx="2" ry="2" />
<line x1="16" x2="16" y1="2" y2="6" />
<line x1="8" x2="8" y1="2" y2="6" />
<line x1="3" x2="21" y1="10" y2="10" />
</svg>
<span style={{ fontSize: 22, color: "#71717a" }}>
Joined {joinDate}
</span>
</div>
</div>
</div>
</div>
</div>
),
{ ...size }
);
} }
+11 -5
View File
@@ -1,17 +1,23 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { getStoragePlugin } from "@/lib/plugins/registry"; import { getStoragePlugin } from "@/lib/plugins/registry";
import sharp from "sharp";
const MAX_IMAGE_SIZE = 4 * 1024 * 1024; // 4MB for images const MAX_IMAGE_SIZE = 4 * 1024 * 1024; // 4MB for images
const MAX_VIDEO_SIZE = 4 * 1024 * 1024; // 4MB for videos (Vercel serverless limit) const MAX_VIDEO_SIZE = 4 * 1024 * 1024; // 4MB for videos (Vercel serverless limit)
const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]; const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
const ALLOWED_VIDEO_TYPES = ["video/mp4"]; const ALLOWED_VIDEO_TYPES = ["video/mp4"];
async function compressToJpg(buffer: Buffer): Promise<Buffer> { async function compressToJpg(buffer: Buffer): Promise<Buffer> {
return await sharp(buffer) try {
.jpeg({ quality: 90, mozjpeg: true }) // Use turbopackIgnore to prevent Turbopack from tracing this native module
.toBuffer(); // sharp is unavailable on Cloudflare Workers and will gracefully fallback
const sharp = (await import(/* turbopackIgnore: true */ "sharp")).default;
return await sharp(buffer)
.jpeg({ quality: 90, mozjpeg: true })
.toBuffer();
} catch {
// sharp is not available (e.g. on Cloudflare Workers), return buffer as-is
return buffer;
}
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
+1 -9
View File
@@ -1,25 +1,17 @@
"use client"; "use client";
import * as Sentry from "@sentry/nextjs";
import NextError from "next/error"; import NextError from "next/error";
import { useEffect } from "react";
export default function GlobalError({ export default function GlobalError({
error, error,
}: { }: {
error: Error & { digest?: string }; error: Error & { digest?: string };
}) { }) {
useEffect(() => { console.error(error);
Sentry.captureException(error);
}, [error]);
return ( return (
<html lang="en"> <html lang="en">
<body> <body>
{/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a
generic error message. */}
<NextError statusCode={0} /> <NextError statusCode={0} />
</body> </body>
</html> </html>
+4 -359
View File
@@ -1,360 +1,5 @@
import { ImageResponse } from "next/og"; // OG image generation disabled for Cloudflare Workers (resvg.wasm too large)
import { db } from "@/lib/db"; // Falls back to static og.png from the public directory
import { getConfig } from "@/lib/config"; export default function OGImage() {
return new Response(null, { status: 302, headers: { Location: "/og.png" } });
export const alt = "Prompt Preview";
export const size = {
width: 1200,
height: 630,
};
export const contentType = "image/png";
const typeLabels: Record<string, string> = {
TEXT: "Text Prompt",
IMAGE: "Image Prompt",
VIDEO: "Video Prompt",
AUDIO: "Audio Prompt",
STRUCTURED: "Structured",
};
const typeColors: Record<string, string> = {
TEXT: "#3b82f6",
IMAGE: "#8b5cf6",
VIDEO: "#ec4899",
AUDIO: "#f59e0b",
STRUCTURED: "#10b981",
};
const radiusMap: Record<string, number> = {
none: 0,
sm: 8,
md: 12,
lg: 16,
};
/**
* Extracts the prompt ID from a URL parameter that may contain a slug
*/
function extractPromptId(idParam: string): string {
const underscoreIndex = idParam.indexOf("_");
if (underscoreIndex !== -1) {
return idParam.substring(0, underscoreIndex);
}
return idParam;
}
export default async function OGImage({ params }: { params: Promise<{ id: string }> }) {
const { id: idParam } = await params;
const id = extractPromptId(idParam);
const config = await getConfig();
const radius = radiusMap[config.theme?.radius || "sm"] || 8;
const radiusLg = radius * 2; // For larger elements like content box
const prompt = await db.prompt.findUnique({
where: { id },
include: {
author: {
select: {
name: true,
username: true,
avatar: true,
},
},
category: {
select: {
name: true,
icon: true,
},
},
_count: {
select: { votes: true, contributors: true },
},
},
});
if (!prompt) {
return new ImageResponse(
(
<div
style={{
height: "100%",
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#0a0a0a",
color: "#fff",
fontSize: 48,
fontWeight: 600,
}}
>
Prompt Not Found
</div>
),
{ ...size }
);
}
// For structured prompts, try to prettify JSON/YAML
let displayContent = prompt.content;
if (prompt.type === "STRUCTURED") {
try {
if (prompt.structuredFormat === "JSON") {
const parsed = JSON.parse(prompt.content);
displayContent = JSON.stringify(parsed, null, 2);
}
} catch {
// Keep original if parsing fails
}
}
const truncatedContent = displayContent.length > 400
? displayContent.slice(0, 400) + "..."
: displayContent;
const isImagePrompt = prompt.type === "IMAGE" && prompt.mediaUrl;
const isStructuredPrompt = prompt.type === "STRUCTURED";
const voteCount = prompt._count.votes;
const contributorCount = prompt._count.contributors;
return new ImageResponse(
(
<div
style={{
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#ffffff",
padding: "48px 56px",
}}
>
{/* Top Bar */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 24,
}}
>
{/* Left: Branding */}
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<span style={{ fontSize: 24, fontWeight: 600, color: config.theme?.colors?.primary || "#6366f1" }}>
{config.branding.name}
</span>
</div>
{/* Right: Stats */}
<div style={{ display: "flex", alignItems: "center", gap: 24 }}>
{/* Upvotes */}
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke={config.theme?.colors?.primary || "#6366f1"} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="m18 15-6-6-6 6" />
</svg>
<span style={{ fontSize: 24, fontWeight: 600, color: config.theme?.colors?.primary || "#6366f1" }}>
{voteCount}
</span>
</div>
{/* Contributors */}
{contributorCount > 0 && (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#a1a1aa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span style={{ fontSize: 20, color: "#71717a" }}>
+{contributorCount}
</span>
</div>
)}
</div>
</div>
{/* Main Content Area */}
<div
style={{
display: "flex",
flex: 1,
gap: 40,
}}
>
{/* Left Content */}
<div
style={{
display: "flex",
flexDirection: "column",
flex: 1,
}}
>
{/* Title Row with Category and Type Badge */}
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 20,
marginBottom: 16,
}}
>
<div
style={{
display: "flex",
fontSize: 48,
fontWeight: 700,
color: "#18181b",
lineHeight: 1.2,
letterSpacing: "-0.02em",
flex: 1,
}}
>
{prompt.title}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexShrink: 0 }}>
{/* Category Badge */}
{prompt.category && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
backgroundColor: "#f4f4f5",
color: "#71717a",
padding: "8px 14px",
borderRadius: radius * 2.5,
fontSize: 20,
fontWeight: 500,
}}
>
{prompt.category.icon && <span>{prompt.category.icon}</span>}
<span>{prompt.category.name}</span>
</div>
)}
{/* Type Badge */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
backgroundColor: typeColors[prompt.type] + "30",
color: typeColors[prompt.type],
padding: "8px 16px",
borderRadius: radius * 2.5,
fontSize: 20,
fontWeight: 600,
}}
>
{typeLabels[prompt.type] || prompt.type}
</div>
</div>
</div>
{/* Content Preview */}
<div
style={{
display: "flex",
flexDirection: "column",
fontSize: isStructuredPrompt ? 18 : 22,
color: "#3f3f46",
lineHeight: isStructuredPrompt ? 1.4 : 1.6,
flex: 1,
backgroundColor: "#fafafa",
padding: "12px 14px",
borderRadius: radius,
border: `2px solid ${config.theme?.colors?.primary || "#6366f1"}20`,
overflow: "hidden",
}}
>
{isStructuredPrompt && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 12,
paddingBottom: 12,
borderBottom: "1px solid #e4e4e7",
}}
>
<span style={{ color: config.theme?.colors?.primary || "#6366f1", fontWeight: 600, fontSize: 14 }}>
{prompt.structuredFormat || "JSON"}
</span>
</div>
)}
<div style={{ display: "flex", whiteSpace: isStructuredPrompt ? "pre" : "pre-wrap" }}>
{truncatedContent}
</div>
</div>
{/* Footer - Author Info */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
marginTop: 20,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
{/* Avatar */}
{prompt.author.avatar ? (
<img
src={prompt.author.avatar}
width={48}
height={48}
style={{ borderRadius: 24, border: "2px solid #e4e4e7" }}
/>
) : (
<div
style={{
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: "#f4f4f5",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#71717a",
fontSize: 20,
fontWeight: 600,
border: "2px solid #e4e4e7",
}}
>
{(prompt.author.name || prompt.author.username).charAt(0).toUpperCase()}
</div>
)}
<div style={{ display: "flex", flexDirection: "column" }}>
<span style={{ color: "#18181b", fontSize: 20, fontWeight: 600 }}>
{prompt.author.name || prompt.author.username}
</span>
<span style={{ color: "#71717a", fontSize: 16 }}>
@{prompt.author.username}
</span>
</div>
</div>
</div>
</div>
{/* Image Preview (for image prompts) */}
{isImagePrompt && (
<img
src={prompt.mediaUrl!}
width={280}
height={420}
style={{
borderRadius: radiusLg,
objectFit: "cover",
objectPosition: "center",
border: "2px solid #e4e4e7",
}}
/>
)}
</div>
</div>
),
{ ...size }
);
} }
+1 -1
View File
@@ -1 +1 @@
export { default, alt, size, contentType } from "./opengraph-image"; export { default } from "./opengraph-image";
+1 -81
View File
@@ -1,81 +1 @@
// This file configures the initialization of Sentry on the client. // Sentry client instrumentation disabled for Cloudflare Workers build
// The added config here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
// Patterns to ignore - typically from browser extensions or third-party scripts
const ignoreErrors = [
// Browser extension errors
/MetaMask/i,
/ethereum/i,
/tronlink/i,
/tron/i,
/webkit\.messageHandlers/i,
/disconnected port/i,
/__firefox__/i,
// DOM manipulation errors often caused by extensions
/removeChild.*not a child/i,
/parentNode.*null/i,
// Third-party/extension scripts
/CONFIG.*not defined/i,
/Can't find variable: CONFIG/i,
];
Sentry.init({
dsn: "https://9c2eb3b4441745efad28a908001c30bf@o4510673866063872.ingest.de.sentry.io/4510673871306832",
// Disable Sentry in development
enabled: process.env.NODE_ENV === "production",
// Add optional integrations for additional features
integrations: [Sentry.replayIntegration()],
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: 1,
// Enable logs to be sent to Sentry
enableLogs: true,
// Define how likely Replay events are sampled.
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
// Define how likely Replay events are sampled when an error occurs.
replaysOnErrorSampleRate: 1.0,
// Enable sending user PII (Personally Identifiable Information)
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
sendDefaultPii: true,
// Filter out browser extension and third-party script errors
beforeSend(event) {
const message = event.exception?.values?.[0]?.value || "";
const type = event.exception?.values?.[0]?.type || "";
const fullMessage = `${type}: ${message}`;
// Check if error matches any ignore pattern
if (ignoreErrors.some((pattern) => pattern.test(fullMessage))) {
return null;
}
// Filter out errors from browser extension scripts
const frames = event.exception?.values?.[0]?.stacktrace?.frames || [];
const hasExtensionFrame = frames.some((frame) => {
const filename = frame.filename || "";
return (
filename.includes("extension://") ||
filename.includes("moz-extension://") ||
filename.includes("chrome-extension://")
);
});
if (hasExtensionFrame) {
return null;
}
return event;
},
});
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
+3 -13
View File
@@ -1,13 +1,3 @@
import * as Sentry from "@sentry/nextjs"; // Sentry disabled for Cloudflare Workers to reduce bundle size
export async function register() {}
export async function register() { export const onRequestError = () => {};
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("../sentry.server.config");
}
if (process.env.NEXT_RUNTIME === "edge") {
await import("../sentry.edge.config");
}
}
export const onRequestError = Sentry.captureRequestError;
+20 -8
View File
@@ -33,14 +33,26 @@ export function loadPrompt(relativePath: string): PromptFile {
return promptCache.get(relativePath)!; return promptCache.get(relativePath)!;
} }
const absolutePath = join(process.cwd(), relativePath); try {
const content = readFileSync(absolutePath, "utf-8"); const absolutePath = join(process.cwd(), relativePath);
const prompt = parse(content) as PromptFile; const content = readFileSync(absolutePath, "utf-8");
const prompt = parse(content) as PromptFile;
// Cache the result
promptCache.set(relativePath, prompt); // Cache the result
promptCache.set(relativePath, prompt);
return prompt;
return prompt;
} catch {
// On edge runtimes (e.g. Cloudflare Workers) filesystem is unavailable.
// Return a minimal fallback so the module can still load.
const fallback: PromptFile = {
name: relativePath,
description: "",
messages: [],
};
promptCache.set(relativePath, fallback);
return fallback;
}
} }
/** /**
+25 -7
View File
@@ -1,11 +1,23 @@
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { PrismaNeon } from "@prisma/adapter-neon";
const globalForPrisma = globalThis as unknown as { const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined; prisma: PrismaClient | undefined;
}; };
// Configure Prisma for serverless environments with connection pooling // Lazily initialize Prisma client on first property access.
const prismaClientSingleton = () => { // On Cloudflare Workers: uses Neon serverless adapter (HTTP-based).
// On Node.js (Vercel, local dev): uses standard PrismaClient with DATABASE_URL.
function createPrismaClient(): PrismaClient {
const dbUrl = process.env.DATABASE_URL || "";
// Neon database: use Neon HTTP adapter (works on both edge and Node.js)
if (dbUrl.includes("neon.tech")) {
const adapter = new PrismaNeon({ connectionString: dbUrl });
return new PrismaClient({ adapter });
}
// Standard Node.js environment with non-Neon database
return new PrismaClient({ return new PrismaClient({
log: log:
process.env.NODE_ENV === "development" process.env.NODE_ENV === "development"
@@ -13,9 +25,15 @@ const prismaClientSingleton = () => {
: ["error"], : ["error"],
datasourceUrl: process.env.DATABASE_URL, datasourceUrl: process.env.DATABASE_URL,
}); });
}; }
export const db = globalForPrisma.prisma ?? prismaClientSingleton(); // Use a Proxy so existing code (`db.user.findMany()`) works unchanged.
// The real PrismaClient is created on first property access (during request handling).
// Always reuse the same instance to prevent connection pool exhaustion export const db: PrismaClient = new Proxy({} as PrismaClient, {
globalForPrisma.prisma = db; get(_target, prop, receiver) {
if (!globalForPrisma.prisma) {
globalForPrisma.prisma = createPrismaClient();
}
return Reflect.get(globalForPrisma.prisma, prop, receiver);
},
});
+2 -26
View File
@@ -1,34 +1,10 @@
import { registerStoragePlugin } from "../registry"; import { registerStoragePlugin } from "../registry";
import { urlStoragePlugin } from "./url"; import { urlStoragePlugin } from "./url";
const ENABLED_STORAGE: string = process.env.ENABLED_STORAGE || "url";
// Register all built-in storage plugins // Register all built-in storage plugins
// S3 and DO Spaces storage removed for Cloudflare Workers (saves ~850 KiB from @aws-sdk)
export function registerBuiltInStoragePlugins(): void { export function registerBuiltInStoragePlugins(): void {
if (ENABLED_STORAGE === "url") { registerStoragePlugin(urlStoragePlugin);
registerStoragePlugin(urlStoragePlugin);
return
}
if (ENABLED_STORAGE === "s3") {
// To enable S3 storage:
// 1. Set S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY env vars
import("./s3").then(({ s3StoragePlugin }) => {
registerStoragePlugin(s3StoragePlugin);
});
return;
}
if (ENABLED_STORAGE === "do-spaces") {
// To enable DO Spaces storage:
// 1. Set DO_SPACES_BUCKET, DO_SPACES_REGION, DO_SPACES_ACCESS_KEY_ID, DO_SPACES_SECRET_ACCESS_KEY env vars
import("./do-spaces").then(({ doSpacesStoragePlugin }) => {
registerStoragePlugin(doSpacesStoragePlugin);
});
return;
}
console.warn(`No storage plugin enabled for "${ENABLED_STORAGE}"`);
} }
export { urlStoragePlugin }; export { urlStoragePlugin };
+1 -1
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
export function proxy(request: NextRequest) { export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
// Rewrite .prompt.md and .prompt.yml requests to the raw API route // Rewrite .prompt.md and .prompt.yml requests to the raw API route
-6
View File
@@ -1,4 +1,3 @@
import * as Sentry from "@sentry/nextjs";
import type { NextPageContext } from "next"; import type { NextPageContext } from "next";
import Error, { ErrorProps } from "next/error"; import Error, { ErrorProps } from "next/error";
@@ -7,11 +6,6 @@ const CustomErrorComponent = (props: ErrorProps) => {
}; };
CustomErrorComponent.getInitialProps = async (contextData: NextPageContext) => { CustomErrorComponent.getInitialProps = async (contextData: NextPageContext) => {
// In case this is running in a serverless function, await this in order to give Sentry
// time to send the error before the lambda exits
await Sentry.captureUnderscoreErrorException(contextData);
// This will contain the status code of the response
return Error.getInitialProps(contextData); return Error.getInitialProps(contextData);
}; };
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "./node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "prompts-chat",
// Set compatibility date
"compatibility_date": "2026-03-18",
"compatibility_flags": [
"nodejs_compat"
],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"hyperdrive": [
{
"binding": "HYPERDRIVE",
"id": "2c6ef99cbde84baaac5edacc418eee72",
"localConnectionString": "postgresql://fka:postgres@localhost:5433/prompts_chat?schema=public"
}
]
}