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:
KevIsDev 2025-04-02 16:47:04 +01:00
parent 53a674dc58
commit c63732d2f4
5 changed files with 401 additions and 328 deletions

View File

@ -0,0 +1,212 @@
import { toast } from 'react-toastify';
import { useStore } from '@nanostores/react';
import { netlifyConnection } from '~/lib/stores/netlify';
import { workbenchStore } from '~/lib/stores/workbench';
import { webcontainer } from '~/lib/webcontainer';
import { path } from '~/utils/path';
import { useState } from 'react';
import type { ActionCallbackData } from '~/lib/runtime/message-parser';
import { chatId } from '~/lib/persistence/useChatHistory';
export function useNetlifyDeploy() {
const [isDeploying, setIsDeploying] = useState(false);
const netlifyConn = useStore(netlifyConnection);
const currentChatId = useStore(chatId);
const handleNetlifyDeploy = async () => {
if (!netlifyConn.user || !netlifyConn.token) {
toast.error('Please connect to Netlify first in the settings tab!');
return false;
}
if (!currentChatId) {
toast.error('No active chat found');
return false;
}
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', '');
console.log('Original buildPath', buildPath);
// Check if the build path exists
let finalBuildPath = buildPath;
// List of common output directories to check if the specified build path doesn't exist
const commonOutputDirs = [buildPath, '/dist', '/build', '/out', '/output', '/.next', '/public'];
// Verify the build path exists, or try to find an alternative
let buildPathExists = false;
for (const dir of commonOutputDirs) {
try {
await container.fs.readdir(dir);
finalBuildPath = dir;
buildPathExists = true;
console.log(`Using build directory: ${finalBuildPath}`);
break;
} catch (error) {
// Directory doesn't exist, try the next one
console.log(`Directory ${dir} doesn't exist, trying next option. ${error}`);
continue;
}
}
if (!buildPathExists) {
throw new Error('Could not find build output directory. Please check your build configuration.');
}
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 build path prefix from the path
const deployPath = fullPath.replace(finalBuildPath, '');
files[deployPath] = content;
} else if (entry.isDirectory()) {
const subFiles = await getAllFiles(fullPath);
Object.assign(files, subFiles);
}
}
return files;
}
const fileContents = await getAllFiles(finalBuildPath);
// Use chatId instead of artifact.id
const existingSiteId = localStorage.getItem(`netlify-site-${currentChatId}`);
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,
}),
});
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');
}
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>,
);
return true;
} catch (error) {
console.error('Deploy error:', error);
toast.error(error instanceof Error ? error.message : 'Deployment failed');
return false;
} finally {
setIsDeploying(false);
}
};
return {
isDeploying,
handleNetlifyDeploy,
isConnected: !!netlifyConn.user,
};
}

View File

