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.
This commit is contained in:
@@ -1,19 +1,16 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { toast } from 'react-toastify';
|
||||
import useViewport from '~/lib/hooks';
|
||||
import { chatStore } from '~/lib/stores/chat';
|
||||
import { netlifyConnection } from '~/lib/stores/netlify';
|
||||
import { vercelConnection } from '~/lib/stores/vercel';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import { webcontainer } from '~/lib/webcontainer';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { path } from '~/utils/path';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ActionCallbackData } from '~/lib/runtime/message-parser';
|
||||
import { chatId } from '~/lib/persistence/useChatHistory';
|
||||
import { streamingState } from '~/lib/stores/streaming';
|
||||
import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client';
|
||||
import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client';
|
||||
import { useVercelDeploy } from '~/components/deploy/VercelDeploy.client';
|
||||
import { useNetlifyDeploy } from '~/components/deploy/NetlifyDeploy.client';
|
||||
|
||||
interface HeaderActionButtonsProps {}
|
||||
|
||||
@@ -32,6 +29,8 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const isStreaming = useStore(streamingState);
|
||||
const { handleVercelDeploy } = useVercelDeploy();
|
||||
const { handleNetlifyDeploy } = useNetlifyDeploy();
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
@@ -44,282 +43,24 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const currentChatId = useStore(chatId);
|
||||
|
||||
const handleNetlifyDeploy = async () => {
|
||||
if (!netlifyConn.user || !netlifyConn.token) {
|
||||
toast.error('Please connect to Netlify first in the settings tab!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentChatId) {
|
||||
toast.error('No active chat found');
|
||||
return;
|
||||
}
|
||||
const onVercelDeploy = async () => {
|
||||
setIsDeploying(true);
|
||||
setDeployingTo('vercel');
|
||||
|
||||
try {
|
||||
setIsDeploying(true);
|
||||
|
||||
const artifact = workbenchStore.firstArtifact;
|
||||
|
||||
if (!artifact) {
|
||||
throw new Error('No active project found');
|
||||
}
|
||||
|
||||
const actionId = 'build-' + Date.now();
|
||||
const actionData: ActionCallbackData = {
|
||||
messageId: 'netlify build',
|
||||
artifactId: artifact.id,
|
||||
actionId,
|
||||
action: {
|
||||
type: 'build' as const,
|
||||
content: 'npm run build',
|
||||
},
|
||||
};
|
||||
|
||||
// Add the action first
|
||||
artifact.runner.addAction(actionData);
|
||||
|
||||
// Then run it
|
||||
await artifact.runner.runAction(actionData);
|
||||
|
||||
if (!artifact.runner.buildOutput) {
|
||||
throw new Error('Build failed');
|
||||
}
|
||||
|
||||
// Get the build files
|
||||
const container = await webcontainer;
|
||||
|
||||
// Remove /home/project from buildPath if it exists
|
||||
const buildPath = artifact.runner.buildOutput.path.replace('/home/project', '');
|
||||
|
||||
// Get all files recursively
|
||||
async function getAllFiles(dirPath: string): Promise<Record<string, string>> {
|
||||
const files: Record<string, string> = {};
|
||||
const entries = await container.fs.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
|
||||
if (entry.isFile()) {
|
||||
const content = await container.fs.readFile(fullPath, 'utf-8');
|
||||
|
||||
// Remove /dist prefix from the path
|
||||
const deployPath = fullPath.replace(buildPath, '');
|
||||
files[deployPath] = content;
|
||||
} else if (entry.isDirectory()) {
|
||||
const subFiles = await getAllFiles(fullPath);
|
||||
Object.assign(files, subFiles);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
const fileContents = await getAllFiles(buildPath);
|
||||
|
||||
// Use chatId instead of artifact.id
|
||||
const existingSiteId = localStorage.getItem(`netlify-site-${currentChatId}`);
|
||||
|
||||
// Deploy using the API route with file contents
|
||||
const response = await fetch('/api/netlify-deploy', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
siteId: existingSiteId || undefined,
|
||||
files: fileContents,
|
||||
token: netlifyConn.token,
|
||||
chatId: currentChatId, // Use chatId instead of artifact.id
|
||||
}),
|
||||
});
|
||||
|
||||
const data = (await response.json()) as any;
|
||||
|
||||
if (!response.ok || !data.deploy || !data.site) {
|
||||
console.error('Invalid deploy response:', data);
|
||||
throw new Error(data.error || 'Invalid deployment response');
|
||||
}
|
||||
|
||||
// Poll for deployment status
|
||||
const maxAttempts = 20; // 2 minutes timeout
|
||||
let attempts = 0;
|
||||
let deploymentStatus;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
const statusResponse = await fetch(
|
||||
`https://api.netlify.com/api/v1/sites/${data.site.id}/deploys/${data.deploy.id}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${netlifyConn.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
deploymentStatus = (await statusResponse.json()) as any;
|
||||
|
||||
if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (deploymentStatus.state === 'error') {
|
||||
throw new Error('Deployment failed: ' + (deploymentStatus.error_message || 'Unknown error'));
|
||||
}
|
||||
|
||||
attempts++;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
} catch (error) {
|
||||
console.error('Status check error:', error);
|
||||
attempts++;
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
throw new Error('Deployment timed out');
|
||||
}
|
||||
|
||||
// Store the site ID if it's a new site
|
||||
if (data.site) {
|
||||
localStorage.setItem(`netlify-site-${currentChatId}`, data.site.id);
|
||||
}
|
||||
|
||||
toast.success(
|
||||
<div>
|
||||
Deployed successfully!{' '}
|
||||
<a
|
||||
href={deploymentStatus.ssl_url || deploymentStatus.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
View site
|
||||
</a>
|
||||
</div>,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Deploy error:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Deployment failed');
|
||||
await handleVercelDeploy();
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
setDeployingTo(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVercelDeploy = async () => {
|
||||
if (!vercelConn.user || !vercelConn.token) {
|
||||
toast.error('Please connect to Vercel first in the settings tab!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentChatId) {
|
||||
toast.error('No active chat found');
|
||||
return;
|
||||
}
|
||||
const onNetlifyDeploy = async () => {
|
||||
setIsDeploying(true);
|
||||
setDeployingTo('netlify');
|
||||
|
||||
try {
|
||||
setIsDeploying(true);
|
||||
setDeployingTo('vercel');
|
||||
|
||||
const artifact = workbenchStore.firstArtifact;
|
||||
|
||||
if (!artifact) {
|
||||
throw new Error('No active project found');
|
||||
}
|
||||
|
||||
const actionId = 'build-' + Date.now();
|
||||
const actionData: ActionCallbackData = {
|
||||
messageId: 'vercel build',
|
||||
artifactId: artifact.id,
|
||||
actionId,
|
||||
action: {
|
||||
type: 'build' as const,
|
||||
content: 'npm run build',
|
||||
},
|
||||
};
|
||||
|
||||
// Add the action first
|
||||
artifact.runner.addAction(actionData);
|
||||
|
||||
// Then run it
|
||||
await artifact.runner.runAction(actionData);
|
||||
|
||||
if (!artifact.runner.buildOutput) {
|
||||
throw new Error('Build failed');
|
||||
}
|
||||
|
||||
// Get the build files
|
||||
const container = await webcontainer;
|
||||
|
||||
// Remove /home/project from buildPath if it exists
|
||||
const buildPath = artifact.runner.buildOutput.path.replace('/home/project', '');
|
||||
|
||||
// Get all files recursively
|
||||
async function getAllFiles(dirPath: string): Promise<Record<string, string>> {
|
||||
const files: Record<string, string> = {};
|
||||
const entries = await container.fs.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
|
||||
if (entry.isFile()) {
|
||||
const content = await container.fs.readFile(fullPath, 'utf-8');
|
||||
|
||||
// Remove /dist prefix from the path
|
||||
const deployPath = fullPath.replace(buildPath, '');
|
||||
files[deployPath] = content;
|
||||
} else if (entry.isDirectory()) {
|
||||
const subFiles = await getAllFiles(fullPath);
|
||||
Object.assign(files, subFiles);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
const fileContents = await getAllFiles(buildPath);
|
||||
|
||||
// Use chatId instead of artifact.id
|
||||
const existingProjectId = localStorage.getItem(`vercel-project-${currentChatId}`);
|
||||
|
||||
// Deploy using the API route with file contents
|
||||
const response = await fetch('/api/vercel-deploy', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectId: existingProjectId || undefined,
|
||||
files: fileContents,
|
||||
token: vercelConn.token,
|
||||
chatId: currentChatId,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = (await response.json()) as any;
|
||||
|
||||
if (!response.ok || !data.deploy || !data.project) {
|
||||
console.error('Invalid deploy response:', data);
|
||||
throw new Error(data.error || 'Invalid deployment response');
|
||||
}
|
||||
|
||||
// Store the project ID if it's a new project
|
||||
if (data.project) {
|
||||
localStorage.setItem(`vercel-project-${currentChatId}`, data.project.id);
|
||||
}
|
||||
|
||||
toast.success(
|
||||
<div>
|
||||
Deployed successfully to Vercel!{' '}
|
||||
<a href={data.deploy.url} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
View site
|
||||
</a>
|
||||
</div>,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Vercel deploy error:', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Vercel deployment failed');
|
||||
await handleNetlifyDeploy();
|
||||
} finally {
|
||||
setIsDeploying(false);
|
||||
setDeployingTo(null);
|
||||
@@ -348,7 +89,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
<Button
|
||||
active
|
||||
onClick={() => {
|
||||
handleNetlifyDeploy();
|
||||
onNetlifyDeploy();
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
disabled={isDeploying || !activePreview || !netlifyConn.user}
|
||||
@@ -369,7 +110,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
|
||||
<Button
|
||||
active
|
||||
onClick={() => {
|
||||
handleVercelDeploy();
|
||||
onVercelDeploy();
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
disabled={isDeploying || !activePreview || !vercelConn.user}
|
||||
|
||||
Reference in New Issue
Block a user