|
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 (!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 { |
|
|
|
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 (!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, |
|
}; |
|
} |
|
} |
|
|
|
|
|
const fileDigests: Record<string, string> = {}; |
|
|
|
for (const [filePath, content] of Object.entries(files)) { |
|
|
|
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath; |
|
const hash = crypto.createHash('sha1').update(content).digest('hex'); |
|
fileDigests[normalizedPath] = hash; |
|
} |
|
|
|
|
|
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, |
|
function_schedules: [], |
|
required: Object.keys(fileDigests), |
|
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; |
|
|
|
|
|
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') { |
|
|
|
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') { |
|
|
|
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 }); |
|
} |
|
|
|
|
|
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 }); |
|
} |
|
} |
|
|