904 lines
27 KiB
TypeScript
904 lines
27 KiB
TypeScript
import * as http from "http";
|
||
import * as https from "https";
|
||
import * as fs from "fs";
|
||
import * as path from "path";
|
||
import * as zlib from "zlib";
|
||
|
||
// 配置文件路径
|
||
const CONFIG_FILE = path.join(__dirname, "config.json");
|
||
const API_LIST_FILE = path.join(__dirname, "mock", "api-list.json");
|
||
const MOCK_DIR = path.join(__dirname, "mock");
|
||
|
||
// 定义类型
|
||
interface RouteConfig {
|
||
[route: string]: string;
|
||
}
|
||
|
||
interface AppConfig {
|
||
/** 为 false 时所有请求走代理,不命中 mock 路由;缺省为 true */
|
||
mockEnabled?: boolean;
|
||
cacheConfig: boolean;
|
||
reloadOnChange: boolean;
|
||
defaultContentType: string;
|
||
proxyPort: number;
|
||
targetHost: string;
|
||
/** 代理转发的目标端口;缺省 HTTPS 为 443,HTTP 为 80 */
|
||
targetPort?: number;
|
||
/** 为 false 时用 HTTP 连接上游;缺省 true(HTTPS) */
|
||
targetHttps?: boolean;
|
||
}
|
||
|
||
interface ConfigFile {
|
||
routes: RouteConfig;
|
||
config: AppConfig;
|
||
}
|
||
|
||
interface ApiListItem {
|
||
name: string;
|
||
route: string;
|
||
}
|
||
|
||
interface MockFileItem {
|
||
filePath: string;
|
||
content: string;
|
||
}
|
||
|
||
// 存储当前的路由配置
|
||
let MOCK_ROUTES: RouteConfig = {};
|
||
let RAW_ROUTES: RouteConfig = {};
|
||
let CONFIG: AppConfig = {
|
||
cacheConfig: true,
|
||
reloadOnChange: true,
|
||
defaultContentType: "application/json",
|
||
proxyPort: 443,
|
||
targetHost: "localhost",
|
||
targetPort: 443,
|
||
};
|
||
|
||
// 过滤掉以 # 开头的路由(视为注释,不参与 mock)
|
||
function filterActiveRoutes(routes: RouteConfig): RouteConfig {
|
||
const filtered: RouteConfig = {};
|
||
for (const [route, filePath] of Object.entries(routes)) {
|
||
if (route.startsWith("#")) continue;
|
||
filtered[route] = filePath;
|
||
}
|
||
return filtered;
|
||
}
|
||
|
||
function isTargetHttps(): boolean {
|
||
return CONFIG.targetHttps !== false;
|
||
}
|
||
|
||
function getTargetPort(): number {
|
||
if (CONFIG.targetPort != null) return CONFIG.targetPort;
|
||
return isTargetHttps() ? 443 : 80;
|
||
}
|
||
|
||
function upstreamRequest(
|
||
options: http.RequestOptions,
|
||
callback: (proxyRes: http.IncomingMessage) => void,
|
||
): http.ClientRequest {
|
||
return isTargetHttps()
|
||
? https.request(options, callback)
|
||
: http.request(options, callback);
|
||
}
|
||
|
||
// 加载配置文件
|
||
function loadConfig() {
|
||
try {
|
||
if (!fs.existsSync(CONFIG_FILE)) {
|
||
console.warn(`[CONFIG] 配置文件不存在: ${CONFIG_FILE}`);
|
||
console.warn(`[CONFIG] 正在创建默认配置文件...`);
|
||
|
||
// 创建默认配置
|
||
const defaultConfig: ConfigFile = {
|
||
routes: {
|
||
"/api2/test": "mock/test.txt",
|
||
},
|
||
config: {
|
||
cacheConfig: true,
|
||
reloadOnChange: true,
|
||
defaultContentType: "application/json",
|
||
proxyPort: 443,
|
||
targetHost: "localhost",
|
||
targetPort: 443,
|
||
},
|
||
};
|
||
|
||
// 确保mock目录存在
|
||
const mockDir = path.join(__dirname, "mock");
|
||
if (!fs.existsSync(mockDir)) {
|
||
fs.mkdirSync(mockDir, { recursive: true });
|
||
}
|
||
|
||
// 保存配置文件
|
||
fs.writeFileSync(
|
||
CONFIG_FILE,
|
||
JSON.stringify(defaultConfig, null, 2),
|
||
"utf-8",
|
||
);
|
||
console.log(`[CONFIG] 已创建默认配置文件: ${CONFIG_FILE}`);
|
||
|
||
// 加载配置(# 开头的路由视为注释,不参与 mock)
|
||
const configData: ConfigFile = JSON.parse(
|
||
fs.readFileSync(CONFIG_FILE, "utf-8"),
|
||
);
|
||
RAW_ROUTES = configData.routes || {};
|
||
MOCK_ROUTES = filterActiveRoutes(RAW_ROUTES);
|
||
CONFIG = configData.config || CONFIG;
|
||
} else {
|
||
const configData: ConfigFile = JSON.parse(
|
||
fs.readFileSync(CONFIG_FILE, "utf-8"),
|
||
);
|
||
RAW_ROUTES = configData.routes || {};
|
||
MOCK_ROUTES = filterActiveRoutes(RAW_ROUTES);
|
||
CONFIG = configData.config || CONFIG;
|
||
console.log(
|
||
`[CONFIG] 配置文件已加载,共 ${Object.keys(MOCK_ROUTES).length} 个路由${CONFIG.mockEnabled !== false ? "" : "(mock 已关闭,全部走代理)"}`,
|
||
);
|
||
}
|
||
|
||
// 验证mock文件是否存在
|
||
validateMockFiles();
|
||
} catch (error) {
|
||
console.error(`[CONFIG] 加载配置文件失败:`, error);
|
||
// 使用默认配置
|
||
MOCK_ROUTES = {
|
||
"/api2/user/list": "mock/user_list.txt",
|
||
};
|
||
RAW_ROUTES = { ...MOCK_ROUTES };
|
||
CONFIG = {
|
||
cacheConfig: true,
|
||
reloadOnChange: true,
|
||
defaultContentType: "application/json",
|
||
proxyPort: 9443,
|
||
targetHost: "devrmtapp.resmart.cn",
|
||
targetPort: 443,
|
||
};
|
||
}
|
||
}
|
||
|
||
function buildCurrentConfigFile(): ConfigFile {
|
||
return {
|
||
routes: { ...RAW_ROUTES },
|
||
config: { ...CONFIG },
|
||
};
|
||
}
|
||
|
||
function saveConfigFile(nextConfig: ConfigFile): void {
|
||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(nextConfig, null, 2), "utf-8");
|
||
RAW_ROUTES = nextConfig.routes || {};
|
||
MOCK_ROUTES = filterActiveRoutes(RAW_ROUTES);
|
||
CONFIG = nextConfig.config || CONFIG;
|
||
}
|
||
|
||
function loadApiList(): ApiListItem[] {
|
||
if (!fs.existsSync(API_LIST_FILE)) {
|
||
return [];
|
||
}
|
||
try {
|
||
const text = fs.readFileSync(API_LIST_FILE, "utf-8").trim();
|
||
if (!text) {
|
||
return [];
|
||
}
|
||
const parsed = JSON.parse(text);
|
||
if (!Array.isArray(parsed)) {
|
||
return [];
|
||
}
|
||
const seenRoutes = new Set<string>();
|
||
const list: ApiListItem[] = [];
|
||
for (const item of parsed) {
|
||
const route = String(item?.route || "").trim();
|
||
const name = String(item?.name || "").trim();
|
||
if (!route || !route.startsWith("/") || !name || seenRoutes.has(route)) {
|
||
continue;
|
||
}
|
||
seenRoutes.add(route);
|
||
list.push({ name, route });
|
||
}
|
||
return list;
|
||
} catch (error) {
|
||
console.warn("[ADMIN] 读取 api-list.json 失败:", error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function normalizeMockFilePath(filePath: string): string {
|
||
const trimmed = filePath.trim().replace(/\\/g, "/");
|
||
if (!trimmed) {
|
||
throw new Error("filePath is required");
|
||
}
|
||
const relative = trimmed.startsWith("mock/") ? trimmed : `mock/${trimmed}`;
|
||
if (path.isAbsolute(relative)) {
|
||
throw new Error("filePath must be a relative path");
|
||
}
|
||
return relative;
|
||
}
|
||
|
||
function resolveMockFullPath(relativePath: string): string {
|
||
const normalized = normalizeMockFilePath(relativePath);
|
||
const fullPath = path.resolve(__dirname, normalized);
|
||
const mockRoot = path.resolve(MOCK_DIR);
|
||
if (!fullPath.startsWith(mockRoot)) {
|
||
throw new Error("filePath is invalid");
|
||
}
|
||
if (path.basename(fullPath) === "api-list.json") {
|
||
throw new Error("api-list.json is read-only in this panel");
|
||
}
|
||
return fullPath;
|
||
}
|
||
|
||
function walkMockFiles(dir: string, baseDir: string, result: string[]): void {
|
||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||
for (const entry of entries) {
|
||
const fullPath = path.join(dir, entry.name);
|
||
if (entry.isDirectory()) {
|
||
walkMockFiles(fullPath, baseDir, result);
|
||
continue;
|
||
}
|
||
if (!entry.isFile()) continue;
|
||
if (entry.name === "api-list.json") continue;
|
||
const relative = path.relative(baseDir, fullPath).replace(/\\/g, "/");
|
||
result.push(`mock/${relative}`);
|
||
}
|
||
}
|
||
|
||
function loadMockFiles(): MockFileItem[] {
|
||
if (!fs.existsSync(MOCK_DIR)) {
|
||
return [];
|
||
}
|
||
const files: string[] = [];
|
||
walkMockFiles(MOCK_DIR, MOCK_DIR, files);
|
||
files.sort((a, b) => a.localeCompare(b));
|
||
return files.map((filePath) => {
|
||
const fullPath = path.join(__dirname, filePath);
|
||
return {
|
||
filePath,
|
||
content: fs.readFileSync(fullPath, "utf-8"),
|
||
};
|
||
});
|
||
}
|
||
|
||
function readBody(req: http.IncomingMessage): Promise<string> {
|
||
return new Promise((resolve, reject) => {
|
||
const chunks: Buffer[] = [];
|
||
req.on("data", (chunk: Buffer) => {
|
||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||
});
|
||
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
||
req.on("error", reject);
|
||
});
|
||
}
|
||
|
||
// 验证mock文件是否存在
|
||
function validateMockFiles() {
|
||
console.log(`[CONFIG] 验证mock文件...`);
|
||
let missingFiles: Array<{
|
||
route: string;
|
||
filePath: string;
|
||
fullPath: string;
|
||
}> = [];
|
||
|
||
for (const [route, filePath] of Object.entries(MOCK_ROUTES)) {
|
||
const fullPath = path.join(__dirname, filePath);
|
||
if (!fs.existsSync(fullPath)) {
|
||
missingFiles.push({ route, filePath, fullPath });
|
||
console.warn(
|
||
`[CONFIG] 警告: mock文件不存在 - ${filePath} (用于路由: ${route})`,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (missingFiles.length > 0) {
|
||
console.log(
|
||
`[CONFIG] 缺少 ${missingFiles.length} 个mock文件,请创建这些文件`,
|
||
);
|
||
} else {
|
||
console.log(`[CONFIG] 所有mock文件验证通过`);
|
||
}
|
||
}
|
||
|
||
// 检查是否为mock路由的函数
|
||
function isMockRoute(requestPath: string): boolean {
|
||
if (CONFIG.mockEnabled === false) return false;
|
||
return MOCK_ROUTES.hasOwnProperty(requestPath);
|
||
}
|
||
|
||
// 获取mock文件路径
|
||
function getMockFilePath(requestPath: string): string {
|
||
return MOCK_ROUTES[requestPath];
|
||
}
|
||
|
||
function decodeBodyByEncoding(
|
||
bodyBuffer: Buffer,
|
||
contentEncoding?: string,
|
||
): Buffer {
|
||
const encoding = (contentEncoding || "").toLowerCase().trim();
|
||
try {
|
||
if (encoding.includes("gzip")) {
|
||
return zlib.gunzipSync(bodyBuffer);
|
||
}
|
||
if (encoding.includes("br")) {
|
||
return zlib.brotliDecompressSync(bodyBuffer);
|
||
}
|
||
if (encoding.includes("deflate")) {
|
||
return zlib.inflateSync(bodyBuffer);
|
||
}
|
||
} catch (error) {
|
||
console.warn("[MOCK] 解压上游响应失败,按原始内容写入文件", error);
|
||
}
|
||
return bodyBuffer;
|
||
}
|
||
|
||
// 代理服务器
|
||
const proxyServer = http.createServer((clientReq, clientRes) => {
|
||
// 解析客户端请求的 URL
|
||
const parsedUrl = new URL(`http://localhost${clientReq.url!}`);
|
||
const requestPath = parsedUrl.pathname;
|
||
|
||
// 管理接口:保留路径,不参与代理转发
|
||
if (requestPath === "/__reload-config") {
|
||
if (clientReq.method !== "POST") {
|
||
clientRes.writeHead(405, {
|
||
"Content-Type": "application/json",
|
||
Allow: "POST",
|
||
});
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
success: false,
|
||
error: "Method Not Allowed",
|
||
allow: ["POST"],
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const oldCount = Object.keys(MOCK_ROUTES).length;
|
||
loadConfig();
|
||
const newCount = Object.keys(MOCK_ROUTES).length;
|
||
|
||
clientRes.writeHead(200, { "Content-Type": "application/json" });
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
success: true,
|
||
message: "Configuration reloaded successfully",
|
||
routesCount: newCount,
|
||
routesChanged: newCount - oldCount,
|
||
}),
|
||
);
|
||
console.log(
|
||
`[ADMIN] 通过API重新加载配置 (路由数: ${oldCount} -> ${newCount})`,
|
||
);
|
||
} catch (error) {
|
||
clientRes.writeHead(500, { "Content-Type": "application/json" });
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
success: false,
|
||
error: (error as Error).message,
|
||
}),
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (requestPath === "/__config") {
|
||
if (clientReq.method !== "GET" && clientReq.method !== "POST") {
|
||
clientRes.writeHead(405, {
|
||
"Content-Type": "application/json",
|
||
Allow: "GET, POST",
|
||
});
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
success: false,
|
||
error: "Method Not Allowed",
|
||
allow: ["GET", "POST"],
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (clientReq.method === "POST") {
|
||
readBody(clientReq)
|
||
.then((bodyText) => {
|
||
const body = bodyText ? JSON.parse(bodyText) : {};
|
||
const nextConfig: ConfigFile = {
|
||
routes: body.routes || {},
|
||
config: body.config || CONFIG,
|
||
};
|
||
saveConfigFile(nextConfig);
|
||
validateMockFiles();
|
||
clientRes.writeHead(200, { "Content-Type": "application/json" });
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
success: true,
|
||
message: "Configuration saved successfully",
|
||
totalRoutes: Object.keys(MOCK_ROUTES).length,
|
||
}),
|
||
);
|
||
})
|
||
.catch((error) => {
|
||
clientRes.writeHead(400, { "Content-Type": "application/json" });
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
success: false,
|
||
error: (error as Error).message,
|
||
}),
|
||
);
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
clientRes.writeHead(200, { "Content-Type": "application/json" });
|
||
clientRes.end(
|
||
JSON.stringify(
|
||
{
|
||
routes: RAW_ROUTES,
|
||
config: CONFIG,
|
||
timestamp: new Date().toISOString(),
|
||
totalRoutes: Object.keys(MOCK_ROUTES).length,
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
);
|
||
} catch (error) {
|
||
clientRes.writeHead(500, { "Content-Type": "application/json" });
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
success: false,
|
||
error: (error as Error).message,
|
||
}),
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (requestPath === "/__routes") {
|
||
if (clientReq.method !== "POST") {
|
||
clientRes.writeHead(405, {
|
||
"Content-Type": "application/json",
|
||
Allow: "POST",
|
||
});
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
success: false,
|
||
error: "Method Not Allowed",
|
||
allow: ["POST"],
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
readBody(clientReq)
|
||
.then((bodyText) => {
|
||
const body = bodyText ? JSON.parse(bodyText) : {};
|
||
let route = String(body.route || "").trim();
|
||
const filePath = String(body.filePath || "").trim();
|
||
const fileContent = String(body.fileContent || "");
|
||
const overwrite = body.overwrite === true;
|
||
const template = String(body.template || "").trim();
|
||
const apiName = String(body.apiName || "").trim();
|
||
const selectedApiRoute = String(body.selectedApiRoute || "").trim();
|
||
const originalRoute = String(body.originalRoute || "").trim();
|
||
const originalRawRoute = String(body.originalRawRoute || "").trim();
|
||
const enabled = body.enabled !== false;
|
||
const useExistingFile =
|
||
body.useExistingFile === true || template === "basicError";
|
||
const apiList = loadApiList();
|
||
const selectedApi = selectedApiRoute
|
||
? apiList.find((item) => item.route === selectedApiRoute)
|
||
: undefined;
|
||
const originalApi = originalRoute
|
||
? apiList.find((item) => item.route === originalRoute)
|
||
: undefined;
|
||
|
||
// 来自 api-list 的接口路由不可在新增/修改时变更
|
||
if (selectedApi) {
|
||
if (route && route !== selectedApi.route) {
|
||
throw new Error("api-list route cannot be changed");
|
||
}
|
||
if (apiName && apiName !== selectedApi.name) {
|
||
throw new Error("api-list name cannot be changed");
|
||
}
|
||
route = selectedApi.route;
|
||
}
|
||
if (originalApi && route !== originalApi.route) {
|
||
throw new Error("api-list route cannot be changed");
|
||
}
|
||
if (originalApi && apiName && apiName !== originalApi.name) {
|
||
throw new Error("api-list name cannot be changed");
|
||
}
|
||
|
||
if (!route.startsWith("/")) {
|
||
throw new Error("route must start with '/'");
|
||
}
|
||
const normalizedFilePath = normalizeMockFilePath(filePath);
|
||
const fullPath = resolveMockFullPath(normalizedFilePath);
|
||
if (useExistingFile) {
|
||
if (!fs.existsSync(fullPath)) {
|
||
throw new Error("mock file does not exist");
|
||
}
|
||
} else {
|
||
if (!overwrite && fs.existsSync(fullPath)) {
|
||
throw new Error(
|
||
"mock file already exists, set overwrite=true to replace",
|
||
);
|
||
}
|
||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||
fs.writeFileSync(fullPath, fileContent, "utf-8");
|
||
}
|
||
|
||
const nextConfig = buildCurrentConfigFile();
|
||
const nextRouteKey = enabled ? route : `#${route}`;
|
||
const oldRouteCandidates = new Set<string>();
|
||
if (originalRawRoute) {
|
||
oldRouteCandidates.add(originalRawRoute);
|
||
}
|
||
if (originalRoute) {
|
||
oldRouteCandidates.add(originalRoute);
|
||
oldRouteCandidates.add(`#${originalRoute}`);
|
||
}
|
||
oldRouteCandidates.forEach((key) => {
|
||
if (key && key !== nextRouteKey) {
|
||
delete nextConfig.routes[key];
|
||
}
|
||
});
|
||
nextConfig.routes[nextRouteKey] = normalizedFilePath;
|
||
saveConfigFile(nextConfig);
|
||
|
||
clientRes.writeHead(200, { "Content-Type": "application/json" });
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
success: true,
|
||
message: "Route and mock file created successfully",
|
||
route,
|
||
routeKey: nextRouteKey,
|
||
filePath: normalizedFilePath,
|
||
enabled,
|
||
}),
|
||
);
|
||
})
|
||
.catch((error) => {
|
||
clientRes.writeHead(400, { "Content-Type": "application/json" });
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
success: false,
|
||
error: (error as Error).message,
|
||
}),
|
||
);
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (requestPath === "/__api-list") {
|
||
if (clientReq.method !== "GET") {
|
||
clientRes.writeHead(405, {
|
||
"Content-Type": "application/json",
|
||
Allow: "GET",
|
||
});
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
success: false,
|
||
error: "Method Not Allowed",
|
||
allow: ["GET"],
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
clientRes.writeHead(200, { "Content-Type": "application/json" });
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
success: true,
|
||
list: loadApiList(),
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (requestPath === "/__mock-files") {
|
||
if (
|
||
clientReq.method !== "GET" &&
|
||
clientReq.method !== "POST" &&
|
||
clientReq.method !== "DELETE"
|
||
) {
|
||
clientRes.writeHead(405, {
|
||
"Content-Type": "application/json",
|
||
Allow: "GET, POST, DELETE",
|
||
});
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
success: false,
|
||
error: "Method Not Allowed",
|
||
allow: ["GET", "POST", "DELETE"],
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (clientReq.method === "GET") {
|
||
clientRes.writeHead(200, { "Content-Type": "application/json" });
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
success: true,
|
||
list: loadMockFiles(),
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
readBody(clientReq)
|
||
.then((bodyText) => {
|
||
const body = bodyText ? JSON.parse(bodyText) : {};
|
||
const filePath = String(body.filePath || "").trim();
|
||
const fullPath = resolveMockFullPath(filePath);
|
||
const normalizedPath = normalizeMockFilePath(filePath);
|
||
|
||
if (clientReq.method === "POST") {
|
||
const content = String(body.content || "");
|
||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||
fs.writeFileSync(fullPath, content, "utf-8");
|
||
clientRes.writeHead(200, { "Content-Type": "application/json" });
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
success: true,
|
||
filePath: normalizedPath,
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
// DELETE
|
||
if (!fs.existsSync(fullPath)) {
|
||
throw new Error("mock file does not exist");
|
||
}
|
||
fs.unlinkSync(fullPath);
|
||
clientRes.writeHead(200, { "Content-Type": "application/json" });
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
success: true,
|
||
filePath: normalizedPath,
|
||
}),
|
||
);
|
||
})
|
||
.catch((error) => {
|
||
clientRes.writeHead(400, { "Content-Type": "application/json" });
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
success: false,
|
||
error: (error as Error).message,
|
||
}),
|
||
);
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (requestPath === "/__admin") {
|
||
if (clientReq.method !== "GET") {
|
||
clientRes.writeHead(405, { "Content-Type": "text/plain; charset=utf-8" });
|
||
clientRes.end("Method Not Allowed");
|
||
return;
|
||
}
|
||
const adminHtmlPath = path.join(__dirname, "admin.html");
|
||
if (!fs.existsSync(adminHtmlPath)) {
|
||
clientRes.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||
clientRes.end("admin.html not found");
|
||
return;
|
||
}
|
||
const html = fs.readFileSync(adminHtmlPath, "utf-8");
|
||
clientRes.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||
clientRes.end(html);
|
||
return;
|
||
}
|
||
|
||
// 检查是否为需要mock的路由
|
||
if (isMockRoute(requestPath)) {
|
||
const mockFile = getMockFilePath(requestPath);
|
||
console.log(`[MOCK] 拦截路由: ${requestPath} -> 使用文件: ${mockFile}`);
|
||
|
||
try {
|
||
// 构建完整的文件路径
|
||
const mockFilePath = path.join(__dirname, mockFile);
|
||
|
||
// 检查文件是否存在
|
||
if (!fs.existsSync(mockFilePath)) {
|
||
console.warn(`[MOCK] Mock文件不存在,回源并自动生成: ${mockFilePath}`);
|
||
|
||
const targetPort = getTargetPort();
|
||
const options: http.RequestOptions = {
|
||
hostname: CONFIG.targetHost,
|
||
port: targetPort,
|
||
method: clientReq.method,
|
||
path: parsedUrl.pathname + parsedUrl.search,
|
||
headers: {
|
||
...clientReq.headers,
|
||
host: CONFIG.targetHost,
|
||
},
|
||
};
|
||
|
||
const proxyReq = upstreamRequest(options, (proxyRes) => {
|
||
const chunks: Buffer[] = [];
|
||
proxyRes.on("data", (chunk: Buffer) => {
|
||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||
});
|
||
|
||
proxyRes.on("end", () => {
|
||
const bodyBuffer = Buffer.concat(chunks);
|
||
|
||
try {
|
||
const decodedBuffer = decodeBodyByEncoding(
|
||
bodyBuffer,
|
||
Array.isArray(proxyRes.headers["content-encoding"])
|
||
? proxyRes.headers["content-encoding"][0]
|
||
: proxyRes.headers["content-encoding"],
|
||
);
|
||
const contentType = String(proxyRes.headers["content-type"] || "");
|
||
const toWrite =
|
||
contentType.includes("application/json") ||
|
||
contentType.includes("text/") ||
|
||
contentType.includes("application/xml") ||
|
||
contentType.includes("application/javascript")
|
||
? decodedBuffer.toString("utf-8")
|
||
: decodedBuffer;
|
||
fs.mkdirSync(path.dirname(mockFilePath), { recursive: true });
|
||
fs.writeFileSync(mockFilePath, toWrite);
|
||
console.log(`[MOCK] 已自动写入mock文件: ${mockFilePath}`);
|
||
} catch (writeErr) {
|
||
console.error(`[MOCK] 自动写入mock文件失败: ${mockFilePath}`, writeErr);
|
||
}
|
||
|
||
clientRes.writeHead(proxyRes.statusCode || 200, {
|
||
...proxyRes.headers,
|
||
"X-Mock-Autogenerated": "true",
|
||
"X-Mock-Source": mockFile,
|
||
});
|
||
clientRes.end(bodyBuffer);
|
||
});
|
||
});
|
||
|
||
proxyReq.on("error", (err) => {
|
||
console.error("Proxy request error:", err);
|
||
clientRes.writeHead(500, { "Content-Type": "application/json" });
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
error: "Proxy error",
|
||
route: requestPath,
|
||
file: mockFile,
|
||
message: err.message,
|
||
timestamp: new Date().toISOString(),
|
||
}),
|
||
);
|
||
});
|
||
|
||
clientReq.pipe(proxyReq);
|
||
return;
|
||
}
|
||
|
||
// 读取本地mock文件
|
||
const mockData = fs.readFileSync(mockFilePath, "utf-8");
|
||
|
||
// 设置响应头
|
||
const contentType = CONFIG.defaultContentType || "application/json";
|
||
clientRes.writeHead(200, {
|
||
"Content-Type": contentType,
|
||
"Access-Control-Allow-Origin": "*",
|
||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||
"Access-Control-Allow-Headers": "Content-Type",
|
||
"X-Mock-Source": mockFile,
|
||
"X-Mock-Timestamp": new Date().toISOString(),
|
||
});
|
||
|
||
// 返回mock数据
|
||
clientRes.end(mockData);
|
||
console.log(`[MOCK] 成功返回mock数据: ${mockFile}`);
|
||
} catch (error) {
|
||
console.error(`[MOCK] 读取mock文件失败: ${mockFile}`, error);
|
||
clientRes.writeHead(500, { "Content-Type": "application/json" });
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
error: "Failed to read mock data",
|
||
route: requestPath,
|
||
file: mockFile,
|
||
message: (error as Error).message,
|
||
timestamp: new Date().toISOString(),
|
||
}),
|
||
);
|
||
}
|
||
return; // 直接返回,不转发到目标服务器
|
||
}
|
||
|
||
// 如果不是mock路由,正常代理转发
|
||
console.log(`[PROXY] 转发请求: ${requestPath}`);
|
||
|
||
const targetPort = getTargetPort();
|
||
|
||
// 目标服务器的选项
|
||
const options: http.RequestOptions = {
|
||
hostname: CONFIG.targetHost,
|
||
port: targetPort,
|
||
method: clientReq.method,
|
||
path: parsedUrl.pathname + parsedUrl.search,
|
||
headers: {
|
||
...clientReq.headers,
|
||
host: CONFIG.targetHost,
|
||
},
|
||
};
|
||
|
||
const proxyReq = upstreamRequest(options, (proxyRes) => {
|
||
// 将目标服务器的响应头复制到客户端响应
|
||
clientRes.writeHead(proxyRes.statusCode!, proxyRes.headers);
|
||
// 将目标服务器的响应数据管道传输到客户端
|
||
proxyRes.pipe(clientRes);
|
||
});
|
||
|
||
// 错误处理
|
||
proxyReq.on("error", (err) => {
|
||
console.error("Proxy request error:", err);
|
||
clientRes.writeHead(500, { "Content-Type": "application/json" });
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
error: "Proxy error",
|
||
message: err.message,
|
||
timestamp: new Date().toISOString(),
|
||
}),
|
||
);
|
||
});
|
||
|
||
// 将客户端请求体管道传输到代理请求
|
||
clientReq.pipe(proxyReq);
|
||
});
|
||
|
||
// 初始化:第一次加载配置
|
||
loadConfig();
|
||
|
||
// 如果配置了文件监视,则监听配置文件变化
|
||
if (CONFIG.reloadOnChange) {
|
||
fs.watchFile(CONFIG_FILE, (curr, prev) => {
|
||
console.log(`[CONFIG] 配置文件已修改,重新加载...`);
|
||
try {
|
||
const oldRoutesCount = Object.keys(MOCK_ROUTES).length;
|
||
loadConfig();
|
||
const newRoutesCount = Object.keys(MOCK_ROUTES).length;
|
||
console.log(
|
||
`[CONFIG] 配置重载完成 (路由数: ${oldRoutesCount} -> ${newRoutesCount})`,
|
||
);
|
||
} catch (error) {
|
||
console.error(`[CONFIG] 重新加载配置文件失败:`, error);
|
||
}
|
||
});
|
||
console.log(`[CONFIG] 已启用配置文件监视: ${CONFIG_FILE}`);
|
||
}
|
||
|
||
proxyServer.listen(CONFIG.proxyPort, "0.0.0.0", () => {
|
||
const targetPort = getTargetPort();
|
||
const proto = isTargetHttps() ? "https" : "http";
|
||
const defaultPort = isTargetHttps() ? 443 : 80;
|
||
console.log(`========================================`);
|
||
console.log(`代理服务器运行在: http://localhost:${CONFIG.proxyPort}`);
|
||
console.log(
|
||
`目标服务器: ${proto}://${CONFIG.targetHost}${targetPort !== defaultPort ? `:${targetPort}` : ""}`,
|
||
);
|
||
console.log(`配置文件: ${CONFIG_FILE}`);
|
||
console.log(
|
||
`已配置Mock路由: ${Object.keys(MOCK_ROUTES).length} 个${CONFIG.mockEnabled !== false ? "" : "(当前 mockEnabled=false,未生效)"}`,
|
||
);
|
||
console.log(`========================================`);
|
||
console.log(`管理接口:`);
|
||
console.log(
|
||
` GET http://localhost:${CONFIG.proxyPort}/__config 查看当前配置`,
|
||
);
|
||
console.log(
|
||
` POST http://localhost:${CONFIG.proxyPort}/__reload-config 重新加载配置`,
|
||
);
|
||
console.log(`========================================`);
|
||
console.log(`Mock路由列表:`);
|
||
|
||
for (const [route, file] of Object.entries(MOCK_ROUTES)) {
|
||
const filePath = path.join(__dirname, file);
|
||
const exists = fs.existsSync(filePath) ? "✓" : "✗";
|
||
console.log(` ${exists} ${route} -> ${file}`);
|
||
}
|
||
console.log(`========================================`);
|
||
});
|