@ -0,0 +1,168 @@
import { toast } from 'react-toastify';
import { useStore } from '@nanostores/react';
import { vercelConnection } from '~/lib/stores/vercel';
import { workbenchStore } from '~/lib/stores/workbench';
import { webcontainer } from '~/lib/webcontainer';
import { path } from '~/utils/path';
import { useState } from 'react';
import type { ActionCallbackData } from '~/lib/runtime/message-parser';
import { chatId } from '~/lib/persistence/useChatHistory';
export function useVercelDeploy() {
const [isDeploying, setIsDeploying] = useState(false);
const vercelConn = useStore(vercelConnection);
const currentChatId = useStore(chatId);
const handleVercelDeploy = async () => {
if (!vercelConn.user || !vercelConn.token) {
toast.error('Please connect to Vercel first in the settings tab!');
return false;
}
if (!currentChatId) {
toast.error('No active chat found');
return false;
}
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: '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', '');
// Check if the build path exists
let finalBuildPath = buildPath;
// List of common output directories to check if the specified build path doesn't exist
const commonOutputDirs = [buildPath, '/dist', '/build', '/out', '/output', '/.next', '/public'];
// Verify the build path exists, or try to find an alternative
let buildPathExists = false;
for (const dir of commonOutputDirs) {
try {
await container.fs.readdir(dir);
finalBuildPath = dir;
buildPathExists = true;
console.log(`Using build directory: ${finalBuildPath}`);
break;
} catch (error) {
console.log(`Directory ${dir} doesn't exist, trying next option. ${error}`);
// Directory doesn't exist, try the next one
continue;
}
}
if (!buildPathExists) {
throw new Error('Could not find build output directory. Please check your build configuration.');
}
// 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 build path prefix from the path
const deployPath = fullPath.replace(finalBuildPath, '');
files[deployPath] = content;
} else if (entry.isDirectory()) {
const subFiles = await getAllFiles(fullPath);
Object.assign(files, subFiles);
}
}
return files;
}
const fileContents = await getAllFiles(finalBuildPath);
// Use chatId instead of artifact.id
const existingProjectId = localStorage.getItem(`vercel-project-${currentChatId}`);
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');
}
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>,
);
return true;
} catch (err) {
console.error('Vercel deploy error:', err);
toast.error(err instanceof Error ? err.message : 'Vercel deployment failed');
return false;
} finally {
setIsDeploying(false);
}
};
return {
isDeploying,
handleVercelDeploy,
isConnected: !!vercelConn.user,
};
}

View File

@ -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}

View File

@ -1,4 +1,5 @@
import { type ActionFunctionArgs, json } from '@remix-run/cloudflare';
import crypto from 'crypto';
import type { NetlifySiteInfo } from '~/types/netlify';
interface DeployRequestBody {
@ -7,15 +8,6 @@ interface DeployRequestBody {
chatId: string;
}
async function sha1(message: string) {
const msgBuffer = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-1', msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
return hashHex;
}
export async function action({ request }: ActionFunctionArgs) {
try {
const { siteId, files, token, chatId } = (await request.json()) as DeployRequestBody & { token: string };
@ -112,7 +104,7 @@ export async function action({ request }: ActionFunctionArgs) {
for (const [filePath, content] of Object.entries(files)) {
// Ensure file path starts with a forward slash
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
const hash = await sha1(content);
const hash = crypto.createHash('sha1').update(content).digest('hex');
fileDigests[normalizedPath] = hash;
}

View File

@ -11,6 +11,7 @@ import { join } from 'path';
dotenv.config();
// Get detailed git info with fallbacks
const getGitInfo = () => {
try {
return {
@ -39,6 +40,7 @@ const getGitInfo = () => {
}
};
// Read package.json with detailed dependency info
const getPackageJson = () => {
try {
const pkgPath = join(process.cwd(), 'package.json');
@ -91,53 +93,11 @@ export default defineConfig((config) => {
},
build: {
target: 'esnext',
rollupOptions: {
output: {
format: 'esm',
},
},
commonjsOptions: {
transformMixedEsModules: true,
},
},
optimizeDeps: {
esbuildOptions: {
define: {
global: 'globalThis',
},
},
},
resolve: {
alias: {
buffer: 'vite-plugin-node-polyfills/polyfills/buffer',
crypto: 'crypto-browserify',
stream: 'stream-browserify',
},
},
plugins: [
nodePolyfills({
include: ['buffer', 'process', 'util', 'stream', 'crypto'],
globals: {
Buffer: true,
process: true,
global: true,
},
protocolImports: true,
exclude: ['child_process', 'fs', 'path'],
include: ['path', 'buffer', 'process'],
}),
{
name: 'buffer-polyfill',
transform(code, id) {
if (id.includes('env.mjs')) {
return {
code: `import { Buffer } from 'buffer';\n${code}`,
map: null,
};
}
return null;
},
},
config.mode !== 'test' && remixCloudflareDevProxy(),
remixVitePlugin({
future: {
@ -193,4 +153,4 @@ function chrome129IssuePlugin() {
});
},
};
}
}