产品化改造:完善本地集成与启动体验
This commit is contained in:
276
qiming-file-server/src/utils/build/buildProjectUtils.js
Normal file
276
qiming-file-server/src/utils/build/buildProjectUtils.js
Normal file
@@ -0,0 +1,276 @@
|
||||
import { exec } from "child_process";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { log, logBuild } from "../log/logUtils.js";
|
||||
import BuildErrorParser from "../error/buildErrorParser.js";
|
||||
import config from "../../appConfig/index.js";
|
||||
import {
|
||||
BusinessError,
|
||||
SystemError,
|
||||
FileError,
|
||||
ResourceError,
|
||||
} from "../error/errorHandler.js";
|
||||
import { installDependencies } from "../buildDependency/dependencyManager.js";
|
||||
import {
|
||||
extractIsolationContext,
|
||||
resolveProjectPath,
|
||||
} from "../common/projectPathUtils.js";
|
||||
|
||||
const distTargetDir = config.DIST_TARGET_DIR;
|
||||
|
||||
// 构建并发控制(无队列)
|
||||
const buildingProjects = new Set(); // 正在构建中的项目
|
||||
let currentBuilds = 0; // 当前并行构建数
|
||||
|
||||
// 将dist目录拷贝到指定目录
|
||||
async function copyBuildOutputToTarget({
|
||||
req,
|
||||
projectPath,
|
||||
projectId,
|
||||
outStream,
|
||||
}) {
|
||||
try {
|
||||
const sourceDir = path.join(projectPath, "dist");
|
||||
const targetBase = distTargetDir;
|
||||
const targetDir = path.join(targetBase, projectId, "dist");
|
||||
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
const msg = `dist directory not found: ${sourceDir}`;
|
||||
log(projectId, "WARN", msg, { projectId });
|
||||
outStream && outStream.write(`${msg}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保目标父目录存在
|
||||
if (!fs.existsSync(targetBase)) {
|
||||
fs.mkdirSync(targetBase, { recursive: true });
|
||||
}
|
||||
|
||||
// 先清空旧目录
|
||||
if (fs.existsSync(targetDir)) {
|
||||
await fs.promises.rm(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 复制 dist -> 目标目录
|
||||
if (fs.promises.cp) {
|
||||
await fs.promises.cp(sourceDir, targetDir, { recursive: true });
|
||||
} else {
|
||||
// Node 版本不支持 fs.promises.cp 时的降级方案
|
||||
const copyRecursiveSync = (src, dest) => {
|
||||
const stat = fs.statSync(src);
|
||||
if (stat.isDirectory()) {
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
}
|
||||
for (const entry of fs.readdirSync(src)) {
|
||||
copyRecursiveSync(path.join(src, entry), path.join(dest, entry));
|
||||
}
|
||||
} else {
|
||||
fs.copyFileSync(src, dest);
|
||||
}
|
||||
};
|
||||
copyRecursiveSync(sourceDir, targetDir);
|
||||
}
|
||||
|
||||
const okMsg = `dist directory copied to: ${targetDir}`;
|
||||
log(projectId, "INFO", okMsg, { projectId });
|
||||
outStream && outStream.write(`${okMsg}\n`);
|
||||
} catch (err) {
|
||||
const errMsg = `Failed to copy dist directory: ${err.message}`;
|
||||
log(projectId, "ERROR", errMsg, { projectId });
|
||||
outStream && outStream.write(`${errMsg}\n`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
//执行build脚本
|
||||
function runBuildScript(projectId, projectPath, scriptName, extraArgs = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const argsPart =
|
||||
Array.isArray(extraArgs) && extraArgs.length > 0
|
||||
? " -- " + extraArgs.map((s) => String(s)).join(" ")
|
||||
: "";
|
||||
const command = `cd ${projectPath} && pnpm run ${scriptName}${argsPart}`;
|
||||
logBuild(projectId, "INFO", "Execute command", { command });
|
||||
// 同步输出到普通日志也打印一次,便于从统一日志流检索
|
||||
try {
|
||||
log(projectId, "INFO", "Execute build script", { command, cwd: projectPath });
|
||||
} catch (_) {}
|
||||
|
||||
exec(
|
||||
command,
|
||||
{
|
||||
env: process.env, // 继承父进程的环境变量,包括 pnpm 配置
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB 缓冲区
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
logBuild(projectId, "ERROR", "Execution error", {
|
||||
error: error.message,
|
||||
stderr,
|
||||
});
|
||||
|
||||
// 使用错误解析器提供用户友好的错误信息
|
||||
const errorParser = new BuildErrorParser();
|
||||
const errorMessage = stderr || error.message;
|
||||
const userFriendlyMessage = errorParser.parseBuildError(
|
||||
errorMessage,
|
||||
projectId
|
||||
);
|
||||
|
||||
// 创建包含用户友好信息的构建错误
|
||||
const buildError = new SystemError(userFriendlyMessage, {
|
||||
originalError: error.message,
|
||||
command: command,
|
||||
});
|
||||
|
||||
return reject(buildError);
|
||||
}
|
||||
logBuild(projectId, "INFO", "Script execution completed", { stdout });
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建项目
|
||||
* @param {Object} req 请求对象
|
||||
* @param {string} projectId 项目ID
|
||||
* @returns {Promise<Object>} 构建结果
|
||||
*/
|
||||
async function buildProject(req, projectId) {
|
||||
const isolationContext = extractIsolationContext(req?.query || {});
|
||||
const projectPath = resolveProjectPath(projectId, isolationContext);
|
||||
const jsonFilePath = path.join(projectPath, "package.json");
|
||||
|
||||
const exists = fs.existsSync(jsonFilePath);
|
||||
if (!exists) {
|
||||
throw new ResourceError("Project missing package.json file", {
|
||||
projectId,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
let jsonContent;
|
||||
try {
|
||||
jsonContent = JSON.parse(fs.readFileSync(jsonFilePath, "utf8"));
|
||||
} catch (error) {
|
||||
throw new FileError("package.json file format error", {
|
||||
projectId,
|
||||
jsonFilePath,
|
||||
originalError: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
const jsonScripts = jsonContent.scripts;
|
||||
const buildScript = jsonScripts.build;
|
||||
if (!buildScript) {
|
||||
log(projectId, "WARN", "Project missing build script", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
throw new BusinessError("Project missing build script", { projectId });
|
||||
}
|
||||
|
||||
// 项目级互斥:同一项目仅允许一个构建
|
||||
if (buildingProjects.has(projectId)) {
|
||||
throw new BusinessError("This project is being built", { projectId });
|
||||
}
|
||||
|
||||
// 全局并发限制
|
||||
const max = Number.isFinite(config.MAX_BUILD_CONCURRENCY)
|
||||
? config.MAX_BUILD_CONCURRENCY
|
||||
: 20;
|
||||
if (currentBuilds >= max) {
|
||||
throw new BusinessError("Concurrency is full, please try again later", {
|
||||
currentBuilds,
|
||||
maxConcurrency: max,
|
||||
});
|
||||
}
|
||||
|
||||
// 直接执行同步构建(空闲)
|
||||
buildingProjects.add(projectId);
|
||||
currentBuilds += 1;
|
||||
try {
|
||||
// 读取并规范化 basePath(仅对 Vite 有效)
|
||||
let basePath = "";
|
||||
if (req && req.query && typeof req.query.basePath === "string") {
|
||||
basePath = req.query.basePath;
|
||||
}
|
||||
if (basePath) {
|
||||
if (!basePath.startsWith("/")) basePath = "/" + basePath;
|
||||
if (!basePath.endsWith("/")) basePath = basePath + "/";
|
||||
}
|
||||
|
||||
const buildExtraArgs = [];
|
||||
// 若脚本包含 vite,则传入 --base
|
||||
if (
|
||||
typeof buildScript === "string" &&
|
||||
buildScript.includes("vite") &&
|
||||
basePath
|
||||
) {
|
||||
buildExtraArgs.push("--base", basePath);
|
||||
}
|
||||
|
||||
// 若使用 Vite 构建,追加 --debug 以输出调试信息
|
||||
if (typeof buildScript === "string" && buildScript.includes("vite")) {
|
||||
buildExtraArgs.push("--debug");
|
||||
}
|
||||
|
||||
// 安装依赖
|
||||
log(projectId, "INFO", "Start installing dependencies", { projectId });
|
||||
await installDependencies(req, projectId, projectPath);
|
||||
|
||||
// 执行构建:Vite 直接使用 pnpm exec,避免 npm-run 参数分隔影响
|
||||
if (typeof buildScript === "string" && buildScript.includes("vite")) {
|
||||
const viteArgs = ["exec", "vite", "build", ...buildExtraArgs, "--debug"];
|
||||
const command = `cd ${projectPath} && pnpm ${viteArgs.join(" ")}`;
|
||||
logBuild(projectId, "INFO", "Execute command(direct vite)", { command });
|
||||
try {
|
||||
log(projectId, "INFO", "Execute build script(direct vite)", {
|
||||
command,
|
||||
cwd: projectPath,
|
||||
});
|
||||
} catch (_) {}
|
||||
await new Promise((resolve, reject) => {
|
||||
exec(command, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
logBuild(projectId, "ERROR", "Execution error", {
|
||||
error: error.message,
|
||||
stderr,
|
||||
});
|
||||
const errorParser = new BuildErrorParser();
|
||||
const errorMessage = stderr || error.message;
|
||||
const userFriendlyMessage = errorParser.parseBuildError(
|
||||
errorMessage,
|
||||
projectId
|
||||
);
|
||||
const buildError = new SystemError(userFriendlyMessage, {
|
||||
originalError: error.message,
|
||||
command,
|
||||
});
|
||||
return reject(buildError);
|
||||
}
|
||||
logBuild(projectId, "INFO", "Script execution completed", { stdout });
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
log(projectId, "INFO", "Start synchronously executing build script", { projectId });
|
||||
await runBuildScript(projectId, projectPath, "build", buildExtraArgs);
|
||||
}
|
||||
|
||||
// 拷贝 dist
|
||||
await copyBuildOutputToTarget({ req, projectPath, projectId });
|
||||
return {
|
||||
success: true,
|
||||
message: "Build completed",
|
||||
projectId,
|
||||
};
|
||||
} finally {
|
||||
buildingProjects.delete(projectId);
|
||||
currentBuilds -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
export { buildProject, copyBuildOutputToTarget, runBuildScript };
|
||||
71
qiming-file-server/src/utils/build/keepAliveDevUtils.js
Normal file
71
qiming-file-server/src/utils/build/keepAliveDevUtils.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { isPortListening } from "../buildArg/portUtils.js";
|
||||
import { log } from "../log/logUtils.js";
|
||||
import { deleteRunningProcess, isProcessRunning } from "./processManager.js";
|
||||
import { isProjectAlive } from "../buildJudge/aliveJudgeUtils.js";
|
||||
import { restartDevServer } from "./restartDevUtils.js";
|
||||
import { startDevServer } from "./startDevUtils.js";
|
||||
import { stopDevServer } from "./stopDevUtils.js";
|
||||
|
||||
/**
|
||||
* 保持开发服务器活跃
|
||||
* @param {Object} req 请求对象
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {number|string} pid 进程ID
|
||||
* @param {number|string} port 端口号
|
||||
* @returns {Promise<Object>} 检查结果
|
||||
*/
|
||||
async function keepAliveDevServer(req, projectId, pid, port, basePath) {
|
||||
const pidNum = Number(pid);
|
||||
const portNum = Number(port);
|
||||
|
||||
log(projectId, "INFO", "Start checking development server status", {
|
||||
projectId,
|
||||
pid: pidNum,
|
||||
port: portNum,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
// 检查项目是否存活
|
||||
const projectAlive = await isProjectAlive(projectId, portNum, basePath);
|
||||
if (projectAlive) {
|
||||
log(projectId, "INFO", "Development server is alive, returning success", {
|
||||
projectId,
|
||||
pid: pidNum,
|
||||
port: portNum,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Development server is alive",
|
||||
projectId,
|
||||
pid: pidNum,
|
||||
port: portNum,
|
||||
};
|
||||
}
|
||||
|
||||
log(projectId, "INFO", "Development server is not alive, restarting", {
|
||||
projectId,
|
||||
pid: pidNum,
|
||||
port: portNum,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
deleteRunningProcess(projectId);
|
||||
|
||||
if(pidNum > 0) {
|
||||
await stopDevServer(req, projectId, pidNum, {
|
||||
strict: false,
|
||||
waitForStop: true,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await startDevServer(req, projectId);
|
||||
return {
|
||||
...result,
|
||||
action: "start",
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export { keepAliveDevServer };
|
||||
920
qiming-file-server/src/utils/build/processManager.js
Normal file
920
qiming-file-server/src/utils/build/processManager.js
Normal file
@@ -0,0 +1,920 @@
|
||||
import { spawn } from "child_process";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { log, getLogDir, getCSTDateString, getCSTTimestampString } from "../log/logUtils.js";
|
||||
import logCacheManager from "../log/logCacheManager.js";
|
||||
import { ProcessError, BusinessError, ValidationError } from "../error/errorHandler.js";
|
||||
import ERROR_CODES from "../error/errorCodes.js";
|
||||
import { sanitizeSensitivePaths } from "../common/sensitiveUtils.js";
|
||||
import config from "../../appConfig/index.js";
|
||||
import {
|
||||
waitPortListening,
|
||||
waitPortFromPid,
|
||||
waitPortFromLog,
|
||||
getPidsByPort,
|
||||
} from "../buildArg/portUtils.js";
|
||||
import ExtraArgsUtils from "../buildArg/extraArgsUtils.js";
|
||||
import { ensureDevBinariesExecutable } from "../buildPermission/permissionManager.js";
|
||||
import { installDependencies } from "../buildDependency/dependencyManager.js";
|
||||
import portPool from "../buildArg/portPool.js";
|
||||
import { isProjectAlive } from "../buildJudge/aliveJudgeUtils.js";
|
||||
|
||||
/**
|
||||
* 从日志文件中提取执行命令后的所有行
|
||||
* 支持多种构建工具和框架:
|
||||
* - 构建工具: vite, webpack, rollup, parcel, esbuild, tsc, ts-node
|
||||
* - 框架: next, nuxt, astro, svelte, remix
|
||||
* - 包管理器: pnpm, npm, yarn, bun
|
||||
* - 脚本执行: node, 自定义脚本
|
||||
*
|
||||
* @param {string} logPath 日志文件路径
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {number} pid 进程ID
|
||||
* @returns {string} 执行命令后的所有输出(已脱敏)
|
||||
*/
|
||||
function extractCommandOutputFromLog(logPath, projectId, pid) {
|
||||
let commandOutput = "开发服务器启动失败";
|
||||
try {
|
||||
if (fs.existsSync(logPath)) {
|
||||
const logContent = fs.readFileSync(logPath, "utf8");
|
||||
const lines = logContent.split("\n");
|
||||
|
||||
// 查找执行命令的行,支持多种构建工具和框架
|
||||
let commandStartIndex = -1;
|
||||
|
||||
// 第一轮:优先查找明确的命令执行行
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (
|
||||
// 直接命令格式: > vite, > webpack, > next, > nuxt 等
|
||||
line.match(
|
||||
/^>\s*(vite|webpack|next|nuxt|rollup|parcel|esbuild|tsc|ts-node|astro|svelte|remix)/
|
||||
) ||
|
||||
// 包管理器运行命令: > pnpm run dev, > npm run start 等
|
||||
line.match(
|
||||
/^>\s*(pnpm|npm|yarn|bun)\s+(run\s+)?(dev|start|serve|build|watch|start:dev)/
|
||||
) ||
|
||||
// 直接执行脚本: > node server.js, > node index.js 等
|
||||
line.match(/^>\s*node\s+\S+\.(js|ts|mjs)$/) ||
|
||||
// 自定义脚本执行标记
|
||||
line.includes("开始执行脚本: dev") ||
|
||||
line.includes("开始执行脚本: start") ||
|
||||
line.includes("开始执行脚本: serve") ||
|
||||
line.includes("开始执行脚本: build")
|
||||
) {
|
||||
commandStartIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 第二轮:如果没有找到命令行,查找构建工具输出标记
|
||||
if (commandStartIndex === -1) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (
|
||||
// 构建工具特定输出标记
|
||||
line.includes("VITE") ||
|
||||
line.includes("Webpack") ||
|
||||
line.includes("Next.js") ||
|
||||
line.includes("Nuxt") ||
|
||||
line.includes("Rollup") ||
|
||||
line.includes("Parcel") ||
|
||||
line.includes("Astro") ||
|
||||
line.includes("Svelte") ||
|
||||
line.includes("Remix") ||
|
||||
// 开发服务器启动标记
|
||||
line.includes("Local:") ||
|
||||
line.includes("Network:") ||
|
||||
line.includes("ready in") ||
|
||||
line.includes("compiled successfully") ||
|
||||
line.includes("dev server running") ||
|
||||
line.includes("server started")
|
||||
) {
|
||||
commandStartIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 第三轮:如果仍然没有找到,查找错误输出标记
|
||||
if (commandStartIndex === -1) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (
|
||||
line.includes("Error") ||
|
||||
line.includes("error") ||
|
||||
line.includes("failed") ||
|
||||
line.includes("Cannot find") ||
|
||||
line.includes("ERR_") ||
|
||||
line.includes("Command failed") ||
|
||||
line.includes("ELIFECYCLE") ||
|
||||
line.includes("npm ERR!") ||
|
||||
line.includes("pnpm ERR!")
|
||||
) {
|
||||
commandStartIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (commandStartIndex >= 0) {
|
||||
// 提取命令执行后的所有行(包含错误行本身)
|
||||
const outputLines = lines.slice(commandStartIndex);
|
||||
commandOutput = outputLines.join("\n").trim();
|
||||
|
||||
// 如果输出为空,返回默认信息
|
||||
if (!commandOutput) {
|
||||
commandOutput = "命令已执行,但无输出信息";
|
||||
}
|
||||
} else {
|
||||
// 如果没有找到命令开始标记,返回所有日志内容
|
||||
commandOutput = logContent.trim();
|
||||
}
|
||||
|
||||
// 脱敏处理:移除敏感路径信息
|
||||
commandOutput = sanitizeSensitivePaths(commandOutput);
|
||||
|
||||
// 如果输出太长,截取最后的部分(保留更多信息)
|
||||
if (commandOutput.length > 1000) {
|
||||
commandOutput = commandOutput.substring(commandOutput.length - 1000);
|
||||
}
|
||||
}
|
||||
} catch (readError) {
|
||||
log(projectId, "WARN", "读取日志文件失败", {
|
||||
projectId,
|
||||
pid: pid,
|
||||
error: readError.message,
|
||||
});
|
||||
}
|
||||
|
||||
return commandOutput;
|
||||
}
|
||||
|
||||
// 进程注册表:维护运行dev的项目
|
||||
// key: projectId
|
||||
// value: { pid, logPath, startedAt, port}
|
||||
const runningDevProcesses = new Map();
|
||||
|
||||
// 项目级启动锁,避免并发重复启动同一项目
|
||||
const startingProjects = new Set();
|
||||
|
||||
/**
|
||||
* 获取运行中的进程信息
|
||||
* @param {string} projectId 项目ID
|
||||
* @returns {Object|null} 进程信息
|
||||
*/
|
||||
function getRunningProcess(projectId) {
|
||||
return runningDevProcesses.get(projectId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置运行中的进程信息
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {Object} processInfo 进程信息
|
||||
*/
|
||||
function setRunningProcess(projectId, processInfo) {
|
||||
runningDevProcesses.set(projectId, processInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除运行中的进程信息
|
||||
* @param {string} projectId 项目ID
|
||||
*/
|
||||
function deleteRunningProcess(projectId) {
|
||||
runningDevProcesses.delete(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查项目是否正在启动中
|
||||
* @param {string} projectId 项目ID
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isProjectStarting(projectId) {
|
||||
return startingProjects.has(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加项目到启动锁
|
||||
* @param {string} projectId 项目ID
|
||||
*/
|
||||
function addStartingProject(projectId) {
|
||||
startingProjects.add(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从启动锁中移除项目
|
||||
* @param {string} projectId 项目ID
|
||||
*/
|
||||
function removeStartingProject(projectId) {
|
||||
startingProjects.delete(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用spawn非阻塞启动开发服务器,并把日志写入文件
|
||||
* @param {Object} options 启动选项
|
||||
* @param {Object} options.req 请求对象
|
||||
* @param {string} options.projectId 项目ID
|
||||
* @param {string} options.projectPath 项目路径
|
||||
* @param {string} options.devScript dev脚本内容
|
||||
* @param {Object} options.envExtra 额外的环境变量
|
||||
* @param {Array} options.extraArgs 额外的参数
|
||||
* @returns {Object} { pid, logPath, port }
|
||||
*/
|
||||
async function startDev_NonBlocking({
|
||||
req,
|
||||
projectId,
|
||||
projectPath,
|
||||
devScript,
|
||||
}) {
|
||||
const lowerScript = (devScript || "").toLowerCase();
|
||||
const isVite = lowerScript.includes("vite");
|
||||
const isNext = lowerScript.includes("next");
|
||||
|
||||
if (!isVite && !isNext) {
|
||||
throw new BusinessError("不支持的脚本类型" + devScript + ",请使用vite或next脚本", {
|
||||
projectId,
|
||||
code: ERROR_CODES.INVALID_SCRIPT_TYPE,
|
||||
});
|
||||
}
|
||||
|
||||
// 创建日志目录
|
||||
const logDir = getLogDir(projectId);
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
// 主日志文件
|
||||
const today = getCSTDateString(); // 格式: YYYY-MM-DD (东八区)
|
||||
const logPath = path.join(logDir, `dev-${today}.log`);
|
||||
|
||||
// 临时日志用于端口解析
|
||||
const tempLogPath = path.join(
|
||||
logDir,
|
||||
`dev-temp-${Date.now().toString()}.log`
|
||||
);
|
||||
|
||||
let outStream, tempOutStream, child;
|
||||
let streamsClosed = false;
|
||||
let childExited = false;
|
||||
let allocatedPort = null; // 跟踪分配的端口,用于失败时释放
|
||||
|
||||
// 标记缓存是否已失效(避免频繁删除缓存)
|
||||
let cacheInvalidated = false;
|
||||
|
||||
// 创建安全的写入函数
|
||||
const safeWrite = (stream, data, streamName, flush = false) => {
|
||||
// 分别检查流的状态,而不是使用共享的 streamsClosed
|
||||
if (!stream || stream.destroyed) {
|
||||
// 只有在需要时才输出调试信息,避免日志过多
|
||||
if (stream && stream.destroyed) {
|
||||
log(projectId, "DEBUG", `${streamName}流已销毁,跳过写入`, {
|
||||
destroyed: true,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果全局标记已关闭,也不写入
|
||||
if (streamsClosed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 在data前拼接时间戳 [2025/10/12 11:51:09] (东八区)
|
||||
const timestamp = getCSTTimestampString();
|
||||
const dataWithTimestamp = `[${timestamp}] ` + data;
|
||||
const result = stream.write(dataWithTimestamp);
|
||||
|
||||
// 如果需要立即刷新(关键日志),使用 cork/uncork 机制强制刷新缓冲区
|
||||
if (flush && typeof stream.cork === 'function' && typeof stream.uncork === 'function') {
|
||||
// cork() 暂停写入,uncork() 立即刷新所有缓冲数据
|
||||
// 这里我们先 cork 再 uncork,强制刷新当前缓冲区
|
||||
stream.cork();
|
||||
// 使用 setImmediate 确保在下一个事件循环刷新
|
||||
setImmediate(() => {
|
||||
try {
|
||||
if (!stream.destroyed) {
|
||||
stream.uncork();
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略 uncork 错误
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 如果缓存已启用,标记缓存失效(只标记一次)
|
||||
// 避免频繁删除缓存,让下次读取时自动重新加载
|
||||
if (logCacheManager.isEnabled() && !cacheInvalidated) {
|
||||
logCacheManager.delete(String(projectId));
|
||||
cacheInvalidated = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
log(projectId, "WARN", `${streamName}写入错误`, {
|
||||
error: err.message,
|
||||
code: err.code,
|
||||
streamName: streamName,
|
||||
});
|
||||
// 只有在严重错误时才关闭所有流
|
||||
// 临时日志的错误不应该影响主日志
|
||||
if (err.code === "ERR_STREAM_WRITE_AFTER_END" || err.code === "EPIPE") {
|
||||
// 只关闭出错的流,不影响其他流
|
||||
try {
|
||||
if (stream && !stream.destroyed) {
|
||||
stream.end();
|
||||
}
|
||||
} catch (closeErr) {
|
||||
log(projectId, "WARN", `关闭${streamName}流时出错`, { error: closeErr.message });
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 安全关闭流的函数
|
||||
const safeCloseStreams = () => {
|
||||
if (streamsClosed) return;
|
||||
streamsClosed = true;
|
||||
|
||||
try {
|
||||
if (outStream && !outStream.destroyed) {
|
||||
outStream.end();
|
||||
}
|
||||
} catch (err) {
|
||||
log(projectId, "WARN", "关闭主日志流时出错", { error: err.message });
|
||||
}
|
||||
|
||||
try {
|
||||
if (tempOutStream && !tempOutStream.destroyed) {
|
||||
tempOutStream.end();
|
||||
}
|
||||
} catch (err) {
|
||||
log(projectId, "WARN", "关闭临时日志流时出错", { error: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 添加流错误处理
|
||||
const handleStreamError = (streamName, err) => {
|
||||
log(projectId, "WARN", `${streamName}流错误`, { error: err.message });
|
||||
// 如果是写入错误且子进程已退出,则关闭流
|
||||
if (
|
||||
(err.code === "ERR_STREAM_WRITE_AFTER_END" || err.code === "EPIPE") &&
|
||||
childExited
|
||||
) {
|
||||
safeCloseStreams();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// 在启动前确保可执行权限
|
||||
try {
|
||||
await ensureDevBinariesExecutable(projectPath);
|
||||
} catch (_) {}
|
||||
|
||||
// 创建日志文件写入流
|
||||
outStream = fs.createWriteStream(logPath, { flags: "a" });
|
||||
tempOutStream = fs.createWriteStream(tempLogPath, { flags: "a" });
|
||||
|
||||
// 组合为单个子进程内串行执行:先 dev-inject,再 vite-plugin-design-mode
|
||||
// 使用 set +e 忽略错误,确保两个命令都会执行,无论第一个是否成功
|
||||
// 注意:即使预处理命令失败,也不会阻塞后续依赖安装和 dev 启动,仅做“尽力而为”的处理
|
||||
const preCmd = "set +e ; pnpm dlx @xagi/dev-inject@latest install --framework ; pnpm dlx @xagi/vite-plugin-design-mode@latest install ; set -e";
|
||||
|
||||
safeWrite(outStream, preCmd, "Main log");
|
||||
safeWrite(tempOutStream, preCmd, "Temp log");
|
||||
|
||||
// 在安装依赖之前先执行预处理命令(失败不影响后续流程)
|
||||
await new Promise((resolve) => {
|
||||
try {
|
||||
const preProcess = spawn("bash", ["-lc", preCmd], {
|
||||
cwd: projectPath,
|
||||
env: { ...process.env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
preProcess.stdout.on("data", (data) => {
|
||||
const msg = data.toString();
|
||||
safeWrite(outStream, msg, "Main log");
|
||||
safeWrite(tempOutStream, msg, "Temp log");
|
||||
});
|
||||
|
||||
preProcess.stderr.on("data", (data) => {
|
||||
const msg = data.toString();
|
||||
safeWrite(outStream, msg, "Main log");
|
||||
safeWrite(tempOutStream, msg, "Temp log");
|
||||
});
|
||||
|
||||
preProcess.on("error", (err) => {
|
||||
const errorMessage = `预处理命令执行出错(忽略并继续后续流程): ${err.message}\n`;
|
||||
safeWrite(outStream, errorMessage, "Main log", true);
|
||||
safeWrite(tempOutStream, errorMessage, "Temp log", true);
|
||||
resolve();
|
||||
});
|
||||
|
||||
preProcess.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
const errorMessage = `Preprocessing command exit code is ${code} (ignore and continue subsequent process)\n`;
|
||||
safeWrite(outStream, errorMessage, "主日志", true);
|
||||
safeWrite(tempOutStream, errorMessage, "临时日志", true);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = `预处理命令启动失败(忽略并继续后续流程): ${error.message}\n`;
|
||||
safeWrite(outStream, errorMessage, "Main log", true);
|
||||
safeWrite(tempOutStream, errorMessage, "Temp log", true);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
// 安装依赖(日志会同时写入主日志和临时日志)
|
||||
try {
|
||||
await installDependencies(req, projectId, projectPath, {
|
||||
outStream,
|
||||
tempOutStream,
|
||||
safeWrite,
|
||||
});
|
||||
} catch (error) {
|
||||
// 安装失败时记录错误并抛出(safeWrite 会自动添加时间戳)- 立即刷新到磁盘
|
||||
const errorMessage = `Dependency installation failed: ${error.message}\n`;
|
||||
safeWrite(outStream, errorMessage, "Main log", true); // flush = true
|
||||
safeWrite(tempOutStream, errorMessage, "Temp log", true); // flush = true
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 在日志文件开头写入开始执行
|
||||
const startMessage = `Start executing script: dev\n`;
|
||||
safeWrite(outStream, startMessage, "Main log");
|
||||
safeWrite(tempOutStream, startMessage, "Temp log");
|
||||
|
||||
// 构建 dev 命令参数
|
||||
const npmArgs = [];
|
||||
|
||||
// 额外参数和环境变量(端口将在内部从端口池获取)
|
||||
const { extraArgs, envExtra, port } = await ExtraArgsUtils.processExtraArgs({
|
||||
devScript,
|
||||
projectId,
|
||||
req,
|
||||
});
|
||||
|
||||
// 记录分配的端口,用于失败时释放
|
||||
allocatedPort = port;
|
||||
|
||||
// 透传额外参数到脚本
|
||||
if (extraArgs && extraArgs.length > 0) {
|
||||
npmArgs.push(...extraArgs);
|
||||
}
|
||||
|
||||
const escapeArg = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
||||
const extraArgsEscaped = (npmArgs && npmArgs.length > 0)
|
||||
? npmArgs.map(escapeArg).join(" ")
|
||||
: "";
|
||||
|
||||
// 记录参数信息以便调试
|
||||
if (extraArgsEscaped) {
|
||||
log(projectId, "INFO", "Generated extra arguments", {
|
||||
extraArgs: extraArgs,
|
||||
npmArgs: npmArgs,
|
||||
extraArgsEscaped: extraArgsEscaped,
|
||||
});
|
||||
}
|
||||
|
||||
// 检测命令是否需要通过 npx 执行
|
||||
// 如果命令是纯命令名(如 vite, next)而不是路径,应该使用 npx
|
||||
const needsNpx = (script) => {
|
||||
if (!script || typeof script !== "string") return false;
|
||||
const trimmed = script.trim();
|
||||
// 如果包含路径分隔符(/ 或 \),说明是路径,不需要 npx
|
||||
if (trimmed.includes("/") || trimmed.includes("\\")) return false;
|
||||
// 提取第一个词(命令名)
|
||||
const firstWord = trimmed.split(/\s+/)[0];
|
||||
// 如果是常见的构建工具命令名,且不是路径,需要 npx
|
||||
const commandsNeedingNpx = ["vite", "next", "webpack", "rollup", "parcel", "esbuild", "tsc", "ts-node", "astro", "svelte", "remix", "nuxt"];
|
||||
return commandsNeedingNpx.includes(firstWord);
|
||||
};
|
||||
|
||||
let fullCommand;
|
||||
let execCommand = ""; // 用于记录实际执行的命令
|
||||
if (isVite) {
|
||||
// 移除脚本中已存在的 --host / --base(包含等号或跟随值),避免重复冲突
|
||||
const sanitizeCliFlags = (script, flagsToRemove) => {
|
||||
if (!script || typeof script !== "string") return script;
|
||||
let result = script;
|
||||
for (const flag of flagsToRemove) {
|
||||
// 1) 移除 --flag=value 形式
|
||||
const eqPattern = new RegExp(`${flag.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}=\\S+`, "g");
|
||||
result = result.replace(eqPattern, "");
|
||||
// 2) 移除 --flag <value> 形式,<value> 为非以 - 开头的单词(含路径)
|
||||
const spacedPattern = new RegExp(`${flag.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}\s+([^\s-][^\s]*)`, "g");
|
||||
result = result.replace(spacedPattern, "");
|
||||
// 3) 移除仅有 --flag(无值)
|
||||
const barePattern = new RegExp(`${flag.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}\b`, "g");
|
||||
result = result.replace(barePattern, "");
|
||||
}
|
||||
// 归一多余空白
|
||||
return result.replace(/\s{2,}/g, " ").trim();
|
||||
};
|
||||
|
||||
const cleanedScript = sanitizeCliFlags(devScript, ["--host", "--base"]);
|
||||
const appended = extraArgsEscaped ? ` ${extraArgsEscaped}` : "";
|
||||
// 如果需要 npx,使用 npx 执行命令
|
||||
const commandPrefix = needsNpx(cleanedScript) ? "npx" : "";
|
||||
execCommand = commandPrefix ? `${commandPrefix} ${cleanedScript}${appended}` : `${cleanedScript}${appended}`;
|
||||
// 使用 exec 替换 shell 进程,确保 child.pid 直接对应 vite 进程
|
||||
fullCommand = `exec ${execCommand}`;
|
||||
} else if (isNext) {
|
||||
const appended = extraArgsEscaped ? ` ${extraArgsEscaped}` : "";
|
||||
// 如果需要 npx,使用 npx 执行命令
|
||||
const commandPrefix = needsNpx(devScript) ? "npx" : "";
|
||||
execCommand = commandPrefix ? `${commandPrefix} ${devScript}${appended}` : `${devScript}${appended}`;
|
||||
fullCommand = `exec ${execCommand}`;
|
||||
} else {
|
||||
const extraForNpm = extraArgsEscaped ? ` -- ${extraArgsEscaped}` : "";
|
||||
execCommand = `pnpm run dev${extraForNpm}`;
|
||||
fullCommand = `exec ${execCommand}`;
|
||||
}
|
||||
|
||||
// 将实际执行的命令写入日志文件
|
||||
if (execCommand) {
|
||||
const commandMessage = `> ${execCommand}\n`;
|
||||
safeWrite(outStream, commandMessage, "Main log");
|
||||
safeWrite(tempOutStream, commandMessage, "Temp log");
|
||||
}
|
||||
|
||||
// 打印将要执行的完整命令与上下文,便于排查
|
||||
try {
|
||||
log(projectId, "INFO", "Start child process, sequentially execute preprocessing and start dev:", {
|
||||
command: fullCommand,
|
||||
cwd: projectPath,
|
||||
devScript,
|
||||
extraArgs,
|
||||
envExtraKeys: Object.keys(envExtra || {}),
|
||||
});
|
||||
} catch (_) {}
|
||||
|
||||
// 使用 shell 执行,以便在同一子进程中串行执行两步
|
||||
child = spawn("sh", ["-c", fullCommand], {
|
||||
cwd: projectPath,
|
||||
env: {
|
||||
PATH: process.env.PATH, // 必需:找到命令
|
||||
//HOME: process.env.HOME, // 用户配置目录
|
||||
NODE_ENV: 'development', //不能指定production,否则hmr会失效
|
||||
...envExtra, // 项目特定变量
|
||||
},
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
// 添加日志流的错误处理
|
||||
outStream.on("error", (err) => {
|
||||
log(projectId, "ERROR", "Main log stream error", {
|
||||
error: err.message,
|
||||
code: err.code,
|
||||
path: logPath,
|
||||
destroyed: outStream.destroyed,
|
||||
});
|
||||
handleStreamError("Main log", err);
|
||||
});
|
||||
tempOutStream.on("error", (err) => {
|
||||
log(projectId, "ERROR", "Temp log stream error", {
|
||||
error: err.message,
|
||||
code: err.code,
|
||||
path: tempLogPath,
|
||||
destroyed: tempOutStream.destroyed,
|
||||
});
|
||||
handleStreamError("Temp log", err);
|
||||
});
|
||||
|
||||
// 安全地重定向子进程输出到双日志文件
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", (data) => {
|
||||
// 使用安全的写入函数,避免向已关闭的流写入
|
||||
const mainWriteOk = safeWrite(outStream, data, "Main log");
|
||||
const tempWriteOk = safeWrite(tempOutStream, data, "Temp log");
|
||||
|
||||
// 如果任一写入失败,记录详细信息(但不影响另一个流)
|
||||
if (!mainWriteOk || !tempWriteOk) {
|
||||
log(projectId, "DEBUG", "Log writing status", {
|
||||
mainWriteOk,
|
||||
tempWriteOk,
|
||||
mainDestroyed: outStream ? outStream.destroyed : null,
|
||||
tempDestroyed: tempOutStream ? tempOutStream.destroyed : null,
|
||||
streamsClosed,
|
||||
});
|
||||
}
|
||||
});
|
||||
child.stdout.on("error", (err) => {
|
||||
log(projectId, "WARN", "Child process stdout error", { error: err.message });
|
||||
});
|
||||
child.stdout.on("end", () => {
|
||||
// stdout 流结束时,不立即关闭日志流,等待子进程完全退出
|
||||
log(projectId, "INFO", "子进程stdout流已结束", { pid: child.pid });
|
||||
});
|
||||
}
|
||||
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (data) => {
|
||||
// 使用安全的写入函数,避免向已关闭的流写入
|
||||
const mainWriteOk = safeWrite(outStream, data, "Main log");
|
||||
const tempWriteOk = safeWrite(tempOutStream, data, "Temp log");
|
||||
|
||||
// 如果任一写入失败,记录详细信息(但不影响另一个流)
|
||||
if (!mainWriteOk || !tempWriteOk) {
|
||||
log(projectId, "DEBUG", "Log writing status(stderr)", {
|
||||
mainWriteOk,
|
||||
tempWriteOk,
|
||||
mainDestroyed: outStream ? outStream.destroyed : null,
|
||||
tempDestroyed: tempOutStream ? tempOutStream.destroyed : null,
|
||||
streamsClosed,
|
||||
});
|
||||
}
|
||||
});
|
||||
child.stderr.on("error", (err) => {
|
||||
log(projectId, "WARN", "Child process stderr error", { error: err.message });
|
||||
});
|
||||
child.stderr.on("end", () => {
|
||||
// stderr 流结束时,不立即关闭日志流,等待子进程完全退出
|
||||
log(projectId, "INFO", "Child process stderr stream ended", { pid: child.pid });
|
||||
});
|
||||
}
|
||||
|
||||
// 监听子进程退出事件,安全关闭日志流
|
||||
child.on("exit", (code, signal) => {
|
||||
childExited = true;
|
||||
log(projectId, "INFO", "Child process exited", {
|
||||
pid: child.pid,
|
||||
code,
|
||||
signal,
|
||||
});
|
||||
// 延迟关闭流,确保所有数据都已写入
|
||||
setTimeout(() => {
|
||||
safeCloseStreams();
|
||||
}, 200);
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
log(projectId, "WARN", "Child process error", { error: err.message });
|
||||
childExited = true;
|
||||
log(projectId, "ERROR", "Child process startup failed", {
|
||||
pid: child.pid,
|
||||
error: err.message,
|
||||
});
|
||||
safeCloseStreams();
|
||||
});
|
||||
|
||||
runningDevProcesses.set(projectId, {
|
||||
pid: child.pid,
|
||||
logPath,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
|
||||
child.unref();// 解除子进程与父进程的关联,子进程可以独立运行
|
||||
|
||||
// 如果使用了 exec 命令(替换进程),需要等待 exec 完成进程替换
|
||||
// exec 会在 preCmd 成功后替换 shell 进程为实际服务进程(如 vite)
|
||||
// 这种情况下 child.pid 会直接对应服务进程,但仍需稍等确保替换完成
|
||||
const usesExec = fullCommand && fullCommand.includes("exec ");
|
||||
if (usesExec) {
|
||||
// 等待 200ms 确保 exec 完成进程替换
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
log(projectId, "INFO", "Using port pool allocated port and current process ID", {
|
||||
projectId,
|
||||
pid: child.pid,
|
||||
port: port
|
||||
});
|
||||
|
||||
// 轮询等待项目对外可访问,最多 30s
|
||||
const basePathFromReq =
|
||||
(req && req.query && req.query.basePath) ||
|
||||
(req && req.body && req.body.basePath) ||
|
||||
undefined;
|
||||
const resolvedBasePath = basePathFromReq || "/";
|
||||
const maxAliveWaitMs = 30000;
|
||||
const alivePollIntervalMs = 1000;
|
||||
const aliveCheckTimeoutPerRequest = 1500;
|
||||
|
||||
let projectAlive = false;
|
||||
const aliveStartedAt = Date.now();
|
||||
// 启动后先等待 1s 再开始轮询,给框架预留初始化时间
|
||||
await new Promise((resolve) => setTimeout(resolve, alivePollIntervalMs));
|
||||
while (Date.now() - aliveStartedAt < maxAliveWaitMs) {
|
||||
projectAlive = await isProjectAlive(projectId, port, basePathFromReq, {
|
||||
timeoutMs: aliveCheckTimeoutPerRequest,
|
||||
});
|
||||
if (projectAlive) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, alivePollIntervalMs));
|
||||
}
|
||||
|
||||
if (!projectAlive) {
|
||||
const waitSeconds = Math.round(maxAliveWaitMs / 1000);
|
||||
log(projectId, "WARN", "Development server is not accessible within the limited time", {
|
||||
port,
|
||||
pid: child.pid,
|
||||
basePath: resolvedBasePath,
|
||||
waitSeconds,
|
||||
});
|
||||
} else {
|
||||
log(projectId, "INFO", "Project accessibility verification passed", {
|
||||
port,
|
||||
basePath: resolvedBasePath,
|
||||
elapsedMs: Date.now() - aliveStartedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// 回填端口和实际进程ID
|
||||
const info = runningDevProcesses.get(projectId);
|
||||
if (info) {
|
||||
info.port = port;
|
||||
info.pid = child.pid; // 进程组pid
|
||||
runningDevProcesses.set(projectId, info);
|
||||
}
|
||||
|
||||
log(projectId, "INFO", "Development server startup successfully", {
|
||||
projectId,
|
||||
pid: child.pid,
|
||||
port: port,
|
||||
});
|
||||
|
||||
//要返回进程组pid和端口,因为后续需要通过进程组pid来停止进程
|
||||
return { pid: child.pid, port: port };
|
||||
} catch (error) {
|
||||
// 启动失败,释放已分配的端口
|
||||
if (allocatedPort) {
|
||||
portPool.release(String(projectId));
|
||||
log(projectId, "INFO", "Startup failed, port released", {
|
||||
port: allocatedPort,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
// 重新抛出错误
|
||||
throw error;
|
||||
} finally {
|
||||
// 只有在发生异常或子进程启动失败时才立即清理资源
|
||||
// 正常情况下,流会在子进程退出时自动关闭
|
||||
if (childExited || !child || !child.pid) {
|
||||
safeCloseStreams();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止指定的进程
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {number} pid 进程ID
|
||||
* @returns {Promise<boolean>} 是否成功停止
|
||||
*/
|
||||
async function killProcess(projectId, pid) {
|
||||
const pidNum = Number(pid);
|
||||
if (!Number.isFinite(pidNum)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 首先检查进程是否存在
|
||||
if (!isProcessRunning(pidNum)) {
|
||||
log(projectId, "INFO", "Process does not exist", { pid: pidNum });
|
||||
runningDevProcesses.delete(projectId);
|
||||
return true;
|
||||
}
|
||||
|
||||
let killed = false;
|
||||
let killMethod = "";
|
||||
|
||||
try {
|
||||
// 优先杀进程组(detached)
|
||||
process.kill(-pidNum);
|
||||
killed = true;
|
||||
killMethod = "Process group";
|
||||
log(projectId, "INFO", "通过进程组杀死进程", { pid: pidNum });
|
||||
} catch (e) {
|
||||
if (e && (e.code === "ESRCH" || e.errno === "ESRCH")) {
|
||||
// 进程组不存在,不能认为已杀死;继续尝试对单个进程发送信号
|
||||
killed = false;
|
||||
killMethod = "Process group(not exist)";
|
||||
log(projectId, "INFO", "Process group does not exist, back to try single process kill", { pid: pidNum });
|
||||
} else {
|
||||
log(projectId, "WARN", "Failed to kill process group", {
|
||||
pid: pidNum,
|
||||
error: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!killed) {
|
||||
try {
|
||||
process.kill(pidNum);
|
||||
killed = true;
|
||||
killMethod = "单个进程";
|
||||
log(projectId, "INFO", "Kill process through single process", { pid: pidNum });
|
||||
} catch (e) {
|
||||
if (e && (e.code === "ESRCH" || e.errno === "ESRCH")) {
|
||||
killed = true;
|
||||
killMethod = "Single process(not exist)";
|
||||
log(projectId, "INFO", "Process does not exist,视为已停止", { pid: pidNum });
|
||||
} else {
|
||||
log(projectId, "ERROR", "Failed to kill process", {
|
||||
pid: pidNum,
|
||||
error: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验证进程是否真的被杀死了
|
||||
if (killed) {
|
||||
// 等待一小段时间让进程完全退出
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
if (!isProcessRunning(pidNum)) {
|
||||
log(projectId, "INFO", "Process stopped successfully", {
|
||||
pid: pidNum,
|
||||
method: killMethod,
|
||||
});
|
||||
runningDevProcesses.delete(projectId);
|
||||
return true;
|
||||
} else {
|
||||
log(projectId, "WARN", "Process still exists, kill may have failed", {
|
||||
pid: pidNum,
|
||||
method: killMethod,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return killed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查进程是否正在运行
|
||||
* @param {number} pid 进程ID
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isProcessRunning(pid) {
|
||||
try {
|
||||
process.kill(pid, 0); // 发送信号0检查进程是否存在
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err && (err.code === 'EPERM' || err.code === 'EACCES')) {
|
||||
// 进程存在,但当前用户无权限发送信号
|
||||
return true;
|
||||
}
|
||||
if (err && err.code === 'ESRCH') {
|
||||
// 进程不存在
|
||||
return false;
|
||||
}
|
||||
// 其他错误,保守返回 false,或根据需要上报日志
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待进程停止
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {number} pid 进程ID
|
||||
* @returns {Promise<Object>} { stopped: boolean, attempts: number }
|
||||
*/
|
||||
async function waitForProcessStop(projectId, pid) {
|
||||
let attempts = 0;
|
||||
const maxAttempts = config.DEV_SERVER_STOP_MAX_ATTEMPTS;
|
||||
while (isProcessRunning(pid) && attempts < maxAttempts) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, config.DEV_SERVER_STOP_CHECK_INTERVAL)
|
||||
);
|
||||
attempts++;
|
||||
}
|
||||
|
||||
const stopped = !isProcessRunning(pid);
|
||||
return { stopped, attempts };
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出所有运行中的进程
|
||||
* @returns {Array} 进程列表
|
||||
*/
|
||||
function listRunningProcesses() {
|
||||
const list = Array.from(runningDevProcesses.entries()).map(([id, info]) => ({
|
||||
projectId: id,
|
||||
pid: info.pid,
|
||||
type: info.type,
|
||||
startedAt: info.startedAt,
|
||||
port: info.port,
|
||||
}));
|
||||
return list;
|
||||
}
|
||||
|
||||
export {
|
||||
getRunningProcess,
|
||||
setRunningProcess,
|
||||
deleteRunningProcess,
|
||||
isProjectStarting,
|
||||
addStartingProject,
|
||||
removeStartingProject,
|
||||
startDev_NonBlocking,
|
||||
killProcess,
|
||||
isProcessRunning,
|
||||
waitForProcessStop,
|
||||
listRunningProcesses,
|
||||
};
|
||||
129
qiming-file-server/src/utils/build/restartDevUtils.js
Normal file
129
qiming-file-server/src/utils/build/restartDevUtils.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { log } from "../log/logUtils.js";
|
||||
import { BusinessError, FileError, ResourceError } from "../error/errorHandler.js";
|
||||
import {
|
||||
isProjectStarting,
|
||||
addStartingProject,
|
||||
removeStartingProject,
|
||||
startDev_NonBlocking,
|
||||
} from "./processManager.js";
|
||||
import ERROR_CODES from "../error/errorCodes.js";
|
||||
import { stopDevServer } from "./stopDevUtils.js";
|
||||
import { removeNodeModules } from "../buildDependency/dependencyManager.js";
|
||||
import { createPnpmNpmrc } from "../common/npmrcUtils.js";
|
||||
import {
|
||||
extractIsolationContext,
|
||||
resolveProjectPath,
|
||||
} from "../common/projectPathUtils.js";
|
||||
|
||||
// 重启开发服务器
|
||||
async function restartDevServer(req, projectId) {
|
||||
log(projectId, "INFO", "Start restarting development server", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
// 检查项目是否存在
|
||||
const isolationContext = extractIsolationContext(req?.query || {});
|
||||
const projectPath = resolveProjectPath(projectId, isolationContext);
|
||||
const jsonFilePath = path.join(projectPath, "package.json");
|
||||
const exists = fs.existsSync(jsonFilePath);
|
||||
if (!exists) {
|
||||
log(projectId, "WARN", "Project missing package.json file", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
throw new ResourceError("Project missing package.json file", {
|
||||
projectId,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
let jsonContent;
|
||||
try {
|
||||
jsonContent = JSON.parse(fs.readFileSync(jsonFilePath, "utf8"));
|
||||
} catch (error) {
|
||||
throw new FileError("package.json file format error", {
|
||||
projectId,
|
||||
jsonFilePath,
|
||||
originalError: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
const jsonScripts = jsonContent.scripts;
|
||||
const devScript = jsonScripts.dev;
|
||||
if (!devScript) {
|
||||
log(projectId, "WARN", "Project missing dev script", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
throw new BusinessError("Project missing dev script", { projectId });
|
||||
}
|
||||
|
||||
// 如果项目正在启动中,等待完成
|
||||
// if (isProjectStarting(projectId)) {
|
||||
// throw new BusinessError("Project is starting, please try again later", {
|
||||
// projectId,
|
||||
// code: ERROR_CODES.PROJECT_STARTING,
|
||||
// });
|
||||
// }
|
||||
|
||||
addStartingProject(projectId);
|
||||
|
||||
try {
|
||||
// 1. 停止现有的开发服务器
|
||||
const pidFromQuery = req.query.pid;
|
||||
|
||||
await stopDevServer(req, projectId, pidFromQuery, {
|
||||
strict: false, // 非严格模式,停止失败不抛出异常
|
||||
waitForStop: true, // 等待进程完全停止
|
||||
});
|
||||
|
||||
// 2. 删除node_modules
|
||||
log(projectId, "INFO", "Start deleting node_modules and lock file", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
await removeNodeModules(projectPath, projectId);
|
||||
|
||||
// 3. 创建.npmrc文件
|
||||
log(projectId, "INFO", "Create .npmrc file", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
await createPnpmNpmrc(projectPath, projectId);
|
||||
|
||||
// 4. 启动dev服务器(依赖安装会在 startDev_NonBlocking 中执行)
|
||||
log(projectId, "INFO", "Start starting dev server", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
const { pid, port: actualPort } = await startDev_NonBlocking({
|
||||
req,
|
||||
projectId,
|
||||
projectPath,
|
||||
devScript,
|
||||
});
|
||||
|
||||
log(projectId, "INFO", "Dev server restart completed", {
|
||||
projectId,
|
||||
pid,
|
||||
port: actualPort,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Development server restart successfully",
|
||||
projectId,
|
||||
pid,
|
||||
port: actualPort,
|
||||
};
|
||||
} finally {
|
||||
removeStartingProject(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
export { restartDevServer };
|
||||
150
qiming-file-server/src/utils/build/startDevUtils.js
Normal file
150
qiming-file-server/src/utils/build/startDevUtils.js
Normal file
@@ -0,0 +1,150 @@
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { log } from "../log/logUtils.js";
|
||||
import { BusinessError, FileError, ResourceError } from "../error/errorHandler.js";
|
||||
import ERROR_CODES from "../error/errorCodes.js";
|
||||
import {
|
||||
getRunningProcess,
|
||||
isProjectStarting,
|
||||
addStartingProject,
|
||||
removeStartingProject,
|
||||
startDev_NonBlocking,
|
||||
} from "./processManager.js";
|
||||
import { removeNodeModules } from "../buildDependency/dependencyManager.js";
|
||||
import {
|
||||
extractIsolationContext,
|
||||
resolveProjectPath,
|
||||
} from "../common/projectPathUtils.js";
|
||||
|
||||
// 启动开发服务器
|
||||
async function startDevServer(req, projectId) {
|
||||
|
||||
// if (isProjectStarting(projectId)) {
|
||||
// throw new BusinessError("该项目正在启动中,请稍后重试", {
|
||||
// projectId,
|
||||
// code: ERROR_CODES.PROJECT_STARTING,
|
||||
// });
|
||||
// }
|
||||
|
||||
addStartingProject(projectId);
|
||||
try {
|
||||
log(projectId, "INFO", "Start starting development server", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
const isolationContext = extractIsolationContext(req?.query || {});
|
||||
const projectPath = resolveProjectPath(projectId, isolationContext);
|
||||
const jsonFilePath = path.join(projectPath, "package.json");
|
||||
|
||||
const exists = fs.existsSync(jsonFilePath);
|
||||
if (!exists) {
|
||||
log(projectId, "WARN", "Project missing package.json file", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
throw new ResourceError("Project missing package.json file", {
|
||||
projectId,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
let jsonContent;
|
||||
try {
|
||||
jsonContent = JSON.parse(fs.readFileSync(jsonFilePath, "utf8"));
|
||||
} catch (error) {
|
||||
throw new FileError("package.json file format error", {
|
||||
projectId,
|
||||
jsonFilePath,
|
||||
originalError: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
const jsonScripts = jsonContent.scripts;
|
||||
const devScript = jsonScripts.dev;
|
||||
if (!devScript) {
|
||||
log(projectId, "WARN", "Project missing dev script", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
throw new BusinessError("Project missing dev script, please add dev script in package.json", { projectId });
|
||||
}
|
||||
|
||||
// Linux 环境下:检测 libc 类型与已安装 Rollup 原生包是否匹配,若不匹配则清理依赖
|
||||
try {
|
||||
const isLinux = process.platform === "linux";
|
||||
if (isLinux) {
|
||||
const report = typeof process.report?.getReport === "function" ? process.report.getReport() : null;
|
||||
const glibcVersion = report && report.header && report.header.glibcVersionRuntime;
|
||||
const isMusl = !glibcVersion; // 没有 glibc 版本通常意味着 musl(如 Alpine)
|
||||
|
||||
const pnpmDir = path.join(projectPath, "node_modules", ".pnpm");
|
||||
if (fs.existsSync(pnpmDir)) {
|
||||
const entries = await fs.promises.readdir(pnpmDir, { withFileTypes: true });
|
||||
const hasRollupGnu = entries.some((ent) => ent.isDirectory() && (ent.name || "").includes("@rollup+rollup-linux-x64-gnu"));
|
||||
const hasRollupMusl = entries.some((ent) => ent.isDirectory() && (ent.name || "").includes("@rollup+rollup-linux-x64-musl"));
|
||||
|
||||
// 在 musl 系统上若装了 gnu 变体,或在 glibc 系统上若装了 musl 变体,则清理
|
||||
const mismatch = (isMusl && hasRollupGnu) || (!isMusl && hasRollupMusl);
|
||||
if (mismatch) {
|
||||
log(projectId, "WARN", "Detected Rollup native package does not match libc, clean dependencies and reinstall", {
|
||||
projectId,
|
||||
isMusl,
|
||||
glibcVersion: glibcVersion || null,
|
||||
});
|
||||
await removeNodeModules(projectPath, projectId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log(projectId, "WARN", "Linux native package matching detection failed (ignore continue)", {
|
||||
error: e && e.message,
|
||||
});
|
||||
}
|
||||
|
||||
// 尝试为后续 dev 进程注入回退环境,优先使用 WASM/JS,避免 .node 装载
|
||||
try {
|
||||
process.env.ROLLUP_WASM = process.env.ROLLUP_WASM || "1";
|
||||
process.env.ROLLUP_DISABLE_NATIVE = process.env.ROLLUP_DISABLE_NATIVE || "1";
|
||||
} catch (_) {}
|
||||
|
||||
// 如果已在运行,则直接返回信息
|
||||
// if (getRunningProcess(projectId)) {
|
||||
// const p = getRunningProcess(projectId);
|
||||
// log(projectId, "INFO", "项目已在运行,直接返回信息", {
|
||||
// projectId,
|
||||
// requestId: req.requestId,
|
||||
// });
|
||||
// return {
|
||||
// success: true,
|
||||
// message: "已在运行",
|
||||
// projectId,
|
||||
// pid: p.pid,
|
||||
// port: p.port,
|
||||
// };
|
||||
// }
|
||||
|
||||
log(projectId, "INFO", "Start executing dev script in non-blocking mode", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
const { pid, port: actualPort } = await startDev_NonBlocking({
|
||||
req,
|
||||
projectId,
|
||||
projectPath,
|
||||
devScript,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: "Development server started",
|
||||
projectId,
|
||||
pid,
|
||||
port: actualPort,
|
||||
};
|
||||
} finally {
|
||||
// 无论成功、失败都清理锁
|
||||
removeStartingProject(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
export { startDevServer };
|
||||
428
qiming-file-server/src/utils/build/stopDevUtils.js
Normal file
428
qiming-file-server/src/utils/build/stopDevUtils.js
Normal file
@@ -0,0 +1,428 @@
|
||||
import { ValidationError, ProcessError } from "../error/errorHandler.js";
|
||||
import {
|
||||
killProcess,
|
||||
getRunningProcess,
|
||||
waitForProcessStop,
|
||||
} from "./processManager.js";
|
||||
import { log, getLogDir } from "../log/logUtils.js";
|
||||
import logCacheManager from "../log/logCacheManager.js";
|
||||
import portPool from "../buildArg/portPool.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
|
||||
|
||||
function findPidsByProjectId(projectId) {
|
||||
try {
|
||||
const cmd = "ps -Ao pid,command -ww";
|
||||
const output = execSync(cmd, { encoding: "utf8" });
|
||||
const lines = output.split("\n");
|
||||
const pids = [];
|
||||
const preciseNeedle = "/" + String(projectId);
|
||||
|
||||
// 匹配包含 /{projectId} 的路径片段,避免误匹配
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
if (line.includes(preciseNeedle)) {
|
||||
const trimmed = line.trim();
|
||||
const firstSpace = trimmed.indexOf(" ");
|
||||
const pidStr = firstSpace > 0 ? trimmed.slice(0, firstSpace) : trimmed;
|
||||
const pidNum = Number(pidStr);
|
||||
if (Number.isFinite(pidNum)) {
|
||||
pids.push(pidNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:使用宽松的包含 projectId 匹配(可能带来噪声)
|
||||
if (pids.length === 0) {
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
if (line.includes(String(projectId))) {
|
||||
const trimmed = line.trim();
|
||||
const firstSpace = trimmed.indexOf(" ");
|
||||
const pidStr = firstSpace > 0 ? trimmed.slice(0, firstSpace) : trimmed;
|
||||
const pidNum = Number(pidStr);
|
||||
if (Number.isFinite(pidNum)) {
|
||||
pids.push(pidNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(pids));
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止开发服务器
|
||||
* @param {Object} req 请求对象
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {number|string} pid 进程ID(可选,如果不提供会从运行表中获取)
|
||||
* @param {Object} options 选项
|
||||
* @param {boolean} options.strict 是否严格模式,严格模式下停止失败会抛出异常
|
||||
* @param {boolean} options.waitForStop 是否等待进程完全停止
|
||||
* @returns {Promise<Object>} 停止结果
|
||||
*
|
||||
* 功能说明:
|
||||
* 1. 确定要停止的进程ID
|
||||
* 2. 停止进程
|
||||
* 3. 等待进程完全停止(可选)
|
||||
* 4. 清理项目下的所有临时日志文件(dev-temp-*.log)
|
||||
*/
|
||||
async function stopDevServerByPid(req, projectId, pid, options = {}) {
|
||||
const { strict = true, waitForStop = false } = options;
|
||||
|
||||
log(projectId, "INFO", "Start stopping development server", {
|
||||
projectId,
|
||||
pid,
|
||||
strict,
|
||||
waitForStop,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
let pidToKill = null;
|
||||
let existingProcess = null;
|
||||
|
||||
// 1. 确定要停止的进程ID
|
||||
if (pid !== undefined && pid !== null) {
|
||||
const pidNum = Number(pid);
|
||||
if (Number.isFinite(pidNum)) {
|
||||
pidToKill = pidNum;
|
||||
log(projectId, "INFO", "Stop development server using incoming pid", {
|
||||
projectId,
|
||||
pid: pidToKill,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} else {
|
||||
log(projectId, "WARN", "Incoming pid is invalid", {
|
||||
projectId,
|
||||
invalidPid: pid,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
if (strict) {
|
||||
throw new ValidationError("Process ID is invalid", { field: "pid", value: pid });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果传入的pid无效或未提供,从运行表中获取
|
||||
if (!pidToKill) {
|
||||
existingProcess = getRunningProcess(projectId);
|
||||
if (existingProcess) {
|
||||
pidToKill = existingProcess.pid;
|
||||
log(projectId, "INFO", "Get pid to stop development server from running table", {
|
||||
projectId,
|
||||
pid: pidToKill,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} else {
|
||||
log(projectId, "INFO", "No development server process found to stop", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
if (strict) {
|
||||
throw new ProcessError("No running development server process found", { projectId });
|
||||
}
|
||||
|
||||
// 即使未找到进程,也尝试释放端口(可能进程已意外退出)
|
||||
portPool.release(String(projectId));
|
||||
log(projectId, "INFO", "Port released (process not running)", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "No running process found",
|
||||
projectId,
|
||||
pid: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 停止进程
|
||||
const killed = await killProcess(projectId, pidToKill);
|
||||
|
||||
if (killed) {
|
||||
log(projectId, "INFO", "Development server stopped", {
|
||||
projectId,
|
||||
pid: pidToKill,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} else {
|
||||
log(projectId, "WARN", "Failed to stop development server", {
|
||||
projectId,
|
||||
pid: pidToKill,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
if (strict) {
|
||||
throw new ProcessError("Failed to stop process", { projectId, pid: pidToKill });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 等待进程完全停止(可选)
|
||||
if (waitForStop && killed) {
|
||||
const { stopped, attempts } = await waitForProcessStop(
|
||||
projectId,
|
||||
pidToKill
|
||||
);
|
||||
|
||||
if (!stopped) {
|
||||
log(projectId, "WARN", "Process stop timeout", {
|
||||
projectId,
|
||||
pid: pidToKill,
|
||||
attempts,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
if (strict) {
|
||||
throw new ProcessError("Process stop timeout", {
|
||||
projectId,
|
||||
pid: pidToKill,
|
||||
attempts,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
log(projectId, "INFO", "Process confirmed stopped", {
|
||||
projectId,
|
||||
pid: pidToKill,
|
||||
attempts,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 清理项目下的所有临时日志文件
|
||||
try {
|
||||
const logDir = getLogDir(projectId);
|
||||
if (fs.existsSync(logDir)) {
|
||||
const files = fs.readdirSync(logDir);
|
||||
const tempFiles = files.filter(
|
||||
(file) => file.startsWith("dev-temp-") && file.endsWith(".log")
|
||||
);
|
||||
|
||||
if (tempFiles.length > 0) {
|
||||
let deletedCount = 0;
|
||||
for (const tempFile of tempFiles) {
|
||||
try {
|
||||
const tempFilePath = path.join(logDir, tempFile);
|
||||
fs.unlinkSync(tempFilePath);
|
||||
deletedCount++;
|
||||
} catch (err) {
|
||||
log(projectId, "WARN", "Failed to delete temporary log file", {
|
||||
projectId,
|
||||
tempFile,
|
||||
error: err.message,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
log(projectId, "INFO", "Temporary log file cleanup completed", {
|
||||
projectId,
|
||||
deletedCount,
|
||||
totalTempFiles: tempFiles.length,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log(projectId, "WARN", "Error cleaning temporary log files", {
|
||||
projectId,
|
||||
error: err.message,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. 删除该项目的日志缓存
|
||||
try {
|
||||
if (logCacheManager.isEnabled()) {
|
||||
logCacheManager.delete(projectId);
|
||||
log(projectId, "INFO", "Log cache cleaned", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log(projectId, "WARN", "Error cleaning log cache", {
|
||||
projectId,
|
||||
error: err.message,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
// 6. 停止成功后释放端口
|
||||
if (killed) {
|
||||
portPool.release(String(projectId));
|
||||
log(projectId, "INFO", "Port released", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: killed ? "Stopped" : "Failed to stop but continue execution",
|
||||
projectId,
|
||||
pid: pidToKill,
|
||||
};
|
||||
}
|
||||
|
||||
async function stopDevServerByProjectId(req, projectId, options = {}) {
|
||||
const { strict = true, waitForStop = false } = options;
|
||||
|
||||
log(projectId, "INFO", "Start stopping development server(stop all by projectId)", {
|
||||
projectId,
|
||||
strict,
|
||||
waitForStop,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
// 忽略传入的 pid,按 projectId 通过系统进程检索
|
||||
const uniquePids = findPidsByProjectId(projectId);
|
||||
const candidates = uniquePids.map((pid) => ({ pid }));
|
||||
|
||||
if (!candidates || candidates.length === 0) {
|
||||
log(projectId, "INFO", "No development server process found to stop", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
// if (strict) {
|
||||
// throw new ProcessError("未找到运行中的开发服务器进程", { projectId });
|
||||
// }
|
||||
|
||||
// 即使未找到进程,也尝试释放端口(可能进程已意外退出)
|
||||
portPool.release(String(projectId));
|
||||
log(projectId, "INFO", "Port released (process not running)", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "No running process found",
|
||||
projectId,
|
||||
pid: null,
|
||||
};
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const thePid of uniquePids) {
|
||||
const killed = await killProcess(projectId, thePid);
|
||||
results.push({ pid: thePid, killed });
|
||||
|
||||
if (waitForStop && killed) {
|
||||
const { stopped, attempts } = await waitForProcessStop(projectId, thePid);
|
||||
log(projectId, stopped ? "INFO" : "WARN", stopped ? "Process confirmed stopped" : "Process stop timeout", {
|
||||
projectId,
|
||||
pid: thePid,
|
||||
attempts,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
if (!stopped && strict) {
|
||||
throw new ProcessError("Process stop timeout", {
|
||||
projectId,
|
||||
pid: thePid,
|
||||
attempts,
|
||||
});
|
||||
}
|
||||
} else if (!killed && strict) {
|
||||
throw new ProcessError("Failed to stop process", { projectId, pid: thePid });
|
||||
}
|
||||
}
|
||||
|
||||
// 清理项目下的所有临时日志文件
|
||||
try {
|
||||
const logDir = getLogDir(projectId);
|
||||
if (fs.existsSync(logDir)) {
|
||||
const files = fs.readdirSync(logDir);
|
||||
const tempFiles = files.filter(
|
||||
(file) => file.startsWith("dev-temp-") && file.endsWith(".log")
|
||||
);
|
||||
|
||||
if (tempFiles.length > 0) {
|
||||
let deletedCount = 0;
|
||||
for (const tempFile of tempFiles) {
|
||||
try {
|
||||
const tempFilePath = path.join(logDir, tempFile);
|
||||
fs.unlinkSync(tempFilePath);
|
||||
deletedCount++;
|
||||
} catch (err) {
|
||||
log(projectId, "WARN", "Failed to delete temporary log file", {
|
||||
projectId,
|
||||
tempFile,
|
||||
error: err.message,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
log(projectId, "INFO", "Temporary log file cleanup completed", {
|
||||
projectId,
|
||||
deletedCount,
|
||||
totalTempFiles: tempFiles.length,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log(projectId, "WARN", "Error cleaning temporary log files", {
|
||||
projectId,
|
||||
error: err.message,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除该项目的日志缓存
|
||||
try {
|
||||
if (logCacheManager.isEnabled()) {
|
||||
logCacheManager.delete(projectId);
|
||||
log(projectId, "INFO", "Log cache cleaned", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log(projectId, "WARN", "Error cleaning log cache", {
|
||||
projectId,
|
||||
error: err.message,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
const allKilled = results.every((r) => r.killed === true);
|
||||
|
||||
// 停止成功后释放端口
|
||||
if (allKilled && results.length > 0) {
|
||||
portPool.release(String(projectId));
|
||||
log(projectId, "INFO", "Port released", {
|
||||
projectId,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: allKilled ? "Stopped" : "Partially stopped but continue execution",
|
||||
projectId,
|
||||
pid: null,
|
||||
killedPids: results,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
async function stopDevServer(req, projectId, pid, options = {}) {
|
||||
return await stopDevServerByProjectId(req, projectId, options);
|
||||
}
|
||||
|
||||
export { stopDevServer };
|
||||
847
qiming-file-server/src/utils/build/syntaxCheckUtils.js
Normal file
847
qiming-file-server/src/utils/build/syntaxCheckUtils.js
Normal file
@@ -0,0 +1,847 @@
|
||||
import { spawn } from "child_process";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { log } from "../log/logUtils.js";
|
||||
|
||||
/**
|
||||
* 在启动开发服务器前进行语法检查
|
||||
* 根据项目配置自动选择检查方式:
|
||||
* - TypeScript 项目:运行 tsc --noEmit
|
||||
* - 纯 JavaScript 项目:使用 esbuild 进行快速语法检查
|
||||
* - HTML 文件:使用 html-validate 进行 HTML 语法检查
|
||||
*
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {string} projectPath 项目路径
|
||||
* @param {number} timeoutMs 超时时间(毫秒)
|
||||
* @returns {Promise<Object>} { passed: boolean, error?: string, method?: string }
|
||||
*/
|
||||
async function runSyntaxCheck(projectId, projectPath, timeoutMs = 15000) {
|
||||
try {
|
||||
// 检查项目类型
|
||||
const projectType = detectProjectType(projectPath);
|
||||
|
||||
log(projectId, "INFO", "Start syntax check", {
|
||||
projectId,
|
||||
projectType,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
const results = [];
|
||||
|
||||
// 1. 代码检查(TypeScript/JavaScript)
|
||||
if (projectType === "typescript") {
|
||||
const tsResult = await runTypeScriptCheck(projectId, projectPath, timeoutMs);
|
||||
results.push(tsResult);
|
||||
|
||||
// 如果 TS 检查失败,立即返回
|
||||
if (!tsResult.passed) {
|
||||
return tsResult;
|
||||
}
|
||||
} else if (projectType === "javascript") {
|
||||
const jsResult = await runJavaScriptCheck(projectId, projectPath, timeoutMs);
|
||||
results.push(jsResult);
|
||||
|
||||
// 如果 JS 检查失败,立即返回
|
||||
if (!jsResult.passed) {
|
||||
return jsResult;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. HTML 文件检查
|
||||
const htmlResult = await runHtmlCheck(projectId, projectPath, timeoutMs);
|
||||
results.push(htmlResult);
|
||||
|
||||
// 如果 HTML 检查失败,返回失败
|
||||
if (!htmlResult.passed) {
|
||||
return htmlResult;
|
||||
}
|
||||
|
||||
// 所有检查都通过
|
||||
if (results.length === 0) {
|
||||
log(projectId, "INFO", "Skip syntax check: no source code files detected", {
|
||||
projectId,
|
||||
});
|
||||
return { passed: true, method: "skipped" };
|
||||
}
|
||||
|
||||
// 返回综合结果
|
||||
const methods = results.map(r => r.method).filter(Boolean).join(", ");
|
||||
const totalDuration = results.reduce((sum, r) => sum + (r.duration || 0), 0);
|
||||
|
||||
log(projectId, "INFO", "All syntax checks passed", {
|
||||
projectId,
|
||||
methods,
|
||||
totalDuration,
|
||||
});
|
||||
|
||||
return {
|
||||
passed: true,
|
||||
method: methods,
|
||||
duration: totalDuration,
|
||||
};
|
||||
} catch (error) {
|
||||
log(projectId, "WARN", "Syntax check execution failed", {
|
||||
projectId,
|
||||
error: error.message,
|
||||
});
|
||||
// 检查失败不阻止启动
|
||||
return { passed: true, method: "error", error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测项目类型
|
||||
* @param {string} projectPath 项目路径
|
||||
* @returns {string} "typescript" | "javascript" | "unknown"
|
||||
*/
|
||||
function detectProjectType(projectPath) {
|
||||
// 检查是否有 tsconfig.json
|
||||
const tsconfigPath = path.join(projectPath, "tsconfig.json");
|
||||
if (fs.existsSync(tsconfigPath)) {
|
||||
// 检查是否有 .ts 或 .tsx 文件
|
||||
const hasTsFiles = hasFilesWithExtension(projectPath, [".ts", ".tsx"]);
|
||||
if (hasTsFiles) {
|
||||
return "typescript";
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有 .js 或 .jsx 文件
|
||||
const hasJsFiles = hasFilesWithExtension(projectPath, [".js", ".jsx"]);
|
||||
if (hasJsFiles) {
|
||||
return "javascript";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查目录下是否存在指定扩展名的文件
|
||||
* @param {string} dir 目录路径
|
||||
* @param {Array<string>} extensions 扩展名列表
|
||||
* @param {number} maxDepth 最大搜索深度
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasFilesWithExtension(dir, extensions, maxDepth = 3) {
|
||||
try {
|
||||
// 排除的目录
|
||||
const excludeDirs = ["node_modules", ".git", "dist", "build", ".next", ".nuxt"];
|
||||
|
||||
function searchDir(currentDir, depth) {
|
||||
if (depth > maxDepth) return false;
|
||||
|
||||
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (!excludeDirs.includes(entry.name)) {
|
||||
if (searchDir(fullPath, depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
const ext = path.extname(entry.name);
|
||||
if (extensions.includes(ext)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return searchDir(dir, 0);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行 TypeScript 类型检查
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {string} projectPath 项目路径
|
||||
* @param {number} timeoutMs 超时时间
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function runTypeScriptCheck(projectId, projectPath, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const tsconfigPath = path.join(projectPath, "tsconfig.json");
|
||||
const checkConfigPath = path.join(projectPath, "tsconfig.check.json");
|
||||
let createdCheckConfig = false;
|
||||
let createdTempTsconfig = false;
|
||||
|
||||
// 创建专门用于语法检查的配置
|
||||
const createCheckConfig = (hasTsconfig) => {
|
||||
const config = {
|
||||
// 明确指定要检查的所有文件模式
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.js",
|
||||
"src/**/*.jsx",
|
||||
"*.ts",
|
||||
"*.tsx"
|
||||
],
|
||||
"exclude": ["node_modules", "dist", "build", ".next", ".nuxt", "**/*.spec.ts", "**/*.test.ts"]
|
||||
};
|
||||
|
||||
// 如果用户有 tsconfig.json,继承它的 compilerOptions
|
||||
if (hasTsconfig) {
|
||||
config.extends = "./tsconfig.json";
|
||||
log(projectId, "INFO", "Check configuration will inherit user's tsconfig.json", {
|
||||
projectId,
|
||||
});
|
||||
} else {
|
||||
// 如果没有 tsconfig.json,需要提供完整的 compilerOptions
|
||||
config.compilerOptions = {
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": false,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
const hasTsconfig = fs.existsSync(tsconfigPath);
|
||||
|
||||
// 始终创建 tsconfig.check.json 用于检查
|
||||
try {
|
||||
const checkConfig = createCheckConfig(hasTsconfig);
|
||||
fs.writeFileSync(checkConfigPath, JSON.stringify(checkConfig, null, 2), "utf8");
|
||||
createdCheckConfig = true;
|
||||
|
||||
log(projectId, "INFO", "Create temporary tsconfig.check.json for syntax check", {
|
||||
projectId,
|
||||
hasTsconfig,
|
||||
extends: checkConfig.extends || "无",
|
||||
include: checkConfig.include,
|
||||
});
|
||||
} catch (error) {
|
||||
log(projectId, "WARN", "Failed to create tsconfig.check.json", {
|
||||
projectId,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
// 降级方案:如果无法创建检查配置且没有 tsconfig.json,创建临时的
|
||||
if (!hasTsconfig) {
|
||||
try {
|
||||
fs.writeFileSync(tsconfigPath, JSON.stringify(createCheckConfig(false), null, 2), "utf8");
|
||||
createdTempTsconfig = true;
|
||||
log(projectId, "INFO", "Create temporary tsconfig.json as fallback", {
|
||||
projectId,
|
||||
});
|
||||
} catch (e) {
|
||||
log(projectId, "ERROR", "Failed to create any TypeScript configuration file", {
|
||||
projectId,
|
||||
error: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 优先使用项目本地的 tsc,如果不存在则使用 npx
|
||||
const localTscPath = path.join(projectPath, "node_modules", ".bin", "tsc");
|
||||
const usesLocalTsc = fs.existsSync(localTscPath);
|
||||
|
||||
const command = usesLocalTsc ? localTscPath : "npx";
|
||||
// 使用 --project 参数指定检查配置文件
|
||||
const configToUse = createdCheckConfig ? "tsconfig.check.json" : "tsconfig.json";
|
||||
const args = usesLocalTsc
|
||||
? ["--project", configToUse, "--noEmit", "--skipLibCheck"]
|
||||
: ["--yes", "tsc", "--project", configToUse, "--noEmit", "--skipLibCheck"];
|
||||
|
||||
log(projectId, "INFO", "Run TypeScript type check", {
|
||||
projectId,
|
||||
command,
|
||||
args: args.join(" "),
|
||||
usesLocalTsc,
|
||||
configFile: configToUse,
|
||||
});
|
||||
|
||||
const child = spawn(command, args, {
|
||||
cwd: projectPath,
|
||||
shell: true,
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
// 清理临时文件的辅助函数
|
||||
const cleanupTempConfig = () => {
|
||||
// 清理检查配置文件
|
||||
if (createdCheckConfig) {
|
||||
try {
|
||||
if (fs.existsSync(checkConfigPath)) {
|
||||
fs.unlinkSync(checkConfigPath);
|
||||
log(projectId, "INFO", "Cleaned temporary tsconfig.check.json", {
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
log(projectId, "WARN", "Failed to clean tsconfig.check.json", {
|
||||
projectId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 清理临时创建的 tsconfig.json(降级方案)
|
||||
if (createdTempTsconfig) {
|
||||
try {
|
||||
if (fs.existsSync(tsconfigPath)) {
|
||||
fs.unlinkSync(tsconfigPath);
|
||||
log(projectId, "INFO", "Cleaned temporary tsconfig.json", {
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
log(projectId, "WARN", "Failed to clean tsconfig.json", {
|
||||
projectId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
child.on("close", (code) => {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// 清理临时配置
|
||||
cleanupTempConfig();
|
||||
|
||||
if (code === 0) {
|
||||
log(projectId, "INFO", "TypeScript type check passed", {
|
||||
projectId,
|
||||
duration,
|
||||
});
|
||||
resolve({ passed: true, method: "typescript", duration });
|
||||
} else {
|
||||
// 提取错误信息
|
||||
const errorOutput = stderr || stdout;
|
||||
const errorSummary = extractTypeScriptErrors(errorOutput);
|
||||
|
||||
log(projectId, "ERROR", "TypeScript type check failed", {
|
||||
projectId,
|
||||
code,
|
||||
duration,
|
||||
errorSummary,
|
||||
});
|
||||
|
||||
resolve({
|
||||
passed: false,
|
||||
method: "typescript",
|
||||
error: errorSummary,
|
||||
fullOutput: errorOutput.substring(0, 2000), // 限制长度
|
||||
duration,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
// 清理临时配置
|
||||
cleanupTempConfig();
|
||||
|
||||
log(projectId, "WARN", "TypeScript check execution failed", {
|
||||
projectId,
|
||||
error: error.message,
|
||||
});
|
||||
// 执行失败不阻止启动
|
||||
resolve({ passed: true, method: "typescript-error", error: error.message });
|
||||
});
|
||||
|
||||
// 超时处理
|
||||
setTimeout(() => {
|
||||
try {
|
||||
child.kill();
|
||||
// 清理临时配置
|
||||
cleanupTempConfig();
|
||||
|
||||
log(projectId, "WARN", "TypeScript check timeout", {
|
||||
projectId,
|
||||
timeoutMs,
|
||||
});
|
||||
resolve({ passed: true, method: "typescript-timeout" });
|
||||
} catch (e) {
|
||||
cleanupTempConfig();
|
||||
resolve({ passed: true, method: "typescript-timeout-error" });
|
||||
}
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行 JavaScript 语法检查(使用 esbuild)
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {string} projectPath 项目路径
|
||||
* @param {number} timeoutMs 超时时间
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function runJavaScriptCheck(projectId, projectPath, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 查找入口文件
|
||||
const entryFiles = findEntryFiles(projectPath);
|
||||
|
||||
if (entryFiles.length === 0) {
|
||||
log(projectId, "INFO", "Skip JavaScript syntax check: no entry files found", {
|
||||
projectId,
|
||||
});
|
||||
resolve({ passed: true, method: "javascript-skipped" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 esbuild 进行快速语法检查(不输出文件)
|
||||
const args = [
|
||||
"--yes",
|
||||
"esbuild",
|
||||
...entryFiles,
|
||||
"--bundle",
|
||||
"--write=false",
|
||||
"--outdir=/tmp",
|
||||
"--format=esm",
|
||||
];
|
||||
|
||||
log(projectId, "INFO", "Run JavaScript syntax check", {
|
||||
projectId,
|
||||
entryFiles,
|
||||
});
|
||||
|
||||
const child = spawn("npx", args, {
|
||||
cwd: projectPath,
|
||||
shell: true,
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (code === 0) {
|
||||
log(projectId, "INFO", "JavaScript syntax check passed", {
|
||||
projectId,
|
||||
duration,
|
||||
});
|
||||
resolve({ passed: true, method: "javascript", duration });
|
||||
} else {
|
||||
const errorOutput = stderr || stdout;
|
||||
const errorSummary = errorOutput.substring(0, 1000);
|
||||
|
||||
log(projectId, "ERROR", "JavaScript syntax check failed", {
|
||||
projectId,
|
||||
code,
|
||||
duration,
|
||||
errorSummary,
|
||||
});
|
||||
|
||||
resolve({
|
||||
passed: false,
|
||||
method: "javascript",
|
||||
error: errorSummary,
|
||||
fullOutput: errorOutput.substring(0, 2000),
|
||||
duration,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
log(projectId, "WARN", "JavaScript check execution failed", {
|
||||
projectId,
|
||||
error: error.message,
|
||||
});
|
||||
resolve({ passed: true, method: "javascript-error", error: error.message });
|
||||
});
|
||||
|
||||
// 超时处理
|
||||
setTimeout(() => {
|
||||
try {
|
||||
child.kill();
|
||||
log(projectId, "WARN", "JavaScript check timeout", {
|
||||
projectId,
|
||||
timeoutMs,
|
||||
});
|
||||
resolve({ passed: true, method: "javascript-timeout" });
|
||||
} catch (e) {
|
||||
resolve({ passed: true, method: "javascript-timeout-error" });
|
||||
}
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找入口文件
|
||||
* @param {string} projectPath 项目路径
|
||||
* @returns {Array<string>} 入口文件路径列表
|
||||
*/
|
||||
function findEntryFiles(projectPath) {
|
||||
const possibleEntries = [
|
||||
"src/main.js",
|
||||
"src/main.jsx",
|
||||
"src/main.ts",
|
||||
"src/main.tsx",
|
||||
"src/index.js",
|
||||
"src/index.jsx",
|
||||
"src/index.ts",
|
||||
"src/index.tsx",
|
||||
"src/app.js",
|
||||
"src/app.jsx",
|
||||
"src/App.js",
|
||||
"src/App.jsx",
|
||||
"index.js",
|
||||
"index.jsx",
|
||||
"index.ts",
|
||||
"index.tsx",
|
||||
];
|
||||
|
||||
const entries = [];
|
||||
for (const entry of possibleEntries) {
|
||||
const fullPath = path.join(projectPath, entry);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保项目有 HTML 验证配置文件(如果不存在则创建宽松配置)
|
||||
* @param {string} projectPath 项目路径
|
||||
* @returns {boolean} 是否创建了新配置
|
||||
*/
|
||||
function ensureHtmlValidateConfig(projectPath) {
|
||||
const configPath = path.join(projectPath, ".htmlvalidate.json");
|
||||
|
||||
// 如果已经存在配置文件,不覆盖
|
||||
if (fs.existsSync(configPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建宽松的默认配置
|
||||
const defaultConfig = {
|
||||
"extends": ["html-validate:recommended"],
|
||||
"rules": {
|
||||
// 允许 void elements 不自闭合(HTML5 标准)
|
||||
"void-style": "off",
|
||||
// 允许不需要 SRI
|
||||
"require-sri": "off",
|
||||
// 允许尾部空白
|
||||
"no-trailing-whitespace": "off",
|
||||
// 允许内联样式(开发时常用)
|
||||
"no-inline-style": "off",
|
||||
// 允许重复的 ID(某些框架会动态处理)
|
||||
"no-dup-id": "warn",
|
||||
// 允许未使用的 disable 指令
|
||||
"no-unused-disable": "off"
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2), "utf8");
|
||||
return true;
|
||||
} catch (error) {
|
||||
// 创建失败不影响检查,使用默认规则
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行 HTML 语法检查
|
||||
* @param {string} projectId 项目ID
|
||||
* @param {string} projectPath 项目路径
|
||||
* @param {number} timeoutMs 超时时间
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function runHtmlCheck(projectId, projectPath, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 查找 HTML 文件
|
||||
const htmlFiles = findHtmlFiles(projectPath);
|
||||
|
||||
if (htmlFiles.length === 0) {
|
||||
log(projectId, "INFO", "Skip HTML syntax check: no HTML files found", {
|
||||
projectId,
|
||||
});
|
||||
resolve({ passed: true, method: "html-skipped" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保有宽松的验证配置
|
||||
const configCreated = ensureHtmlValidateConfig(projectPath);
|
||||
if (configCreated) {
|
||||
log(projectId, "INFO", "Automatically create宽松的 HTML 验证配置", {
|
||||
projectId,
|
||||
configPath: ".htmlvalidate.json",
|
||||
});
|
||||
}
|
||||
|
||||
// 使用 html-validate 进行 HTML 语法检查
|
||||
const args = [
|
||||
"--yes",
|
||||
"html-validate",
|
||||
...htmlFiles,
|
||||
"--formatter=text",
|
||||
];
|
||||
|
||||
log(projectId, "INFO", "Run HTML syntax check", {
|
||||
projectId,
|
||||
htmlFiles,
|
||||
fileCount: htmlFiles.length,
|
||||
});
|
||||
|
||||
const child = spawn("npx", args, {
|
||||
cwd: projectPath,
|
||||
shell: true,
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (code === 0) {
|
||||
log(projectId, "INFO", "HTML syntax check passed", {
|
||||
projectId,
|
||||
duration,
|
||||
fileCount: htmlFiles.length,
|
||||
});
|
||||
resolve({ passed: true, method: "html", duration });
|
||||
} else {
|
||||
const errorOutput = stdout || stderr;
|
||||
const errorSummary = extractHtmlErrors(errorOutput);
|
||||
|
||||
log(projectId, "ERROR", "HTML syntax check failed", {
|
||||
projectId,
|
||||
code,
|
||||
duration,
|
||||
errorSummary,
|
||||
});
|
||||
|
||||
resolve({
|
||||
passed: false,
|
||||
method: "html",
|
||||
error: errorSummary,
|
||||
fullOutput: errorOutput.substring(0, 2000),
|
||||
duration,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
log(projectId, "WARN", "HTML check execution failed", {
|
||||
projectId,
|
||||
error: error.message,
|
||||
});
|
||||
// 执行失败不阻止启动
|
||||
resolve({ passed: true, method: "html-error", error: error.message });
|
||||
});
|
||||
|
||||
// 超时处理
|
||||
setTimeout(() => {
|
||||
try {
|
||||
child.kill();
|
||||
log(projectId, "WARN", "HTML check timeout", {
|
||||
projectId,
|
||||
timeoutMs,
|
||||
});
|
||||
resolve({ passed: true, method: "html-timeout" });
|
||||
} catch (e) {
|
||||
resolve({ passed: true, method: "html-timeout-error" });
|
||||
}
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找 HTML 文件
|
||||
* @param {string} projectPath 项目路径
|
||||
* @returns {Array<string>} HTML 文件相对路径列表
|
||||
*/
|
||||
function findHtmlFiles(projectPath) {
|
||||
const htmlFiles = [];
|
||||
|
||||
// 常见的 HTML 文件位置
|
||||
const possibleLocations = [
|
||||
"index.html",
|
||||
"public/index.html",
|
||||
"src/index.html",
|
||||
"dist/index.html",
|
||||
];
|
||||
|
||||
// 检查常见位置
|
||||
for (const location of possibleLocations) {
|
||||
const fullPath = path.join(projectPath, location);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
htmlFiles.push(location);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没找到,递归搜索 public 和 src 目录
|
||||
if (htmlFiles.length === 0) {
|
||||
const dirsToSearch = ["public", "src", "."];
|
||||
|
||||
for (const dir of dirsToSearch) {
|
||||
const dirPath = path.join(projectPath, dir);
|
||||
if (fs.existsSync(dirPath)) {
|
||||
const found = searchHtmlFilesInDir(dirPath, projectPath, 2);
|
||||
htmlFiles.push(...found);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 去重
|
||||
return [...new Set(htmlFiles)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归搜索目录中的 HTML 文件
|
||||
* @param {string} dir 要搜索的目录
|
||||
* @param {string} basePath 基础路径(用于生成相对路径)
|
||||
* @param {number} maxDepth 最大搜索深度
|
||||
* @returns {Array<string>} HTML 文件相对路径列表
|
||||
*/
|
||||
function searchHtmlFilesInDir(dir, basePath, maxDepth = 2) {
|
||||
const htmlFiles = [];
|
||||
const excludeDirs = ["node_modules", ".git", "dist", "build", ".next", ".nuxt"];
|
||||
|
||||
function search(currentDir, depth) {
|
||||
if (depth > maxDepth) return;
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (!excludeDirs.includes(entry.name)) {
|
||||
search(fullPath, depth + 1);
|
||||
}
|
||||
} else if (entry.isFile() && entry.name.endsWith(".html")) {
|
||||
// 生成相对路径
|
||||
const relativePath = path.relative(basePath, fullPath);
|
||||
htmlFiles.push(relativePath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略读取错误
|
||||
}
|
||||
}
|
||||
|
||||
search(dir, 0);
|
||||
return htmlFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 HTML 错误输出中提取关键错误信息
|
||||
* @param {string} output 错误输出
|
||||
* @returns {string} 错误摘要
|
||||
*/
|
||||
function extractHtmlErrors(output) {
|
||||
if (!output) return "Unknown error";
|
||||
|
||||
const lines = output.split("\n");
|
||||
const errorLines = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// 提取 html-validate 的错误行
|
||||
// 格式: "error: ..." 或 "✖ ..." 或包含文件路径和行号
|
||||
if (
|
||||
trimmed.includes("error:") ||
|
||||
trimmed.includes("✖") ||
|
||||
/\.html:\d+:\d+/.test(trimmed) ||
|
||||
trimmed.includes("Element") ||
|
||||
trimmed.includes("Attribute")
|
||||
) {
|
||||
errorLines.push(trimmed);
|
||||
if (errorLines.length >= 10) break; // 保留前10个错误
|
||||
}
|
||||
}
|
||||
|
||||
if (errorLines.length > 0) {
|
||||
return errorLines.join("\n");
|
||||
}
|
||||
|
||||
// 如果没有找到特定格式的错误,返回前几行
|
||||
return lines.slice(0, 15).join("\n").trim().substring(0, 800);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 TypeScript 错误输出中提取关键错误信息
|
||||
* @param {string} output 错误输出
|
||||
* @returns {string} 错误摘要
|
||||
*/
|
||||
function extractTypeScriptErrors(output) {
|
||||
if (!output) return "Unknown error";
|
||||
|
||||
const lines = output.split("\n");
|
||||
const errorLines = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// 提取错误行(包含文件路径和错误信息)
|
||||
if (line.includes("error TS") || line.includes("): error")) {
|
||||
errorLines.push(line.trim());
|
||||
if (errorLines.length >= 5) break; // 只保留前5个错误
|
||||
}
|
||||
}
|
||||
|
||||
if (errorLines.length > 0) {
|
||||
return errorLines.join("\n");
|
||||
}
|
||||
|
||||
// 如果没有找到特定格式的错误,返回前几行
|
||||
return lines.slice(0, 10).join("\n").trim().substring(0, 500);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runSyntaxCheck,
|
||||
detectProjectType,
|
||||
findHtmlFiles,
|
||||
ensureHtmlValidateConfig,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user