api-proxy-mock/index.api.ts

1202 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * 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 为 443HTTP 为 80 */
targetPort?: number;
/** 为 false 时用 HTTP 连接上游;缺省 trueHTTPS */
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 持久化到 sqliteconfig 仍使用 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");
}
const innerPath = relative.replace(/^mock\//, "");
if (!/^[A-Za-z0-9/.]+$/.test(innerPath)) {
throw new Error(
"filePath only allows English letters, numbers, '/', and '.'",
);
}
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);
});