/** * 增强的 Express 服务器,用于 SPA 预览和可配置的反向代理支持 * * 功能特性: * - 基于环境变量的配置 * - 从可配置目录提供静态资源服务 * - 增强的单页应用 (SPA) 回退机制,支持路由排除 * - 可配置的 API 请求反向代理 * - 支持可配置源的 CORS * - 改进的日志记录和错误处理 * * 使用方法: * - 通过 .env 文件或环境变量进行配置 * - node src/server.js * - 或使用 package.json 脚本: pnpm dev */ import express from "express"; import cors from "cors"; import { createProxyMiddleware } from "http-proxy-middleware"; import path from "node:path"; import { fileURLToPath } from "node:url"; import fs from "node:fs"; import dotenv from "dotenv"; import chalk from "chalk"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // 从 .env 文件加载环境变量 dotenv.config({ path: path.resolve(__dirname, "..", ".env") }); /** * 解析相对于项目根目录的路径 * @param {...string} segs - 路径片段 * @returns {string} 解析后的路径 */ function r(...segs) { return path.resolve(__dirname, "..", ...segs); } /** * 配置对象,包含环境变量默认值 */ const config = { // 服务器配置 port: Number(process.env.PORT) || 5173, host: process.env.HOST || "0.0.0.0", // 静态文件配置 publicDir: process.env.PUBLIC_DIR || "public", fallbackFile: process.env.FALLBACK_FILE || "index.html", // API 代理配置 apiTarget: process.env.API_TARGET || "", apiPrefix: process.env.API_PREFIX || "/api", apiProxyMode: process.env.API_PROXY_MODE || "prefix", // 'prefix' 或 'include' // 开发配置 devMode: process.env.DEV_MODE === "true" || process.env.NODE_ENV === "development", logLevel: process.env.LOG_LEVEL || "info", // CORS 配置 corsEnabled: process.env.CORS_ENABLED !== "false", corsOrigin: process.env.CORS_ORIGIN || "*", // SPA 路由配置 spaFallbackEnabled: process.env.SPA_FALLBACK_ENABLED !== "false", spaExcludeExtensions: ( process.env.SPA_EXCLUDE_EXTENSIONS || ".js,.css,.png,.jpg,.jpeg,.gif,.svg,.ico,.woff,.woff2,.ttf,.eot" ) .split(",") .map((ext) => ext.trim()), }; /** * 不同级别的日志工具 */ const logger = { info: (message, ...args) => { if (["info", "debug"].includes(config.logLevel)) { console.log(chalk.blue("[INFO]"), message, ...args); } }, warn: (message, ...args) => { if (["info", "warn", "debug"].includes(config.logLevel)) { console.warn(chalk.yellow("[WARN]"), message, ...args); } }, error: (message, ...args) => { console.error(chalk.red("[ERROR]"), message, ...args); }, debug: (message, ...args) => { if (config.logLevel === "debug") { console.log(chalk.gray("[DEBUG]"), message, ...args); } }, }; const app = express(); // 如果配置了 CORS 则启用 if (config.corsEnabled) { const corsOptions = { origin: config.corsOrigin === "*" ? true : config.corsOrigin.split(",").map((o) => o.trim()), credentials: true, }; app.use(cors(corsOptions)); logger.debug("CORS enabled with origin:", config.corsOrigin); } // 静态文件目录设置 const publicDir = r(config.publicDir); const fallbackIndex = r(config.publicDir, config.fallbackFile); /** * 确保 public 文件夹存在,如果缺少则创建基本的 index.html */ function ensurePublicDirectory() { if (!fs.existsSync(publicDir)) { fs.mkdirSync(publicDir, { recursive: true }); logger.info("Created public directory:", publicDir); } if (!fs.existsSync(fallbackIndex)) { const defaultContent = ` SPA Preview Server

🚀 SPA Preview Server

✅ Server is running successfully!

Configuration

Server: http://${config.host}:${config.port}

Public Directory: ${config.publicDir}

API Proxy: ${ config.apiTarget ? `${config.apiPrefix}${config.apiTarget}` : "Disabled" }

SPA Fallback: ${ config.spaFallbackEnabled ? "Enabled" : "Disabled" }

Getting Started

  1. Place your built SPA files into apps/spa-preview/${ config.publicDir }
  2. Configure API proxy in .env file if needed
  3. Your SPA routes will be handled automatically

📖 Documentation

