mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
feat: add one-click netlify deployment
This commit is contained in:
223
app/routes/api.deploy.ts
Normal file
223
app/routes/api.deploy.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user