mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
also extract Netlify and Vercel deploy logic into separate components Move the Netlify and Vercel deployment logic from HeaderActionButtons.client.tsx into dedicated components (NetlifyDeploy.client.tsx and VercelDeploy.client.tsx) to improve code maintainability and reusability.
230 lines
7.0 KiB
TypeScript
230 lines
7.0 KiB
TypeScript
import { type ActionFunctionArgs, json } from '@remix-run/cloudflare';
|
|
import crypto from 'crypto';
|
|
import type { NetlifySiteInfo } from '~/types/netlify';
|
|
|
|
interface DeployRequestBody {
|
|
siteId?: string;
|
|
files: Record<string, string>;
|
|
chatId: string;
|
|
}
|
|
|
|
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 = crypto.createHash('sha1').update(content).digest('hex');
|
|
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 });
|
|
}
|
|
}
|