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

@@ -0,0 +1,237 @@
import { type ActionFunctionArgs, json } from '@remix-run/cloudflare';
import type { NetlifySiteInfo } from '~/types/netlify';
interface DeployRequestBody {
siteId?: string;
files: Record<string, string>;
chatId: string;
}
async function sha1(message: string) {
const msgBuffer = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-1', msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
return hashHex;
}
export async function action({ request }: ActionFunctionArgs) {
try {
const { siteId, files, token, chatId } = (await request.json()) as DeployRequestBody & { token: string };
if (!token) {
return json({ error: 'Not connected to Netlify' }, { status: 401 });
}
let targetSiteId = siteId;
let siteInfo: NetlifySiteInfo | undefined;
// If no siteId provided, create a new site
if (!targetSiteId) {
const siteName = `bolt-diy-${chatId}-${Date.now()}`;
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: siteName,
custom_domain: null,
}),
});
if (!createSiteResponse.ok) {
return json({ error: 'Failed to create site' }, { status: 400 });
}
const newSite = (await createSiteResponse.json()) as any;
targetSiteId = newSite.id;
siteInfo = {
id: newSite.id,
name: newSite.name,
url: newSite.url,
chatId,
};
} else {
// Get existing site info
if (targetSiteId) {
const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (siteResponse.ok) {
const existingSite = (await siteResponse.json()) as any;
siteInfo = {
id: existingSite.id,
name: existingSite.name,
url: existingSite.url,
chatId,
};
} else {
targetSiteId = undefined;
}
}
// If no siteId provided or site doesn't exist, create a new site
if (!targetSiteId) {
const siteName = `bolt-diy-${chatId}-${Date.now()}`;
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: siteName,
custom_domain: null,
}),
});
if (!createSiteResponse.ok) {
return json({ error: 'Failed to create site' }, { status: 400 });
}
const newSite = (await createSiteResponse.json()) as any;
targetSiteId = newSite.id;
siteInfo = {
id: newSite.id,
name: newSite.name,
url: newSite.url,
chatId,
};
}
}
// Create file digests
const fileDigests: Record<string, string> = {};
for (const [filePath, content] of Object.entries(files)) {
// Ensure file path starts with a forward slash
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
const hash = await sha1(content);
fileDigests[normalizedPath] = hash;
}
// Create a new deploy with digests
const deployResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
files: fileDigests,
async: true,
skip_processing: false,
draft: false, // Change this to false for production deployments
function_schedules: [],
required: Object.keys(fileDigests), // Add this line
framework: null,
}),
});
if (!deployResponse.ok) {
return json({ error: 'Failed to create deployment' }, { status: 400 });
}
const deploy = (await deployResponse.json()) as any;
let retryCount = 0;
const maxRetries = 60;
// Poll until deploy is ready for file uploads
while (retryCount < maxRetries) {
const statusResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys/${deploy.id}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const status = (await statusResponse.json()) as any;
if (status.state === 'prepared' || status.state === 'uploaded') {
// Upload all files regardless of required array
for (const [filePath, content] of Object.entries(files)) {
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
let uploadSuccess = false;
let uploadRetries = 0;
while (!uploadSuccess && uploadRetries < 3) {
try {
const uploadResponse = await fetch(
`https://api.netlify.com/api/v1/deploys/${deploy.id}/files${normalizedPath}`,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/octet-stream',
},
body: content,
},
);
uploadSuccess = uploadResponse.ok;
if (!uploadSuccess) {
console.error('Upload failed:', await uploadResponse.text());
uploadRetries++;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
} catch (error) {
console.error('Upload error:', error);
uploadRetries++;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
if (!uploadSuccess) {
return json({ error: `Failed to upload file ${filePath}` }, { status: 500 });
}
}
}
if (status.state === 'ready') {
// Only return after files are uploaded
if (Object.keys(files).length === 0 || status.summary?.status === 'ready') {
return json({
success: true,
deploy: {
id: status.id,
state: status.state,
url: status.ssl_url || status.url,
},
site: siteInfo,
});
}
}
if (status.state === 'error') {
return json({ error: status.error_message || 'Deploy preparation failed' }, { status: 500 });
}
retryCount++;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (retryCount >= maxRetries) {
return json({ error: 'Deploy preparation timed out' }, { status: 500 });
}
// Make sure we're returning the deploy ID and site info
return json({
success: true,
deploy: {
id: deploy.id,
state: deploy.state,
},
site: siteInfo,
});
} catch (error) {
console.error('Deploy error:', error);
return json({ error: 'Deployment failed' }, { status: 500 });
}
}