mirror of
https://github.com/stackblitz-labs/bolt.diy.git
synced 2025-06-18 01:49:07 +01: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.
238 lines
7.3 KiB
TypeScript
238 lines
7.3 KiB
TypeScript
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 });
|
|
}
|
|
}
|