1196 lines
36 KiB
TypeScript
1196 lines
36 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";
|
||
import sqlite3 from "sqlite3";
|
||
|
||
// 配置文件路径
|
||
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");
|
||
const DATA_DIR = path.join(__dirname, "data");
|
||
const DB_FILE = path.join(DATA_DIR, "mock-mappings.sqlite3");
|
||
const LEGACY_DB_FILE = path.join(MOCK_DIR, "mock-mappings.sqlite3");
|
||
|
||
// 定义类型
|
||
interface RouteConfig {
|
||
[route: string]: string;
|
||
}
|
||
|
||
interface RouteStatusConfig {
|
||
[route: string]: number;
|
||
}
|
||
|
||
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;
|
||
routeStatuses?: RouteStatusConfig;
|
||
config: AppConfig;
|
||
}
|
||
|
||
interface ApiListItem {
|
||
name: string;
|
||
route: string;
|
||
}
|
||
|
||
interface MockFileItem {
|
||
filePath: string;
|
||
alias: string;
|
||
content: string;
|
||
}
|
||
|
||
interface RouteRow {
|
||
route_key: string;
|
||
file_path: string;
|
||
status_code: number;
|
||
}
|
||
|
||
interface MockFileRow {
|
||
file_path: string;
|
||
alias: string;
|
||
}
|
||
|
||
// 存储当前的路由配置
|
||
let MOCK_ROUTES: RouteConfig = {};
|
||
let RAW_ROUTES: RouteConfig = {};
|
||
let MOCK_ROUTE_STATUSES: RouteStatusConfig = {};
|
||
let RAW_ROUTE_STATUSES: RouteStatusConfig = {};
|
||
let CONFIG: AppConfig = {
|
||
cacheConfig: true,
|
||
reloadOnChange: true,
|
||
defaultContentType: "application/json",
|
||
proxyPort: 443,
|
||
targetHost: "localhost",
|
||
targetPort: 443,
|
||
};
|
||
let DB: sqlite3.Database;
|
||
|
||
// 过滤掉以 # 开头的路由(视为注释,不参与 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 filterActiveRouteStatuses(statuses: RouteStatusConfig): RouteStatusConfig {
|
||
const filtered: RouteStatusConfig = {};
|
||
for (const [route, statusCode] of Object.entries(statuses)) {
|
||
if (route.startsWith("#")) continue;
|
||
filtered[route] = statusCode;
|
||
}
|
||
return filtered;
|
||
}
|
||
|
||
function normalizeStatusCode(input: unknown, fallback = 200): number {
|
||
const numeric =
|
||
typeof input === "number" ? input : Number.parseInt(String(input ?? ""), 10);
|
||
if (Number.isInteger(numeric) && numeric >= 100 && numeric <= 599) {
|
||
return numeric;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
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 dbRun(sql: string, params: unknown[] = []): Promise<void> {
|
||
return new Promise((resolve, reject) => {
|
||
DB.run(sql, params, (error) => {
|
||
if (error) {
|
||
reject(error);
|
||
return;
|
||
}
|
||
resolve();
|
||
});
|
||
});
|
||
}
|
||
|
||
function dbAll<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
|
||
return new Promise((resolve, reject) => {
|
||
DB.all(sql, params, (error, rows) => {
|
||
if (error) {
|
||
reject(error);
|
||
return;
|
||
}
|
||
resolve((rows as T[]) || []);
|
||
});
|
||
});
|
||
}
|
||
|
||
function openDatabase(): Promise<void> {
|
||
return new Promise((resolve, reject) => {
|
||
DB = new sqlite3.Database(DB_FILE, (error) => {
|
||
if (error) {
|
||
reject(error);
|
||
return;
|
||
}
|
||
resolve();
|
||
});
|
||
});
|
||
}
|
||
|
||
async function initDatabase(): Promise<void> {
|
||
fs.mkdirSync(MOCK_DIR, { recursive: true });
|
||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||
if (!fs.existsSync(DB_FILE) && fs.existsSync(LEGACY_DB_FILE)) {
|
||
fs.copyFileSync(LEGACY_DB_FILE, DB_FILE);
|
||
}
|
||
await openDatabase();
|
||
await dbRun(`
|
||
CREATE TABLE IF NOT EXISTS route_mappings (
|
||
route_key TEXT PRIMARY KEY,
|
||
file_path TEXT NOT NULL,
|
||
status_code INTEGER NOT NULL DEFAULT 200
|
||
)
|
||
`);
|
||
await dbRun(
|
||
"ALTER TABLE route_mappings ADD COLUMN status_code INTEGER NOT NULL DEFAULT 200",
|
||
).catch(() => {
|
||
// ignore when column already exists
|
||
});
|
||
await dbRun(`
|
||
CREATE TABLE IF NOT EXISTS api_list (
|
||
route TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL
|
||
)
|
||
`);
|
||
await dbRun(`
|
||
CREATE TABLE IF NOT EXISTS mock_files (
|
||
file_path TEXT PRIMARY KEY,
|
||
alias TEXT NOT NULL DEFAULT ''
|
||
)
|
||
`);
|
||
await dbRun("ALTER TABLE mock_files ADD COLUMN alias TEXT NOT NULL DEFAULT ''").catch(
|
||
() => {
|
||
// ignore when column already exists
|
||
},
|
||
);
|
||
// 清理不该出现在 mock 列表中的系统文件
|
||
await dbRun(
|
||
"DELETE FROM mock_files WHERE file_path = ? OR file_path LIKE ?",
|
||
["mock/api-list.json", "%.sqlite3"],
|
||
);
|
||
}
|
||
|
||
async function saveRoutesToDb(
|
||
routes: RouteConfig,
|
||
routeStatuses: RouteStatusConfig = {},
|
||
): Promise<void> {
|
||
await dbRun("DELETE FROM route_mappings");
|
||
for (const [routeKey, filePath] of Object.entries(routes)) {
|
||
const statusCode = normalizeStatusCode(routeStatuses[routeKey], 200);
|
||
await dbRun(
|
||
"INSERT INTO route_mappings(route_key, file_path, status_code) VALUES(?, ?, ?)",
|
||
[routeKey, filePath, statusCode],
|
||
);
|
||
await dbRun("INSERT OR IGNORE INTO mock_files(file_path) VALUES(?)", [
|
||
filePath,
|
||
]);
|
||
}
|
||
}
|
||
|
||
async function loadRoutesFromDb(): Promise<{
|
||
routes: RouteConfig;
|
||
routeStatuses: RouteStatusConfig;
|
||
}> {
|
||
const rows = await dbAll<RouteRow>(
|
||
"SELECT route_key, file_path, status_code FROM route_mappings ORDER BY route_key ASC",
|
||
);
|
||
const routes: RouteConfig = {};
|
||
const routeStatuses: RouteStatusConfig = {};
|
||
for (const row of rows) {
|
||
routes[row.route_key] = row.file_path;
|
||
routeStatuses[row.route_key] = normalizeStatusCode(row.status_code, 200);
|
||
}
|
||
return { routes, routeStatuses };
|
||
}
|
||
|
||
async function upsertApiListToDb(list: ApiListItem[]): Promise<void> {
|
||
await dbRun("DELETE FROM api_list");
|
||
for (const item of list) {
|
||
await dbRun("INSERT INTO api_list(route, name) VALUES(?, ?)", [
|
||
item.route,
|
||
item.name,
|
||
]);
|
||
}
|
||
}
|
||
|
||
async function loadApiListFromDb(): Promise<ApiListItem[]> {
|
||
const rows = await dbAll<{ route: string; name: string }>(
|
||
"SELECT route, name FROM api_list ORDER BY route ASC",
|
||
);
|
||
return rows.map((row) => ({ route: row.route, name: row.name }));
|
||
}
|
||
|
||
async function upsertMockFilePathToDb(filePath: string, alias?: string): Promise<void> {
|
||
if (typeof alias === "string") {
|
||
await dbRun(
|
||
"INSERT INTO mock_files(file_path, alias) VALUES(?, ?) ON CONFLICT(file_path) DO UPDATE SET alias = excluded.alias",
|
||
[filePath, alias],
|
||
);
|
||
return;
|
||
}
|
||
await dbRun("INSERT OR IGNORE INTO mock_files(file_path, alias) VALUES(?, '')", [
|
||
filePath,
|
||
]);
|
||
}
|
||
|
||
async function removeMockFilePathFromDb(filePath: string): Promise<void> {
|
||
await dbRun("DELETE FROM mock_files WHERE file_path = ?", [filePath]);
|
||
}
|
||
|
||
async function loadMockFilePathsFromDb(): Promise<MockFileRow[]> {
|
||
const rows = await dbAll<MockFileRow>(
|
||
"SELECT file_path, alias FROM mock_files ORDER BY file_path ASC",
|
||
);
|
||
return rows.map((row) => ({
|
||
file_path: row.file_path,
|
||
alias: String(row.alias || ""),
|
||
}));
|
||
}
|
||
|
||
// 加载配置文件
|
||
async 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}`);
|
||
|
||
// 加载配置,路由最终以 sqlite 为准
|
||
const configData: ConfigFile = JSON.parse(
|
||
fs.readFileSync(CONFIG_FILE, "utf-8"),
|
||
);
|
||
CONFIG = configData.config || CONFIG;
|
||
await saveRoutesToDb(configData.routes || {}, configData.routeStatuses || {});
|
||
} else {
|
||
const configData: ConfigFile = JSON.parse(
|
||
fs.readFileSync(CONFIG_FILE, "utf-8"),
|
||
);
|
||
CONFIG = configData.config || CONFIG;
|
||
const dbMappings = await loadRoutesFromDb();
|
||
if (Object.keys(dbMappings.routes).length === 0 && configData.routes) {
|
||
await saveRoutesToDb(configData.routes, configData.routeStatuses || {});
|
||
}
|
||
}
|
||
|
||
const dbMappings = await loadRoutesFromDb();
|
||
RAW_ROUTES = dbMappings.routes;
|
||
RAW_ROUTE_STATUSES = dbMappings.routeStatuses;
|
||
MOCK_ROUTES = filterActiveRoutes(RAW_ROUTES);
|
||
MOCK_ROUTE_STATUSES = filterActiveRouteStatuses(RAW_ROUTE_STATUSES);
|
||
console.log(
|
||
`[CONFIG] 配置已加载(sqlite 路由 ${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 };
|
||
RAW_ROUTE_STATUSES = {};
|
||
MOCK_ROUTE_STATUSES = {};
|
||
CONFIG = {
|
||
cacheConfig: true,
|
||
reloadOnChange: true,
|
||
defaultContentType: "application/json",
|
||
proxyPort: 9443,
|
||
targetHost: "devrmtapp.resmart.cn",
|
||
targetPort: 443,
|
||
};
|
||
}
|
||
}
|
||
|
||
function buildCurrentConfigFile(): ConfigFile {
|
||
return {
|
||
routes: { ...RAW_ROUTES },
|
||
routeStatuses: { ...RAW_ROUTE_STATUSES },
|
||
config: { ...CONFIG },
|
||
};
|
||
}
|
||
|
||
async function saveConfigFile(nextConfig: ConfigFile): Promise<void> {
|
||
// routes 持久化到 sqlite,config 仍使用 config.json
|
||
await saveRoutesToDb(nextConfig.routes || {}, nextConfig.routeStatuses || {});
|
||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(nextConfig, null, 2), "utf-8");
|
||
const dbMappings = await loadRoutesFromDb();
|
||
RAW_ROUTES = dbMappings.routes;
|
||
RAW_ROUTE_STATUSES = dbMappings.routeStatuses;
|
||
MOCK_ROUTES = filterActiveRoutes(RAW_ROUTES);
|
||
MOCK_ROUTE_STATUSES = filterActiveRouteStatuses(RAW_ROUTE_STATUSES);
|
||
CONFIG = nextConfig.config || CONFIG;
|
||
}
|
||
|
||
function loadApiListFromJson(): 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 [];
|
||
}
|
||
}
|
||
|
||
async function loadApiList(): Promise<ApiListItem[]> {
|
||
const dbList = await loadApiListFromDb();
|
||
if (dbList.length > 0) {
|
||
return dbList;
|
||
}
|
||
const jsonList = loadApiListFromJson();
|
||
if (jsonList.length > 0) {
|
||
await upsertApiListToDb(jsonList);
|
||
}
|
||
return jsonList;
|
||
}
|
||
|
||
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;
|
||
if (entry.name.endsWith(".sqlite3")) continue;
|
||
const relative = path.relative(baseDir, fullPath).replace(/\\/g, "/");
|
||
result.push(`mock/${relative}`);
|
||
}
|
||
}
|
||
|
||
async function loadMockFiles(): Promise<MockFileItem[]> {
|
||
let files = await loadMockFilePathsFromDb();
|
||
files = files.filter(
|
||
(item) =>
|
||
item.file_path !== "mock/api-list.json" &&
|
||
!item.file_path.endsWith(".sqlite3"),
|
||
);
|
||
if (files.length === 0 && fs.existsSync(MOCK_DIR)) {
|
||
const fsFiles: string[] = [];
|
||
walkMockFiles(MOCK_DIR, MOCK_DIR, fsFiles);
|
||
fsFiles.sort((a, b) => a.localeCompare(b));
|
||
for (const filePath of fsFiles) {
|
||
await upsertMockFilePathToDb(filePath);
|
||
}
|
||
files = fsFiles.map((filePath) => ({ file_path: filePath, alias: "" }));
|
||
}
|
||
|
||
const result: MockFileItem[] = [];
|
||
for (const item of files) {
|
||
const filePath = item.file_path;
|
||
const fullPath = path.join(__dirname, filePath);
|
||
if (!fs.existsSync(fullPath)) continue;
|
||
result.push({
|
||
filePath,
|
||
alias: String(item.alias || ""),
|
||
content: fs.readFileSync(fullPath, "utf-8"),
|
||
});
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async function initMockFilesFromFsIfNeeded(): Promise<void> {
|
||
const filesInDb = await loadMockFilePathsFromDb();
|
||
if (filesInDb.length > 0 || !fs.existsSync(MOCK_DIR)) return;
|
||
if (!fs.existsSync(MOCK_DIR)) {
|
||
return;
|
||
}
|
||
const files: string[] = [];
|
||
walkMockFiles(MOCK_DIR, MOCK_DIR, files);
|
||
files.sort((a, b) => a.localeCompare(b));
|
||
for (const filePath of files) {
|
||
await upsertMockFilePathToDb(filePath);
|
||
}
|
||
}
|
||
|
||
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 getMockStatusCode(requestPath: string): number {
|
||
return normalizeStatusCode(MOCK_ROUTE_STATUSES[requestPath], 200);
|
||
}
|
||
|
||
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(async (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;
|
||
await 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(async (bodyText) => {
|
||
const body = bodyText ? JSON.parse(bodyText) : {};
|
||
const nextConfig: ConfigFile = {
|
||
routes: body.routes ?? RAW_ROUTES,
|
||
routeStatuses: body.routeStatuses ?? RAW_ROUTE_STATUSES,
|
||
config: body.config || CONFIG,
|
||
};
|
||
await 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,
|
||
routeStatuses: RAW_ROUTE_STATUSES,
|
||
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(async (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 statusCode = normalizeStatusCode(body.statusCode, 200);
|
||
const enabled = body.enabled !== false;
|
||
const useExistingFile =
|
||
body.useExistingFile === true || template === "basicError";
|
||
const apiList = await 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];
|
||
delete nextConfig.routeStatuses?.[key];
|
||
}
|
||
});
|
||
nextConfig.routes[nextRouteKey] = normalizedFilePath;
|
||
if (!nextConfig.routeStatuses) {
|
||
nextConfig.routeStatuses = {};
|
||
}
|
||
nextConfig.routeStatuses[nextRouteKey] = statusCode;
|
||
await saveConfigFile(nextConfig);
|
||
await upsertMockFilePathToDb(normalizedFilePath);
|
||
|
||
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,
|
||
statusCode,
|
||
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: await 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: await loadMockFiles(),
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
readBody(clientReq)
|
||
.then(async (bodyText) => {
|
||
const body = bodyText ? JSON.parse(bodyText) : {};
|
||
const filePath = String(body.filePath || "").trim();
|
||
const alias = String(body.alias || "");
|
||
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");
|
||
await upsertMockFilePathToDb(normalizedPath, alias);
|
||
clientRes.writeHead(200, { "Content-Type": "application/json" });
|
||
clientRes.end(
|
||
JSON.stringify({
|
||
success: true,
|
||
filePath: normalizedPath,
|
||
alias,
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
// DELETE
|
||
if (!fs.existsSync(fullPath)) {
|
||
throw new Error("mock file does not exist");
|
||
}
|
||
fs.unlinkSync(fullPath);
|
||
await removeMockFilePathFromDb(normalizedPath);
|
||
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);
|
||
const mockStatusCode = getMockStatusCode(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(mockStatusCode, {
|
||
...proxyRes.headers,
|
||
"X-Mock-Autogenerated": "true",
|
||
"X-Mock-Source": mockFile,
|
||
"X-Mock-Status-Code": String(mockStatusCode),
|
||
});
|
||
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(mockStatusCode, {
|
||
"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-Status-Code": String(mockStatusCode),
|
||
"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);
|
||
});
|
||
|
||
function setupConfigWatcher(): void {
|
||
if (!CONFIG.reloadOnChange) return;
|
||
fs.watchFile(CONFIG_FILE, async () => {
|
||
console.log(`[CONFIG] 配置文件已修改,重新加载...`);
|
||
try {
|
||
const oldRoutesCount = Object.keys(MOCK_ROUTES).length;
|
||
await loadConfig();
|
||
const newRoutesCount = Object.keys(MOCK_ROUTES).length;
|
||
console.log(
|
||
`[CONFIG] 配置重载完成 (路由数: ${oldRoutesCount} -> ${newRoutesCount})`,
|
||
);
|
||
} catch (error) {
|
||
console.error(`[CONFIG] 重新加载配置文件失败:`, error);
|
||
}
|
||
});
|
||
console.log(`[CONFIG] 已启用配置文件监视: ${CONFIG_FILE}`);
|
||
}
|
||
|
||
function startServer(): void {
|
||
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(`========================================`);
|
||
});
|
||
}
|
||
|
||
async function bootstrap(): Promise<void> {
|
||
await initDatabase();
|
||
await initMockFilesFromFsIfNeeded();
|
||
await loadConfig();
|
||
const apiListFromDb = await loadApiListFromDb();
|
||
if (apiListFromDb.length === 0) {
|
||
await upsertApiListToDb(loadApiListFromJson());
|
||
}
|
||
setupConfigWatcher();
|
||
startServer();
|
||
}
|
||
|
||
bootstrap().catch((error) => {
|
||
console.error("[BOOTSTRAP] 启动失败:", error);
|
||
process.exit(1);
|
||
});
|