产品化改造:完善本地集成与启动体验

This commit is contained in:
baiyanyun
2026-06-02 17:55:08 +08:00
parent a280774f50
commit 873cd6ef53
24 changed files with 3490 additions and 45 deletions

View 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 };

View 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 };

View 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,
};

View 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 };

View 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 };

View 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 };

View 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,
};