From 9fbeb3e0e9845dfab9b620e1438e83a3273c3a42 Mon Sep 17 00:00:00 2001 From: vgcman16 <155417613+vgcman16@users.noreply.github.com> Date: Thu, 5 Jun 2025 16:30:46 -0500 Subject: [PATCH] feat: add one-click deploy to cloudflare pages --- .env.production | 4 + README.md | 4 +- .../chat/CloudflareDeploymentLink.client.tsx | 52 +++++++ .../deploy/CloudflareDeploy.client.tsx | 134 ++++++++++++++++++ app/components/deploy/DeployButton.tsx | 33 ++++- app/lib/runtime/action-runner.ts | 2 +- app/lib/stores/cloudflare.ts | 43 ++++++ app/routes/api.cloudflare-deploy.ts | 70 +++++++++ app/types/actions.ts | 2 +- app/types/cloudflare.ts | 11 ++ docs/docs/index.md | 1 + 11 files changed, 349 insertions(+), 7 deletions(-) create mode 100644 app/components/chat/CloudflareDeploymentLink.client.tsx create mode 100644 app/components/deploy/CloudflareDeploy.client.tsx create mode 100644 app/lib/stores/cloudflare.ts create mode 100644 app/routes/api.cloudflare-deploy.ts create mode 100644 app/types/cloudflare.ts diff --git a/.env.production b/.env.production index 8fe4367a..60b9d494 100644 --- a/.env.production +++ b/.env.production @@ -106,6 +106,10 @@ VITE_GITHUB_TOKEN_TYPE= # Netlify Authentication VITE_NETLIFY_ACCESS_TOKEN= +# Cloudflare Pages Authentication +VITE_CLOUDFLARE_API_TOKEN= +VITE_CLOUDFLARE_ACCOUNT_ID= + # Example Context Values for qwen2.5-coder:32b # # DEFAULT_NUM_CTX=32768 # Consumes 36GB of VRAM diff --git a/README.md b/README.md index 803cefa8..306b88b5 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ project, please check the [project management guide](./PROJECT.md) to get starte - ✅ **HIGH PRIORITY** - Better prompting for smaller LLMs (code window sometimes doesn't start) - ⬜ **HIGH PRIORITY** - Run agents in the backend as opposed to a single model call - ✅ Deploy directly to Netlify (@xKevIsDev) +- ✅ Deploy directly to Vercel +- ✅ Deploy directly to Cloudflare Pages - ✅ Supabase Integration (@xKevIsDev) - ⬜ Have LLM plan the project in a MD file for better results/transparency - ✅ VSCode Integration with git-like confirmations @@ -104,7 +106,7 @@ project, please check the [project management guide](./PROJECT.md) to get starte - **Revert code to earlier versions** for easier debugging and quicker changes. - **Download projects as ZIP** for easy portability Sync to a folder on the host. - **Integration-ready Docker support** for a hassle-free setup. -- **Deploy** directly to **Netlify** +- **Deploy** directly to **Netlify**, **Vercel**, or **Cloudflare Pages** ## Setup diff --git a/app/components/chat/CloudflareDeploymentLink.client.tsx b/app/components/chat/CloudflareDeploymentLink.client.tsx new file mode 100644 index 00000000..e118278b --- /dev/null +++ b/app/components/chat/CloudflareDeploymentLink.client.tsx @@ -0,0 +1,52 @@ +import { useStore } from '@nanostores/react'; +import { cloudflareConnection, fetchCloudflareProjects } from '~/lib/stores/cloudflare'; +import { chatId } from '~/lib/persistence/useChatHistory'; +import * as Tooltip from '@radix-ui/react-tooltip'; +import { useEffect } from 'react'; + +export function CloudflareDeploymentLink() { + const connection = useStore(cloudflareConnection); + const currentChatId = useStore(chatId); + + useEffect(() => { + if (connection.token && connection.accountId) { + fetchCloudflareProjects(connection.token, connection.accountId); + } + }, [connection.token, connection.accountId]); + + const project = connection.projects?.find((p) => p.name.includes(`bolt-diy-${currentChatId}`)); + if (!project) { + return null; + } + + const url = project.subdomain ? `https://${project.subdomain}` : `https://${project.name}.pages.dev`; + + return ( + + + + { + e.stopPropagation(); + }} + > +
+ + + + + {url} + + + + + + ); +} diff --git a/app/components/deploy/CloudflareDeploy.client.tsx b/app/components/deploy/CloudflareDeploy.client.tsx new file mode 100644 index 00000000..d50e00d0 --- /dev/null +++ b/app/components/deploy/CloudflareDeploy.client.tsx @@ -0,0 +1,134 @@ +import { toast } from 'react-toastify'; +import { useStore } from '@nanostores/react'; +import { cloudflareConnection } from '~/lib/stores/cloudflare'; +import { workbenchStore } from '~/lib/stores/workbench'; +import { webcontainer } from '~/lib/webcontainer'; +import { path } from '~/utils/path'; +import { useState } from 'react'; +import type { ActionCallbackData } from '~/lib/runtime/message-parser'; +import { chatId } from '~/lib/persistence/useChatHistory'; + +export function useCloudflareDeploy() { + const [isDeploying, setIsDeploying] = useState(false); + const cfConn = useStore(cloudflareConnection); + const currentChatId = useStore(chatId); + + const handleCloudflareDeploy = async () => { + if (!cfConn.accountId || !cfConn.token) { + toast.error('Please configure Cloudflare account and token in settings.'); + return false; + } + + if (!currentChatId) { + toast.error('No active chat found'); + return false; + } + + try { + setIsDeploying(true); + + const artifact = workbenchStore.firstArtifact; + if (!artifact) { + throw new Error('No active project found'); + } + + const deploymentId = `deploy-cloudflare`; + workbenchStore.addArtifact({ + id: deploymentId, + messageId: deploymentId, + title: 'Cloudflare Deployment', + type: 'standalone', + }); + const deployArtifact = workbenchStore.artifacts.get()[deploymentId]; + + deployArtifact.runner.handleDeployAction('building', 'running', { source: 'cloudflare' }); + + const actionId = 'build-' + Date.now(); + const actionData: ActionCallbackData = { + messageId: 'cloudflare build', + artifactId: artifact.id, + actionId, + action: { type: 'build' as const, content: 'npm run build' }, + }; + + artifact.runner.addAction(actionData); + await artifact.runner.runAction(actionData); + + if (!artifact.runner.buildOutput) { + deployArtifact.runner.handleDeployAction('building', 'failed', { error: 'Build failed', source: 'cloudflare' }); + throw new Error('Build failed'); + } + + deployArtifact.runner.handleDeployAction('deploying', 'running', { source: 'cloudflare' }); + + const container = await webcontainer; + const buildPath = artifact.runner.buildOutput.path.replace('/home/project', ''); + const commonDirs = [buildPath, '/dist', '/build', '/out', '/output', '/.next', '/public']; + let finalBuildPath = buildPath; + let found = false; + for (const dir of commonDirs) { + try { + await container.fs.readdir(dir); + finalBuildPath = dir; + found = true; + break; + } catch {} + } + if (!found) throw new Error('Could not find build output directory'); + + async function getAllFiles(dirPath: string): Promise> { + const files: Record = {}; + const entries = await container.fs.readdir(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isFile()) { + const content = await container.fs.readFile(fullPath, 'utf-8'); + const deployPath = fullPath.replace(finalBuildPath, ''); + files[deployPath] = content; + } else if (entry.isDirectory()) { + Object.assign(files, await getAllFiles(fullPath)); + } + } + return files; + } + + const fileContents = await getAllFiles(finalBuildPath); + const existingProject = localStorage.getItem(`cloudflare-project-${currentChatId}`); + + const response = await fetch('/api/cloudflare-deploy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + projectName: existingProject || undefined, + files: fileContents, + token: cfConn.token, + accountId: cfConn.accountId, + chatId: currentChatId, + }), + }); + + const data = await response.json(); + if (!response.ok || !data.deploy || !data.project) { + deployArtifact.runner.handleDeployAction('deploying', 'failed', { + error: data.error || 'Invalid deployment response', + source: 'cloudflare', + }); + throw new Error(data.error || 'Invalid deployment response'); + } + + localStorage.setItem(`cloudflare-project-${currentChatId}`, data.project.name); + + deployArtifact.runner.handleDeployAction('complete', 'complete', { url: data.deploy.url, source: 'cloudflare' }); + + return true; + } catch (err) { + console.error('Cloudflare deploy error:', err); + toast.error(err instanceof Error ? err.message : 'Cloudflare deployment failed'); + return false; + } finally { + setIsDeploying(false); + } + }; + + return { isDeploying, handleCloudflareDeploy, isConnected: !!cfConn.token }; +} diff --git a/app/components/deploy/DeployButton.tsx b/app/components/deploy/DeployButton.tsx index 50c06d37..83ffe2b1 100644 --- a/app/components/deploy/DeployButton.tsx +++ b/app/components/deploy/DeployButton.tsx @@ -2,14 +2,17 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import { useStore } from '@nanostores/react'; import { netlifyConnection } from '~/lib/stores/netlify'; import { vercelConnection } from '~/lib/stores/vercel'; +import { cloudflareConnection } from '~/lib/stores/cloudflare'; import { workbenchStore } from '~/lib/stores/workbench'; import { streamingState } from '~/lib/stores/streaming'; import { classNames } from '~/utils/classNames'; import { useState } from 'react'; import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client'; import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client'; +import { CloudflareDeploymentLink } from '~/components/chat/CloudflareDeploymentLink.client'; import { useVercelDeploy } from '~/components/deploy/VercelDeploy.client'; import { useNetlifyDeploy } from '~/components/deploy/NetlifyDeploy.client'; +import { useCloudflareDeploy } from '~/components/deploy/CloudflareDeploy.client'; interface DeployButtonProps { onVercelDeploy?: () => Promise; @@ -19,14 +22,16 @@ interface DeployButtonProps { export const DeployButton = ({ onVercelDeploy, onNetlifyDeploy }: DeployButtonProps) => { const netlifyConn = useStore(netlifyConnection); const vercelConn = useStore(vercelConnection); + const cloudflareConn = useStore(cloudflareConnection); const [activePreviewIndex] = useState(0); const previews = useStore(workbenchStore.previews); const activePreview = previews[activePreviewIndex]; const [isDeploying, setIsDeploying] = useState(false); - const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | null>(null); + const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | 'cloudflare' | null>(null); const isStreaming = useStore(streamingState); const { handleVercelDeploy } = useVercelDeploy(); const { handleNetlifyDeploy } = useNetlifyDeploy(); + const { handleCloudflareDeploy } = useCloudflareDeploy(); const handleVercelDeployClick = async () => { setIsDeploying(true); @@ -60,6 +65,18 @@ export const DeployButton = ({ onVercelDeploy, onNetlifyDeploy }: DeployButtonPr } }; + const handleCloudflareDeployClick = async () => { + setIsDeploying(true); + setDeployingTo('cloudflare'); + + try { + await handleCloudflareDeploy(); + } finally { + setIsDeploying(false); + setDeployingTo(null); + } + }; + return (
@@ -126,8 +143,15 @@ export const DeployButton = ({ onVercelDeploy, onNetlifyDeploy }: DeployButtonPr cloudflare - Deploy to Cloudflare (Coming Soon) + {!cloudflareConn.token ? 'No Cloudflare Token' : 'Deploy to Cloudflare'} + {cloudflareConn.token && cloudflareConn.accountId && } diff --git a/app/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts index 20db0816..a7234c49 100644 --- a/app/lib/runtime/action-runner.ts +++ b/app/lib/runtime/action-runner.ts @@ -522,7 +522,7 @@ export class ActionRunner { details?: { url?: string; error?: string; - source?: 'netlify' | 'vercel' | 'github'; + source?: 'netlify' | 'vercel' | 'cloudflare' | 'github'; }, ): void { if (!this.onDeployAlert) { diff --git a/app/lib/stores/cloudflare.ts b/app/lib/stores/cloudflare.ts new file mode 100644 index 00000000..04b22c1b --- /dev/null +++ b/app/lib/stores/cloudflare.ts @@ -0,0 +1,43 @@ +import { atom } from 'nanostores'; +import type { CloudflareConnection, CloudflareProject } from '~/types/cloudflare'; +import { logStore } from './logs'; +import { toast } from 'react-toastify'; + +const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('cloudflare_connection') : null; + +const envToken = import.meta.env.VITE_CLOUDFLARE_API_TOKEN; +const envAccountId = import.meta.env.VITE_CLOUDFLARE_ACCOUNT_ID; + +const initialConnection: CloudflareConnection = storedConnection + ? JSON.parse(storedConnection) + : { accountId: envAccountId || '', token: envToken || '', projects: undefined }; + +export const cloudflareConnection = atom(initialConnection); +export const isConnecting = atom(false); + +export const updateCloudflareConnection = (updates: Partial) => { + const current = cloudflareConnection.get(); + const newState = { ...current, ...updates }; + cloudflareConnection.set(newState); + + if (typeof window !== 'undefined') { + localStorage.setItem('cloudflare_connection', JSON.stringify(newState)); + } +}; + +export async function fetchCloudflareProjects(token: string, accountId: string) { + try { + const response = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch projects: ${response.status}`); + } + const data = (await response.json()) as any; + updateCloudflareConnection({ projects: data.result as CloudflareProject[] }); + } catch (error) { + console.error('Cloudflare API Error:', error); + logStore.logError('Failed to fetch Cloudflare projects', { error }); + toast.error('Failed to fetch Cloudflare projects'); + } +} diff --git a/app/routes/api.cloudflare-deploy.ts b/app/routes/api.cloudflare-deploy.ts new file mode 100644 index 00000000..f9facd11 --- /dev/null +++ b/app/routes/api.cloudflare-deploy.ts @@ -0,0 +1,70 @@ +import { type ActionFunctionArgs, json } from '@remix-run/cloudflare'; +import JSZip from 'jszip'; + +interface DeployRequestBody { + accountId: string; + projectName?: string; + files: Record; + chatId: string; + token: string; +} + +export async function action({ request }: ActionFunctionArgs) { + try { + const { accountId, projectName, files, token, chatId } = (await request.json()) as DeployRequestBody; + + if (!accountId || !token) { + return json({ error: 'Missing Cloudflare credentials' }, { status: 401 }); + } + + let targetProject = projectName; + + if (!targetProject) { + const name = `bolt-diy-${chatId}-${Date.now()}`; + const createRes = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + if (!createRes.ok) { + const txt = await createRes.text(); + return json({ error: `Failed to create project: ${txt}` }, { status: 400 }); + } + targetProject = name; + } + + const zip = new JSZip(); + for (const [filePath, content] of Object.entries(files)) { + const normalized = filePath.startsWith('/') ? filePath.substring(1) : filePath; + zip.file(normalized, content); + } + const zipData = await zip.generateAsync({ type: 'nodebuffer' }); + + const form = new FormData(); + form.append('metadata', JSON.stringify({})); + form.append('file', new Blob([zipData]), 'deploy.zip'); + + const deployRes = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/pages/projects/${targetProject}/deployments`, + { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: form as any, + }, + ); + + const deployData = await deployRes.json(); + if (!deployRes.ok) { + return json({ error: deployData.errors?.[0]?.message || 'Failed to deploy' }, { status: 400 }); + } + + return json({ + success: true, + deploy: { id: deployData.result.id, url: deployData.result.url }, + project: { name: targetProject, id: deployData.result.project_id }, + }); + } catch (error) { + console.error('Cloudflare deploy error:', error); + return json({ error: 'Deployment failed' }, { status: 500 }); + } +} diff --git a/app/types/actions.ts b/app/types/actions.ts index 0e1411d8..b997bfdc 100644 --- a/app/types/actions.ts +++ b/app/types/actions.ts @@ -59,7 +59,7 @@ export interface DeployAlert { stage?: 'building' | 'deploying' | 'complete'; buildStatus?: 'pending' | 'running' | 'complete' | 'failed'; deployStatus?: 'pending' | 'running' | 'complete' | 'failed'; - source?: 'vercel' | 'netlify' | 'github'; + source?: 'vercel' | 'netlify' | 'cloudflare' | 'github'; } export interface FileHistory { diff --git a/app/types/cloudflare.ts b/app/types/cloudflare.ts new file mode 100644 index 00000000..2f42c41d --- /dev/null +++ b/app/types/cloudflare.ts @@ -0,0 +1,11 @@ +export interface CloudflareProject { + id: string; + name: string; + subdomain?: string; +} + +export interface CloudflareConnection { + accountId: string; + token: string; + projects?: CloudflareProject[]; +} diff --git a/docs/docs/index.md b/docs/docs/index.md index e5f9908a..ce637280 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -40,6 +40,7 @@ Also [this pinned post in our community](https://thinktank.ottomator.ai/t/videos - **Revert code to earlier versions** for easier debugging and quicker changes. - **Download projects as ZIP** for easy portability. - **Integration-ready Docker support** for a hassle-free setup. +- **One-click deployment** to **Netlify**, **Vercel**, or **Cloudflare Pages**. ---