`; fs.writeFileSync(fallbackIndex, defaultContent); logger.info("Created default index.html:", fallbackIndex); } } ensurePublicDirectory(); // 提供静态资源服务 app.use( express.static(publicDir, { index: false, fallthrough: true, setHeaders: (res, path) => { // 在生产环境中为静态资源添加缓存头 if (!config.devMode) { if ( path.match(/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)$/) ) { res.setHeader("Cache-Control", "public, max-age=31536000"); // 1 year } } }, }) ); // 为 API 请求设置反向代理 if (config.apiTarget) { const proxyOptions = { target: config.apiTarget, changeOrigin: true, pathRewrite: (pathStr) => { let rewritten = pathStr; // 根据代理模式处理路径 if (config.apiProxyMode === "prefix") { // 路径替换模式:移除前缀 rewritten = pathStr.replace(new RegExp(`^${config.apiPrefix}`), ""); } // 路径包含模式不修改路径 logger.debug(`Proxy rewrite: ${pathStr} → ${rewritten}`); return rewritten; }, logLevel: config.logLevel === "debug" ? "debug" : "warn", onProxyReq: (proxyReq, req) => { logger.debug(`Proxying ${req.method} ${req.url} to ${config.apiTarget}`); }, onError: (err, req, res) => { logger.error("Proxy error:", err.message); res.status(500).json({ error: "Proxy error", message: err.message }); }, }; // 根据代理模式设置中间件 if (config.apiProxyMode === "prefix") { // 路径替换模式:只代理特定前缀的请求 app.use(config.apiPrefix, createProxyMiddleware(proxyOptions)); logger.info( `API proxy enabled (prefix mode): ${config.apiPrefix} → ${config.apiTarget}` ); } else if (config.apiProxyMode === "include") { // 路径包含模式:代理所有包含特定路径的请求 const pathFilter = (path) => path.includes(config.apiPrefix); app.use(pathFilter, createProxyMiddleware(proxyOptions)); logger.info( `API proxy enabled (include mode): paths containing '${config.apiPrefix}' → ${config.apiTarget}` ); } } else { logger.info("API proxy disabled (set API_TARGET to enable)"); } /** * 增强的 SPA 回退中间件 * 通过为非文件请求提供 index.html 来处理客户端路由 */ if (config.spaFallbackEnabled) { app.get("*", (req, res, next) => { const requestPath = req.path; const fileExtension = path.extname(requestPath); // 如果请求有应该从 SPA 回退中排除的扩展名,则跳过 if (fileExtension && config.spaExcludeExtensions.includes(fileExtension)) { logger.debug(`Skipping SPA fallback for file: ${requestPath}`); return next(); } // 跳过 API 路由 if ( config.apiProxyMode === "prefix" && requestPath.startsWith(config.apiPrefix) ) { return next(); } else if ( config.apiProxyMode === "include" && requestPath.includes(config.apiPrefix) ) { return next(); } logger.debug(`SPA fallback serving index.html for: ${requestPath}`); res.sendFile(fallbackIndex, (err) => { if (err) { logger.error("Error serving fallback index.html:", err.message); res.status(500).send("Internal Server Error"); } }); }); logger.info("SPA fallback enabled"); } // 错误处理中间件 app.use((err, req, res, next) => { logger.error("Unhandled error:", err.message); res.status(500).json({ error: "Internal Server Error" }); }); // 404 处理器 app.use((req, res) => { logger.warn(`404 Not Found: ${req.method} ${req.url}`); res.status(404).json({ error: "Not Found", path: req.url }); }); // 启动服务器 app.listen(config.port, config.host, () => { console.log(chalk.green("🚀 SPA Preview Server Started")); console.log(chalk.cyan(`📍 Server: http://${config.host}:${config.port}`)); console.log(chalk.cyan(`📁 Public: ${publicDir}`)); if (config.apiTarget) { console.log( chalk.cyan(`🔄 Proxy: ${config.apiPrefix} → ${config.apiTarget}`) ); } if (config.devMode) { console.log(chalk.yellow("🔧 Development mode enabled")); } console.log(chalk.gray("Press Ctrl+C to stop")); }); // 优雅关闭 process.on("SIGTERM", () => { logger.info("Received SIGTERM, shutting down gracefully"); process.exit(0); }); process.on("SIGINT", () => { logger.info("Received SIGINT, shutting down gracefully"); process.exit(0); });