Files
upage-git/app/routes/api.vercel.$action/deploy.server.ts
2025-09-24 17:02:44 +08:00

309 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { getVercelConnectionSettings, saveVercelConnectionSettings } from '~/lib/.server/connectionSettings';
import { createOrUpdateDeployment, getLatestDeployment } from '~/lib/.server/deployment';
import { createScopedLogger } from '~/lib/.server/logger';
import { DeploymentPlatformEnum, DeploymentStatusEnum } from '~/types/deployment';
import type { VercelProjectInfo } from '~/types/vercel';
import { errorResponse, successResponse } from '~/utils/api-response';
import { isBinaryString } from '~/utils/file-utils';
const logger = createScopedLogger('api.vercel.deploy');
export type GetVercelDeployByProjectIdArgs = {
request: Request;
userId: string;
};
export async function getVercelDeployByProjectId({ request, userId }: GetVercelDeployByProjectIdArgs) {
const url = new URL(request.url);
const projectId = url.searchParams.get('projectId');
const requestToken = url.searchParams.get('token');
// 从用户设置中获取连接信息
let connectionSettings = await getVercelConnectionSettings(userId);
// 如果请求参数中提供了token优先使用请求参数中的信息并更新用户设置
if (requestToken) {
connectionSettings = {
token: requestToken,
};
// 更新用户设置
await saveVercelConnectionSettings(userId, requestToken);
}
// 如果没有连接信息,返回错误
if (!connectionSettings) {
return errorResponse(401, '未连接到Vercel请先设置访问令牌');
}
const { token } = connectionSettings;
if (!projectId) {
return errorResponse(400, '缺少项目ID');
}
try {
// Get project info
const projectResponse = await fetch(`https://api.vercel.com/v9/projects/${projectId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!projectResponse.ok) {
return errorResponse(400, '无法获取项目');
}
const projectData = (await projectResponse.json()) as any;
// Get latest deployment
const deploymentsResponse = await fetch(`https://api.vercel.com/v6/deployments?projectId=${projectId}&limit=1`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!deploymentsResponse.ok) {
return errorResponse(400, '获取部署信息失败');
}
const deploymentsData = (await deploymentsResponse.json()) as any;
const latestDeployment = deploymentsData.deployments?.[0];
return successResponse(
{
project: {
id: projectData.id,
name: projectData.name,
url: `https://${projectData.name}.vercel.app`,
},
deploy: latestDeployment
? {
id: latestDeployment.id,
state: latestDeployment.state,
url: latestDeployment.url ? `https://${latestDeployment.url}` : `https://${projectData.name}.vercel.app`,
}
: null,
},
'获取部署信息成功',
);
} catch (error) {
logger.error('Error fetching Vercel deployment:', error);
return errorResponse(500, '获取部署失败');
}
}
interface DeployRequestBody {
projectId?: string;
files: Record<string, string>;
chatId: string;
token?: string;
}
export type HandleVercelDeployArgs = {
request: Request;
userId: string;
};
// Existing action function for POST requests
export async function handleVercelDeploy({ request, userId }: HandleVercelDeployArgs) {
try {
const { projectId, files, token: requestToken, chatId } = (await request.json()) as DeployRequestBody;
// 从用户设置中获取连接信息
let connectionSettings = await getVercelConnectionSettings(userId);
// 如果请求体中提供了token优先使用请求体中的信息并更新用户设置
if (requestToken) {
connectionSettings = {
token: requestToken,
};
// 更新用户设置
await saveVercelConnectionSettings(userId, requestToken);
}
// 如果没有连接信息,返回错误
if (!connectionSettings) {
return errorResponse(401, '未连接到Vercel请先设置访问令牌');
}
const { token } = connectionSettings;
const existingDeployment = await getLatestDeployment(userId, chatId, DeploymentPlatformEnum.VERCEL);
let targetProjectId;
if (projectId) {
targetProjectId = projectId;
} else if (existingDeployment?.deploymentId) {
targetProjectId = existingDeployment.deploymentId;
} else {
targetProjectId = undefined;
}
let projectInfo: VercelProjectInfo | undefined;
if (targetProjectId) {
const projectResponse = await fetch(`https://api.vercel.com/v9/projects/${targetProjectId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (projectResponse.ok) {
const existingProject = (await projectResponse.json()) as any;
projectInfo = {
id: existingProject.id,
name: existingProject.name,
url: `https://${existingProject.name}.vercel.app`,
chatId,
};
}
}
if (!projectInfo) {
const projectName = `upage-${chatId}-${Date.now()}`.toLocaleLowerCase();
const createProjectResponse = await fetch('https://api.vercel.com/v9/projects', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: projectName,
framework: null,
}),
});
if (!createProjectResponse.ok) {
const errorData = (await createProjectResponse.json()) as any;
return errorResponse(400, `创建项目失败: ${errorData.error?.message || '未知错误'}`);
}
const newProject = (await createProjectResponse.json()) as any;
targetProjectId = newProject.id;
projectInfo = {
id: newProject.id,
name: newProject.name,
url: `https://${newProject.name}.vercel.app`,
chatId,
};
}
// Prepare files for deployment
const deploymentFiles = [];
for (const [filePath, content] of Object.entries(files)) {
// Ensure file path doesn't start with a slash for Vercel
const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
deploymentFiles.push({
file: normalizedPath,
data: isBinaryString(content) ? Buffer.from(content, 'binary').toString('base64') : content,
encoding: isBinaryString(content) ? 'base64' : 'utf-8',
});
}
// Create a new deployment
const deployResponse = await fetch(`https://api.vercel.com/v13/deployments`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: projectInfo.name,
project: targetProjectId,
target: 'production',
files: deploymentFiles,
routes: [{ src: '/(.*)', dest: '/$1' }],
}),
});
if (!deployResponse.ok) {
const errorData = (await deployResponse.json()) as any;
return errorResponse(400, `创建部署失败: ${errorData.error?.message || '未知错误'}`);
}
const deployData = (await deployResponse.json()) as any;
// Poll for deployment status
let retryCount = 0;
const maxRetries = 60;
let deploymentUrl = '';
let deploymentState = '';
while (retryCount < maxRetries) {
const statusResponse = await fetch(`https://api.vercel.com/v13/deployments/${deployData.id}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (statusResponse.ok) {
const status = (await statusResponse.json()) as any;
deploymentState = status.readyState;
const alias = status.alias;
const automaticAliases = status.automaticAliases;
if (status.readyState === 'READY' || status.readyState === 'ERROR') {
if (status.aliasAssigned) {
const diffAlias = alias.filter((item: string) => !automaticAliases.includes(item));
deploymentUrl = `https://${diffAlias[0]}`;
} else {
deploymentUrl = status.url ? `https://${status.url}` : '';
}
break;
}
}
retryCount++;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (deploymentState === 'ERROR') {
return errorResponse(500, '部署失败');
}
if (retryCount >= maxRetries) {
return errorResponse(500, '部署超时');
}
const finalUrl = deploymentUrl || projectInfo.url;
// 记录部署信息
try {
await createOrUpdateDeployment({
userId,
chatId,
platform: DeploymentPlatformEnum.VERCEL,
deploymentId: deployData.id,
url: finalUrl,
status: deploymentState === 'READY' ? DeploymentStatusEnum.SUCCESS : DeploymentStatusEnum.PENDING,
metadata: {
projectId: projectInfo.id,
projectName: projectInfo.name,
},
});
logger.info(`为用户 ${userId} 创建了 Vercel 部署记录`);
} catch (error) {
logger.error('创建部署记录失败:', error);
// 不影响主流程,继续返回成功
}
return successResponse(
{
deploy: {
id: deployData.id,
state: deploymentState,
url: finalUrl,
},
project: projectInfo,
},
'部署成功',
);
} catch (error) {
logger.error('Vercel deploy error:', error);
return errorResponse(500, '部署失败');
}
}