mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
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.
466 lines
16 KiB
TypeScript
466 lines
16 KiB
TypeScript
import { useStore } from '@nanostores/react';
|
|
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';
|
|
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 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);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const isStreaming = useStore(streamingState);
|
|
|
|
useEffect(() => {
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
setIsDropdownOpen(false);
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const currentChatId = useStore(chatId);
|
|
|
|
const handleNetlifyDeploy = async () => {
|
|
if (!netlifyConn.user || !netlifyConn.token) {
|
|
toast.error('Please connect to Netlify first in the settings tab!');
|
|
return;
|
|
}
|
|
|
|
if (!currentChatId) {
|
|
toast.error('No active chat found');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsDeploying(true);
|
|
|
|
const artifact = workbenchStore.firstArtifact;
|
|
|
|
if (!artifact) {
|
|
throw new Error('No active project found');
|
|
}
|
|
|
|
const actionId = 'build-' + Date.now();
|
|
const actionData: ActionCallbackData = {
|
|
messageId: 'netlify 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 existingSiteId = localStorage.getItem(`netlify-site-${currentChatId}`);
|
|
|
|
// Deploy using the API route with file contents
|
|
const response = await fetch('/api/netlify-deploy', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
siteId: existingSiteId || undefined,
|
|
files: fileContents,
|
|
token: netlifyConn.token,
|
|
chatId: currentChatId, // Use chatId instead of artifact.id
|
|
}),
|
|
});
|
|
|
|
const data = (await response.json()) as any;
|
|
|
|
if (!response.ok || !data.deploy || !data.site) {
|
|
console.error('Invalid deploy response:', data);
|
|
throw new Error(data.error || 'Invalid deployment response');
|
|
}
|
|
|
|
// Poll for deployment status
|
|
const maxAttempts = 20; // 2 minutes timeout
|
|
let attempts = 0;
|
|
let deploymentStatus;
|
|
|
|
while (attempts < maxAttempts) {
|
|
try {
|
|
const statusResponse = await fetch(
|
|
`https://api.netlify.com/api/v1/sites/${data.site.id}/deploys/${data.deploy.id}`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${netlifyConn.token}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
deploymentStatus = (await statusResponse.json()) as any;
|
|
|
|
if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') {
|
|
break;
|
|
}
|
|
|
|
if (deploymentStatus.state === 'error') {
|
|
throw new Error('Deployment failed: ' + (deploymentStatus.error_message || 'Unknown error'));
|
|
}
|
|
|
|
attempts++;
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
} catch (error) {
|
|
console.error('Status check error:', error);
|
|
attempts++;
|
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
}
|
|
}
|
|
|
|
if (attempts >= maxAttempts) {
|
|
throw new Error('Deployment timed out');
|
|
}
|
|
|
|
// Store the site ID if it's a new site
|
|
if (data.site) {
|
|
localStorage.setItem(`netlify-site-${currentChatId}`, data.site.id);
|
|
}
|
|
|
|
toast.success(
|
|
<div>
|
|
Deployed successfully!{' '}
|
|
<a
|
|
href={deploymentStatus.ssl_url || deploymentStatus.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="underline"
|
|
>
|
|
View site
|
|
</a>
|
|
</div>,
|
|
);
|
|
} catch (error) {
|
|
console.error('Deploy error:', error);
|
|
toast.error(error instanceof Error ? error.message : 'Deployment failed');
|
|
} finally {
|
|
setIsDeploying(false);
|
|
}
|
|
};
|
|
|
|
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}>
|
|
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden mr-2 text-sm">
|
|
<Button
|
|
active
|
|
disabled={isDeploying || !activePreview || isStreaming}
|
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
|
className="px-4 hover:bg-bolt-elements-item-backgroundActive flex items-center gap-2"
|
|
>
|
|
{isDeploying ? `Deploying to ${deployingTo}...` : 'Deploy'}
|
|
<div
|
|
className={classNames('i-ph:caret-down w-4 h-4 transition-transform', isDropdownOpen ? 'rotate-180' : '')}
|
|
/>
|
|
</Button>
|
|
</div>
|
|
|
|
{isDropdownOpen && (
|
|
<div className="absolute right-2 flex flex-col gap-1 z-50 p-1 mt-1 min-w-[13.5rem] bg-bolt-elements-background-depth-2 rounded-md shadow-lg bg-bolt-elements-backgroundDefault border border-bolt-elements-borderColor">
|
|
<Button
|
|
active
|
|
onClick={() => {
|
|
handleNetlifyDeploy();
|
|
setIsDropdownOpen(false);
|
|
}}
|
|
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
|
|
className="w-5 h-5"
|
|
height="24"
|
|
width="24"
|
|
crossOrigin="anonymous"
|
|
src="https://cdn.simpleicons.org/netlify"
|
|
/>
|
|
<span className="mx-auto">
|
|
{!netlifyConn.user ? 'No Netlify Account Connected' : 'Deploy to Netlify'}
|
|
</span>
|
|
{netlifyConn.user && <NetlifyDeploymentLink />}
|
|
</Button>
|
|
<Button
|
|
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"
|
|
>
|
|
<img
|
|
className="w-5 h-5 bg-black p-1 rounded"
|
|
height="24"
|
|
width="24"
|
|
crossOrigin="anonymous"
|
|
src="https://cdn.simpleicons.org/vercel/white"
|
|
alt="vercel"
|
|
/>
|
|
<span className="mx-auto">{!vercelConn.user ? 'No Vercel Account Connected' : 'Deploy to Vercel'}</span>
|
|
{vercelConn.user && <VercelDeploymentLink />}
|
|
</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"
|
|
>
|
|
<span className="sr-only">Coming Soon</span>
|
|
<img
|
|
className="w-5 h-5"
|
|
height="24"
|
|
width="24"
|
|
crossOrigin="anonymous"
|
|
src="https://cdn.simpleicons.org/cloudflare"
|
|
alt="cloudflare"
|
|
/>
|
|
<span className="mx-auto">Deploy to Cloudflare (Coming Soon)</span>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex border border-bolt-elements-borderColor rounded-md overflow-hidden">
|
|
<Button
|
|
active={showChat}
|
|
disabled={!canHideChat || isSmallViewport} // expand button is disabled on mobile as it's not needed
|
|
onClick={() => {
|
|
if (canHideChat) {
|
|
chatStore.setKey('showChat', !showChat);
|
|
}
|
|
}}
|
|
>
|
|
<div className="i-bolt:chat text-sm" />
|
|
</Button>
|
|
<div className="w-[1px] bg-bolt-elements-borderColor" />
|
|
<Button
|
|
active={showWorkbench}
|
|
onClick={() => {
|
|
if (showWorkbench && !showChat) {
|
|
chatStore.setKey('showChat', true);
|
|
}
|
|
|
|
workbenchStore.showWorkbench.set(!showWorkbench);
|
|
}}
|
|
>
|
|
<div className="i-ph:code-bold" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ButtonProps {
|
|
active?: boolean;
|
|
disabled?: boolean;
|
|
children?: any;
|
|
onClick?: VoidFunction;
|
|
className?: string;
|
|
}
|
|
|
|
function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) {
|
|
return (
|
|
<button
|
|
className={classNames(
|
|
'flex items-center p-1.5',
|
|
{
|
|
'bg-bolt-elements-item-backgroundDefault hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary':
|
|
!active,
|
|
'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent': active && !disabled,
|
|
'bg-bolt-elements-item-backgroundDefault text-alpha-gray-20 dark:text-alpha-white-20 cursor-not-allowed':
|
|
disabled,
|
|
},
|
|
className,
|
|
)}
|
|
onClick={onClick}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|