bolt.diy/app/routes/api.netlify-deploy.ts
KevIsDev c63732d2f4 fix: simplify the SHA-1 hash function in api.netlify-deploy.ts by using the crypto module directly this allows it to work in both dev and prod environments.
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.
2025-04-02 16:47:04 +01:00

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 });
}
}