diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6525bb6 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_FFRAME_BASE_URL=http://127.0.0.1:3000 \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72e1136 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/create-fframe-app.iml b/.idea/create-fframe-app.iml new file mode 100644 index 0000000..24643cc --- /dev/null +++ b/.idea/create-fframe-app.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..a32b30e --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d55fceb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 fframe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..48392cd --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# create-fframe-app + +This is an interactive CLI to start a [farcaster frames](https://docs.farcaster.xyz/learn/what-is-farcaster/frames) server application using Next.js and TypeScript. + +Load this example frame using [warpcast's frame validator](https://warpcast.com/~/developers/frames): +`https://create-fframe-app.vercel.app/api/example` + +## getting started + +make sure you have Node.js and npx installed, then run: +```bash +npx create-fframe-app +# or +npx create-fframe-app@latest +``` + +to add a new frame applet, run this from your project root: +```bash +npm run generate-applet my-applet +# or +yarn generate-applet my-applet +``` + +## local testing +start the development server locally: +```bash +npm run dev +# or +yarn dev +``` + +then: +* [click here](http://127.0.0.1:3000/api/example/images?frameId=1) to test image generation +* [click here](http://127.0.0.1:3000/api/example?frameId=0) to test the API response + +## server testing + +to test a live server deployment: +* deploy your _fframe_ app on [vercel](https://vercel.com) +* add `NEXT_PUBLIC_FFRAME_BASE_URL=https://{your-project-name}.vercel.app` as an environment variable and redeploy the project +* test using [warpcast's frame validator](https://warpcast.com/~/developers/frames) (paste `https://{your-project-name}.vercel.app/api/{your_applet_id}`) diff --git a/bin/create-project.js b/bin/create-project.js new file mode 100755 index 0000000..09b1118 --- /dev/null +++ b/bin/create-project.js @@ -0,0 +1,105 @@ +#!/usr/bin/env node + + +const path = require('path'); +const { execSync } = require('child_process'); +const fs = require('fs'); +const { generateApplet } = require('./generate-applet'); + +function createProject(projectName, firstAppletName) { + const projectPath = path.resolve(projectName); + const gitRepoUrl = 'https://github.com/fframes/create-fframe-app.git'; + + // clone the repository + console.log(`cloning the template into ${projectPath}`); + execSync(`git clone ${gitRepoUrl} "${projectPath}"`); + + // remove the .git directory + fs.rmSync(path.join(projectPath, '.git'), { recursive: true, force: true }); + + // update package.json + const packageJsonPath = path.join(projectPath, 'package.json'); + let packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + packageJson.name = projectName; + const cfaVersion = packageJson.version; + packageJson.version = '0.1.0'; + delete packageJson.author; + delete packageJson.keywords; + delete packageJson.repository; + delete packageJson.license; + delete packageJson.bin; + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); + + // remove the bin directory + const binPath = path.join(projectPath, 'bin'); + if (fs.existsSync(binPath)) { + fs.rmSync(binPath, { recursive: true, force: true }); + } + + // remove the example directory + const examplePath = path.join(projectPath, 'src/app/api/example'); + if (fs.existsSync(examplePath)) { + fs.rmSync(examplePath, { recursive: true, force: true }); + } + + const envExamplePath = path.join(projectPath, '.env.example'); + const envPath = path.join(projectPath, '.env'); + + // generate .env file for local server + if (fs.existsSync(envExamplePath)) { + fs.copyFileSync(envExamplePath, envPath); + fs.unlinkSync(envExamplePath); + } else { + console.log('.env.example does not exist, skipping copy and remove operations'); + } + + // run npm install + console.log('installing dependencies...'); + execSync('npm install', { cwd: projectPath, stdio: 'inherit' }); + + // create first applet + generateApplet(firstAppletName, projectPath); + + // update readme with create-fframe-app version + const readmePath = path.join(projectPath, 'README.md'); + const prependText = `\n\n`; + if (fs.existsSync(readmePath)) { + const originalReadmeContent = fs.readFileSync(readmePath, { encoding: 'utf8' }); + const updatedReadmeContent = prependText + originalReadmeContent; + fs.writeFileSync(readmePath, updatedReadmeContent); + } else { + fs.writeFileSync(readmePath, prependText); + } + + // initialize a new git repository + console.log('initializing a new git repository...'); + execSync('git init', { cwd: projectPath }); + execSync('git add .', { cwd: projectPath }); + execSync('git commit -m "initial commit from create-fframe-app"', { cwd: projectPath }); + + console.log(`🔲✅ ${projectName} has been initialized with git and dependencies installed ✅🔲\n`); +} + +function main() { + const readline = require('readline'); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + rl.question('enter the name of your project: ', function (projectName) { + rl.question('enter your first fframe name (default is project name): ', function (fframeName) { + fframeName = fframeName.trim() || projectName; + createProject(projectName, fframeName); + rl.close(); + }); + }); + + rl.on('close', function () { + console.log(`🔲✅ fframe app successfully generated ✅🔲\n`); + process.exit(0); + }); +} + +module.exports = { createProject: main }; diff --git a/bin/generate-applet.js b/bin/generate-applet.js new file mode 100755 index 0000000..8d75b37 --- /dev/null +++ b/bin/generate-applet.js @@ -0,0 +1,169 @@ +#! /usr/bin/env node + +const path = require("path"); +const fs = require("fs"); + + +function generateApplet(name, projectPath = '') { + // create directories + const apiRootPath = path.join(projectPath || process.cwd(), 'src/app/api'); + const dirPath = path.join(apiRootPath, name); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + console.log(`directory ${name} created`); + } else { + console.error(`directory ${name} already exists`); + process.exit(1); + } + const imagesDirPath = path.join(apiRootPath, name + '/images'); + if (!fs.existsSync(imagesDirPath)) { + fs.mkdirSync(imagesDirPath, { recursive: true }); + console.log(`directory ${name}/images created`); + } else { + console.error(`directory ${name}/images already exists`); + process.exit(1); + } + + // create constants.ts file + const constantsAppletName = name.toUpperCase().replaceAll('-','_'); + const constantsContent = ` +import { getBaseUrl } from '@/utils'; + + +export const ${constantsAppletName}_APPLET_ID = '${name}'; +export const ${constantsAppletName}_APPLET_BASE_URL = \`\${getBaseUrl()}/api/\${${constantsAppletName}_APPLET_ID}\`; +export enum FRAME_ID { + 'initial' = '0', + 'success' = '1', +} +`; + const constantsFilePath = path.join(dirPath, 'constants.ts'); + fs.writeFileSync(constantsFilePath, constantsContent.trim()); + console.log(`constants.ts created in ${dirPath}`); + + // create route.ts file + const routeContent = ` +import { NextRequest, NextResponse } from 'next/server'; +import { getFrameMessage } from '@coinbase/onchainkit'; +import { ${constantsAppletName}_APPLET_BASE_URL, FRAME_ID } from '@/app/api/${name}/constants'; +import { getFrameHtmlResponse } from "@/utils"; + + +export async function GET(request: NextRequest) { + // return initial frame + return new NextResponse( + getFrameHtmlResponse({ + title: 'Example Frame', + buttons: [ + { label: 'Button 1' }, + { label: 'Button 2' }, + ], + image: getImageURL(FRAME_ID.initial), + post_url: getPostURL(FRAME_ID.initial), + }), + ); +} + +export async function POST(request: NextRequest): Promise { + const frameId = request.nextUrl.searchParams.get('frameId') as FRAME_ID; + const body = await request.json(); + const { isValid, message } = await getFrameMessage(body); + if (!isValid || !message) { + return new NextResponse('error: unable to validate message', { status: 400 }); + } + + if (frameId === FRAME_ID.initial) { + // handle post from initial frame + if (message.button === 1) { + // handle button 1 click + return new NextResponse(getFrameHtmlResponse({ + title: 'Success 1', + image: getImageURL(FRAME_ID.success), + })); + } else { + // handle button 2 click + return new NextResponse(getFrameHtmlResponse({ + title: 'Success 2', + buttons: [{ + label: 'by veganbeef <3', + action: 'post_redirect' + }], + image: getImageURL(FRAME_ID.success), + post_url: getPostURL(FRAME_ID.success), + })); + } + } else if (frameId === FRAME_ID.success) { + // handle post_redirect button click from second frame + const headers = new Headers({ 'Location': 'https://github.com/veganbeef' }); + return new NextResponse(null, { status: 302, statusText: 'OK', headers }); + } + + return new NextResponse(null, { status: 400 }); +} + +function getImageURL(frameId: string) { + return \`\${${constantsAppletName}_APPLET_BASE_URL}/images?frameId=\${frameId}\`; +} + +function getPostURL(frameId: string) { + return \`\${${constantsAppletName}_APPLET_BASE_URL}?frameId=\${frameId}\`; +} + +export const dynamic = 'force-dynamic'; +`; + const routeFilePath = path.join(dirPath, 'route.ts'); + fs.writeFileSync(routeFilePath, routeContent.trim()); + console.log(`route.ts created in ${dirPath}`); + + // create images/route.tsx file + const imagesRouteContent = ` +import { NextRequest } from 'next/server'; +import { FRAME_ID } from '@/app/api/${name}/constants'; +import { ImageResponse } from 'next/og'; +import { Property } from "csstype"; + + +const defaultStyles = { + display: 'flex', + flexDirection: 'column' as Property.FlexDirection, + backgroundColor: 'black', + width: '100%', + height: '100%', + padding: '20px', + justifyContent: 'center' +}; + +export async function GET(request: NextRequest): Promise { + const frameId = request.nextUrl.searchParams.get('frameId') as FRAME_ID; + + if (frameId === FRAME_ID.initial) { + return new ImageResponse(( +
+
Click below!
+
Click a button below to succeed
+
+ ), { width: 600, height: 400 }); + } else if (frameId === FRAME_ID.success) { + return new ImageResponse(( +
+
Success!
+
+ ), { width: 600, height: 400 }); + } else { + return new ImageResponse(( +
+
Error!
+
Server experienced an error processing your request.
+
+ ), {width: 600, height: 400}) + } +} +`; + const imagesRoutePath = path.join(imagesDirPath, 'route.tsx'); + fs.writeFileSync(imagesRoutePath, imagesRouteContent.trim()); + console.log(`route.tsx created in ${imagesDirPath}`); + + console.log(`🔲✅ fframe applet ${name} successfully generated ✅🔲\n`); +} + +module.exports = { generateApplet }; diff --git a/bin/index.js b/bin/index.js new file mode 100755 index 0000000..77deb4d --- /dev/null +++ b/bin/index.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); + +const { createProject } = require('./create-project'); +const { generateApplet } = require('./generate-applet'); + +// manual argument parsing +const args = process.argv.slice(2); // Remove node and script path + +let generateAppletFlag = false; +let appletName = ''; + +// loop through arguments +for (let i = 0; i < args.length; i++) { + if (args[i] === '-g') { + if (!args[i + 1]) { + throw new Error('applet name is required'); + } + generateAppletFlag = true; + appletName = args[i + 1]; + // exit the loop if -g is found + break; + } +} + +if (generateAppletFlag) { + // call generateApplet if -g is provided + console.log(`generating applet with name: ${appletName}`); + generateApplet(appletName); +} else { + // default to createProject if no -g flag is found + console.log(`creating project with interactive cli`); + createProject(); +} diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..4678774 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..e3b4568 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "create-fframe-app", + "version": "1.0.0", + "repository": { + "type": "git", + "url": "git+https://github.com/fframes/create-fframe-app.git" + }, + "license": "MIT", + "author": { + "name": "veganbeef", + "email": "veganbeef@protonmail.com", + "url": "https://github.com/veganbeef" + }, + "keywords": ["farcaster", "frames", "nodejs", "node", "nextjs", "next", "typescript", "npx"], + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "lint:fix": "next lint --fix", + "generate-applet": "npx create-fframe-app -g" + }, + "bin": { + "create-fframe-app": "bin/index.js" + }, + "dependencies": { + "@coinbase/onchainkit": "^0.4.5", + "next": "14.1.0", + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.1.0", + "typescript": "^5" + } +} diff --git a/src/app/api/example/constants.ts b/src/app/api/example/constants.ts new file mode 100644 index 0000000..43c40e4 --- /dev/null +++ b/src/app/api/example/constants.ts @@ -0,0 +1,9 @@ +import { getBaseUrl } from '@/utils'; + + +export const EXAMPLE_APPLET_ID = 'example'; +export const EXAMPLE_APPLET_BASE_URL = `${getBaseUrl()}/api/${EXAMPLE_APPLET_ID}`; +export enum FRAME_ID { + 'initial' = '0', + 'success' = '1', +} diff --git a/src/app/api/example/images/route.tsx b/src/app/api/example/images/route.tsx new file mode 100644 index 0000000..52bd123 --- /dev/null +++ b/src/app/api/example/images/route.tsx @@ -0,0 +1,41 @@ +import { NextRequest } from 'next/server'; +import { FRAME_ID } from '@/app/api/example/constants'; +import { ImageResponse } from 'next/og'; +import { Property } from "csstype"; + + +const defaultStyles = { + display: 'flex', + flexDirection: 'column' as Property.FlexDirection, + backgroundColor: 'black', + width: '100%', + height: '100%', + padding: '20px', + justifyContent: 'center' +}; + +export async function GET(request: NextRequest): Promise { + const frameId = request.nextUrl.searchParams.get('frameId') as FRAME_ID; + + if (frameId === FRAME_ID.initial) { + return new ImageResponse(( +
+
Click below!
+
Click a button below to succeed
+
+ ), { width: 600, height: 400 }); + } else if (frameId === FRAME_ID.success) { + return new ImageResponse(( +
+
Success!
+
+ ), { width: 600, height: 400 }); + } else { + return new ImageResponse(( +
+
Error!
+
Server experienced an error processing your request.
+
+ ), {width: 600, height: 400}) + } +} diff --git a/src/app/api/example/route.ts b/src/app/api/example/route.ts new file mode 100644 index 0000000..584d7d5 --- /dev/null +++ b/src/app/api/example/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getFrameMessage } from '@coinbase/onchainkit'; +import { EXAMPLE_APPLET_BASE_URL, FRAME_ID } from '@/app/api/example/constants'; +import { getFrameHtmlResponse } from "@/utils"; + + +export async function GET(request: NextRequest) { + // return initial frame + return new NextResponse( + getFrameHtmlResponse({ + title: 'Example Frame', + buttons: [ + { + label: 'Click me!', + }, + { + label: 'GitHub repo', + action: 'post_redirect' + }, + ], + image: getImageURL(FRAME_ID.initial), + post_url: getPostURL(FRAME_ID.initial), + }), + ); +} + +export async function POST(request: NextRequest): Promise { + const frameId = request.nextUrl.searchParams.get('frameId') as FRAME_ID; + const body = await request.json(); + const { isValid, message } = await getFrameMessage(body); + if (!isValid || !message) { + return new NextResponse('error: unable to validate message', { status: 400 }); + } + + if (frameId === FRAME_ID.initial) { + // handle button click from initial frame + if (message.button === 1) { + // return second frame + return new NextResponse(getFrameHtmlResponse({ + title: 'Success!', + buttons: [{ + label: 'by veganbeef <3', + action: 'post_redirect' + }], + image: getImageURL(FRAME_ID.success), + post_url: getPostURL(FRAME_ID.success), + })); + } else { + // handle post_redirect click + const headers = new Headers({ 'Location': 'https://github.com/fframes/create-fframe-app' }); + return new NextResponse(null, { status: 302, statusText: 'OK', headers }); + } + } else if (frameId === FRAME_ID.success) { + // handle post_redirect button click from second frame + const headers = new Headers({ 'Location': 'https://github.com/veganbeef' }); + return new NextResponse(null, { status: 302, statusText: 'OK', headers }); + } else { + return new NextResponse(null, { status: 400 }) + } +} + +function getImageURL(frameId: string) { + return `${EXAMPLE_APPLET_BASE_URL}/images?frameId=${frameId}`; +} + +function getPostURL(frameId: string) { + return `${EXAMPLE_APPLET_BASE_URL}?frameId=${frameId}`; +} + +export const dynamic = 'force-dynamic'; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..355227d --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,13 @@ +import { NextRequest, NextResponse } from "next/server"; + +export function middleware(request: NextRequest) { + if (!process.env.NEXT_PUBLIC_FFRAME_BASE_URL) { + return new NextResponse('error: missing NEXT_PUBLIC_FFRAME_BASE_URL env variable'); + } + if (request.method === 'POST' || (request.method === 'GET' && request.nextUrl.toString().includes('images'))) { + const frameId = request.nextUrl.searchParams.get('frameId'); + if (!frameId) { + return new NextResponse('error: frameId is required', { status: 400 }); + } + } +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..f96722a --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,44 @@ +import { FrameMetadataType } from "@coinbase/onchainkit/dist/types/core/types"; + + +export interface FrameMetadata extends FrameMetadataType { + title: string; +} + +/** + * custom implementation of @coinbase/onchainkit's getFrameHtmlResponse function to add og properties + */ +export function getFrameHtmlResponse({ buttons, image, post_url, title }: FrameMetadata): string { + let buttonHtml = ''; + if (buttons) { + for (let i = 0; i < buttons.length; i++) { + buttonHtml += ``; + if (buttons[i].action === 'post_redirect') { + buttonHtml += ``; + } + } + } + return ` + + + ${title} + + + + + + ${buttonHtml} + + `; +} + +export function getBaseUrl() { + if (!process.env.NEXT_PUBLIC_FFRAME_BASE_URL) return ''; + + const rawBaseUrl = process.env.NEXT_PUBLIC_FFRAME_BASE_URL; + if (rawBaseUrl[rawBaseUrl.length - 1] === '/') { + return rawBaseUrl.slice(0, rawBaseUrl.length - 1); + } else { + return rawBaseUrl; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7b28589 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}