// ═══════════════════════════════════════════════════════════════════════════════ // server.js - 自定义 Express 服务器(集成 Remix + HTTP 日志记录) // ═══════════════════════════════════════════════════════════════════════════════ // // 【用途】 // 为多地区客户端实例提供统一的 HTTP 请求日志记录和静态资源服务 // 替代默认的 remix-serve,实现更精细的请求监控和性能分析 // // 【核心功能】 // 1. HTTP 请求日志记录(morgan)- 记录所有请求的详细信息 // 2. 响应压缩(compression)- 提升性能,减少带宽消耗 // 3. 静态资源服务 - 提供构建文件和公共资源 // 4. Remix 集成 - 处理 SSR、路由、loader/action // // 【请求处理流程】 // HTTP 请求 // ↓ // [1] Morgan 中间件(记录请求日志:方法、URL、时间戳等) // ↓ // [2] Compression 中间件(压缩响应数据:gzip/deflate) // ↓ // [3] 静态资源中间件(匹配 CSS/JS/图片等文件) // ├─ 匹配成功 → 直接返回文件(跳过 Remix) // └─ 未匹配 → 继续 // ↓ // [4] Remix 请求处理器(路由匹配、SSR 渲染、API 处理) // ↓ // 响应返回 // ↓ // Morgan 记录响应(状态码、响应时间、内容长度等) // // ═══════════════════════════════════════════════════════════════════════════════ import express from 'express'; import compression from 'compression'; import morgan from 'morgan'; import { createRequestHandler } from '@remix-run/express'; import { broadcastDevReady } from '@remix-run/node'; import chalk from 'chalk'; // ═══════════════════════════════════════════════════════════════════════════════ // 第一步:Vite 开发服务器初始化(仅开发环境) // ═══════════════════════════════════════════════════════════════════════════════ /** * Vite 开发服务器实例 * * 【作用】 * - 开发环境:启动 Vite,提供 HMR(热模块替换)和即时编译 * - 测试/生产环境:设置为 null,使用预构建的文件 * * 【条件判断】 * - process.env.NODE_ENV === 'development': 开发模式(npm run dev) * - process.env.NODE_ENV === 'testing': 测试模式(PM2 测试环境) * - process.env.NODE_ENV === 'production': 生产模式(PM2 生产环境) * * 【Vite 配置参数】 * - server.middlewareMode: true - 将 Vite 作为中间件集成到 Express * (不独立启动服务器,由 Express 统一管理端口和请求) */ const viteDevServer = process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'testing' ? null // 测试/生产环境:不启动 Vite,使用构建文件 : await import('vite').then((vite) => vite.createServer({ server: { middlewareMode: true // 关键:让 Vite 以中间件模式运行,不监听端口 }, }) ).catch(() => null); // 启动失败时返回 null(优雅降级) // ═══════════════════════════════════════════════════════════════════════════════ // 第二步:创建 Express 应用实例 // ═══════════════════════════════════════════════════════════════════════════════ /** * Express 应用实例 * * 【作用】 * 核心 HTTP 服务器,负责: * - 接收所有 HTTP 请求 * - 按顺序执行中间件链 * - 管理请求/响应生命周期 */ const app = express(); // ═══════════════════════════════════════════════════════════════════════════════ // 第三步:配置 Morgan HTTP 日志记录器 // ═══════════════════════════════════════════════════════════════════════════════ // ────────────────────────────────────────────────────────────────────────────── // 3.1 定义颜色输出函数(仅开发环境) // ────────────────────────────────────────────────────────────────────────────── /** * 根据 HTTP 状态码返回带颜色的字符串 * * @param {number} status - HTTP 状态码 * @returns {string} 彩色状态码字符串(仅开发环境) * * 【颜色映射】 * - 5xx(服务器错误): 红色 - 需要立即修复 * - 4xx(客户端错误): 黄色 - 可能是前端问题或权限问题 * - 3xx(重定向): 青色 - 正常的重定向行为 * - 2xx(成功): 绿色 - 请求成功处理 * - 其他: 无颜色 * * 【chalk 库说明】 * chalk.red(text) - 返回红色 ANSI 转义码包裹的文本 * 示例:'\x1b[31m500\x1b[0m' 会在终端显示为红色的 "500" */ const colorizeStatus = (status) => { if (status >= 500) return chalk.red(status); // 500-599: 服务器错误 if (status >= 400) return chalk.yellow(status); // 400-499: 客户端错误 if (status >= 300) return chalk.cyan(status); // 300-399: 重定向 if (status >= 200) return chalk.green(status); // 200-299: 成功 return status; // 1xx 或其他: 无颜色 }; /** * 根据 HTTP 方法返回带颜色的字符串 * * @param {string} method - HTTP 方法(GET, POST, PUT, DELETE 等) * @returns {string} 彩色方法字符串(仅开发环境) * * 【颜色映射】 * - GET: 蓝色 - 读取操作 * - POST: 绿色 - 创建操作 * - PUT: 黄色 - 更新操作 * - DELETE: 红色 - 删除操作(危险操作) * - PATCH: 品红色 - 部分更新 */ const colorizeMethod = (method) => { const colors = { GET: chalk.blue(method), // 查询 POST: chalk.green(method), // 创建 PUT: chalk.yellow(method), // 全量更新 DELETE: chalk.red(method), // 删除 PATCH: chalk.magenta(method), // 部分更新 }; return colors[method] || method; // 未匹配的方法(如 OPTIONS)返回原文本 }; // ────────────────────────────────────────────────────────────────────────────── // 3.2 注册自定义 Morgan Token // ────────────────────────────────────────────────────────────────────────────── /** * Morgan Token 机制说明: * * morgan.token(name, function) 注册一个自定义占位符,可在日志格式中使用 * 例如:':client-id' 会在运行时被替换为 token 函数的返回值 * * Token 函数参数: * @param {express.Request} req - Express 请求对象 * @param {express.Response} res - Express 响应对象 * @returns {string} 要在日志中显示的值 */ /** * 自定义 Token: :client-id * * 【作用】显示当前实例的客户端标识符 * * 【数据来源】 * process.env.CLIENT_ID - 从 PM2 配置的环境变量获取 * * 【示例值】 * - 'main' - 主服务实例(端口 5183) * - 'chaozhou' - 潮州客户端(端口 51704) * - 'jieyang' - 揭阳客户端(端口 51705) * * 【日志示例】 * [2025-12-09T12:00:00.000Z] 127.0.0.1 chaozhou:51704 GET /documents ... * ^^^^^^^^ 这里显示客户端 ID */ morgan.token('client-id', (req, res) => { return process.env.CLIENT_ID || 'unknown'; }); /** * 自定义 Token: :server-port * * 【作用】显示当前服务器监听的端口号 * * 【数据来源】 * process.env.PORT - 从 PM2 配置或启动命令获取 * * 【日志示例】 * [2025-12-09T12:00:00.000Z] 127.0.0.1 chaozhou:51704 GET /documents ... * ^^^^^ 这里显示端口号 */ morgan.token('server-port', (req, res) => { return process.env.PORT || '0'; }); /** * 自定义 Token: :status-colored * * 【作用】显示带颜色的 HTTP 状态码(仅开发环境) * * 【工作原理】 * 1. 从响应对象获取状态码:res.statusCode * 2. 如果是开发环境,调用 colorizeStatus() 添加颜色 * 3. 如果是生产环境,返回纯文本状态码 * * 【时机】 * 这个 token 在响应完成后被调用,此时 res.statusCode 已经设置 * * 【日志示例】 * 开发环境:GET /api/users \x1b[32m200\x1b[0m 15ms (绿色 200) * 生产环境:GET /api/users 200 15ms (纯文本) */ morgan.token('status-colored', (req, res) => { const status = res.statusCode; return process.env.NODE_ENV === 'development' ? colorizeStatus(status) // 开发:添加 ANSI 颜色码 : status; // 生产:纯数字 }); /** * 自定义 Token: :method-colored * * 【作用】显示带颜色的 HTTP 方法(仅开发环境) * * 【工作原理】 * 1. 从请求对象获取方法:req.method(如 'GET', 'POST') * 2. 如果是开发环境,调用 colorizeMethod() 添加颜色 * 3. 如果是生产环境,返回纯文本方法 * * 【日志示例】 * 开发环境:\x1b[34mGET\x1b[0m /api/users 200 15ms (蓝色 GET) * 生产环境:GET /api/users 200 15ms (纯文本) */ morgan.token('method-colored', (req, res) => { const method = req.method; return process.env.NODE_ENV === 'development' ? colorizeMethod(method) // 开发:添加 ANSI 颜色码 : method; // 生产:纯文本 }); // ────────────────────────────────────────────────────────────────────────────── // 3.3 定义日志格式 // ────────────────────────────────────────────────────────────────────────────── /** * Morgan 日志格式字符串 * * 【格式说明】 * 字符串中的 :token-name 会被替换为对应 token 的值 * * 【内置 Token】 * - :method - HTTP 方法(GET, POST 等) * - :url - 请求 URL 路径 * - :status - HTTP 状态码 * - :response-time - 响应时间(毫秒) * - :res[content-length] - 响应体大小(字节) * - :date[format] - 日期时间(iso: ISO 8601 格式) * - :remote-addr - 客户端 IP 地址 * - :http-version - HTTP 协议版本(1.1, 2.0 等) * - :referrer - 来源页面 URL * - :user-agent - 客户端 User-Agent 字符串 * * 【开发环境格式】(简洁,易读) * GET /api/users 200 15ms - 1234 bytes * ^^^ 彩色方法 ^^^ 彩色状态码 * * 【生产环境格式】(详细,用于分析) * [2025-12-09T12:00:00.000Z] 192.168.1.100 chaozhou:51704 GET /api/users HTTP/1.1 200 1234 bytes 15ms "https://example.com" "Mozilla/5.0..." * ^^^^^^^^^^^^^^^^^^^^^^^^^ 时间戳 * ^^^^^^^^^^^^^^ 客户端 IP * ^^^^^^^^^^^^^^^ 客户端ID:端口 * ^^^ HTTP方法 * ^^^^^^^^^^ URL路径 * ^^^^^^^^ 协议版本 * ^^^ 状态码 * ^^^^^^^^^^^^ 响应大小 * ^^^^^ 响应时间 * ^^^^^^^^^^^^^^^^^^^ 来源 * ^^^^^^^^^^^^^^ UA */ const logFormat = process.env.NODE_ENV === 'development' ? ':method-colored :url :status-colored :response-time ms - :res[content-length] bytes' : '[:date[iso]] :remote-addr :client-id:server-port :method :url HTTP/:http-version :status :res[content-length] bytes :response-time ms ":referrer" ":user-agent"'; // ═══════════════════════════════════════════════════════════════════════════════ // 第四步:应用中间件(按顺序执行) // ═══════════════════════════════════════════════════════════════════════════════ /** * Express 中间件执行顺序说明: * * 中间件按 app.use() 调用的顺序依次执行 * 每个中间件可以: * 1. 处理请求并立即响应(终止链) * 2. 修改请求/响应对象后调用 next() 继续 * 3. 抛出错误或调用 next(error) 进入错误处理 * * 【当前顺序】 * 1. Morgan(日志记录)- 最先执行,记录所有请求 * 2. Compression(压缩)- 修改响应头,启用 gzip * 3. Vite/静态资源 - 尝试匹配文件,匹配成功则终止 * 4. Public 静态资源 - 尝试匹配 public/ 目录文件 * 5. Remix 处理器 - 最后执行,处理所有未匹配的路由 */ // ────────────────────────────────────────────────────────────────────────────── // 4.1 HTTP 请求日志中间件(Morgan) // ────────────────────────────────────────────────────────────────────────────── /** * Morgan 中间件 - 记录所有 HTTP 请求 * * 【工作原理】 * 1. 请求到达时:记录开始时间戳 * 2. 调用 next():继续执行后续中间件 * 3. 响应完成时:计算响应时间,格式化日志,输出 * * 【参数 1: logFormat】 * 日志格式字符串(见上方定义) * * 【参数 2: 配置对象】 * { * skip: Function - 跳过特定请求的日志记录 * stream: Object - 自定义日志输出流 * } */ app.use(morgan(logFormat, { /** * skip 函数 - 决定是否跳过某个请求的日志 * * @param {express.Request} req - Express 请求对象 * @param {express.Response} res - Express 响应对象 * @returns {boolean} true = 跳过,false = 记录 * * 【使用场景】 * - 跳过健康检查请求:return req.url === '/health'; * - 跳过静态资源:return req.url.startsWith('/build/'); * - 只记录错误:return res.statusCode < 400; * * 【当前配置】 * return false - 不跳过任何请求,记录所有请求 */ skip: (req, res) => { // 示例:跳过健康检查请求(当前注释掉) // return req.url === '/health'; return false; // 记录所有请求 }, /** * stream 对象 - 自定义日志输出流 * * 【默认行为】 * morgan 默认输出到 process.stdout(标准输出) * * 【自定义行为】 * 通过提供 stream.write() 方法拦截日志输出 * * 【为什么要自定义】 * 1. PM2 会捕获 console.log 输出到日志文件 * 2. 可以移除末尾换行符,避免空行 * 3. 可以添加额外的日志处理(如发送到监控系统) */ stream: { /** * write 方法 - 接收格式化后的日志消息 * * @param {string} message - 格式化后的日志消息(包含末尾 \n) * * 【工作流程】 * 1. Morgan 格式化日志 → 生成字符串(末尾带 \n) * 2. 调用 stream.write(message) * 3. 我们移除 \n 并使用 console.log 输出 * 4. PM2 捕获 console.log → 写入 logs/xxx-out.log * * 【为什么用 trim()】 * 因为 console.log 会自动添加换行符,如果不移除原有的 \n * 会导致日志文件中出现空行 */ write: (message) => { // 移除末尾换行符,使用 console.log 输出 // PM2 会自动捕获到日志文件(logs/{client-id}-out.log) console.log(message.trim()); } } })); // ────────────────────────────────────────────────────────────────────────────── // 4.2 压缩中间件(Compression) // ────────────────────────────────────────────────────────────────────────────── /** * Compression 中间件 - 压缩 HTTP 响应体 * * 【作用】 * 对响应体进行 gzip/deflate 压缩,减少网络传输大小 * * 【工作原理】 * 1. 检查请求头 Accept-Encoding(客户端支持的压缩算法) * 2. 检查响应头 Content-Type(只压缩文本类型) * 3. 设置响应头 Content-Encoding: gzip * 4. 压缩响应体数据 * 5. 调用 next() 继续执行 * * 【压缩效果】 * - HTML/CSS/JS: 通常压缩 70-80% * - JSON: 通常压缩 50-70% * - 图片/视频: 不压缩(已经是压缩格式) * * 【默认配置】 * - 阈值:1KB(小于 1KB 的响应不压缩) * - 级别:6(压缩级别 1-9,默认 6 是平衡点) * - 过滤:自动排除已压缩的 MIME 类型 * * 【性能影响】 * - CPU 消耗:轻微增加(压缩计算) * - 内存消耗:每个请求增加约 16KB * - 网络带宽:大幅减少 50-80% * - 响应时间:可能略微增加(但总体传输时间更短) */ app.use(compression()); // ────────────────────────────────────────────────────────────────────────────── // 4.3 静态资源中间件(Vite 或 Express Static) // ────────────────────────────────────────────────────────────────────────────── /** * 静态资源服务 - 根据环境选择不同策略 * * 【开发环境】Vite Dev Server * - 实时编译:每次请求时编译源代码 * - HMR 支持:代码修改后自动刷新浏览器 * - Source Map:提供完整的源码映射 * * 【测试/生产环境】Express Static * - 预构建文件:从 build/client 目录提供文件 * - 缓存策略:immutable + maxAge 1年 * - 性能优化:直接读取文件,无需编译 */ if (viteDevServer) { /** * Vite 开发服务器中间件 * * 【作用】 * 拦截对源代码的请求(如 /src/routes/index.tsx) * 编译 TypeScript/JSX → JavaScript * 返回编译后的代码 * * 【处理的请求示例】 * - GET /@vite/client → Vite HMR 客户端代码 * - GET /src/root.tsx → 编译并返回 root.tsx * - GET /node_modules/.vite/deps/react.js → 依赖预构建 * * 【工作流程】 * 1. 请求到达 → Vite 检查是否是源代码请求 * 2. 如果是 → 编译源代码 → 返回 JS(终止链) * 3. 如果不是 → 调用 next() → 继续到下一个中间件 */ app.use(viteDevServer.middlewares); } else { /** * Express.static 中间件 - 提供构建后的静态资源 * * 【参数 1: 目录路径】 * 'build/client' - Remix 构建输出目录 * * Remix 构建后的目录结构: * build/ * ├── client/ ← 客户端资源(浏览器加载) * │ ├── assets/ ← CSS/JS 文件(带 hash 文件名) * │ │ ├── root-K8ExMHas.js * │ │ ├── login-CWMdsJSf.js * │ │ └── main-DvLl4hFZ.css * │ └── ... * └── server/ ← 服务端代码(SSR) * └── index.js * * 【参数 2: 配置对象】 * { * immutable: boolean - 是否添加 Cache-Control: immutable * maxAge: string - 缓存时间(毫秒或字符串) * } * * 【immutable 说明】 * immutable: true → 响应头添加 'Cache-Control: public, max-age=31536000, immutable' * 含义:告诉浏览器这个文件永远不会改变 * * 【为什么可以设置 immutable】 * Remix 构建的文件名包含内容哈希(如 main-DvLl4hFZ.css) * 文件内容改变 → 哈希改变 → 文件名改变 → 不会冲突 * * 【maxAge: '1y'】 * 缓存时间:1 年(31536000 秒) * 浏览器会缓存文件 1 年,除非手动清除缓存 * * 【请求匹配逻辑】 * GET /assets/main-DvLl4hFZ.css → 查找 build/client/assets/main-DvLl4hFZ.css * ├─ 文件存在 → 返回文件内容(Content-Type: text/css)→ 终止链 * └─ 文件不存在 → 调用 next() → 继续到下一个中间件 * * GET /favicon.ico → 查找 build/client/favicon.ico * ├─ 文件存在 → 返回文件 * └─ 文件不存在 → 继续 */ app.use(express.static('build/client', { immutable: true, // 添加 immutable 指令(永久缓存) maxAge: '1y' // 缓存时间 1 年 })); } // ────────────────────────────────────────────────────────────────────────────── // 4.4 Public 目录静态资源中间件 // ────────────────────────────────────────────────────────────────────────────── /** * Express.static 中间件 - 提供 public 目录的静态资源 * * 【作用】 * 提供不需要构建的公共资源(字体、图片、PDF 等) * * 【目录结构】 * public/ * ├── fonts/ ← 字体文件(remixicon, source-han-sans) * │ ├── remixicon.woff2 * │ └── ... * ├── images/ ← 图片资源 * │ ├── logo.png * │ └── ... * └── pdf.worker.js ← PDF.js Worker(不需要构建) * * 【参数 1: 目录路径】 * 'public' - 公共资源目录 * * 【参数 2: 配置对象】 * { maxAge: '1h' } - 缓存时间 1 小时(3600 秒) * * 【为什么缓存时间较短】 * public 目录的文件名通常不包含哈希 * 文件可能被更新但文件名不变 * 所以缓存时间设置较短(1 小时) * * 【请求匹配逻辑】 * GET /fonts/remixicon.woff2 → 查找 public/fonts/remixicon.woff2 * ├─ 文件存在 → 返回文件(Content-Type: font/woff2)→ 终止链 * └─ 文件不存在 → 调用 next() → 继续到 Remix 处理器 * * GET /logo.png → 查找 public/logo.png * ├─ 文件存在 → 返回文件 * └─ 文件不存在 → 继续 * * 【注意】 * 这个中间件在 Remix 处理器之前 * 如果 public 目录有 /login 文件,会覆盖 Remix 的 /login 路由 * 所以避免在 public 目录创建与路由同名的文件 */ app.use(express.static('public', { maxAge: '1h' // 缓存时间 1 小时 })); // ═══════════════════════════════════════════════════════════════════════════════ // 第五步:Remix 请求处理器(最后一个中间件) // ═══════════════════════════════════════════════════════════════════════════════ /** * 加载 Remix 构建文件 * * 【开发环境】 * viteDevServer 存在 → remixBuild = undefined * Remix 会通过 Vite 实时编译加载 * * 【测试/生产环境】 * viteDevServer = null → 导入预构建的 server/index.js * * 【构建文件说明】 * build/server/index.js 包含: * - 所有路由的 loader/action 函数 * - 服务端渲染逻辑 * - 路由配置信息 * * 【为什么用 await import()】 * 动态导入,允许在模块顶层使用(ESM 规范) * 如果构建文件不存在,会抛出错误并终止进程 */ const remixBuild = viteDevServer ? undefined // 开发环境:不预加载,通过 Vite SSR 加载 : await import('./build/server/index.js'); // 生产环境:导入构建文件 /** * Remix 请求处理器 - 核心中间件 * * 【app.all() 说明】 * app.all(path, handler) - 匹配所有 HTTP 方法(GET, POST, PUT, DELETE 等) * 参数 1: '*' - 通配符,匹配所有路径 * 参数 2: handler - 请求处理函数 * * 【为什么用 app.all('*')】 * Remix 需要处理所有未被静态资源中间件匹配的请求 * 包括: * - 页面路由(GET /documents, GET /login) * - API 路由(POST /api/documents, GET /api/users) * - 资源路由(GET /api/pdf/123) * - 未找到的路由(404 页面) * * 【执行时机】 * 只有当前面所有中间件都调用 next() 时才执行 * 即:请求不是静态资源时 * * 【createRequestHandler 函数】 * 从 @remix-run/express 导入 * 将 Remix 应用转换为 Express 中间件 * * 【参数说明】 * { * build: ServerBuild | (() => Promise) * mode: string * } */ app.all( '*', // 匹配所有路径 createRequestHandler({ /** * build 参数 - Remix 构建配置 * * 【类型】 * ServerBuild | (() => Promise) * * 【开发环境值】 * () => viteDevServer.ssrLoadModule('virtual:remix/server-build') * * 函数形式,每次请求时调用 * * 【工作流程】 * 1. 请求到达 → 调用函数 * 2. viteDevServer.ssrLoadModule() → 加载虚拟模块 * 3. Vite 编译所有路由的服务端代码 → 返回 ServerBuild 对象 * 4. Remix 使用 ServerBuild 处理请求 * * 【什么是 'virtual:remix/server-build'】 * Vite 虚拟模块(不是真实文件) * Remix Vite 插件动态生成,包含所有路由信息 * * 【为什么每次调用】 * 开发环境代码可能随时修改 * 每次重新加载确保使用最新代码 * * 【生产环境值】 * remixBuild(预加载的构建对象) * * 直接使用预构建的对象,性能更高 */ build: viteDevServer ? () => viteDevServer.ssrLoadModule('virtual:remix/server-build') : remixBuild, /** * mode 参数 - 运行模式 * * 【值】 * - 'development' - 开发模式(npm run dev) * - 'testing' - 测试模式(PM2 测试环境) * - 'production' - 生产模式(PM2 生产环境) * * 【作用】 * 1. 影响错误页面显示(开发显示堆栈,生产隐藏) * 2. 影响日志级别(开发详细,生产简洁) * 3. 影响性能优化(生产启用更多优化) * * 【Remix 内部使用】 * - 开发模式:显示详细错误堆栈,启用 HMR * - 生产模式:隐藏错误详情,启用缓存优化 */ mode: process.env.NODE_ENV, }) ); /** * ═══════════════════════════════════════════════════════════════════════════════ * Remix 请求处理流程详解 * ═══════════════════════════════════════════════════════════════════════════════ * * 当请求到达 createRequestHandler() 时: * * 1. 【路由匹配】 * Remix 根据 URL 路径匹配路由文件 * 例:GET /documents → app/routes/documents.tsx * * 2. 【执行 Loader】 * 如果路由有 loader 函数,在服务端执行 * loader 可以访问数据库、调用 API、读取会话 * 返回数据注入到组件 props * * 3. 【服务端渲染(SSR)】 * Remix 渲染 React 组件为 HTML 字符串 * 包括: * - app/root.tsx(根组件) * - app/routes/documents.tsx(匹配的路由) * - 所有嵌套布局和子组件 * * 4. 【注入资源链接】 * 自动添加