Files
leaudit-platform-frontend/server.js
T
LiangShiyong e82e61b589 feat: 1. 添加morgan这个web中间件去接收记录所有的http请求。
2. 更改打包配置文件,服务的启动由remix/server改成自定义server.js(Express服务器+morgan中间件:记录http日志)
2025-12-09 21:04:37 +08:00

950 lines
39 KiB
JavaScript
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.
// ═══════════════════════════════════════════════════════════════════════════════
// 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<ServerBuild>)
* mode: string
* }
*/
app.all(
'*', // 匹配所有路径
createRequestHandler({
/**
* build 参数 - Remix 构建配置
*
* 【类型】
* ServerBuild | (() => Promise<ServerBuild>)
*
* 【开发环境值】
* () => 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. 【注入资源链接】
* 自动添加 <link> 和 <script> 标签
* 指向构建的 CSS/JS 文件(如 /assets/main-DvLl4hFZ.css
*
* 5. 【返回 HTML】
* 返回完整的 HTML 文档
* 设置响应头:Content-Type: text/html
*
* 6. 【客户端激活(Hydration)】
* 浏览器接收 HTML → 渲染页面
* 加载 JS 文件 → React 接管交互
*
* 【API 路由处理】
* 如果路由是 API(如 app/routes/api.users.ts
* - 执行 loaderGET)或 actionPOST/PUT/DELETE
* - 返回 JSON 响应(不渲染 HTML)
* - 设置响应头:Content-Type: application/json
*
* 【错误处理】
* - 如果 loader 抛出错误 → 渲染 ErrorBoundary 组件
* - 如果路由不存在 → 渲染 404 页面
* - 如果服务器错误 → 渲染 500 错误页面
*
* ═══════════════════════════════════════════════════════════════════════════════
*/
// ═══════════════════════════════════════════════════════════════════════════════
// 第六步:启动 Express 服务器
// ═══════════════════════════════════════════════════════════════════════════════
/**
* 从环境变量读取配置
*
* 【PORT】
* 服务器监听端口
* 来源:PM2 配置的 env.PORT(如 5183, 51704
* 默认:3000
*
* 【CLIENT_ID】
* 客户端标识符
* 来源:PM2 配置的 env.CLIENT_ID(如 'main', 'chaozhou'
* 默认:'unknown'
*/
const port = process.env.PORT || 3000;
const clientId = process.env.CLIENT_ID || 'unknown';
/**
* app.listen() - 启动 HTTP 服务器
*
* 【参数 1: port】
* 监听端口号
*
* 【参数 2: callback】
* 服务器启动成功后的回调函数
*
* 【工作原理】
* 1. 创建 TCP Socket
* 2. 绑定到指定端口(如 5183)
* 3. 开始监听连接请求
* 4. 每个新连接 → 创建新的请求处理流
* 5. 执行中间件链处理请求
*
* 【端口占用处理】
* 如果端口已被占用,会抛出 EADDRINUSE 错误
* 进程会终止(PM2 会自动重启)
*/
app.listen(port, () => {
// 打印启动信息(格式化输出)
console.log('═══════════════════════════════════════════════════════════');
console.log(`🚀 Server started successfully!`);
console.log(` Client ID: ${chalk.cyan(clientId)}`);
console.log(` Port: ${chalk.green(port)}`);
console.log(` Environment: ${chalk.yellow(process.env.NODE_ENV || 'development')}`);
console.log(` URL: ${chalk.blue(`http://localhost:${port}`)}`);
console.log('═══════════════════════════════════════════════════════════');
console.log('📝 HTTP Request Logging: ENABLED');
console.log(` Log format: ${process.env.NODE_ENV === 'development' ? 'colored (dev)' : 'detailed (production)'}`);
console.log(` Log output: PM2 captures to logs/${clientId}-out.log`);
console.log('═══════════════════════════════════════════════════════════\n');
/**
* broadcastDevReady() - 通知开发工具服务器已就绪
*
* 【作用】
* 发送信号到开发工具(IDE、浏览器扩展等)
* 告知 Remix 应用已准备好接收请求
*
* 【条件】
* 只在开发环境且有构建文件时调用
* 生产环境不需要(没有开发工具监听)
*
* 【参数】
* remixBuild - 构建对象,包含路由信息
*/
if (process.env.NODE_ENV === 'development' && remixBuild) {
broadcastDevReady(remixBuild);
}
});
// ═══════════════════════════════════════════════════════════════════════════════
// 第七步:优雅关闭(Graceful Shutdown
// ═══════════════════════════════════════════════════════════════════════════════
/**
* SIGTERM 信号处理器
*
* 【SIGTERM 说明】
* 终止信号(Termination Signal
* 请求进程正常退出(可以被捕获和处理)
*
* 【来源】
* - PM2 重启:pm2 restart xxx
* - PM2 停止:pm2 stop xxx
* - Docker 停止:docker stop xxx
* - Kubernetes Pod 终止
*
* 【处理流程】
* 1. 接收 SIGTERM 信号
* 2. 打印日志
* 3. 停止接收新请求
* 4. 等待现有请求完成(最多 30 秒)
* 5. 关闭数据库连接
* 6. 退出进程(process.exit(0)
*
* 【为什么需要优雅关闭】
* 避免:
* - 正在处理的请求被中断
* - 数据库事务未提交
* - 文件写入未完成
* - WebSocket 连接突然断开
*/
process.on('SIGTERM', () => {
console.log('\n⏹️ Received SIGTERM signal. Shutting down gracefully...');
// 这里可以添加清理逻辑:
// - 关闭数据库连接:await db.close()
// - 等待请求完成:await server.close()
// - 清理临时文件:await fs.unlink(tempFile)
process.exit(0);
});
/**
* SIGINT 信号处理器
*
* 【SIGINT 说明】
* 中断信号(Interrupt Signal
* 通常由用户按 Ctrl+C 触发
*
* 【来源】
* - 终端按 Ctrl+C
* - IDE 停止调试
* - kill -2 <pid>
*
* 【处理流程】
* 与 SIGTERM 相同,确保一致的关闭行为
*/
process.on('SIGINT', () => {
console.log('\n⏹️ Received SIGINT signal. Shutting down gracefully...');
process.exit(0);
});
/**
* ═══════════════════════════════════════════════════════════════════════════════
* 完整请求流程示例
* ═══════════════════════════════════════════════════════════════════════════════
*
* 【示例 1:访问页面】
* 用户在浏览器输入:http://localhost:5183/documents
*
* 1. HTTP 请求到达 Express 服务器(端口 5183
* ↓
* 2. Morgan 中间件:记录请求开始时间
* ↓
* 3. Compression 中间件:标记响应需要压缩
* ↓
* 4. Static 中间件(build/client):查找 build/client/documents
* - 文件不存在 → 调用 next()
* ↓
* 5. Static 中间件(public):查找 public/documents
* - 文件不存在 → 调用 next()
* ↓
* 6. Remix 处理器:
* - 匹配路由 → app/routes/documents.tsx
* - 执行 loader → 从数据库获取文档列表
* - 渲染组件 → 生成 HTML
* - 返回响应:
* Content-Type: text/html
* Content-Encoding: gzip
* Body: <html>...</html>(压缩后)
* ↓
* 7. Morgan 记录日志:
* GET /documents 200 45ms - 15234 bytes
* ↓
* 8. 浏览器接收 HTML
* - 解压 gzip → 渲染页面
* - 加载 CSSGET /assets/main-DvLl4hFZ.css
* - 加载 JSGET /assets/documents-BcUjVGQw.js
*
* 【示例 2:加载 CSS 文件】
* 浏览器请求:GET /assets/main-DvLl4hFZ.css
*
* 1. HTTP 请求到达
* ↓
* 2. Morgan:记录开始时间
* ↓
* 3. Compression:标记压缩
* ↓
* 4. Staticbuild/client):
* - 查找 build/client/assets/main-DvLl4hFZ.css
* - 文件存在!→ 返回文件
* Content-Type: text/css
* Content-Encoding: gzip
* Cache-Control: public, max-age=31536000, immutable
* - 终止链(不继续到 Remix)
* ↓
* 5. Morgan 记录日志:
* GET /assets/main-DvLl4hFZ.css 200 2ms - 12345 bytes
* ↓
* 6. 浏览器接收 CSS
* - 缓存 1 年(immutable
* - 下次访问直接从缓存读取(不发请求)
*
* 【示例 3API 请求】
* 前端调用:POST /api/documents(上传文档)
*
* 1. HTTP 请求到达
* ↓
* 2. Morgan:记录请求
* ↓
* 3. Compression:标记压缩
* ↓
* 4. Static 中间件:路径不匹配 → next()
* ↓
* 5. Remix 处理器:
* - 匹配路由 → app/routes/api.documents.ts
* - 执行 actionPOST 处理函数):
* · 解析请求体(FormData
* · 保存文件到 MinIO
* · 写入数据库
* - 返回 JSON
* Content-Type: application/json
* Body: { success: true, id: "123" }
* ↓
* 6. Morgan 记录:
* POST /api/documents 200 234ms - 45 bytes
* ↓
* 7. 前端接收响应:
* - 解析 JSON
* - 更新界面
*
* ═══════════════════════════════════════════════════════════════════════════════
*/