├── .env.sample ├── .gitignore ├── README.md ├── components.json ├── docker-compose.yml ├── env.d.ts ├── next.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── file.svg ├── globe.svg ├── next.svg ├── vercel.svg └── window.svg ├── src ├── app │ ├── .well-known │ │ └── farcaster.json │ │ │ └── route.ts │ ├── api │ │ ├── challenge │ │ │ └── route.ts │ │ ├── sign-in │ │ │ └── route.ts │ │ ├── user │ │ │ ├── notifications │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ └── webhooks │ │ │ └── farcaster │ │ │ └── route.ts │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ └── providers.tsx ├── lib │ ├── auth.ts │ ├── bullboard.ts │ ├── constants.ts │ ├── db.ts │ ├── errors.ts │ ├── farcaster.ts │ ├── keys.ts │ ├── notifications.ts │ ├── queue.ts │ ├── redis.ts │ └── utils.ts ├── migrations │ └── 001_initial.ts ├── providers │ └── SessionProvider.tsx ├── scripts │ └── migrate.ts ├── types │ ├── db.ts │ ├── jobs.ts │ └── user.ts └── workers │ ├── index.ts │ └── notifications.ts ├── tailwind.config.ts └── tsconfig.json /.env.sample: -------------------------------------------------------------------------------- 1 | 2 | # App URL - For frame webhooks 3 | APP_URL="http://localhost:3000" 4 | 5 | # Farcaster Hub URL for fetching user data 6 | HUB_URL="https://nemes.farcaster.xyz:2281" 7 | 8 | # Postgres database URL 9 | DATABASE_URL="postgresql://postgres:password@localhost:5432/postgres" 10 | 11 | # Neynar - For getting farcaster user data 12 | NEYNAR_API_KEY= 13 | 14 | # Redis 15 | REDIS_URL="redis://localhost:6379" 16 | 17 | # Bullboard - For monitoring the queue (will be unprotected if not set) 18 | BULL_BOARD_USERNAME= 19 | BULL_BOARD_PASSWORD= 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stephan's Frames v2 Starter 2 | 3 | This is an opinionated starter for building v2 frames 4 | 5 | ## Stack 6 | - Next.js 7 | - PostgreSQL (with Kysely) 8 | - Redis 9 | - Lucia Auth 10 | - BullMQ 11 | - shadcn/ui 12 | 13 | ## Features 14 | - Sign in with Farcaster 15 | - Customizable db schema 16 | - Authenticated endpoints 17 | - Notification utils 18 | - Farcaster social graph helpers 19 | 20 | ## Getting Started 21 | 22 | ### Database 23 | 24 | First, start the database and run the migrations 25 | 26 | ``` 27 | docker-compose up -d 28 | ``` 29 | 30 | ``` 31 | pnpm run migrate 32 | ``` 33 | 34 | ### Environment Variables 35 | 36 | Copy the `.env.sample` file to `.env` and fill in the values. 37 | 38 | > **Note:** The `APP_URL` environment variable is used to configure the frame. It should be the URL of your local dev server. 39 | 40 | ### Frame 41 | 42 | To start your local dev server, run the following command: 43 | 44 | ``` 45 | pnpm run dev 46 | ``` 47 | 48 | This should start the dev server at `http://localhost:3000`. 49 | 50 | To debug the frame locally, you can use the frames.js debugger, which you can run with the following command: 51 | 52 | ``` 53 | npx @frames.js/debugger@latest 54 | ``` 55 | 56 | Once the debugger is running, you can load the frame in the debugger by entering the URL of your local dev server in the debugger. 57 | 58 | > **Note:** Make sure to select the Farcaster v2 option in the debugger next to the "Debug" button and ensure that you are signed in with your Farcaster account and not impersonating another account. 59 | 60 | ### Debugging on other URLs 61 | 62 | If you want to test the frame on a different URL such as ngrok or a production URL, you can update the `accountAssociations` in `src/app/.well-known/farcaster.json/route.ts` to include the URL you want to test. You can generate account associations in the frames.js debugger or in the Warpcast app (Settings -> Developer -> Domains). 63 | 64 | Then update the `APP_URL` environment variable in your `.env` file to the URL you want to test to ensure the correct associations are used. 65 | 66 | ## Authentication 67 | 68 | This project uses Lucia Auth for authentication. You can learn more about it [here](https://lucia-auth.com/). 69 | 70 | You can create new authorized endpoints by using the `withAuth` exported from `src/lib/auth.ts`. See the `src/app/api/user/route.ts` file for an example of how to use it. 71 | 72 | ## Farcaster data 73 | 74 | There are some helpers for fetching farcaster data in `src/lib/farcaster.ts`. Wrap these calls with `withCache` to cache the results in Redis. 75 | 76 | ```ts 77 | const mutuals = await withCache(`fc:mutuals:${user.fid}`, () => 78 | getMutuals(user.fid) 79 | ); 80 | ``` 81 | 82 | ## Custommization 83 | 84 | ### Database 85 | 86 | To make a change to the database, create a migration file in `src/migrations` and run the following command: 87 | 88 | ``` 89 | pnpm run migrate 90 | ``` 91 | 92 | And update the `src/types/db.ts` file to reflect the new schema. (Kysely camelcase plugin is enabled so you can use camelcase in your types) 93 | 94 | ## Workers 95 | 96 | This project uses a redis queue for dispatching notification jobs. 97 | 98 | There is no build step. Just run the workers file 99 | 100 | ``` 101 | pnpm run workers 102 | ``` 103 | 104 | This project uses Bullboard for monitoring the queue. You can access it at `http://localhost:3005/`. In production you can protect this endpoint with a password by setting the `BULL_BOARD_USERNAME` and `BULL_BOARD_PASSWORD` environment variables. 105 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | postgres: 5 | image: 'postgres:16-alpine' 6 | restart: unless-stopped 7 | ports: 8 | - '5432:5432' 9 | environment: 10 | - POSTGRES_DB=postgres 11 | - POSTGRES_USER=postgres 12 | - POSTGRES_PASSWORD=password 13 | volumes: 14 | - postgres-data:/var/lib/postgresql/data 15 | healthcheck: 16 | test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB'] 17 | interval: 5s # Check every 5 seconds for readiness 18 | timeout: 5s # Allow up to 5 seconds for a response 19 | retries: 3 # Fail after 3 unsuccessful attempts 20 | start_period: 10s # Start checks after 10 seconds 21 | networks: 22 | - app-network 23 | 24 | redis: 25 | image: 'redis:7.2-alpine' 26 | restart: unless-stopped 27 | command: --loglevel warning --maxmemory-policy noeviction 28 | volumes: 29 | - redis-data:/data 30 | ports: 31 | - '6379:6379' 32 | healthcheck: 33 | test: ['CMD-SHELL', 'redis-cli ping'] 34 | interval: 5s # Check every 5 seconds 35 | timeout: 5s # Allow up to 5 seconds for a response 36 | retries: 3 # Fail after 3 unsuccessful attempts 37 | start_period: 5s # Start health checks after 5 seconds 38 | networks: 39 | - app-network 40 | 41 | volumes: 42 | postgres-data: 43 | redis-data: 44 | 45 | networks: 46 | app-network: 47 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | DATABASE_URL: string; 5 | APP_URL: string; 6 | NEYNAR_API_KEY: string; 7 | HUB_URL: string; 8 | } 9 | } 10 | } 11 | 12 | export {}; 13 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-frame", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "migrate": "tsx src/scripts/migrate.ts", 11 | "test": "NODE_OPTIONS=--experimental-vm-modules jest", 12 | "workers": "tsx src/workers/index.ts", 13 | "db:reset": "docker-compose down -v && docker-compose up -d" 14 | }, 15 | "dependencies": { 16 | "@bull-board/api": "^6.5.4", 17 | "@bull-board/express": "^6.5.4", 18 | "@farcaster/auth-client": "^0.3.0", 19 | "@farcaster/core": "^0.15.6", 20 | "@farcaster/frame-node": "^0.0.10", 21 | "@farcaster/frame-sdk": "^0.0.26", 22 | "@lucia-auth/adapter-postgresql": "^3.1.2", 23 | "@neynar/nodejs-sdk": "^2.7.0", 24 | "@radix-ui/react-toast": "^1.2.4", 25 | "@sentry/nextjs": "^8", 26 | "@tanstack/react-query": "^5.62.7", 27 | "bullmq": "^5.34.2", 28 | "class-variance-authority": "^0.7.1", 29 | "clsx": "^2.1.1", 30 | "date-fns": "^4.1.0", 31 | "date-fns-tz": "^3.2.0", 32 | "dotenv": "^16.4.7", 33 | "express": "^4.21.2", 34 | "ioredis": "^5.4.1", 35 | "kysely": "^0.27.5", 36 | "lucia": "^3.2.2", 37 | "lucide-react": "^0.468.0", 38 | "next": "15.1.4", 39 | "ox": "^0.4.4", 40 | "pg": "^8.13.1", 41 | "pg-cursor": "^2.12.1", 42 | "react": "^19.0.0", 43 | "react-dom": "^19.0.0", 44 | "siwe": "^2.3.2", 45 | "tailwind-merge": "^2.6.0", 46 | "tailwindcss-animate": "^1.0.7", 47 | "viem": "^2.21.57", 48 | "zod": "^3.24.1" 49 | }, 50 | "devDependencies": { 51 | "@types/express": "^5.0.0", 52 | "@types/jest": "^29.5.14", 53 | "@types/node": "^20", 54 | "@types/pg": "^8.11.10", 55 | "@types/pg-cursor": "^2.7.2", 56 | "@types/react": "^19", 57 | "@types/react-dom": "^19", 58 | "jest": "^29.7.0", 59 | "postcss": "^8", 60 | "tailwindcss": "^3.4.1", 61 | "ts-jest": "^29.2.5", 62 | "ts-node": "^10.9.2", 63 | "tsx": "^4.19.2", 64 | "typescript": "^5" 65 | }, 66 | "engines": { 67 | "node": ">=19" 68 | } 69 | } -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/.well-known/farcaster.json/route.ts: -------------------------------------------------------------------------------- 1 | export async function GET() { 2 | const appUrl = process.env.APP_URL; 3 | 4 | if (!appUrl) { 5 | throw new Error("APP_URL is not set"); 6 | } 7 | 8 | const config = { 9 | accountAssociation: accountAssociations[appUrl], 10 | frame: { 11 | version: "1", 12 | name: "Frame", 13 | iconUrl: `${appUrl}/icon.png`, 14 | homeUrl: appUrl, 15 | imageUrl: `${appUrl}/og.png`, 16 | buttonTitle: "launch", 17 | splashImageUrl: `${appUrl}/splash.png`, 18 | splashBackgroundColor: "#f7f7f7", 19 | webhookUrl: `${appUrl}/api/webhooks/farcaster`, 20 | }, 21 | }; 22 | 23 | return Response.json(config); 24 | } 25 | 26 | /** Domain associations for different environments. Default is signed by @stephancill and is valid for localhost */ 27 | const accountAssociations = { 28 | "http://localhost:3000": { 29 | header: 30 | "eyJmaWQiOjE2ODksInR5cGUiOiJjdXN0b2R5Iiwia2V5IjoiMHgyNzM4QjIxY0I5NTIwMzM4RjlBMzc1YzNiOTcxQjE3NzhhZTEwMDRhIn0", 31 | payload: "eyJkb21haW4iOiJsb2NhbGhvc3QifQ", 32 | signature: 33 | "MHhmOWJkZGQ1MDA4Njc3NjZlYmI1ZmNjODk1NThjZWIxMTc5NjAwNjRlZmFkZWZjZmY4NGZhMzdiMjYxZjU1ZmYzMmZiMDg5NmY4NWU0MmM1YjM4MjQxN2NlMjFhOTBlYmM4YTIzOWFkNjE0YzA2ODM0ZDQ1ODk5NDI3YjE5ZjNkYTFi", 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /src/app/api/challenge/route.ts: -------------------------------------------------------------------------------- 1 | import { redisCache } from "@/lib/redis"; 2 | import { CHALLENGE_DURATION_SECONDS } from "@/lib/constants"; 3 | 4 | export async function POST(req: Request) { 5 | const { challengeId } = await req.json(); 6 | 7 | if (!challengeId) { 8 | return Response.json({ error: "Missing required fields" }, { status: 400 }); 9 | } 10 | 11 | const challenge = Buffer.from( 12 | crypto.getRandomValues(new Uint8Array(32)) 13 | ).toString("hex"); 14 | 15 | // Set the challenge with an expiration 16 | await redisCache.setex( 17 | `challenge:${challengeId}`, 18 | CHALLENGE_DURATION_SECONDS, 19 | challenge 20 | ); 21 | 22 | return Response.json({ 23 | challenge, 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /src/app/api/sign-in/route.ts: -------------------------------------------------------------------------------- 1 | import { lucia } from "@/lib/auth"; 2 | import { db } from "@/lib/db"; 3 | import { redisCache } from "@/lib/redis"; 4 | import { createAppClient, viemConnector } from "@farcaster/auth-client"; 5 | import { NextRequest } from "next/server"; 6 | 7 | const selectUser = db.selectFrom("users").selectAll(); 8 | 9 | export async function POST(req: NextRequest) { 10 | const { message, signature, challengeId, referrerId } = await req.json(); 11 | 12 | if (!signature || !challengeId || !message) { 13 | console.error("Missing required fields", { 14 | signature, 15 | challengeId, 16 | message, 17 | }); 18 | return Response.json({ error: "Missing required fields" }, { status: 400 }); 19 | } 20 | 21 | const challenge = await redisCache.get(`challenge:${challengeId}`); 22 | 23 | if (!challenge) { 24 | console.error("Challenge not found", { challengeId }); 25 | return Response.json({ error: "Challenge not found" }, { status: 400 }); 26 | } 27 | 28 | const appClient = createAppClient({ 29 | ethereum: viemConnector(), 30 | }); 31 | 32 | const verifyResponse = await appClient.verifySignInMessage({ 33 | message, 34 | signature, 35 | domain: new URL(process.env.APP_URL ?? "").hostname, 36 | nonce: challenge, 37 | }); 38 | 39 | if (!verifyResponse.success) { 40 | console.error("Invalid signature", { verifyResponse }); 41 | return Response.json({ error: "Invalid signature" }, { status: 400 }); 42 | } 43 | 44 | const fid = verifyResponse.fid; 45 | 46 | let dbUser; 47 | 48 | // Check if the fid is already registered 49 | const existingUser = await selectUser 50 | .where("fid", "=", fid) 51 | .executeTakeFirst(); 52 | 53 | if (existingUser) { 54 | dbUser = existingUser; 55 | } else { 56 | // Create user 57 | try { 58 | // Create the new user 59 | const newUser = await db 60 | .insertInto("users") 61 | .values({ 62 | fid, 63 | }) 64 | .returningAll() 65 | .executeTakeFirst(); 66 | 67 | if (!newUser) { 68 | throw new Error("Failed to create user"); 69 | } 70 | 71 | dbUser = newUser; 72 | } catch (error) { 73 | console.error(error); 74 | 75 | if (error instanceof Error) { 76 | return Response.json({ error: error.message }, { status: 500 }); 77 | } 78 | 79 | return Response.json({ error: "Failed to create user" }, { status: 500 }); 80 | } 81 | } 82 | 83 | if (!dbUser) { 84 | console.error("No db user found"); 85 | return Response.json({ error: "Failed to create user" }, { status: 500 }); 86 | } 87 | 88 | const session = await lucia.createSession(dbUser!.id, {}); 89 | 90 | return Response.json( 91 | { 92 | success: true, 93 | session, 94 | }, 95 | { 96 | headers: { 97 | "Set-Cookie": lucia.createSessionCookie(session.id).serialize(), 98 | }, 99 | } 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /src/app/api/user/notifications/route.ts: -------------------------------------------------------------------------------- 1 | import { withAuth } from "@/lib/auth"; 2 | import { setUserNotificationDetails } from "@/lib/notifications"; 3 | 4 | export const PATCH = withAuth(async (req, user) => { 5 | const { token, url } = await req.json(); 6 | 7 | if (token && url) { 8 | await setUserNotificationDetails(user.fid, { 9 | token, 10 | url, 11 | }); 12 | } 13 | 14 | return Response.json({ success: true }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/app/api/user/route.ts: -------------------------------------------------------------------------------- 1 | import { withAuth } from "@/lib/auth"; 2 | import { db } from "@/lib/db"; 3 | import { getUserData } from "@/lib/farcaster"; 4 | import { withCache } from "@/lib/redis"; 5 | import { User } from "@/types/user"; 6 | import { UserDataType } from "@farcaster/core"; 7 | import { getUserDataKey } from "../../../lib/keys"; 8 | 9 | export const GET = withAuth(async (req, luciaUser) => { 10 | const dbUser = await db 11 | .selectFrom("users") 12 | .selectAll() 13 | .where("users.id", "=", luciaUser.id) 14 | .executeTakeFirst(); 15 | 16 | if (!dbUser) { 17 | return Response.json({ error: "User not found" }, { status: 400 }); 18 | } 19 | 20 | const farcasterUser = await withCache( 21 | getUserDataKey(dbUser.fid), 22 | async () => { 23 | return await getUserData(dbUser.fid); 24 | }, 25 | { 26 | ttl: 60 * 60 * 24 * 7, // 1 week 27 | } 28 | ); 29 | 30 | const user: User = { 31 | fid: dbUser.fid, 32 | id: dbUser.id, 33 | notificationsEnabled: dbUser.notificationUrl !== null, 34 | username: farcasterUser[UserDataType.USERNAME], 35 | imageUrl: farcasterUser[UserDataType.PFP], 36 | }; 37 | 38 | return Response.json(user); 39 | }); 40 | -------------------------------------------------------------------------------- /src/app/api/webhooks/farcaster/route.ts: -------------------------------------------------------------------------------- 1 | import { 2 | deleteUserNotificationDetails, 3 | sendFrameNotification, 4 | setUserNotificationDetails, 5 | } from "@/lib/notifications"; 6 | import { 7 | createVerifyAppKeyWithHub, 8 | ParseWebhookEvent, 9 | parseWebhookEvent, 10 | } from "@farcaster/frame-node"; 11 | import { NextRequest } from "next/server"; 12 | 13 | export async function POST(request: NextRequest) { 14 | const requestJson = await request.json(); 15 | const verifier = createVerifyAppKeyWithHub(process.env.HUB_URL!); 16 | 17 | let data; 18 | try { 19 | data = await parseWebhookEvent(requestJson, verifier); 20 | } catch (e: unknown) { 21 | const error = e as ParseWebhookEvent.ErrorType; 22 | 23 | switch (error.name) { 24 | case "VerifyJsonFarcasterSignature.InvalidDataError": 25 | case "VerifyJsonFarcasterSignature.InvalidEventDataError": 26 | // The request data is invalid 27 | return Response.json( 28 | { success: false, error: error.message }, 29 | { status: 400 } 30 | ); 31 | case "VerifyJsonFarcasterSignature.InvalidAppKeyError": 32 | // The app key is invalid 33 | return Response.json( 34 | { success: false, error: error.message }, 35 | { status: 401 } 36 | ); 37 | case "VerifyJsonFarcasterSignature.VerifyAppKeyError": 38 | // Internal error verifying the app key (caller may want to try again) 39 | return Response.json( 40 | { success: false, error: error.message }, 41 | { status: 500 } 42 | ); 43 | } 44 | } 45 | 46 | const fid = data.fid; 47 | const event = data.event; 48 | 49 | switch (event.event) { 50 | case "frame_added": 51 | if (event.notificationDetails) { 52 | await setUserNotificationDetails(fid, event.notificationDetails); 53 | await sendFrameNotification({ 54 | token: event.notificationDetails.token, 55 | url: event.notificationDetails.url, 56 | title: "Welcome to Frame", 57 | body: "This frame has been added.", 58 | targetUrl: process.env.APP_URL, 59 | }); 60 | } else { 61 | await deleteUserNotificationDetails(fid); 62 | } 63 | 64 | break; 65 | case "frame_removed": 66 | await deleteUserNotificationDetails(fid); 67 | 68 | break; 69 | case "notifications_enabled": 70 | await setUserNotificationDetails(fid, event.notificationDetails); 71 | await sendFrameNotification({ 72 | token: event.notificationDetails.token, 73 | url: event.notificationDetails.url, 74 | title: "Notifications enabled", 75 | body: "Notifications have been enabled.", 76 | targetUrl: process.env.APP_URL, 77 | }); 78 | 79 | break; 80 | case "notifications_disabled": 81 | await deleteUserNotificationDetails(fid); 82 | 83 | break; 84 | } 85 | 86 | return Response.json({ success: true }); 87 | } 88 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephancill/frames-v2-template/b57eacc749d1f9c21679c35e0923d6521b21d5e3/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | 8 | --background: 0 0% 100%; 9 | 10 | --foreground: 0 0% 3.9%; 11 | 12 | --card: 0 0% 100%; 13 | 14 | --card-foreground: 0 0% 3.9%; 15 | 16 | --popover: 0 0% 100%; 17 | 18 | --popover-foreground: 0 0% 3.9%; 19 | 20 | --primary: 0 0% 9%; 21 | 22 | --primary-foreground: 0 0% 98%; 23 | 24 | --secondary: 0 0% 96.1%; 25 | 26 | --secondary-foreground: 0 0% 9%; 27 | 28 | --muted: 0 0% 96.1%; 29 | 30 | --muted-foreground: 0 0% 45.1%; 31 | 32 | --accent: 0 0% 96.1%; 33 | 34 | --accent-foreground: 0 0% 9%; 35 | 36 | --destructive: 0 84.2% 60.2%; 37 | 38 | --destructive-foreground: 0 0% 98%; 39 | 40 | --border: 0 0% 89.8%; 41 | 42 | --input: 0 0% 89.8%; 43 | 44 | --ring: 0 0% 3.9%; 45 | 46 | --chart-1: 12 76% 61%; 47 | 48 | --chart-2: 173 58% 39%; 49 | 50 | --chart-3: 197 37% 24%; 51 | 52 | --chart-4: 43 74% 66%; 53 | 54 | --chart-5: 27 87% 67%; 55 | 56 | --radius: 0.5rem} 57 | .dark { 58 | 59 | --background: 0 0% 3.9%; 60 | 61 | --foreground: 0 0% 98%; 62 | 63 | --card: 0 0% 3.9%; 64 | 65 | --card-foreground: 0 0% 98%; 66 | 67 | --popover: 0 0% 3.9%; 68 | 69 | --popover-foreground: 0 0% 98%; 70 | 71 | --primary: 0 0% 98%; 72 | 73 | --primary-foreground: 0 0% 9%; 74 | 75 | --secondary: 0 0% 14.9%; 76 | 77 | --secondary-foreground: 0 0% 98%; 78 | 79 | --muted: 0 0% 14.9%; 80 | 81 | --muted-foreground: 0 0% 63.9%; 82 | 83 | --accent: 0 0% 14.9%; 84 | 85 | --accent-foreground: 0 0% 98%; 86 | 87 | --destructive: 0 62.8% 30.6%; 88 | 89 | --destructive-foreground: 0 0% 98%; 90 | 91 | --border: 0 0% 14.9%; 92 | 93 | --input: 0 0% 14.9%; 94 | 95 | --ring: 0 0% 83.1%; 96 | 97 | --chart-1: 220 70% 50%; 98 | 99 | --chart-2: 160 60% 45%; 100 | 101 | --chart-3: 30 80% 55%; 102 | 103 | --chart-4: 280 65% 60%; 104 | 105 | --chart-5: 340 75% 55%} 106 | } 107 | 108 | @layer base { 109 | * { 110 | @apply border-border; 111 | } 112 | body { 113 | @apply bg-background text-foreground; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import "./globals.css"; 3 | import { Provider } from "./providers"; 4 | import { FRAME_METADATA } from "../lib/constants"; 5 | 6 | export async function generateMetadata(): Promise { 7 | return { 8 | title: "Frame", 9 | description: "This is an example frame.", 10 | other: { 11 | "fc:frame": JSON.stringify(FRAME_METADATA), 12 | }, 13 | openGraph: { 14 | images: [ 15 | { 16 | url: `${process.env.APP_URL}/og.png`, 17 | }, 18 | ], 19 | }, 20 | }; 21 | } 22 | 23 | export default function RootLayout({ 24 | children, 25 | }: Readonly<{ 26 | children: React.ReactNode; 27 | }>) { 28 | return ( 29 | 30 | 31 | {children} 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useSession } from "../providers/SessionProvider"; 4 | 5 | export default function Home() { 6 | const { user, isLoading } = useSession(); 7 | 8 | if (isLoading) return
Loading...
; 9 | 10 | return ( 11 |
12 |

User

13 |
{JSON.stringify(user, null, 2)}
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/app/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 4 | import { SessionProvider } from "../providers/SessionProvider"; 5 | import { Suspense } from "react"; 6 | 7 | const queryClient = new QueryClient(); 8 | 9 | export function Provider({ children }: { children: React.ReactNode }) { 10 | return ( 11 | 12 | 13 | {children} 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { Lucia } from "lucia"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | import { AUTH_SESSION_COOKIE_NAME } from "./constants"; 4 | import { getAuthAdapter } from "./db"; 5 | import { AuthError } from "./errors"; 6 | 7 | const adapter = getAuthAdapter(); 8 | 9 | export const lucia = new Lucia(adapter, { 10 | sessionCookie: { 11 | attributes: { 12 | // set to `true` when using HTTPS 13 | secure: process.env.NODE_ENV === "production", 14 | }, 15 | name: AUTH_SESSION_COOKIE_NAME, 16 | }, 17 | getUserAttributes: (attributes) => { 18 | return { 19 | id: attributes.id, 20 | createdAt: attributes.created_at, 21 | updatedAt: attributes.updated_at, 22 | fid: attributes.fid, 23 | }; 24 | }, 25 | }); 26 | 27 | type NextContext = { params: Promise<{}> }; 28 | 29 | export type UserRouteHandler< 30 | T extends Record = NextContext 31 | > = ( 32 | req: NextRequest, 33 | user: NonNullable>["user"]>, 34 | context: T 35 | ) => Promise; 36 | 37 | export function withAuth< 38 | T extends Record = NextContext 39 | >(handler: UserRouteHandler, options: {} = {}) { 40 | return async (req: NextRequest, context: T): Promise => { 41 | try { 42 | const cookieHeader = req.headers.get("Cookie"); 43 | const authorizationHeader = req.headers.get("Authorization"); 44 | const token = 45 | lucia.readBearerToken(authorizationHeader ?? "") || 46 | lucia.readSessionCookie(cookieHeader ?? ""); 47 | 48 | const result = await lucia.validateSession(token ?? ""); 49 | if (!result.session) { 50 | throw new AuthError("Invalid session"); 51 | } 52 | 53 | return handler(req, result.user, context); 54 | } catch (error) { 55 | if (error instanceof AuthError) { 56 | return NextResponse.json({ error: error.message }, { status: 401 }); 57 | } 58 | console.error("Unexpected error in withAuth:", error); 59 | return NextResponse.json( 60 | { error: "Internal server error" }, 61 | { status: 500 } 62 | ); 63 | } 64 | }; 65 | } 66 | 67 | declare module "lucia" { 68 | interface Register { 69 | Lucia: typeof lucia; 70 | DatabaseUserAttributes: { 71 | id: string; 72 | fid: number; 73 | created_at: Date; 74 | updated_at: Date; 75 | }; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/lib/bullboard.ts: -------------------------------------------------------------------------------- 1 | import { createBullBoard } from "@bull-board/api"; 2 | import { BullMQAdapter } from "@bull-board/api/bullMQAdapter.js"; 3 | import { ExpressAdapter } from "@bull-board/express"; 4 | import express from "express"; 5 | import { notificationsBulkQueue } from "./queue"; 6 | 7 | // Add basic auth middleware 8 | const basicAuth = ( 9 | req: express.Request, 10 | res: express.Response, 11 | next: express.NextFunction 12 | ) => { 13 | const auth = req.headers.authorization; 14 | const envUsername = process.env.BULL_BOARD_USERNAME; 15 | const envPassword = process.env.BULL_BOARD_PASSWORD; 16 | 17 | if (!envUsername || !envPassword) { 18 | throw new Error("BULL_BOARD_USERNAME and BULL_BOARD_PASSWORD must be set"); 19 | } 20 | 21 | if (!auth || auth.indexOf("Basic ") === -1) { 22 | res.setHeader("WWW-Authenticate", 'Basic realm="Bull Board"'); 23 | res.status(401).send("Authentication required"); 24 | return; 25 | } 26 | 27 | const credentials = Buffer.from(auth.split(" ")[1], "base64") 28 | .toString() 29 | .split(":"); 30 | const username = credentials[0]; 31 | const password = credentials[1]; 32 | 33 | if (username === envUsername && password === envPassword) { 34 | next(); 35 | } else { 36 | res.setHeader("WWW-Authenticate", 'Basic realm="Bull Board"'); 37 | res.status(401).send("Invalid credentials"); 38 | } 39 | }; 40 | 41 | export function initExpressApp() { 42 | const port = process.env.PORT || 3005; 43 | const app = express(); 44 | const serverAdapter = new ExpressAdapter(); 45 | 46 | // Add basic auth middleware before the bull board routes 47 | if (process.env.BULL_BOARD_PASSWORD && process.env.BULL_BOARD_USERNAME) { 48 | app.use("/", basicAuth); 49 | } 50 | 51 | serverAdapter.setBasePath("/"); 52 | app.use("/", serverAdapter.getRouter()); 53 | 54 | createBullBoard({ 55 | queues: [new BullMQAdapter(notificationsBulkQueue)], 56 | serverAdapter, 57 | }); 58 | 59 | app.listen(port, () => { 60 | console.log("Server started on http://localhost:" + port); 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const CHALLENGE_DURATION_SECONDS = 60; 2 | export const AUTH_SESSION_COOKIE_NAME = "auth_session"; 3 | 4 | // Jobs that send notifications to users in bulk 5 | export const NOTIFICATIONS_BULK_QUEUE_NAME = "notifications-bulk"; 6 | 7 | export const FRAME_METADATA = { 8 | version: "next", 9 | imageUrl: `${process.env.APP_URL}/og.png`, 10 | button: { 11 | title: "Launch Frame", 12 | action: { 13 | type: "launch_frame", 14 | name: "Launch Frame", 15 | url: process.env.APP_URL, 16 | splashImageUrl: `${process.env.APP_URL}/splash.png`, 17 | splashBackgroundColor: "#f7f7f7", 18 | }, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /src/lib/db.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | dotenv.config({ path: ".env.local" }); 3 | dotenv.config(); 4 | 5 | import { promises as fs } from "fs"; 6 | import { 7 | CamelCasePlugin, 8 | FileMigrationProvider, 9 | Kysely, 10 | Migrator, 11 | PostgresDialect, 12 | } from "kysely"; 13 | import { fileURLToPath } from "node:url"; 14 | import path from "path"; 15 | import pg from "pg"; 16 | import Cursor from "pg-cursor"; 17 | import { Tables } from "../types/db"; 18 | import { NodePostgresAdapter } from "@lucia-auth/adapter-postgresql"; 19 | 20 | const { Pool } = pg; 21 | 22 | const pool = new Pool({ 23 | max: 20, 24 | connectionString: process.env.DATABASE_URL, 25 | }); 26 | 27 | export const getDbClient = ( 28 | connectionString: string | undefined = process.env.DATABASE_URL, 29 | pool: pg.Pool | undefined = undefined 30 | ) => { 31 | if (!connectionString) { 32 | throw new Error("DATABASE_URL is not set"); 33 | } 34 | 35 | return new Kysely({ 36 | dialect: new PostgresDialect({ 37 | pool: 38 | pool ?? 39 | new Pool({ 40 | max: 20, 41 | connectionString, 42 | }), 43 | cursor: Cursor, 44 | }), 45 | plugins: [new CamelCasePlugin()], 46 | log: ["error", "query"], 47 | }); 48 | }; 49 | 50 | export const getAuthAdapter = ( 51 | connectionString: string | undefined = process.env.DATABASE_URL 52 | ) => { 53 | if (!connectionString) { 54 | throw new Error("DATABASE_URL is not set"); 55 | } 56 | 57 | const adapter = new NodePostgresAdapter(pool, { 58 | user: "users", 59 | session: "user_session", 60 | }); 61 | 62 | return adapter; 63 | }; 64 | 65 | export const db = getDbClient(); 66 | export const luciaDb = getDbClient(); 67 | 68 | const createMigrator = async (db: Kysely) => { 69 | const currentDir = path.dirname(fileURLToPath(import.meta.url)); 70 | const migrator = new Migrator({ 71 | db, 72 | provider: new FileMigrationProvider({ 73 | fs, 74 | path, 75 | migrationFolder: path.join(currentDir, "../", "migrations"), 76 | }), 77 | }); 78 | 79 | return migrator; 80 | }; 81 | 82 | export const migrateToLatest = async (db: Kysely): Promise => { 83 | const migrator = await createMigrator(db); 84 | 85 | const { error, results } = await migrator.migrateToLatest(); 86 | 87 | results?.forEach((it) => { 88 | if (it.status === "Success") { 89 | console.log(`Migration "${it.migrationName}" was executed successfully`); 90 | } else if (it.status === "Error") { 91 | console.error(`failed to execute migration "${it.migrationName}"`); 92 | } 93 | }); 94 | 95 | if (error) { 96 | console.error("Failed to apply all database migrations"); 97 | console.error(error); 98 | throw error; 99 | } 100 | 101 | console.log("Migrations up to date"); 102 | }; 103 | 104 | export async function ensureMigrations(db: Kysely) { 105 | console.log("Ensuring database migrations are up to date"); 106 | 107 | try { 108 | await migrateToLatest(db); 109 | } catch (error) { 110 | console.error("Failed to migrate database", error); 111 | throw error; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/lib/errors.ts: -------------------------------------------------------------------------------- 1 | export class AuthError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = "AuthError"; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/farcaster.ts: -------------------------------------------------------------------------------- 1 | import { Message, UserDataAddMessage, UserDataType } from "@farcaster/core"; 2 | import { Configuration, NeynarAPIClient } from "@neynar/nodejs-sdk"; 3 | import { type User as NeynarUser } from "@neynar/nodejs-sdk/build/api"; 4 | import { getUserDataKey } from "./keys"; 5 | import { redisCache } from "./redis"; 6 | 7 | export async function getUserData(fid: number) { 8 | const res = await fetch(`${process.env.HUB_URL}/v1/userDataByFid?fid=${fid}`); 9 | 10 | if (!res.ok) { 11 | throw new Error( 12 | `Failed to fetch user data: ${res.statusText} (${res.status})` 13 | ); 14 | } 15 | 16 | const data = (await res.json()) as { messages: any[] }; 17 | 18 | const userData = data.messages.reduce< 19 | Record 20 | >((acc, message) => { 21 | const decoded = Message.fromJSON(message) as UserDataAddMessage; 22 | 23 | acc[decoded.data.userDataBody.type] = decoded.data.userDataBody.value; 24 | return acc; 25 | }, {} as Record); 26 | 27 | return userData; 28 | } 29 | 30 | export async function getUserDatasCached( 31 | fids: number[] 32 | ): Promise { 33 | if (fids.length === 0) { 34 | return []; 35 | } 36 | 37 | const neynarClient = new NeynarAPIClient( 38 | new Configuration({ 39 | apiKey: process.env.NEYNAR_API_KEY!, 40 | }) 41 | ); 42 | 43 | // Get users from cache 44 | const cachedUsersRes = await redisCache.mget( 45 | fids.map((fid) => getUserDataKey(fid)) 46 | ); 47 | const cachedUsers: NeynarUser[] = cachedUsersRes 48 | .filter((user) => user !== null) 49 | .map((user) => JSON.parse(user)); 50 | 51 | // Users to fetch 52 | const cachedUserFids = new Set(cachedUsers.map((user) => user.fid)); 53 | const uncachedFids = fids.filter((fid) => !cachedUserFids.has(fid)); 54 | 55 | if (uncachedFids.length === 0) { 56 | return cachedUsers; 57 | } 58 | 59 | // TODO: Implement pagination 60 | if (uncachedFids.length > 100) { 61 | throw new Error("Can't fetch more than 100 users at a time"); 62 | } 63 | 64 | const res = await neynarClient.fetchBulkUsers({ fids: uncachedFids }); 65 | 66 | // Cache fetched users 67 | await redisCache.mset( 68 | res.users 69 | .map((user) => [getUserDataKey(user.fid), JSON.stringify(user)]) 70 | .flat() 71 | ); 72 | 73 | // Set expiration for all newly cached users 74 | let multi = redisCache.multi(); 75 | for (const user of res.users) { 76 | multi = multi.expire(getUserDataKey(user.fid), 60 * 60 * 24 * 3); // 3 days 77 | } 78 | await multi.exec(); 79 | 80 | return [...cachedUsers, ...res.users]; 81 | } 82 | -------------------------------------------------------------------------------- /src/lib/keys.ts: -------------------------------------------------------------------------------- 1 | export function getUserDataKey(fid: number) { 2 | return `farcaster:user:${fid}`; 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/notifications.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SendNotificationRequest, 3 | sendNotificationResponseSchema, 4 | } from "@farcaster/frame-sdk"; 5 | import { db } from "./db"; 6 | import { FrameNotificationDetails } from "@farcaster/frame-node"; 7 | import { NotificationsBulkJobData } from "../types/jobs"; 8 | import { notificationsBulkQueue } from "./queue"; 9 | 10 | type SendFrameNotificationResult = 11 | | { 12 | state: "error"; 13 | error: unknown; 14 | } 15 | | { state: "no_token" } 16 | | { state: "rate_limit" } 17 | | { state: "success" }; 18 | 19 | export async function sendFrameNotifications({ 20 | title, 21 | body, 22 | url, 23 | tokens, 24 | notificationId, 25 | targetUrl, 26 | }: { 27 | /** The title of the notification - max 32 character */ 28 | title: string; 29 | body: string; 30 | tokens: string[]; 31 | /** The url to send the notification to */ 32 | url: string; 33 | /** The url that will open when the notification is clicked */ 34 | targetUrl: string; 35 | /** The id of the notification (defaults to a random uuid) */ 36 | notificationId?: string; 37 | }): Promise { 38 | notificationId = notificationId || crypto.randomUUID(); 39 | const response = await fetch(url, { 40 | method: "POST", 41 | headers: { 42 | "Content-Type": "application/json", 43 | }, 44 | body: JSON.stringify({ 45 | notificationId, 46 | title, 47 | body, 48 | targetUrl, 49 | tokens: tokens, 50 | } satisfies SendNotificationRequest), 51 | }); 52 | 53 | const responseJson = await response.json(); 54 | 55 | if (response.status === 200) { 56 | const responseBody = sendNotificationResponseSchema.safeParse(responseJson); 57 | if (responseBody.success === false) { 58 | // Malformed response 59 | throw new Error("Malformed response"); 60 | } 61 | 62 | if (responseBody.data.result.rateLimitedTokens.length) { 63 | // Rate limited 64 | throw new Error("Rate limited"); 65 | } 66 | 67 | return { state: "success" }; 68 | } else { 69 | // Error response 70 | const message = JSON.stringify(responseJson) || "Unknown error"; 71 | throw new Error(message); 72 | } 73 | } 74 | 75 | export async function sendFrameNotification({ 76 | title, 77 | body, 78 | targetUrl, 79 | notificationId, 80 | ...params 81 | }: { 82 | title: string; 83 | body: string; 84 | targetUrl: string; 85 | notificationId?: string; 86 | } & ( 87 | | { fid: number } 88 | | { 89 | token: string; 90 | url: string; 91 | } 92 | )) { 93 | let token: string; 94 | let url: string; 95 | 96 | if ("fid" in params) { 97 | const user = await db 98 | .selectFrom("users") 99 | .selectAll() 100 | .where("fid", "=", params.fid) 101 | .where("notificationUrl", "is not", null) 102 | .where("notificationToken", "is not", null) 103 | .executeTakeFirst(); 104 | 105 | if (!user) { 106 | throw new Error("User not found"); 107 | } 108 | 109 | token = user.notificationToken!; 110 | url = user.notificationUrl!; 111 | } else { 112 | token = params.token; 113 | url = params.url; 114 | } 115 | 116 | const result = await sendFrameNotifications({ 117 | title, 118 | body, 119 | url, 120 | targetUrl, 121 | tokens: [token], 122 | notificationId, 123 | }); 124 | 125 | return result; 126 | } 127 | 128 | export async function setUserNotificationDetails( 129 | fid: number, 130 | notificationDetails: FrameNotificationDetails 131 | ): Promise { 132 | // Update user notification details 133 | await db 134 | .updateTable("users") 135 | .set({ 136 | notificationUrl: notificationDetails.url, 137 | notificationToken: notificationDetails.token, 138 | }) 139 | .where("fid", "=", fid) 140 | .execute(); 141 | } 142 | 143 | export async function deleteUserNotificationDetails( 144 | fid: number 145 | ): Promise { 146 | await db 147 | .updateTable("users") 148 | .set({ 149 | notificationUrl: null, 150 | notificationToken: null, 151 | }) 152 | .where("fid", "=", fid) 153 | .execute(); 154 | } 155 | 156 | export async function notifyUsers({ 157 | users, 158 | title, 159 | body, 160 | targetUrl, 161 | notificationId, 162 | }: { 163 | users: { fid?: number; token: string; url: string }[]; 164 | title: string; 165 | body: string; 166 | targetUrl: string; 167 | notificationId?: string; 168 | }) { 169 | // Group users by notification url 170 | const usersByUrl = users.reduce((acc, user) => { 171 | const notificationUrl = user.url; 172 | if (!acc[notificationUrl]) { 173 | acc[notificationUrl] = []; 174 | } 175 | acc[notificationUrl].push(user); 176 | return acc; 177 | }, {} as Record); 178 | 179 | // Then chunk each webhook group into groups of 100 180 | const allChunks: Array<{ 181 | notificationUrl: string; 182 | users: typeof users; 183 | chunkId: number; 184 | }> = []; 185 | Object.entries(usersByUrl).forEach(([notificationUrl, webhookUsers]) => { 186 | let chunkId = 0; 187 | 188 | for (let i = 0; i < webhookUsers.length; i += 100) { 189 | allChunks.push({ 190 | notificationUrl, 191 | users: webhookUsers.slice(i, i + 100), 192 | chunkId: chunkId++, 193 | }); 194 | } 195 | }); 196 | 197 | const jobs = await notificationsBulkQueue.addBulk( 198 | allChunks.map((chunk) => ({ 199 | name: `${title}-${new URL(chunk.notificationUrl).hostname}-${ 200 | chunk.chunkId 201 | }`, 202 | data: { 203 | notifications: chunk.users.map((user) => ({ 204 | token: user.token!, 205 | fid: user.fid, 206 | })), 207 | url: chunk.notificationUrl, 208 | title, 209 | body, 210 | targetUrl, 211 | notificationId, 212 | } satisfies NotificationsBulkJobData, 213 | })) 214 | ); 215 | 216 | return jobs; 217 | } 218 | -------------------------------------------------------------------------------- /src/lib/queue.ts: -------------------------------------------------------------------------------- 1 | import { Queue } from "bullmq"; 2 | import { NOTIFICATIONS_BULK_QUEUE_NAME } from "./constants"; 3 | import { redisQueue } from "./redis"; 4 | 5 | export const notificationsBulkQueue = new Queue(NOTIFICATIONS_BULK_QUEUE_NAME, { 6 | connection: redisQueue, 7 | }); 8 | -------------------------------------------------------------------------------- /src/lib/redis.ts: -------------------------------------------------------------------------------- 1 | import Redis, { RedisOptions } from "ioredis"; 2 | 3 | const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379"; 4 | const REDIS_QUEUE_URL = process.env.REDIS_QUEUE_URL || REDIS_URL; 5 | 6 | export const getRedisClient = (redisUrl: string, redisOpts?: RedisOptions) => { 7 | const client = new Redis(redisUrl, { 8 | connectTimeout: 5_000, 9 | maxRetriesPerRequest: null, 10 | ...redisOpts, 11 | }); 12 | return client; 13 | }; 14 | 15 | export const redisCache = getRedisClient(REDIS_URL); 16 | export const redisQueue = getRedisClient(REDIS_QUEUE_URL); 17 | 18 | export async function withCache( 19 | key: string, 20 | fetcher: () => Promise, 21 | { ttl = 60 * 60 * 24 }: { ttl?: number } = {} 22 | ) { 23 | const cached = await redisCache.get(key); 24 | if (cached) { 25 | return JSON.parse(cached) as T; 26 | } 27 | 28 | const result = await fetcher(); 29 | 30 | await redisCache.setex(key, ttl, JSON.stringify(result)); 31 | 32 | return result; 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/migrations/001_initial.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, sql } from "kysely"; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .createTable("users") 6 | .addColumn("id", "varchar", (col) => 7 | col.primaryKey().defaultTo(sql`gen_random_uuid()`) 8 | ) 9 | .addColumn("fid", "integer", (col) => col.notNull().unique()) 10 | .addColumn("created_at", "timestamp", (col) => 11 | col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) 12 | ) 13 | .addColumn("updated_at", "timestamp", (col) => 14 | col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) 15 | ) 16 | .addColumn("notification_url", "varchar") 17 | .addColumn("notification_token", "varchar") 18 | .execute(); 19 | 20 | await db.schema 21 | .createTable("user_session") 22 | .addColumn("id", "varchar", (col) => col.primaryKey()) 23 | .addColumn("user_id", "text", (col) => 24 | col.notNull().references("users.id").onDelete("cascade") 25 | ) 26 | .addColumn("expires_at", "timestamptz", (col) => col.notNull()) 27 | .execute(); 28 | 29 | await db.schema 30 | .createIndex("idx_users_fid") 31 | .on("users") 32 | .column("fid") 33 | .execute(); 34 | 35 | await db.schema 36 | .createIndex("idx_users_notification") 37 | .on("users") 38 | .columns(["notification_url", "notification_token"]) 39 | .where("notification_url", "is not", null) 40 | .where("notification_token", "is not", null) 41 | .execute(); 42 | 43 | await db.schema 44 | .createIndex("idx_user_session_user") 45 | .on("user_session") 46 | .column("user_id") 47 | .execute(); 48 | } 49 | 50 | export async function down(db: Kysely): Promise { 51 | await db.schema.dropIndex("idx_users_fid").execute(); 52 | await db.schema.dropIndex("idx_users_notification").execute(); 53 | await db.schema.dropIndex("idx_user_session_user").execute(); 54 | 55 | await db.schema.dropTable("user_session").execute(); 56 | await db.schema.dropTable("users").execute(); 57 | } 58 | -------------------------------------------------------------------------------- /src/providers/SessionProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { User } from "@/types/user"; 4 | import sdk, { Context, FrameNotificationDetails } from "@farcaster/frame-sdk"; 5 | import { useQuery, useMutation } from "@tanstack/react-query"; 6 | import { Session } from "lucia"; 7 | import { useRouter, useSearchParams } from "next/navigation"; 8 | import { 9 | createContext, 10 | ReactNode, 11 | useCallback, 12 | useContext, 13 | useEffect, 14 | useState, 15 | } from "react"; 16 | 17 | interface SessionContextType { 18 | user: User | null | undefined; 19 | session: Session | null | undefined; 20 | context: Context.FrameContext | null | undefined; 21 | isLoading: boolean; 22 | isError: boolean; 23 | authFetch: typeof fetch; 24 | /** Trigger refetch of user query */ 25 | refetchUser: () => void; 26 | /** Fetch user from server */ 27 | fetchUser: () => Promise; 28 | } 29 | 30 | function formatLocalStorageSessionKey(fid: number) { 31 | return `userSession-${fid}`; 32 | } 33 | 34 | const SessionContext = createContext(undefined); 35 | 36 | export function SessionProvider({ children }: { children: ReactNode }) { 37 | const searchParams = useSearchParams(); 38 | const router = useRouter(); 39 | const [isSDKLoaded, setIsSDKLoaded] = useState(false); 40 | const [context, setContext] = useState(); 41 | 42 | const { 43 | data: session, 44 | isLoading: isLoadingSession, 45 | refetch: refetchSession, 46 | isFetching: isFetchingSession, 47 | } = useQuery({ 48 | queryKey: ["session"], 49 | queryFn: async () => { 50 | if (!context?.user?.fid) return null; 51 | 52 | // Check local storage first 53 | const storedSession = localStorage.getItem( 54 | formatLocalStorageSessionKey(context.user.fid) 55 | ); 56 | if (storedSession) { 57 | const session = JSON.parse(storedSession) as Session; 58 | 59 | if (new Date(session.expiresAt).getTime() < Date.now()) { 60 | localStorage.removeItem( 61 | formatLocalStorageSessionKey(context.user.fid) 62 | ); 63 | } else { 64 | return session; 65 | } 66 | } 67 | 68 | const challengeId = crypto.randomUUID(); 69 | const challenge = await fetchChallenge(challengeId); 70 | 71 | const result = await sdk.actions.signIn({ nonce: challenge }); 72 | 73 | const session = await signIn({ 74 | ...result, 75 | challengeId, 76 | }); 77 | 78 | // Store the session in localStorage 79 | localStorage.setItem( 80 | formatLocalStorageSessionKey(context.user.fid), 81 | JSON.stringify(session) 82 | ); 83 | 84 | return session; 85 | }, 86 | enabled: isSDKLoaded && !!context?.user?.fid, 87 | }); 88 | 89 | const authFetch = useCallback( 90 | (input: RequestInfo | URL, init?: RequestInit) => { 91 | return fetch(input, { 92 | ...init, 93 | headers: session?.id 94 | ? { 95 | Authorization: `Bearer ${session?.id}`, 96 | } 97 | : undefined, 98 | }); 99 | }, 100 | [session?.id] 101 | ); 102 | 103 | const fetchUser = useCallback(async () => { 104 | const response = await authFetch("/api/user"); 105 | 106 | if (!response.ok) { 107 | throw new Error(`Failed to fetch user ${response.status}`); 108 | } 109 | 110 | return response.json() as Promise; 111 | }, [authFetch]); 112 | 113 | const { 114 | data: user, 115 | isLoading: isLoadingUser, 116 | isError, 117 | refetch: refetchUser, 118 | } = useQuery({ 119 | queryKey: ["user", session?.id], 120 | queryFn: fetchUser, 121 | enabled: isSDKLoaded && !!context?.user?.fid && !!session, 122 | refetchInterval: 1000 * 60, 123 | retry: false, 124 | }); 125 | 126 | const { mutate: setNotificationsMutation } = useMutation({ 127 | mutationFn: setNotificationDetails, 128 | onSuccess: () => { 129 | refetchUser(); 130 | }, 131 | }); 132 | 133 | useEffect(() => { 134 | refetchUser(); 135 | }, [session, refetchUser]); 136 | 137 | useEffect(() => { 138 | if ( 139 | isSDKLoaded && 140 | context?.user?.fid && 141 | session && 142 | isError && 143 | !isLoadingUser 144 | ) { 145 | localStorage.removeItem(formatLocalStorageSessionKey(context.user.fid)); 146 | refetchSession(); 147 | } 148 | }, [isSDKLoaded, context?.user?.fid, refetchSession, isError, session]); 149 | 150 | useEffect(() => { 151 | const load = async () => { 152 | try { 153 | const awaitedContext = await sdk.context; 154 | setContext(awaitedContext); 155 | sdk.actions.ready(); 156 | } catch (error) { 157 | console.error("Error loading SDK:", error); 158 | } 159 | }; 160 | if (sdk && !isSDKLoaded) { 161 | setIsSDKLoaded(true); 162 | load(); 163 | } 164 | }, [isSDKLoaded]); 165 | 166 | useEffect(() => { 167 | // Set notification details if somehow not set in db after webhook already called 168 | if ( 169 | user && 170 | !user?.notificationsEnabled && 171 | context?.client.notificationDetails 172 | ) { 173 | setNotificationsMutation(context.client.notificationDetails); 174 | } 175 | }, [user, context, setNotificationsMutation]); 176 | 177 | /** Handle redirect if user needs to complete onboarding */ 178 | useEffect(() => { 179 | if (isLoadingUser || !isSDKLoaded) return; 180 | const currentPath = window.location.pathname; 181 | const searchParams = window.location.search; 182 | 183 | const redirectUrl = encodeURIComponent(`${currentPath}${searchParams}`); 184 | 185 | const onboardingComplete = true; 186 | 187 | if (user && !onboardingComplete) { 188 | router.push(`/onboarding?redirect=${redirectUrl}`); 189 | } 190 | }, [user, isLoadingUser, isError, router, isSDKLoaded]); 191 | 192 | return ( 193 | 205 | {children} 206 | 207 | ); 208 | } 209 | 210 | async function fetchChallenge(challengeId: string): Promise { 211 | const response = await fetch("/api/challenge", { 212 | method: "POST", 213 | body: JSON.stringify({ challengeId }), 214 | }); 215 | 216 | if (!response.ok) { 217 | throw new Error("Failed to fetch challenge"); 218 | } 219 | 220 | const { challenge } = await response.json(); 221 | 222 | return challenge; 223 | } 224 | 225 | async function signIn({ 226 | message, 227 | signature, 228 | challengeId, 229 | }: { 230 | message: string; 231 | signature: string; 232 | challengeId: string; 233 | }): Promise { 234 | const response = await fetch("/api/sign-in", { 235 | method: "POST", 236 | body: JSON.stringify({ message, signature, challengeId }), 237 | }); 238 | 239 | if (!response.ok) { 240 | throw new Error(`Failed to sign in ${response.status}`); 241 | } 242 | 243 | const { session } = await response.json(); 244 | 245 | if (!session) { 246 | throw new Error("Could not create session"); 247 | } 248 | 249 | return session; 250 | } 251 | 252 | async function setNotificationDetails( 253 | notificationDetails: FrameNotificationDetails 254 | ): Promise { 255 | const response = await fetch("/api/user/notifications", { 256 | method: "PATCH", 257 | body: JSON.stringify(notificationDetails), 258 | }); 259 | 260 | if (!response.ok) { 261 | throw new Error("Failed to set notification details"); 262 | } 263 | } 264 | 265 | export function useSession() { 266 | const context = useContext(SessionContext); 267 | if (context === undefined) { 268 | throw new Error("useSession must be used within a SessionProvider"); 269 | } 270 | return context; 271 | } 272 | -------------------------------------------------------------------------------- /src/scripts/migrate.ts: -------------------------------------------------------------------------------- 1 | import { ensureMigrations, getDbClient } from "../lib/db"; 2 | 3 | const db = getDbClient(); 4 | 5 | ensureMigrations(db); 6 | -------------------------------------------------------------------------------- /src/types/db.ts: -------------------------------------------------------------------------------- 1 | import { Generated } from "kysely"; 2 | 3 | export type UserRow = { 4 | id: Generated; 5 | fid: number; 6 | createdAt: Generated; 7 | updatedAt: Generated; 8 | notificationUrl: string | null; 9 | notificationToken: string | null; 10 | }; 11 | 12 | export interface UserSessionRow { 13 | id: string; 14 | userId: string; 15 | expiresAt: Date; 16 | } 17 | 18 | export type Tables = { 19 | users: UserRow; 20 | userSession: UserSessionRow; 21 | }; 22 | -------------------------------------------------------------------------------- /src/types/jobs.ts: -------------------------------------------------------------------------------- 1 | export type NotificationsBulkJobData = { 2 | notifications: { 3 | fid?: number; 4 | token: string; 5 | }[]; 6 | url: string; 7 | title: string; 8 | body: string; 9 | targetUrl: string; 10 | notificationId?: string; 11 | }; 12 | -------------------------------------------------------------------------------- /src/types/user.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | id: string; 3 | fid: number; 4 | notificationsEnabled: boolean; 5 | username?: string; 6 | imageUrl?: string; 7 | }; 8 | -------------------------------------------------------------------------------- /src/workers/index.ts: -------------------------------------------------------------------------------- 1 | import { initExpressApp } from "../lib/bullboard"; 2 | 3 | export { notificationsBulkWorker } from "./notifications"; 4 | 5 | // Run bull board 6 | initExpressApp(); 7 | -------------------------------------------------------------------------------- /src/workers/notifications.ts: -------------------------------------------------------------------------------- 1 | import { Worker } from "bullmq"; 2 | import { NOTIFICATIONS_BULK_QUEUE_NAME } from "../lib/constants"; 3 | import { redisQueue } from "../lib/redis"; 4 | import { NotificationsBulkJobData } from "../types/jobs"; 5 | import { sendFrameNotifications } from "../lib/notifications"; 6 | 7 | export const notificationsBulkWorker = new Worker( 8 | NOTIFICATIONS_BULK_QUEUE_NAME, 9 | async (job) => { 10 | const { notifications, url, body, notificationId, targetUrl, title } = 11 | job.data; 12 | 13 | const tokens = notifications.map(({ token }) => token); 14 | 15 | const result = await sendFrameNotifications({ 16 | tokens, 17 | title, 18 | body, 19 | url, 20 | targetUrl, 21 | notificationId, 22 | }); 23 | 24 | return result; 25 | }, 26 | { connection: redisQueue } 27 | ); 28 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | darkMode: ["class"], 5 | content: [ 6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 9 | ], 10 | theme: { 11 | extend: { 12 | colors: { 13 | background: 'hsl(var(--background))', 14 | foreground: 'hsl(var(--foreground))', 15 | card: { 16 | DEFAULT: 'hsl(var(--card))', 17 | foreground: 'hsl(var(--card-foreground))' 18 | }, 19 | popover: { 20 | DEFAULT: 'hsl(var(--popover))', 21 | foreground: 'hsl(var(--popover-foreground))' 22 | }, 23 | primary: { 24 | DEFAULT: 'hsl(var(--primary))', 25 | foreground: 'hsl(var(--primary-foreground))' 26 | }, 27 | secondary: { 28 | DEFAULT: 'hsl(var(--secondary))', 29 | foreground: 'hsl(var(--secondary-foreground))' 30 | }, 31 | muted: { 32 | DEFAULT: 'hsl(var(--muted))', 33 | foreground: 'hsl(var(--muted-foreground))' 34 | }, 35 | accent: { 36 | DEFAULT: 'hsl(var(--accent))', 37 | foreground: 'hsl(var(--accent-foreground))' 38 | }, 39 | destructive: { 40 | DEFAULT: 'hsl(var(--destructive))', 41 | foreground: 'hsl(var(--destructive-foreground))' 42 | }, 43 | border: 'hsl(var(--border))', 44 | input: 'hsl(var(--input))', 45 | ring: 'hsl(var(--ring))', 46 | chart: { 47 | '1': 'hsl(var(--chart-1))', 48 | '2': 'hsl(var(--chart-2))', 49 | '3': 'hsl(var(--chart-3))', 50 | '4': 'hsl(var(--chart-4))', 51 | '5': 'hsl(var(--chart-5))' 52 | } 53 | }, 54 | borderRadius: { 55 | lg: 'var(--radius)', 56 | md: 'calc(var(--radius) - 2px)', 57 | sm: 'calc(var(--radius) - 4px)' 58 | } 59 | } 60 | }, 61 | plugins: [require("tailwindcss-animate")], 62 | } satisfies Config; 63 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | --------------------------------------------------------------------------------