feat: add Vercel integration for project deployment

This commit introduces Vercel integration, enabling users to deploy projects directly to Vercel. It includes:
- New Vercel types and store for managing connections and stats.
- A VercelConnection component for managing Vercel account connections.
- A VercelDeploymentLink component for displaying deployment links.
- API routes for handling Vercel deployments.
- Updates to the HeaderActionButtons component to support Vercel deployment.

The integration allows users to connect their Vercel accounts, view project stats, and deploy projects with ease.
This commit is contained in:
KevIsDev 2025-03-27 00:06:10 +00:00
parent 1364d4a503
commit 687b03ba74
9 changed files with 982 additions and 20 deletions

View File

@ -1,5 +1,6 @@
import { motion } from 'framer-motion';
import React, { Suspense } from 'react';
import VercelConnection from './VercelConnection';
// Use React.lazy for dynamic imports
const GithubConnection = React.lazy(() => import('./GithubConnection'));
@ -39,6 +40,9 @@ export default function ConnectionsTab() {
<Suspense fallback={<LoadingFallback />}>
<NetlifyConnection />
</Suspense>
<Suspense fallback={<LoadingFallback />}>
<VercelConnection />
</Suspense>
</div>
</div>
);

View File

@ -0,0 +1,289 @@
import React, { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { useStore } from '@nanostores/react';
import { logStore } from '~/lib/stores/logs';
import { classNames } from '~/utils/classNames';
import {
vercelConnection,
isConnecting,
isFetchingStats,
updateVercelConnection,
fetchVercelStats,
} from '~/lib/stores/vercel';
export default function VercelConnection() {
const connection = useStore(vercelConnection);
const connecting = useStore(isConnecting);
const fetchingStats = useStore(isFetchingStats);
const [isProjectsExpanded, setIsProjectsExpanded] = useState(false);
useEffect(() => {
const fetchProjects = async () => {
if (connection.user && connection.token) {
await fetchVercelStats(connection.token);
}
};
fetchProjects();
}, [connection.user, connection.token]);
const handleConnect = async (event: React.FormEvent) => {
event.preventDefault();
isConnecting.set(true);
try {
const response = await fetch('https://api.vercel.com/v2/user', {
headers: {
Authorization: `Bearer ${connection.token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Invalid token or unauthorized');
}
const userData = (await response.json()) as any;
updateVercelConnection({
user: userData.user || userData, // Handle both possible structures
token: connection.token,
});
await fetchVercelStats(connection.token);
toast.success('Successfully connected to Vercel');
} catch (error) {
console.error('Auth error:', error);
logStore.logError('Failed to authenticate with Vercel', { error });
toast.error('Failed to connect to Vercel');
updateVercelConnection({ user: null, token: '' });
} finally {
isConnecting.set(false);
}
};
const handleDisconnect = () => {
updateVercelConnection({ user: null, token: '' });
toast.success('Disconnected from Vercel');
};
console.log('connection', connection);
return (
<motion.div
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<img
className="w-5 h-5 dark:invert"
height="24"
width="24"
crossOrigin="anonymous"
src={`https://cdn.simpleicons.org/vercel/black`}
/>
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Vercel Connection</h3>
</div>
</div>
{!connection.user ? (
<div className="space-y-4">
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label>
<input
type="password"
value={connection.token}
onChange={(e) => updateVercelConnection({ ...connection, token: e.target.value })}
disabled={connecting}
placeholder="Enter your Vercel personal access token"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
<a
href="https://vercel.com/account/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
</div>
</div>
<button
onClick={handleConnect}
disabled={connecting || !connection.token}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-bolt-elements-borderColor text-white',
'hover:bg-bolt-elements-borderColorActive',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{connecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
</div>
) : (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={handleDisconnect}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-red-500 text-white',
'hover:bg-red-600',
)}
>
<div className="i-ph:plug w-4 h-4" />
Disconnect
</button>
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
<div className="i-ph:check-circle w-4 h-4 text-green-500" />
Connected to Vercel
</span>
</div>
</div>
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
{/* Debug output */}
<pre className="hidden">{JSON.stringify(connection.user, null, 2)}</pre>
<img
src={`https://vercel.com/api/www/avatar?u=${connection.user?.username || connection.user?.user?.username}`}
referrerPolicy="no-referrer"
crossOrigin="anonymous"
alt="User Avatar"
className="w-12 h-12 rounded-full border-2 border-bolt-elements-borderColorActive"
/>
<div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">
{connection.user?.username || connection.user?.user?.username || 'Vercel User'}
</h4>
<p className="text-sm text-bolt-elements-textSecondary">
{connection.user?.email || connection.user?.user?.email || 'No email available'}
</p>
</div>
</div>
{fetchingStats ? (
<div className="flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
Fetching Vercel projects...
</div>
) : (
<div>
<button
onClick={() => setIsProjectsExpanded(!isProjectsExpanded)}
className="w-full bg-transparent text-left text-sm font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2"
>
<div className="i-ph:buildings w-4 h-4" />
Your Projects ({connection.stats?.totalProjects || 0})
<div
className={classNames(
'i-ph:caret-down w-4 h-4 ml-auto transition-transform',
isProjectsExpanded ? 'rotate-180' : '',
)}
/>
</button>
{isProjectsExpanded && connection.stats?.projects?.length ? (
<div className="grid gap-3">
{connection.stats.projects.map((project) => (
<a
key={project.id}
href={`https://vercel.com/dashboard/${project.id}`}
target="_blank"
rel="noopener noreferrer"
className="block p-4 rounded-lg border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive transition-colors"
>
<div className="flex items-center justify-between">
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
<div className="i-ph:globe w-4 h-4 text-bolt-elements-borderColorActive" />
{project.name}
</h5>
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
{project.targets?.production?.alias && project.targets.production.alias.length > 0 ? (
<>
<a
href={`https://${project.targets.production.alias.find((a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app')) || project.targets.production.alias[0]}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-bolt-elements-borderColorActive"
>
{project.targets.production.alias.find(
(a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app'),
) || project.targets.production.alias[0]}
</a>
<span></span>
<span className="flex items-center gap-1">
<div className="i-ph:clock w-3 h-3" />
{new Date(project.createdAt).toLocaleDateString()}
</span>
</>
) : project.latestDeployments && project.latestDeployments.length > 0 ? (
<>
<a
href={`https://${project.latestDeployments[0].url}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-bolt-elements-borderColorActive"
>
{project.latestDeployments[0].url}
</a>
<span></span>
<span className="flex items-center gap-1">
<div className="i-ph:clock w-3 h-3" />
{new Date(project.latestDeployments[0].created).toLocaleDateString()}
</span>
</>
) : null}
</div>
</div>
{project.framework && (
<div className="text-xs text-bolt-elements-textSecondary px-2 py-1 rounded-md bg-[#F0F0F0] dark:bg-[#252525]">
<span className="flex items-center gap-1">
<div className="i-ph:code w-3 h-3" />
{project.framework}
</span>
</div>
)}
</div>
</a>
))}
</div>
) : isProjectsExpanded ? (
<div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
<div className="i-ph:info w-4 h-4" />
No projects found in your Vercel account
</div>
) : null}
</div>
)}
</div>
)}
</div>
</motion.div>
);
}

View File

@ -30,10 +30,10 @@ export function NetlifyDeploymentLink() {
rel="noopener noreferrer"
className="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textSecondary hover:text-[#00AD9F] z-50"
onClick={(e) => {
e.stopPropagation(); // Add this to prevent click from bubbling up
e.stopPropagation(); // This is to prevent click from bubbling up
}}
>
<div className="i-ph:rocket-launch w-5 h-5" />
<div className="i-ph:link w-4 h-4 hover:text-blue-400" />
</a>
</Tooltip.Trigger>
<Tooltip.Portal>

View File

@ -0,0 +1,158 @@
import { useStore } from '@nanostores/react';
import { vercelConnection } from '~/lib/stores/vercel';
import { chatId } from '~/lib/persistence/useChatHistory';
import * as Tooltip from '@radix-ui/react-tooltip';
import { useEffect, useState } from 'react';
export function VercelDeploymentLink() {
const connection = useStore(vercelConnection);
const currentChatId = useStore(chatId);
const [deploymentUrl, setDeploymentUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
async function fetchProjectData() {
if (!connection.token || !currentChatId) {
return;
}
// Check if we have a stored project ID for this chat
const projectId = localStorage.getItem(`vercel-project-${currentChatId}`);
if (!projectId) {
return;
}
setIsLoading(true);
try {
// Fetch projects directly from the API
const projectsResponse = await fetch('https://api.vercel.com/v9/projects', {
headers: {
Authorization: `Bearer ${connection.token}`,
'Content-Type': 'application/json',
},
cache: 'no-store',
});
if (!projectsResponse.ok) {
throw new Error(`Failed to fetch projects: ${projectsResponse.status}`);
}
const projectsData = (await projectsResponse.json()) as any;
const projects = projectsData.projects || [];
// Extract the chat number from currentChatId
const chatNumber = currentChatId.split('-')[0];
// Find project by matching the chat number in the name
const project = projects.find((p: { name: string | string[] }) => p.name.includes(`bolt-diy-${chatNumber}`));
if (project) {
// Fetch project details including deployments
const projectDetailsResponse = await fetch(`https://api.vercel.com/v9/projects/${project.id}`, {
headers: {
Authorization: `Bearer ${connection.token}`,
'Content-Type': 'application/json',
},
cache: 'no-store',
});
if (projectDetailsResponse.ok) {
const projectDetails = (await projectDetailsResponse.json()) as any;
// Try to get URL from production aliases first
if (projectDetails.targets?.production?.alias && projectDetails.targets.production.alias.length > 0) {
// Find the clean URL (without -projects.vercel.app)
const cleanUrl = projectDetails.targets.production.alias.find(
(a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app'),
);
if (cleanUrl) {
setDeploymentUrl(`https://${cleanUrl}`);
return;
} else {
// If no clean URL found, use the first alias
setDeploymentUrl(`https://${projectDetails.targets.production.alias[0]}`);
return;
}
}
}
// If no aliases or project details failed, try fetching deployments
const deploymentsResponse = await fetch(
`https://api.vercel.com/v6/deployments?projectId=${project.id}&limit=1`,
{
headers: {
Authorization: `Bearer ${connection.token}`,
'Content-Type': 'application/json',
},
cache: 'no-store',
},
);
if (deploymentsResponse.ok) {
const deploymentsData = (await deploymentsResponse.json()) as any;
if (deploymentsData.deployments && deploymentsData.deployments.length > 0) {
setDeploymentUrl(`https://${deploymentsData.deployments[0].url}`);
return;
}
}
}
// Fallback to API call if not found in fetched projects
const fallbackResponse = await fetch(`/api/vercel-deploy?projectId=${projectId}&token=${connection.token}`, {
method: 'GET',
});
const data = await fallbackResponse.json();
if ((data as { deploy?: { url?: string } }).deploy?.url) {
setDeploymentUrl((data as { deploy: { url: string } }).deploy.url);
} else if ((data as { project?: { url?: string } }).project?.url) {
setDeploymentUrl((data as { project: { url: string } }).project.url);
}
} catch (err) {
console.error('Error fetching Vercel deployment:', err);
} finally {
setIsLoading(false);
}
}
fetchProjectData();
}, [connection.token, currentChatId]);
if (!deploymentUrl) {
return null;
}
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<a
href={deploymentUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textSecondary hover:text-[#000000] z-50"
onClick={(e) => {
e.stopPropagation();
}}
>
<div className={`i-ph:link w-4 h-4 hover:text-blue-400 ${isLoading ? 'animate-pulse' : ''}`} />
</a>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="px-3 py-2 rounded bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary text-xs z-50"
sideOffset={5}
>
{deploymentUrl}
<Tooltip.Arrow className="fill-bolt-elements-background-depth-3" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@ -3,26 +3,30 @@ import { toast } from 'react-toastify';
import useViewport from '~/lib/hooks';
import { chatStore } from '~/lib/stores/chat';
import { netlifyConnection } from '~/lib/stores/netlify';
import { vercelConnection } from '~/lib/stores/vercel';
import { workbenchStore } from '~/lib/stores/workbench';
import { webcontainer } from '~/lib/webcontainer';
import { classNames } from '~/utils/classNames';
import { path } from '~/utils/path';
import { useEffect, useRef, useState } from 'react';
import type { ActionCallbackData } from '~/lib/runtime/message-parser';
import { chatId } from '~/lib/persistence/useChatHistory'; // Add this import
import { chatId } from '~/lib/persistence/useChatHistory';
import { streamingState } from '~/lib/stores/streaming';
import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client';
import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client';
interface HeaderActionButtonsProps {}
export function HeaderActionButtons({}: HeaderActionButtonsProps) {
const showWorkbench = useStore(workbenchStore.showWorkbench);
const { showChat } = useStore(chatStore);
const connection = useStore(netlifyConnection);
const netlifyConn = useStore(netlifyConnection);
const vercelConn = useStore(vercelConnection);
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 isSmallViewport = useViewport(1024);
const canHideChat = showWorkbench || !showChat;
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
@ -42,8 +46,8 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
const currentChatId = useStore(chatId);
const handleDeploy = async () => {
if (!connection.user || !connection.token) {
const handleNetlifyDeploy = async () => {
if (!netlifyConn.user || !netlifyConn.token) {
toast.error('Please connect to Netlify first in the settings tab!');
return;
}
@ -118,7 +122,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
const existingSiteId = localStorage.getItem(`netlify-site-${currentChatId}`);
// Deploy using the API route with file contents
const response = await fetch('/api/deploy', {
const response = await fetch('/api/netlify-deploy', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -126,7 +130,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
body: JSON.stringify({
siteId: existingSiteId || undefined,
files: fileContents,
token: connection.token,
token: netlifyConn.token,
chatId: currentChatId, // Use chatId instead of artifact.id
}),
});
@ -149,7 +153,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
`https://api.netlify.com/api/v1/sites/${data.site.id}/deploys/${data.deploy.id}`,
{
headers: {
Authorization: `Bearer ${connection.token}`,
Authorization: `Bearer ${netlifyConn.token}`,
},
},
);
@ -203,6 +207,125 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
}
};
const handleVercelDeploy = async () => {
if (!vercelConn.user || !vercelConn.token) {
toast.error('Please connect to Vercel first in the settings tab!');
return;
}
if (!currentChatId) {
toast.error('No active chat found');
return;
}
try {
setIsDeploying(true);
setDeployingTo('vercel');
const artifact = workbenchStore.firstArtifact;
if (!artifact) {
throw new Error('No active project found');
}
const actionId = 'build-' + Date.now();
const actionData: ActionCallbackData = {
messageId: 'vercel build',
artifactId: artifact.id,
actionId,
action: {
type: 'build' as const,
content: 'npm run build',
},
};
// Add the action first
artifact.runner.addAction(actionData);
// Then run it
await artifact.runner.runAction(actionData);
if (!artifact.runner.buildOutput) {
throw new Error('Build failed');
}
// Get the build files
const container = await webcontainer;
// Remove /home/project from buildPath if it exists
const buildPath = artifact.runner.buildOutput.path.replace('/home/project', '');
// Get all files recursively
async function getAllFiles(dirPath: string): Promise<Record<string, string>> {
const files: Record<string, string> = {};
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');
// Remove /dist prefix from the path
const deployPath = fullPath.replace(buildPath, '');
files[deployPath] = content;
} else if (entry.isDirectory()) {
const subFiles = await getAllFiles(fullPath);
Object.assign(files, subFiles);
}
}
return files;
}
const fileContents = await getAllFiles(buildPath);
// Use chatId instead of artifact.id
const existingProjectId = localStorage.getItem(`vercel-project-${currentChatId}`);
// Deploy using the API route with file contents
const response = await fetch('/api/vercel-deploy', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
projectId: existingProjectId || undefined,
files: fileContents,
token: vercelConn.token,
chatId: currentChatId,
}),
});
const data = (await response.json()) as any;
if (!response.ok || !data.deploy || !data.project) {
console.error('Invalid deploy response:', data);
throw new Error(data.error || 'Invalid deployment response');
}
// Store the project ID if it's a new project
if (data.project) {
localStorage.setItem(`vercel-project-${currentChatId}`, data.project.id);
}
toast.success(
<div>
Deployed successfully to Vercel!{' '}
<a href={data.deploy.url} target="_blank" rel="noopener noreferrer" className="underline">
View site
</a>
</div>,
);
} catch (error) {
console.error('Vercel deploy error:', error);
toast.error(error instanceof Error ? error.message : 'Vercel deployment failed');
} finally {
setIsDeploying(false);
setDeployingTo(null);
}
};
return (
<div className="flex">
<div className="relative" ref={dropdownRef}>
@ -213,7 +336,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="px-4 hover:bg-bolt-elements-item-backgroundActive flex items-center gap-2"
>
{isDeploying ? 'Deploying...' : 'Deploy'}
{isDeploying ? `Deploying to ${deployingTo}...` : 'Deploy'}
<div
className={classNames('i-ph:caret-down w-4 h-4 transition-transform', isDropdownOpen ? 'rotate-180' : '')}
/>
@ -225,10 +348,10 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
<Button
active
onClick={() => {
handleDeploy();
handleNetlifyDeploy();
setIsDropdownOpen(false);
}}
disabled={isDeploying || !activePreview || !connection.user}
disabled={isDeploying || !activePreview || !netlifyConn.user}
className="flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative"
>
<img
@ -238,15 +361,20 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/netlify"
/>
<span className="mx-auto">{!connection.user ? 'No Account Connected' : 'Deploy to Netlify'}</span>
{connection.user && <NetlifyDeploymentLink />}
<span className="mx-auto">
{!netlifyConn.user ? 'No Netlify Account Connected' : 'Deploy to Netlify'}
</span>
{netlifyConn.user && <NetlifyDeploymentLink />}
</Button>
<Button
active={false}
disabled
className="flex items-center w-full rounded-md px-4 py-2 text-sm text-bolt-elements-textTertiary gap-2"
active
onClick={() => {
handleVercelDeploy();
setIsDropdownOpen(false);
}}
disabled={isDeploying || !activePreview || !vercelConn.user}
className="flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative"
>
<span className="sr-only">Coming Soon</span>
<img
className="w-5 h-5 bg-black p-1 rounded"
height="24"
@ -255,7 +383,8 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
src="https://cdn.simpleicons.org/vercel/white"
alt="vercel"
/>
<span className="mx-auto">Deploy to Vercel (Coming Soon)</span>
<span className="mx-auto">{!vercelConn.user ? 'No Vercel Account Connected' : 'Deploy to Vercel'}</span>
{vercelConn.user && <VercelDeploymentLink />}
</Button>
<Button
active={false}
@ -269,7 +398,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/cloudflare"
alt="vercel"
alt="cloudflare"
/>
<span className="mx-auto">Deploy to Cloudflare (Coming Soon)</span>
</Button>

94
app/lib/stores/vercel.ts Normal file
View File

@ -0,0 +1,94 @@
import { atom } from 'nanostores';
import type { VercelConnection } from '~/types/vercel';
import { logStore } from './logs';
import { toast } from 'react-toastify';
// Initialize with stored connection or defaults
const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('vercel_connection') : null;
const initialConnection: VercelConnection = storedConnection
? JSON.parse(storedConnection)
: {
user: null,
token: '',
stats: undefined,
};
export const vercelConnection = atom<VercelConnection>(initialConnection);
export const isConnecting = atom<boolean>(false);
export const isFetchingStats = atom<boolean>(false);
export const updateVercelConnection = (updates: Partial<VercelConnection>) => {
const currentState = vercelConnection.get();
const newState = { ...currentState, ...updates };
vercelConnection.set(newState);
// Persist to localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('vercel_connection', JSON.stringify(newState));
}
};
export async function fetchVercelStats(token: string) {
try {
isFetchingStats.set(true);
const projectsResponse = await fetch('https://api.vercel.com/v9/projects', {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!projectsResponse.ok) {
throw new Error(`Failed to fetch projects: ${projectsResponse.status}`);
}
const projectsData = (await projectsResponse.json()) as any;
const projects = projectsData.projects || [];
// Fetch latest deployment for each project
const projectsWithDeployments = await Promise.all(
projects.map(async (project: any) => {
try {
const deploymentsResponse = await fetch(
`https://api.vercel.com/v6/deployments?projectId=${project.id}&limit=1`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
},
);
if (deploymentsResponse.ok) {
const deploymentsData = (await deploymentsResponse.json()) as any;
return {
...project,
latestDeployments: deploymentsData.deployments || [],
};
}
return project;
} catch (error) {
console.error(`Error fetching deployments for project ${project.id}:`, error);
return project;
}
}),
);
const currentState = vercelConnection.get();
updateVercelConnection({
...currentState,
stats: {
projects: projectsWithDeployments,
totalProjects: projectsWithDeployments.length,
},
});
} catch (error) {
console.error('Vercel API Error:', error);
logStore.logError('Failed to fetch Vercel stats', { error });
toast.error('Failed to fetch Vercel statistics');
} finally {
isFetchingStats.set(false);
}
}

View File

@ -0,0 +1,248 @@
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from '@remix-run/cloudflare';
import type { VercelProjectInfo } from '~/types/vercel';
// Add loader function to handle GET requests
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const projectId = url.searchParams.get('projectId');
const token = url.searchParams.get('token');
if (!projectId || !token) {
return json({ error: 'Missing projectId or token' }, { status: 400 });
}
try {
// Get project info
const projectResponse = await fetch(`https://api.vercel.com/v9/projects/${projectId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!projectResponse.ok) {
return json({ error: 'Failed to fetch project' }, { status: 400 });
}
const projectData = (await projectResponse.json()) as any;
// Get latest deployment
const deploymentsResponse = await fetch(`https://api.vercel.com/v6/deployments?projectId=${projectId}&limit=1`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!deploymentsResponse.ok) {
return json({ error: 'Failed to fetch deployments' }, { status: 400 });
}
const deploymentsData = (await deploymentsResponse.json()) as any;
const latestDeployment = deploymentsData.deployments?.[0];
return json({
project: {
id: projectData.id,
name: projectData.name,
url: `https://${projectData.name}.vercel.app`,
},
deploy: latestDeployment
? {
id: latestDeployment.id,
state: latestDeployment.state,
url: latestDeployment.url ? `https://${latestDeployment.url}` : `https://${projectData.name}.vercel.app`,
}
: null,
});
} catch (error) {
console.error('Error fetching Vercel deployment:', error);
return json({ error: 'Failed to fetch deployment' }, { status: 500 });
}
}
interface DeployRequestBody {
projectId?: string;
files: Record<string, string>;
chatId: string;
}
// Existing action function for POST requests
export async function action({ request }: ActionFunctionArgs) {
try {
const { projectId, files, token, chatId } = (await request.json()) as DeployRequestBody & { token: string };
if (!token) {
return json({ error: 'Not connected to Vercel' }, { status: 401 });
}
let targetProjectId = projectId;
let projectInfo: VercelProjectInfo | undefined;
// If no projectId provided, create a new project
if (!targetProjectId) {
const projectName = `bolt-diy-${chatId}-${Date.now()}`;
const createProjectResponse = await fetch('https://api.vercel.com/v9/projects', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: projectName,
framework: null,
}),
});
if (!createProjectResponse.ok) {
const errorData = (await createProjectResponse.json()) as any;
return json(
{ error: `Failed to create project: ${errorData.error?.message || 'Unknown error'}` },
{ status: 400 },
);
}
const newProject = (await createProjectResponse.json()) as any;
targetProjectId = newProject.id;
projectInfo = {
id: newProject.id,
name: newProject.name,
url: `https://${newProject.name}.vercel.app`,
chatId,
};
} else {
// Get existing project info
const projectResponse = await fetch(`https://api.vercel.com/v9/projects/${targetProjectId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (projectResponse.ok) {
const existingProject = (await projectResponse.json()) as any;
projectInfo = {
id: existingProject.id,
name: existingProject.name,
url: `https://${existingProject.name}.vercel.app`,
chatId,
};
} else {
// If project doesn't exist, create a new one
const projectName = `bolt-diy-${chatId}-${Date.now()}`;
const createProjectResponse = await fetch('https://api.vercel.com/v9/projects', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: projectName,
framework: null,
}),
});
if (!createProjectResponse.ok) {
const errorData = (await createProjectResponse.json()) as any;
return json(
{ error: `Failed to create project: ${errorData.error?.message || 'Unknown error'}` },
{ status: 400 },
);
}
const newProject = (await createProjectResponse.json()) as any;
targetProjectId = newProject.id;
projectInfo = {
id: newProject.id,
name: newProject.name,
url: `https://${newProject.name}.vercel.app`,
chatId,
};
}
}
// Prepare files for deployment
const deploymentFiles = [];
for (const [filePath, content] of Object.entries(files)) {
// Ensure file path doesn't start with a slash for Vercel
const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
deploymentFiles.push({
file: normalizedPath,
data: content,
});
}
// Create a new deployment
const deployResponse = await fetch(`https://api.vercel.com/v13/deployments`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: projectInfo.name,
project: targetProjectId,
target: 'production',
files: deploymentFiles,
routes: [{ src: '/(.*)', dest: '/$1' }],
}),
});
if (!deployResponse.ok) {
const errorData = (await deployResponse.json()) as any;
return json(
{ error: `Failed to create deployment: ${errorData.error?.message || 'Unknown error'}` },
{ status: 400 },
);
}
const deployData = (await deployResponse.json()) as any;
// Poll for deployment status
let retryCount = 0;
const maxRetries = 60;
let deploymentUrl = '';
let deploymentState = '';
while (retryCount < maxRetries) {
const statusResponse = await fetch(`https://api.vercel.com/v13/deployments/${deployData.id}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (statusResponse.ok) {
const status = (await statusResponse.json()) as any;
deploymentState = status.readyState;
deploymentUrl = status.url ? `https://${status.url}` : '';
if (status.readyState === 'READY' || status.readyState === 'ERROR') {
break;
}
}
retryCount++;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (deploymentState === 'ERROR') {
return json({ error: 'Deployment failed' }, { status: 500 });
}
if (retryCount >= maxRetries) {
return json({ error: 'Deployment timed out' }, { status: 500 });
}
return json({
success: true,
deploy: {
id: deployData.id,
state: deploymentState,
url: deploymentUrl || projectInfo.url,
},
project: projectInfo,
});
} catch (error) {
console.error('Vercel deploy error:', error);
return json({ error: 'Deployment failed' }, { status: 500 });
}
}

40
app/types/vercel.ts Normal file
View File

@ -0,0 +1,40 @@
export interface VercelUser {
user: any;
id: string;
username: string;
email: string;
name: string;
avatar?: string;
}
export interface VercelProject {
createdAt: string | number | Date;
targets: any;
id: string;
name: string;
framework?: string;
latestDeployments?: Array<{
id: string;
url: string;
created: number;
state: string;
}>;
}
export interface VercelStats {
projects: VercelProject[];
totalProjects: number;
}
export interface VercelConnection {
user: VercelUser | null;
token: string;
stats?: VercelStats;
}
export interface VercelProjectInfo {
id: string;
name: string;
url: string;
chatId: string;
}