Files
upage-git/app/routes/api.netlify.$action/deploy.server.ts

320 lines
10 KiB
TypeScript
Raw Permalink 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 { getNetlifyConnectionSettings, saveNetlifyConnectionSettings } from '~/.server/service/connection-settings';
import { createOrUpdateDeployment, getLatestDeployment } from '~/.server/service/deployment';
import { errorResponse, successResponse } from '~/.server/utils/api-response';
import { DeploymentPlatformEnum, DeploymentStatusEnum } from '~/types/deployment';
import type { NetlifySiteInfo } from '~/types/netlify';
import { binaryStringToUint8Array, isBinaryString } from '~/utils/file-utils';
import { createScopedLogger } from '~/utils/logger';
export type HandleDeployArgs = {
request: Request;
userId: string;
};
interface DeployRequestBody {
siteId?: string;
files: Record<string, string>;
chatId: string;
token?: string;
}
/**
* 计算字符串或二进制数据的 SHA1 哈希值
*
* @param message 要计算哈希的字符串或二进制数据
* @returns SHA1 哈希值
*/
async function sha1(message: string) {
// 检查是否为二进制字符串
let msgBuffer;
if (isBinaryString(message)) {
// 对于二进制字符串,使用 Buffer.from 处理
const buffer = Buffer.from(message, 'binary');
msgBuffer = new Uint8Array(buffer);
} else {
// 对于普通字符串,使用 TextEncoder
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;
}
const logger = createScopedLogger('api.netlify.deploy');
export async function handleDeploy({ request, userId }: HandleDeployArgs) {
try {
const { siteId, files, token: requestToken, chatId } = (await request.json()) as DeployRequestBody;
let connectionSettings = await getNetlifyConnectionSettings(userId);
if (requestToken) {
connectionSettings = {
token: requestToken,
};
await saveNetlifyConnectionSettings(userId, requestToken);
}
if (!connectionSettings) {
logger.warn('未连接到Netlify');
return errorResponse(401, '未连接到Netlify请先设置访问令牌');
}
const { token } = connectionSettings;
const existingDeployment = await getLatestDeployment(userId, chatId, DeploymentPlatformEnum.NETLIFY);
let targetSiteId = siteId ? siteId : existingDeployment?.deploymentId ? existingDeployment.deploymentId : undefined;
let siteInfo: NetlifySiteInfo | undefined;
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,
};
}
}
if (!siteInfo) {
const siteName = `upage-${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 errorResponse(400, 'Failed to create site');
}
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> = {};
const filePathsAndHashes: 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 = await sha1(content);
fileDigests[normalizedPath] = hash;
filePathsAndHashes[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,
function_schedules: [],
required: Object.keys(fileDigests),
framework: null,
}),
});
if (!deployResponse.ok) {
const errorText = await deployResponse.text();
logger.error(`创建部署失败: ${deployResponse.status} - ${errorText}`);
return errorResponse(400, `Failed to create deployment: ${errorText}`);
}
const deploy = (await deployResponse.json()) as any;
let retryCount = 0;
const maxRetries = 60;
let deploymentStatus;
while (retryCount < maxRetries) {
const statusResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys/${deploy.id}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!statusResponse.ok) {
if (statusResponse.status === 401) {
return errorResponse(401, '链接已过期,请重新设置访问令牌。');
}
return errorResponse(400, '获取部署状态失败');
}
deploymentStatus = (await statusResponse.json()) as any;
if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') {
logger.info('部署完成,状态:', deploymentStatus.state);
break;
}
if (deploymentStatus.state === 'prepared' || deploymentStatus.state === 'uploaded') {
// Upload all files regardless of required array
for (const [filePath, content] of Object.entries(files)) {
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
logger.info(`准备上传文件: ${normalizedPath}, 是否二进制: ${isBinaryString(content)}`);
let uploadSuccess = false;
let uploadRetries = 0;
while (!uploadSuccess && uploadRetries < 3) {
try {
let uploadBody: string | Uint8Array | ArrayBuffer;
if (isBinaryString(content)) {
uploadBody = binaryStringToUint8Array(content);
} else {
uploadBody = content;
}
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: uploadBody as BodyInit,
},
);
uploadSuccess = uploadResponse.ok;
if (!uploadSuccess) {
if (uploadResponse.status === 422) {
logger.warn(
`Upload failed for ${normalizedPath} (${uploadResponse.status}, But it may be uploaded successfully`,
);
uploadSuccess = true;
} else {
const errorText = await uploadResponse.text();
logger.error(`Upload failed for ${normalizedPath} (${uploadResponse.status}): ${errorText}`);
uploadRetries++;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
} else {
logger.info(`Successfully uploaded ${normalizedPath}`);
}
} catch (error) {
logger.error('Upload error:', error);
uploadRetries++;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
if (!uploadSuccess) {
return errorResponse(500, `上传文件失败: ${filePath}`);
}
}
break;
}
if (deploymentStatus.state === 'error') {
return errorResponse(500, deploymentStatus.error_message || 'Deploy preparation failed');
}
retryCount++;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (retryCount >= maxRetries) {
return errorResponse(500, 'Deploy preparation timed out');
}
// 第二阶段:轮询直到部署完成
logger.info('文件上传完成,等待部署完成...');
retryCount = 0;
const maxDeploymentRetries = 60; // 60秒超时
while (retryCount < maxDeploymentRetries) {
const statusResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys/${deploy.id}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
deploymentStatus = (await statusResponse.json()) as any;
if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') {
logger.info('部署完成,状态:', deploymentStatus.state);
break;
}
if (deploymentStatus.state === 'error') {
return errorResponse(500, deploymentStatus.error_message || 'Deployment failed');
}
retryCount++;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (retryCount >= maxDeploymentRetries) {
return errorResponse(500, 'Deployment timed out');
}
try {
await createOrUpdateDeployment({
userId,
chatId,
platform: DeploymentPlatformEnum.NETLIFY,
deploymentId: deploymentStatus.id,
url: deploymentStatus.ssl_url || deploymentStatus.url,
status: DeploymentStatusEnum.SUCCESS,
metadata: {
siteId: siteInfo.id,
siteName: siteInfo.name,
},
});
logger.info(`为用户 ${userId} 创建或更新了 Netlify 部署记录`);
} catch (error) {
logger.error('创建部署记录失败:', error);
}
return successResponse(
{
deploy: {
id: deploymentStatus.id,
state: deploymentStatus.state,
url: deploymentStatus.ssl_url || deploymentStatus.url,
},
site: siteInfo,
},
'部署成功',
);
} catch (error) {
logger.error('Deploy error:', error);
return errorResponse(500, 'Deployment failed');
}
}