diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index c73bb1b..4b143ba 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -26,6 +26,7 @@ src/ config.ts CLI 参数解析与配置文件加载 facade(可选 YAML configPath,支持 --help/-h) config/ 配置解析模块(types、issues、variables、normalizer、schema) dev.ts 开发模式启动入口(mode: "development") + logger.ts 统一日志接口和运行时实现 main.ts 生产模式启动入口(mode: "production",安全头启用) server.ts HTTP server 启动工厂(Bun.serve routes 声明式路由 + fetch fallback 静态资源服务) static.ts 生产模式静态资源服务(SPA fallback、Content-Type 映射、immutable 缓存) @@ -71,7 +72,7 @@ src/ bump-version-logic.ts 纯版本管理逻辑(parse、validate、bump、format) bump-version.ts 版本升迁 CLI 脚本 clean.ts 清理构建产物与临时文件 -tests/ Bun test 测试(结构镜像 src 目录) + tests/ Bun test 测试(结构镜像 src 目录) setup.ts 全局测试配置(jsdom、polyfill) helpers.ts 测试辅助工具(rmRetry) server/ 后端测试 @@ -88,6 +89,7 @@ tests/ Bun test 测试(结构镜像 src 目录) openspec/ OpenSpec 变更、规格文档与 fast-drive workflow schema config.example.yaml 配置文件示例(server.listen 布局 + 显式变量引用) config.schema.json 配置文件 JSON Schema(由 bun run schema 生成) +data/ 运行时数据目录(日志文件、数据库等) ``` --- @@ -104,11 +106,13 @@ config.schema.json 配置文件 JSON Schema(由 bun run schema 生成) ``` 启动流程: - dev.ts / main.ts → parseRuntimeArgs(cli args) - → bootstrap({ configPath, mode }) - → loadServerConfig(configPath):可选 YAML 解析 → ServerConfig{ host, port } - → startServer({ config, mode }):Bun.serve routes 声明式路由 + fetch fallback - → 注册 SIGINT/SIGTERM shutdown + dev.ts / main.ts → parseRuntimeArgs(cli args) → 必须指定 config.yaml + → bootstrap({ configPath, mode, version? }) + → loadServerConfig(configPath):YAML 解析 → configDir、dataDir、logging + → createRuntimeLogger(config.logging, mode, version) + → mkdirSync(config.dataDir, { recursive: true }) + → startServer({ config, logger }):Bun.serve 声明式路由 + fetch fallback + → logger 记录启动成功;SIGINT/SIGTERM → logger.flush() → exit HTTP 请求: Request → Bun.serve routes 声明式匹配 → routes/*.ts(handler) @@ -183,6 +187,12 @@ export function handleMeta(mode: RuntimeMode, version: string): Response; - `serveStaticAsset(pathname, assets)` — 静态资源分发(文件扩展名路由 → immutable 缓存,无扩展名 → SPA fallback 返回 index.html) - `hasFileExtension(path)` / `contentTypeFor(path)` / `htmlResponse(html)` — 辅助函数 +- **`logger.ts`**:统一日志接口和运行时实现 + - `Logger` 接口:`trace`/`debug`/`info`/`warn`/`error`/`fatal`/`child`/`flush` + - `createRuntimeLogger(config)`:生产运行时,基于 Pino 结构化日志,console pretty + file JSONL rolling + - `createConsoleFallback()`:配置加载失败前的降级日志 + - `createNoopLogger()` / `createMemoryLogger()`:测试替身 + ### 1.5 类型定义规范 - **共享类型**以 `src/shared/api.ts` 为唯一源头,前后端共同引用 @@ -191,16 +201,19 @@ export function handleMeta(mode: RuntimeMode, version: string): Response; - 前端不得 `import src/server/` 下的任何文件 - **严格联合类型**优先于宽类型:如 `RuntimeMode: "development" | "production" | "test"` 而非 `RuntimeMode: string` - API 响应类型(`ApiErrorResponse`、`MetaResponse`)定义在 shared 中 +- `ResolvedConfig` 类型包含 `configDir`、`dataDir`、`logging`,由配置解析流程产出 ### 1.6 配置文件规范 配置采用分层生命周期:`unknown → AuthoringConfig → NormalizedConfig → ValidatedConfig → ServerConfig`。 ``` -CLI argv → parseRuntimeArgs → { configPath? } +CLI argv → parseRuntimeArgs → { configPath } + → 必须指定 configPath(无 configPath 启动失败) → loadServerConfig(configPath) - → 无 configPath → 默认值 { host: "127.0.0.1", port: 3000 } - → 有 configPath → YAML 解析 → normalize(变量替换) → strict validate → resolve → ServerConfig{ host, port } + → YAML 解析 → normalize(变量替换) → strict validate → resolve + → ServerConfig{ host, port, configDir, dataDir, logging } + → logging 字段用于 createRuntimeLogger;dataDir 字段用于 mkdirSync ``` 配置加载流程: @@ -208,14 +221,28 @@ CLI argv → parseRuntimeArgs → { configPath? } 1. **解析 YAML**:`Bun.YAML.parse` 将配置文件解析为 `unknown` 2. **normalize**:提取 `variables`,替换 `${KEY}` / `${KEY|default}` 变量引用,移除 `variables` 段,产出 `NormalizedConfig` 3. **strict validate**:使用 Ajv + TypeBox 生成的 schema 严格校验(拒绝未知字段、校验类型和范围) -4. **resolve**:从校验后的配置中提取 `server.listen.host` 和 `server.listen.port`,填充默认值 +4. **resolve**:从校验后的配置中提取 `server.listen.host`、`server.listen.port`、`server.storage.dataDir`、`server.logging` 等字段,填充默认值 `ServerConfig` 包含以下字段: -| 字段 | 来源 | 默认值 | -| ------ | ------------------------------------------ | ----------- | -| `host` | `server.listen.host`(可含变量引用)→ 默认 | `127.0.0.1` | -| `port` | `server.listen.port`(可含变量引用)→ 默认 | `3000` | +| 字段 | 来源 | 默认值 | +| --------- | ------------------------------------------ | --------------------- | +| `host` | `server.listen.host`(可含变量引用)→ 默认 | `127.0.0.1` | +| `port` | `server.listen.port`(可含变量引用)→ 默认 | `3000` | +| `dataDir` | `server.storage.dataDir` → 默认 `./data` | 配置文件目录下 `data` | +| `logging` | `server.logging` | 见下方日志配置 | + +**日志配置(`server.logging`)**: + +| 字段 | 类型 | 说明 | 默认值 | +| ------------------------- | ------ | -------------------------------------------------------------------- | -------------------------------- | +| `level` | string | 全局日志级别(trace/debug/info/warn/error/fatal),未设置时默认 info | `"info"` | +| `console.level` | string | 控制台日志级别,未设置时继承 `level` | 继承 `level` | +| `file.level` | string | 文件日志级别,未设置时继承 `level` | 继承 `level` | +| `file.path` | string | 日志文件路径,相对路径基于配置文件目录,默认基于 `dataDir` | `/logs/${APP.name}.log` | +| `file.rotation.size` | string | 按大小滚动,支持 `B`/`KB`/`MB`/`GB` 单位 | `"50MB"` | +| `file.rotation.frequency` | string | 按时间滚动(`hourly`/`daily`/`weekly`) | `"daily"` | +| `file.rotation.maxFiles` | number | 最大归档文件数 | `14` | 配置文件示例(`config.example.yaml`): @@ -225,6 +252,19 @@ server: listen: host: "${HOST|127.0.0.1}" port: ${PORT|3000} + storage: + dataDir: ${DATA_DIR|./data} + logging: + level: ${LOG_LEVEL|info} + console: + level: info + file: + level: info + path: "./data/logs/my-app.log" + rotation: + size: "50MB" + frequency: daily + maxFiles: 14 ``` 变量语法: @@ -287,7 +327,32 @@ bun run version:set 2.0.0 # 显式设置版本号 ### 1.8 错误模式 - **API 错误**:`{ error: "描述", status: }`,状态码 400/404 -- **日志**:非致命异常用 `console.warn`,启动失败用 `console.error` + `process.exit(1)` +- **日志**:后端运行时代码统一通过 `Logger` 接口输出结构化和纯文本日志,禁止直接使用 `console.*`(`src/server/logger.ts` 是唯一例外)。开发环境控制台输出 pretty 格式,生产环境同时输出 JSONL 文件日志并支持大小和时间滚动。敏感字段自动 redaction。启动失败和未初始化场景使用 `ConsoleFallbackLogger` 兜底。 + +### 1.9 日志模块 + +`Logger` 接口定义统一的日志抽象,支持以下实现: + +| 实现 | 用途 | +| ----------------------- | --------------------------------------------- | +| `PinoLoggerWrapper` | 生产运行时,封装 Pino、pino-pretty、pino-roll | +| `ConsoleFallbackLogger` | 配置加载失败前的降级日志 | +| `NoopLogger` | 静默丢弃日志 | +| `MemoryLogger` | 测试替身,可断言日志内容 | + +使用方式: + +```typescript +// 生产运行时创建 +const logger = createRuntimeLogger(config.logging, mode, version); + +// 测试中使用 MemoryLogger +const logger = createMemoryLogger(); +// ... 执行被测代码 ... +expect(logger.entries).toContainEqual({ level: "info", msg: "server started" }); +``` + +敏感字段自动 redaction:请求头中的 `authorization`、`cookie`、`x-api-key` 以及请求体中的 `password`、`token`、`secret` 等字段在日志输出时自动替换为 `[redacted]`。 --- @@ -699,6 +764,8 @@ bun run verify # 完整验证(check + build) **前端导入限制**:`src/web/` 下的文件禁止 `import src/server/` 下的运行时实现,通过 `no-restricted-imports` 规则强制执行。 +**后端日志限制**:`src/server/**/*.ts` 下的文件(除 `src/server/logger.ts` 外)禁止直接使用 `console.*`,通过 `no-restricted-syntax` 规则强制执行,确保所有日志统一通过 `Logger` 接口输出。 + ### Prettier 配置 配置文件:`.prettierrc.json`,通过 `eslint-plugin-prettier` 集成为 ESLint 规则(`lint` 命令同时检查格式),也可通过 `format` 命令独立运行。 diff --git a/README.md b/README.md index d4c6a36..c988aab 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,9 @@ Bun 全栈应用模板,基于 Bun + React + TDesign 的前后端一体化开 ```bash git clone my-project cd my-project +cp config.example.yaml config.yaml bun install -bun run dev +bun run dev config.yaml ``` 访问 http://127.0.0.1:5173 查看应用。 @@ -42,7 +43,15 @@ export const APP = { > **注意**:localStorage key 已从 `"my-app.theme.preference"` 变更为 `"theme.preference"`。如果从旧版本升级,用户的主题偏好设置将丢失,需重新选择。 -### 3. 清理 OpenSpec 历史 +### 3. 准备配置文件 + +```bash +cp config.example.yaml config.yaml +``` + +按需编辑 `config.yaml` 中的监听地址、日志、存储路径等配置。 + +### 4. 清理 OpenSpec 历史 删除模板自带的 OpenSpec 变更历史,保留框架配置: @@ -53,44 +62,45 @@ rm -rf openspec/changes/* > `openspec/config.yaml` 和 `openspec/schemas/fast-drive/` 需要保留,其中包含项目开发规范配置与自定义 OpenSpec workflow schema。 -### 4. 安装依赖 +### 5. 安装依赖 ```bash bun install ``` -### 5. 开始开发 +### 6. 开始开发 ```bash -bun run dev +bun run dev config.yaml ``` ## 项目管理 -| 命令 | 说明 | -| ----------------------- | ---------------------------------------------------------- | -| `bun run dev` | 启动开发模式(并行启动后端 + 前端 Vite 开发服务器) | -| `bun run dev:server` | 仅启动后端开发服务(`--watch` 热重载) | -| `bun run dev:web` | 仅启动前端 Vite 开发服务器 | -| `bun run build` | 生产构建(Vite 打包前端 → Bun compile 生成独立可执行文件) | -| `bun test` | 运行全部测试 | -| `bun run lint` | ESLint 代码风格检查 | -| `bun run format` | Prettier 代码格式化 | -| `bun run typecheck` | TypeScript 类型检查 | -| `bun run schema` | 生成 config.schema.json | -| `bun run schema:check` | 校验 config.schema.json 是否同步 | -| `bun run check` | 完整质量检查:schema:check + typecheck + lint + test | -| `bun run verify` | 验证构建流程:check + build | -| `bun run clean` | 清理构建产物和临时文件 | -| `bun run version:patch` | 升迁 patch 版本(x.y.Z) | -| `bun run version:minor` | 升迁 minor 版本(x.Y.0) | -| `bun run version:major` | 升迁 major 版本(X.0.0) | -| `bun run version:set` | 显式设置版本号 | +| 命令 | 说明 | +| ----------------------- | ------------------------------------------------------------------- | +| `bun run dev ` | 启动开发模式(并行启动后端 + 前端 Vite 开发服务器,需指定配置文件) | +| `bun run dev:server` | 仅启动后端开发服务(`--watch` 热重载) | +| `bun run dev:web` | 仅启动前端 Vite 开发服务器 | +| `bun run build` | 生产构建(Vite 打包前端 → Bun compile 生成独立可执行文件) | +| `bun test` | 运行全部测试 | +| `bun run lint` | ESLint 代码风格检查 | +| `bun run format` | Prettier 代码格式化 | +| `bun run typecheck` | TypeScript 类型检查 | +| `bun run schema` | 生成 config.schema.json | +| `bun run schema:check` | 校验 config.schema.json 是否同步 | +| `bun run check` | 完整质量检查:schema:check + typecheck + lint + test | +| `bun run verify` | 验证构建流程:check + build | +| `bun run clean` | 清理构建产物和临时文件 | +| `bun run version:patch` | 升迁 patch 版本(x.y.Z) | +| `bun run version:minor` | 升迁 minor 版本(x.Y.0) | +| `bun run version:major` | 升迁 major 版本(X.0.0) | +| `bun run version:set` | 显式设置版本号 | ## 项目结构 ```text . +├── data/ # 运行时数据目录(日志等) ├── config.example.yaml # 配置文件示例(server.listen 布局 + 显式变量引用) ├── config.schema.json # 配置文件 JSON Schema(由 bun run schema 生成) ├── bunfig.toml # Bun 配置(测试预加载等) @@ -116,11 +126,12 @@ bun run dev │ │ ├── main.ts # 生产模式入口 │ │ ├── server.ts # HTTP 服务器(Bun.serve routes 声明式路由) │ │ ├── helpers.ts # 共享响应工具(健康检查、JSON 响应) -│ │ ├── middleware.ts # API 参数校验中间件 +│ │ ├── logger.ts # 结构化日志(基于 pino + pino-roll) +│ │ ├── middleware.ts # API 参数校验中间件 │ │ ├── static.ts # 静态资源服务 -│ │ └── routes/ # API 路由处理器 -│ │ └── meta.ts # 应用元信息端点(GET /api/meta) -│ │ version.ts # 版本号读取 +│ │ └── routes/ # API 路由处理器 +│ │ ├── meta.ts # 应用元信息端点(GET /api/meta) +│ │ └── version.ts # 版本号读取 │ ├── shared/ │ │ ├── api.ts # 前后端共享 TypeScript 类型定义 │ │ └── app.ts # 应用全局常量(name、title、subtitle、description) @@ -150,7 +161,7 @@ bun run dev ## 配置 -项目使用 YAML 配置文件,支持通过 JSON Schema 编辑器提示和显式变量引用。 +项目使用 YAML 配置文件,配置文件为**必传参数**,支持通过 JSON Schema 编辑器提示和显式变量引用。配置中的相对路径均基于配置文件所在目录解析,绝对路径保持不变。 ### 配置文件 @@ -162,9 +173,62 @@ server: listen: host: "${HOST|127.0.0.1}" port: ${PORT|3000} + storage: + dataDir: ./data + logging: + level: info + console: + level: info + file: + level: info + path: "./logs/${APP.name}.log" + rotation: + size: 50MB + frequency: daily + maxFiles: 14 ``` -配置文件唯一合法布局为 `server.listen.host` 和 `server.listen.port`。 +### 配置字段说明 + +#### server.listen + +| 字段 | 类型 | 说明 | +| ---- | ------ | -------------------------- | +| host | string | 监听地址,默认 `127.0.0.1` | +| port | number | 监听端口,默认 `3000` | + +#### server.storage + +| 字段 | 类型 | 说明 | +| ------- | ------ | --------------------------------------------------------- | +| dataDir | string | 数据目录,默认 `./data`,相对路径基于配置文件所在目录解析 | + +#### server.logging + +| 字段 | 类型 | 说明 | +| ----- | ------ | ------------------------------------------------------------------------------------ | +| level | string | 全局日志级别(`trace` / `debug` / `info` / `warn` / `error` / `fatal`),默认 `info` | + +##### server.logging.console + +| 字段 | 类型 | 说明 | +| ----- | ------ | --------------------------------------------------- | +| level | string | 控制台日志级别,未设置时继承 `server.logging.level` | + +##### server.logging.file + +| 字段 | 类型 | 说明 | +| ----- | ------ | --------------------------------------------------- | +| level | string | 文件日志级别,未设置时继承 `server.logging.level` | +| path | string | 日志文件路径,默认 `/logs/${APP.name}.log` | + +##### server.logging.file.rotation + +| 字段 | 类型 | 说明 | +| --------- | ------ | ----------------------------------------------------------- | +| size | string | 按大小轮转,支持 `B` / `KB` / `MB` / `GB` 单位,默认 `50MB` | +| frequency | string | 按时间轮转(`hourly` / `daily` / `weekly`),默认 `daily` | +| maxFiles | number | 最大归档文件数,默认 `14` | ### JSON Schema @@ -222,6 +286,9 @@ bun run dev custom-config.yaml | [@sinclair/typebox](https://github.com/sinclairzx81/typebox) | JSON Schema 类型构建器 | | [Ajv](https://ajv.js.org/) | JSON Schema 运行时校验 | | [es-toolkit](https://es-toolkit.slash.page/) | 高性能工具库(推荐优先使用) | +| [pino](https://getpino.io/) | 结构化日志库 | +| [pino-pretty](https://github.com/pinojs/pino-pretty) | 开发环境日志美化输出 | +| [pino-roll](https://github.com/feugy/pino-roll) | 日志文件轮转 | ### 前端 diff --git a/bun.lock b/bun.lock index 973b599..acb4682 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,9 @@ "@tanstack/react-query": "^5.100.10", "ajv": "^8.20.0", "es-toolkit": "^1.46.1", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", + "pino-roll": "^4.0.0", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router": "^7.15.1", @@ -186,6 +189,8 @@ "@oxc-project/types": ["@oxc-project/types@0.130.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.130.0.tgz", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "https://registry.npmmirror.com/@pinojs/redact/-/redact-0.4.0.tgz", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@pkgr/core": ["@pkgr/core@0.2.9", "https://registry.npmmirror.com/@pkgr/core/-/core-0.2.9.tgz", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], "@popperjs/core": ["@popperjs/core@2.11.8", "https://registry.npmmirror.com/@popperjs/core/-/core-2.11.8.tgz", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="], @@ -388,6 +393,8 @@ "async-function": ["async-function@1.0.0", "https://registry.npmmirror.com/async-function/-/async-function-1.0.0.tgz", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + "atomic-sleep": ["atomic-sleep@1.0.0", "https://registry.npmmirror.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -422,6 +429,8 @@ "clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "colorette": ["colorette@2.0.20", "https://registry.npmmirror.com/colorette/-/colorette-2.0.20.tgz", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + "compare-func": ["compare-func@2.0.0", "https://registry.npmmirror.com/compare-func/-/compare-func-2.0.0.tgz", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="], "concat-map": ["concat-map@0.0.1", "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], @@ -476,6 +485,10 @@ "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "https://registry.npmmirror.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + "date-fns": ["date-fns@4.3.0", "https://registry.npmmirror.com/date-fns/-/date-fns-4.3.0.tgz", {}, "sha512-OYcL+3N/jyWbYdFGqoMAhytDgxP9pbYPUUiRCOgn4Fewaadk9l/Wam4Avciiyp2BgkpfQyBV9B+ehnVJych+eQ=="], + + "dateformat": ["dateformat@4.6.3", "https://registry.npmmirror.com/dateformat/-/dateformat-4.6.3.tgz", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], + "dayjs": ["dayjs@1.11.10", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.10.tgz", {}, "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="], "debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -508,6 +521,8 @@ "emoji-regex": ["emoji-regex@10.6.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "end-of-stream": ["end-of-stream@1.4.5", "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "entities": ["entities@8.0.0", "https://registry.npmmirror.com/entities/-/entities-8.0.0.tgz", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], "env-paths": ["env-paths@2.2.1", "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -574,6 +589,8 @@ "eventemitter3": ["eventemitter3@5.0.4", "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "fast-copy": ["fast-copy@4.0.3", "https://registry.npmmirror.com/fast-copy/-/fast-copy-4.0.3.tgz", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-diff": ["fast-diff@1.3.0", "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], @@ -582,6 +599,8 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + "fast-uri": ["fast-uri@3.1.2", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.2.tgz", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], "fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -642,6 +661,8 @@ "hasown": ["hasown@2.0.3", "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + "help-me": ["help-me@5.0.0", "https://registry.npmmirror.com/help-me/-/help-me-5.0.0.tgz", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], + "hermes-estree": ["hermes-estree@0.25.1", "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.25.1.tgz", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], "hermes-parser": ["hermes-parser@0.25.1", "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], @@ -732,6 +753,8 @@ "jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "joycon": ["joycon@3.1.1", "https://registry.npmmirror.com/joycon/-/joycon-3.1.1.tgz", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + "js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -840,6 +863,10 @@ "object.values": ["object.values@1.2.1", "https://registry.npmmirror.com/object.values/-/object.values-1.2.1.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "https://registry.npmmirror.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + + "once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "onetime": ["onetime@7.0.0", "https://registry.npmmirror.com/onetime/-/onetime-7.0.0.tgz", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "optionator": ["optionator@0.9.4", "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -868,6 +895,16 @@ "picomatch": ["picomatch@4.0.4", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "pino": ["pino@10.3.1", "https://registry.npmmirror.com/pino/-/pino-10.3.1.tgz", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="], + + "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], + + "pino-pretty": ["pino-pretty@13.1.3", "https://registry.npmmirror.com/pino-pretty/-/pino-pretty-13.1.3.tgz", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg=="], + + "pino-roll": ["pino-roll@4.0.0", "https://registry.npmmirror.com/pino-roll/-/pino-roll-4.0.0.tgz", { "dependencies": { "date-fns": "^4.1.0", "sonic-boom": "^4.0.1" } }, "sha512-axI1aQaIxXdw1F4OFFli1EDxIrdYNGLowkw/ZoZogX8oCSLHUghzwVVXUS8U+xD/Savwa5IXpiXmsSGKFX/7Sg=="], + + "pino-std-serializers": ["pino-std-serializers@7.1.0", "https://registry.npmmirror.com/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.14", "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], @@ -880,10 +917,16 @@ "pretty-format": ["pretty-format@27.5.1", "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "process-warning": ["process-warning@5.0.0", "https://registry.npmmirror.com/process-warning/-/process-warning-5.0.0.tgz", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + "prop-types": ["prop-types@15.8.1", "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "pump": ["pump@3.0.4", "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + "raf": ["raf@3.4.1", "https://registry.npmmirror.com/raf/-/raf-3.4.1.tgz", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="], "react": ["react@19.2.6", "https://registry.npmmirror.com/react/-/react-19.2.6.tgz", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], @@ -900,6 +943,8 @@ "react-transition-group": ["react-transition-group@4.4.5", "https://registry.npmmirror.com/react-transition-group/-/react-transition-group-4.4.5.tgz", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + "real-require": ["real-require@0.2.0", "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + "recharts": ["recharts@3.8.1", "https://registry.npmmirror.com/recharts/-/recharts-3.8.1.tgz", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="], "redux": ["redux@5.0.1", "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], @@ -934,10 +979,14 @@ "safe-regex-test": ["safe-regex-test@1.1.0", "https://registry.npmmirror.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + "saxes": ["saxes@6.0.0", "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], "scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "secure-json-parse": ["secure-json-parse@4.1.0", "https://registry.npmmirror.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], + "semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "set-cookie-parser": ["set-cookie-parser@2.7.2", "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], @@ -964,10 +1013,14 @@ "slice-ansi": ["slice-ansi@8.0.0", "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-8.0.0.tgz", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], + "sonic-boom": ["sonic-boom@4.2.1", "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + "sortablejs": ["sortablejs@1.15.7", "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz", {}, "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A=="], "source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "split2": ["split2@4.2.0", "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "stable-hash-x": ["stable-hash-x@0.2.0", "https://registry.npmmirror.com/stable-hash-x/-/stable-hash-x-0.2.0.tgz", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -986,6 +1039,8 @@ "strip-bom": ["strip-bom@3.0.0", "https://registry.npmmirror.com/strip-bom/-/strip-bom-3.0.0.tgz", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + "strip-json-comments": ["strip-json-comments@5.0.3", "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-5.0.3.tgz", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "symbol-tree": ["symbol-tree@3.2.4", "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], @@ -996,6 +1051,8 @@ "tdesign-react": ["tdesign-react@1.16.9", "https://registry.npmmirror.com/tdesign-react/-/tdesign-react-1.16.9.tgz", { "dependencies": { "@babel/runtime": "~7.26.7", "@popperjs/core": "~2.11.2", "@types/sortablejs": "^1.10.7", "@types/validator": "^13.1.3", "classnames": "~2.5.1", "dayjs": "1.11.10", "hoist-non-react-statics": "~3.3.2", "lodash-es": "^4.17.21", "mitt": "^3.0.0", "raf": "~3.4.1", "react-fast-compare": "^3.2.2", "react-is": "^18.2.0", "react-transition-group": "~4.4.1", "sortablejs": "^1.15.0", "tdesign-icons-react": "^0.6.4", "tslib": "~2.3.1", "validator": "~13.15.0" }, "peerDependencies": { "react": ">=16.13.1", "react-dom": ">=16.13.1" } }, "sha512-C3uZRTkJ1iQ62BrMkuvqvBK+4HEuhl82rABxa6kAHGHL3eBI4DPfzAJGF0T3b+DKCBeJxb0x10elumT6NkQEaw=="], + "thread-stream": ["thread-stream@4.2.0", "https://registry.npmmirror.com/thread-stream/-/thread-stream-4.2.0.tgz", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tinyexec": ["tinyexec@1.1.2", "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.1.2.tgz", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], @@ -1072,6 +1129,8 @@ "wrap-ansi": ["wrap-ansi@10.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-10.0.0.tgz", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], + "wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], "xmlchars": ["xmlchars@2.2.0", "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], @@ -1176,6 +1235,8 @@ "tdesign-react/@babel/runtime": ["@babel/runtime@7.26.10", "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.26.10.tgz", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw=="], + "thread-stream/real-require": ["real-require@1.0.0", "https://registry.npmmirror.com/real-require/-/real-require-1.0.0.tgz", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], + "typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.3", "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.3.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], diff --git a/config.example.yaml b/config.example.yaml index 5698d0e..c17956c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -3,3 +3,16 @@ server: listen: host: "${HOST|127.0.0.1}" port: ${PORT|3000} + storage: + dataDir: "./data" + logging: + level: "${LOG_LEVEL|info}" + console: + level: "info" + file: + level: "info" + path: "./data/logs/my-app.log" + rotation: + size: "50MB" + frequency: "daily" + maxFiles: 14 diff --git a/config.schema.json b/config.schema.json index fa880be..19ae737 100644 --- a/config.schema.json +++ b/config.schema.json @@ -27,6 +27,206 @@ ] } } + }, + "logging": { + "additionalProperties": false, + "type": "object", + "properties": { + "console": { + "additionalProperties": false, + "type": "object", + "properties": { + "level": { + "anyOf": [ + { + "anyOf": [ + { + "const": "trace", + "type": "string" + }, + { + "const": "debug", + "type": "string" + }, + { + "const": "info", + "type": "string" + }, + { + "const": "warn", + "type": "string" + }, + { + "const": "error", + "type": "string" + }, + { + "const": "fatal", + "type": "string" + } + ] + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + } + } + }, + "file": { + "additionalProperties": false, + "type": "object", + "properties": { + "level": { + "anyOf": [ + { + "anyOf": [ + { + "const": "trace", + "type": "string" + }, + { + "const": "debug", + "type": "string" + }, + { + "const": "info", + "type": "string" + }, + { + "const": "warn", + "type": "string" + }, + { + "const": "error", + "type": "string" + }, + { + "const": "fatal", + "type": "string" + } + ] + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + }, + "path": { + "minLength": 1, + "type": "string" + }, + "rotation": { + "additionalProperties": false, + "type": "object", + "properties": { + "frequency": { + "anyOf": [ + { + "anyOf": [ + { + "const": "hourly", + "type": "string" + }, + { + "const": "daily", + "type": "string" + }, + { + "const": "weekly", + "type": "string" + } + ] + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + }, + "maxFiles": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + }, + "size": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "minimum": 0, + "type": "integer" + } + ] + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + } + } + } + } + }, + "level": { + "anyOf": [ + { + "anyOf": [ + { + "const": "trace", + "type": "string" + }, + { + "const": "debug", + "type": "string" + }, + { + "const": "info", + "type": "string" + }, + { + "const": "warn", + "type": "string" + }, + { + "const": "error", + "type": "string" + }, + { + "const": "fatal", + "type": "string" + } + ] + }, + { + "pattern": "^\\$\\{[^}]+\\}$", + "type": "string" + } + ] + } + } + }, + "storage": { + "additionalProperties": false, + "type": "object", + "properties": { + "dataDir": { + "type": "string" + } + } } } }, diff --git a/eslint.config.js b/eslint.config.js index 2c9e2bf..2920b42 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,6 +6,9 @@ import reactHooks from "eslint-plugin-react-hooks"; import reactRefresh from "eslint-plugin-react-refresh"; import tseslint from "typescript-eslint"; +const noDirectConsoleMessage = + "后端运行时代码禁止直接使用 console.*;请通过注入的 Logger 实例输出日志,配置加载失败前使用 createConsoleFallback()。"; + export default tseslint.config( { ignores: [ @@ -44,6 +47,7 @@ export default tseslint.config( "@typescript-eslint/array-type": ["error", { default: "array-simple" }], "@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "as" }], "@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }], + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], "@typescript-eslint/only-throw-error": "error", "@typescript-eslint/prefer-nullish-coalescing": "error", "@typescript-eslint/prefer-optional-chain": "error", @@ -58,6 +62,19 @@ export default tseslint.config( "import/no-named-as-default-member": "off", }, }, + { + files: ["src/server/**/*.ts"], + ignores: ["src/server/logger.ts"], + rules: { + "no-restricted-syntax": [ + "error", + { + message: noDirectConsoleMessage, + selector: "MemberExpression[object.name='console']", + }, + ], + }, + }, { files: ["src/web/**/*.{ts,tsx}"], plugins: { diff --git a/package.json b/package.json index ec44c50..aca58a1 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,9 @@ "@tanstack/react-query": "^5.100.10", "ajv": "^8.20.0", "es-toolkit": "^1.46.1", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", + "pino-roll": "^4.0.0", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router": "^7.15.1", diff --git a/scripts/build.ts b/scripts/build.ts index 9c041ed..a7c597d 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -114,6 +114,7 @@ async function codeGeneration() { const serverEntryTs = [ `import { bootstrap } from "../src/server/bootstrap";`, `import { parseRuntimeArgs } from "../src/server/config";`, + `import { createConsoleFallback } from "../src/server/logger";`, `import { staticAssets } from "./static-assets";`, "", `const APP_VERSION = "${version}" as const;`, @@ -124,7 +125,7 @@ async function codeGeneration() { `}`, "", `void main().catch((error) => {`, - ` console.error("启动失败:", error instanceof Error ? error.message : error);`, + ` createConsoleFallback().fatal(\`启动失败: \${error instanceof Error ? error.message : String(error)}\`);`, ` process.exit(1);`, `});`, "", diff --git a/src/pino-roll.d.ts b/src/pino-roll.d.ts new file mode 100644 index 0000000..0ef7d38 --- /dev/null +++ b/src/pino-roll.d.ts @@ -0,0 +1,11 @@ +declare module "pino-roll" { + interface RollingStreamOptions { + file: string; + frequency?: string; + limit?: { count?: number }; + mkdir?: boolean; + size?: string; + } + + export default function build(options: RollingStreamOptions): Promise; +} diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 306059f..d46cbb0 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -1,20 +1,24 @@ +import { mkdirSync } from "node:fs"; + import type { RuntimeMode } from "../shared/api"; -import type { ServerConfig } from "./config"; +import type { ResolvedConfig, ResolvedLoggingConfig } from "./config/types"; +import type { Logger } from "./logger"; import type { StartServerOptions } from "./server"; import { loadServerConfig } from "./config"; +import { createConsoleFallback, createRuntimeLogger } from "./logger"; import { startServer } from "./server"; export interface BootstrapDependencies { - loadConfig?: (configPath?: string) => Promise; - logError?: (...data: unknown[]) => void; + createLogger?: (config: ResolvedLoggingConfig, mode: string, version?: string) => Promise; + exit?: (code: number) => never; + loadConfig?: (configPath: string) => Promise; onSignal?: (signal: "SIGINT" | "SIGTERM", handler: () => void) => void; startServer?: (options: StartServerOptions) => unknown; } export interface BootstrapOptions { - config?: ServerConfig; - configPath?: string; + configPath: string; mode: RuntimeMode; staticAssets?: StartServerOptions["staticAssets"]; version?: string; @@ -22,26 +26,61 @@ export interface BootstrapOptions { export async function bootstrap(options: BootstrapOptions, dependencies: BootstrapDependencies = {}): Promise { const load = dependencies.loadConfig ?? loadServerConfig; + const buildLogger = dependencies.createLogger ?? createRuntimeLogger; const serve = dependencies.startServer ?? startServer; const onSignal = dependencies.onSignal ?? ((signal: "SIGINT" | "SIGTERM", handler: () => void) => { process.on(signal, handler); }); - const logError = dependencies.logError ?? console.error; + const exit = dependencies.exit ?? ((code: number) => process.exit(code)); + + const createFallback = (): Logger => createConsoleFallback(); + + let logger: Logger | undefined; try { - const config = options.config ?? (await load(options.configPath)); + const config = await load(options.configPath); + + try { + logger = await buildLogger(config.logging, options.mode, options.version); + } catch (logInitError) { + createFallback().fatal( + `日志初始化失败: ${logInitError instanceof Error ? logInitError.message : String(logInitError)}`, + ); + exit(1); + } + + logger!.info( + { configDir: config.configDir, configPath: options.configPath, mode: options.mode, version: options.version }, + "配置加载成功", + ); + + mkdirSync(config.dataDir, { recursive: true }); + logger!.info({ dataDir: config.dataDir }, "数据目录就绪"); const shutdown = () => { - process.exit(0); + logger?.info("收到退出信号,开始优雅关闭"); + logger?.flush(); + exit(0); }; onSignal("SIGINT", shutdown); onSignal("SIGTERM", shutdown); - serve({ config, mode: options.mode, staticAssets: options.staticAssets, version: options.version }); + serve({ + config: { host: config.host, port: config.port }, + logger: logger!.child({ component: "server" }), + mode: options.mode, + staticAssets: options.staticAssets, + version: options.version, + }); } catch (error) { - logError("启动失败:", error instanceof Error ? error.message : error); - process.exit(1); + if (logger) { + logger.fatal({ error: error instanceof Error ? error.message : String(error) }, "启动失败"); + logger.flush(); + } else { + createFallback().fatal(`启动失败: ${error instanceof Error ? error.message : String(error)}`); + } + exit(1); } } diff --git a/src/server/config.ts b/src/server/config.ts index 969d4ac..45c5050 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -1,25 +1,28 @@ import { isNumber, isString } from "es-toolkit"; +import { dirname, isAbsolute, resolve } from "node:path"; import type { ConfigValidationIssue } from "./config/issues"; +import type { LoggingConfig, LogLevel, ResolvedConfig, ResolvedLoggingConfig, RotationFrequency } from "./config/types"; import { APP } from "../shared/app"; import { dedupeIssues, issue, throwConfigIssues } from "./config/issues"; import { normalizeAuthoringConfig } from "./config/normalizer"; import { validateConfigContract } from "./config/schema/validate"; -export interface ServerConfig { - host: string; - port: number; -} - const DEFAULT_HOST = "127.0.0.1"; const DEFAULT_PORT = 3000; +const DEFAULT_DATA_DIR = "./data"; +const DEFAULT_LOG_LEVEL: LogLevel = "info"; +const DEFAULT_ROTATION_SIZE = "50MB"; +const DEFAULT_ROTATION_FREQUENCY: RotationFrequency = "daily"; +const DEFAULT_ROTATION_MAX_FILES = 14; -export async function loadServerConfig(configPath?: string): Promise { - if (!configPath) { - return { host: DEFAULT_HOST, port: DEFAULT_PORT }; - } +const VALID_LOG_LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"]; +const VALID_ROTATION_FREQUENCIES: RotationFrequency[] = ["hourly", "daily", "weekly"]; +const SIZE_REGEX = /^(\d+(?:\.\d+)?)(B|KB|MB|GB)$/; + +export async function loadServerConfig(configPath: string): Promise { const file = Bun.file(configPath); if (!(await file.exists())) { throw new Error(`配置文件不存在: ${configPath}`); @@ -43,33 +46,178 @@ export async function loadServerConfig(configPath?: string): Promise; + const server = configRecord["server"] as Record | undefined; + const listen = server?.["listen"] as Record | undefined; + const storage = server?.["storage"] as Record | undefined; + + const host = (listen?.["host"] as string | undefined) ?? DEFAULT_HOST; + const port = (listen?.["port"] as number | undefined) ?? DEFAULT_PORT; + const dataDir = resolveDataDir(storage, configDir); + + const rawLogging = server?.["logging"] as LoggingConfig | undefined; + const logging = resolveLogging(rawLogging ?? {}, dataDir, configDir); + validateLoggingConfig(rawLogging, allIssues); + if (allIssues.length > 0) { throwConfigIssues(dedupeIssues(allIssues)); } - return resolveServerConfig(contractResult.config); + return { configDir, dataDir, host, logging, port }; } -export function parseRuntimeArgs(argv: string[] = Bun.argv.slice(2)): { configPath?: string } { - if (argv.length === 0) return {}; +export function parseRuntimeArgs(argv: string[] = Bun.argv.slice(2)): { configPath: string } { + if (argv.length === 0) { + throw new Error(`需要指定 YAML 配置文件路径\n用法: ${APP.name} `); + } const firstArg = argv[0]; if (firstArg === "--help" || firstArg === "-h") { - console.log(`用法: ${APP.name} [config.yaml]`); - console.log(" config.yaml 可选 YAML 配置文件路径(不存在时使用默认配置)"); - process.exit(0); + throw new Error(`用法: ${APP.name} `); } - return { configPath: firstArg }; + return { configPath: firstArg! }; } -function resolveServerConfig(config: object): ServerConfig { - const configRecord = config as Record; - const server = configRecord["server"] as Record | undefined; - const listen = server?.["listen"] as Record | undefined; +export function parseSize(value: number | string): number { + if (isNumber(value)) { + if (!Number.isInteger(value) || value < 0 || !Number.isSafeInteger(value)) { + throw new Error(`无效的 size 数值: ${value},必须为非负安全整数`); + } + return value; + } - const host = (listen?.["host"] as string | undefined) ?? DEFAULT_HOST; - const port = (listen?.["port"] as number | undefined) ?? DEFAULT_PORT; + const match = SIZE_REGEX.exec(value); + if (!match) { + throw new Error(`无效的 size 格式: "${value}",支持格式如 "100MB"、"512KB"、"1GB"、"1024B"`); + } - return { host, port }; + const num = parseFloat(match[1]!); + const unit = match[2]!; + + const bytes = + unit === "B" ? num : unit === "KB" ? num * 1024 : unit === "MB" ? num * 1024 * 1024 : num * 1024 * 1024 * 1024; + if (!Number.isInteger(bytes) || bytes < 0 || !Number.isSafeInteger(bytes)) { + throw new Error(`无效的 size 数值: ${value},必须解析为非负安全整数字节数`); + } + return bytes; +} + +function resolveDataDir(storage: Record | undefined, configDir: string): string { + const raw = storage?.["dataDir"]; + if (isString(raw) && raw.trim() !== "") { + return isAbsolute(raw) ? resolve(raw) : resolve(configDir, raw); + } + return resolve(configDir, DEFAULT_DATA_DIR); +} + +function resolveLogging(logging: LoggingConfig, dataDir: string, configDir: string): ResolvedLoggingConfig { + const globalLevel = resolveLogLevel(logging.level, DEFAULT_LOG_LEVEL); + const consoleLevel = resolveLogLevel(logging.console?.level, globalLevel); + const fileLevel = resolveLogLevel(logging.file?.level, globalLevel); + + const rawPath = logging.file?.path; + const filePath = rawPath + ? isAbsolute(rawPath) + ? resolve(rawPath) + : resolve(configDir, rawPath) + : resolve(dataDir, "logs", `${APP.name}.log`); + + const rotationRaw = logging.file?.rotation; + const rotationSizeRaw = rotationRaw?.size ?? DEFAULT_ROTATION_SIZE; + const rotationSizeBytes = parseSize(rotationSizeRaw); + const rotationFrequency = rotationRaw?.frequency ?? DEFAULT_ROTATION_FREQUENCY; + const rotationMaxFiles = rotationRaw?.maxFiles ?? DEFAULT_ROTATION_MAX_FILES; + + return { + consoleLevel, + fileLevel, + filePath, + rotationFrequency, + rotationMaxFiles, + rotationSizeBytes, + rotationSizeRaw, + }; +} + +function resolveLogLevel(level: unknown, fallback: LogLevel): LogLevel { + if (!isString(level)) return fallback; + if (VALID_LOG_LEVELS.includes(level as LogLevel)) return level as LogLevel; + return fallback; +} + +function validateLoggingConfig(logging: LoggingConfig | undefined, issues: ConfigValidationIssue[]): void { + if (logging === undefined) return; + + if (logging.level !== undefined && !VALID_LOG_LEVELS.includes(logging.level)) { + issues.push( + issue( + "invalid-value", + "server.logging.level", + `日志等级非法: "${logging.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`, + ), + ); + } + + if (logging.console?.level !== undefined && !VALID_LOG_LEVELS.includes(logging.console.level)) { + issues.push( + issue( + "invalid-value", + "server.logging.console.level", + `日志等级非法: "${logging.console.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`, + ), + ); + } + + if (logging.file?.level !== undefined && !VALID_LOG_LEVELS.includes(logging.file.level)) { + issues.push( + issue( + "invalid-value", + "server.logging.file.level", + `日志等级非法: "${logging.file.level}",支持: ${VALID_LOG_LEVELS.join(", ")}`, + ), + ); + } + + if (logging.file?.path !== undefined) { + if (!isString(logging.file.path) || logging.file.path.trim() === "") { + issues.push(issue("invalid-value", "server.logging.file.path", "日志路径不能为空字符串或空白字符串")); + } + } + + const rotation = logging.file?.rotation; + if (rotation?.size !== undefined) { + try { + const bytes = parseSize(rotation.size); + if (bytes <= 0) { + issues.push(issue("invalid-value", "server.logging.file.rotation.size", "滚动大小必须为正整数字节数")); + } + } catch (error) { + issues.push( + issue( + "invalid-value", + "server.logging.file.rotation.size", + error instanceof Error ? error.message : "size 格式非法", + ), + ); + } + } + + if (rotation?.frequency !== undefined && !VALID_ROTATION_FREQUENCIES.includes(rotation.frequency)) { + issues.push( + issue( + "invalid-value", + "server.logging.file.rotation.frequency", + `滚动频率非法: "${rotation.frequency}",支持: ${VALID_ROTATION_FREQUENCIES.join(", ")}`, + ), + ); + } + + if (rotation?.maxFiles !== undefined) { + if (!isNumber(rotation.maxFiles) || !Number.isInteger(rotation.maxFiles) || rotation.maxFiles <= 0) { + issues.push(issue("invalid-value", "server.logging.file.rotation.maxFiles", "maxFiles 必须为正整数")); + } + } } function validateRuntimeConfig(config: object): ConfigValidationIssue[] { diff --git a/src/server/config/index.ts b/src/server/config/index.ts index ae5a4c2..e422e89 100644 --- a/src/server/config/index.ts +++ b/src/server/config/index.ts @@ -9,9 +9,19 @@ export { createConfigJsonSchema } from "./schema/export"; export { createConfigAjv, issuesFromAjvErrors, validateConfigContract } from "./schema/validate"; export type { AuthoringConfig, + AuthoringLoggingConfig, + AuthoringLoggingFileConfig, + AuthoringLoggingFileRotationConfig, + AuthoringServer, ConfigVariableValue, + LoggingConfig, + LogLevel, NormalizedConfig, + NormalizedLoggingConfig, NormalizedServer, + ResolvedConfig, + ResolvedLoggingConfig, + RotationFrequency, ValidatedConfig, } from "./types"; export { extractVariables, resolveVariables } from "./variables"; diff --git a/src/server/config/schema/builder.ts b/src/server/config/schema/builder.ts index f5db787..4255d40 100644 --- a/src/server/config/schema/builder.ts +++ b/src/server/config/schema/builder.ts @@ -6,6 +6,11 @@ import { variableValueSchema } from "./fragments"; type SchemaKind = "authoring" | "normalized"; +const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "fatal"] as const; +const ROTATION_FREQUENCIES = ["hourly", "daily", "weekly"] as const; + +const sizeSchema = Type.Union([Type.String(), Type.Integer({ minimum: 0 })]); + export function createAuthoringConfigSchema(): TSchema { return createConfigSchemaForKind("authoring"); } @@ -42,6 +47,47 @@ function createConfigSchemaForKind(kind: SchemaKind): TSchema { return Type.Object(properties, { additionalProperties: false }); } +function createLoggingSchema(kind: SchemaKind): TSchema { + const logLevelSchema = Type.Union(LOG_LEVELS.map((l) => Type.Literal(l)) as unknown as [TSchema, ...TSchema[]]); + const logLevel = kind === "authoring" ? createAuthoringFieldSchema(logLevelSchema) : logLevelSchema; + const frequency = + kind === "authoring" + ? createAuthoringFieldSchema( + Type.Union(ROTATION_FREQUENCIES.map((f) => Type.Literal(f)) as unknown as [TSchema, ...TSchema[]]), + ) + : Type.Union(ROTATION_FREQUENCIES.map((f) => Type.Literal(f)) as unknown as [TSchema, ...TSchema[]]); + const rotationSize = kind === "authoring" ? createAuthoringFieldSchema(sizeSchema) : sizeSchema; + const rotationMaxFiles = + kind === "authoring" ? createAuthoringFieldSchema(Type.Integer({ minimum: 1 })) : Type.Integer({ minimum: 1 }); + + return Type.Object( + { + console: Type.Optional(Type.Object({ level: Type.Optional(logLevel) }, { additionalProperties: false })), + file: Type.Optional( + Type.Object( + { + level: Type.Optional(logLevel), + path: Type.Optional(Type.String({ minLength: 1 })), + rotation: Type.Optional( + Type.Object( + { + frequency: Type.Optional(frequency), + maxFiles: Type.Optional(rotationMaxFiles), + size: Type.Optional(rotationSize), + }, + { additionalProperties: false }, + ), + ), + }, + { additionalProperties: false }, + ), + ), + level: Type.Optional(logLevel), + }, + { additionalProperties: false }, + ); +} + function createServerSchema(kind: SchemaKind): TSchema { return Type.Object( { @@ -54,6 +100,15 @@ function createServerSchema(kind: SchemaKind): TSchema { { additionalProperties: false }, ), ), + logging: Type.Optional(createLoggingSchema(kind)), + storage: Type.Optional( + Type.Object( + { + dataDir: Type.Optional(Type.String()), + }, + { additionalProperties: false }, + ), + ), }, { additionalProperties: false }, ); diff --git a/src/server/config/types.ts b/src/server/config/types.ts index 38cd436..8b56201 100644 --- a/src/server/config/types.ts +++ b/src/server/config/types.ts @@ -3,8 +3,32 @@ export interface AuthoringConfig { variables?: Record; } +export interface AuthoringLoggingConfig { + console?: AuthoringLoggingConsoleConfig; + file?: AuthoringLoggingFileConfig; + level?: string; +} + +export interface AuthoringLoggingConsoleConfig { + level?: string; +} + +export interface AuthoringLoggingFileConfig { + level?: string; + path?: string; + rotation?: AuthoringLoggingFileRotationConfig; +} + +export interface AuthoringLoggingFileRotationConfig { + frequency?: string; + maxFiles?: number | string; + size?: string; +} + export interface AuthoringServer { listen?: AuthoringServerListen; + logging?: AuthoringLoggingConfig; + storage?: AuthoringServerStorage; } export interface AuthoringServerListen { @@ -12,14 +36,58 @@ export interface AuthoringServerListen { port?: number | string; } +export interface AuthoringServerStorage { + dataDir?: string; +} + export type ConfigVariableValue = boolean | number | string; +export interface LoggingConfig { + console?: { level?: LogLevel }; + file?: { + level?: LogLevel; + path?: string; + rotation?: { + frequency?: RotationFrequency; + maxFiles?: number; + size?: string; + }; + }; + level?: LogLevel; +} + +export type LogLevel = "debug" | "error" | "fatal" | "info" | "trace" | "warn"; + export interface NormalizedConfig { server?: NormalizedServer; } +export interface NormalizedLoggingConfig { + console?: NormalizedLoggingConsoleConfig; + file?: NormalizedLoggingFileConfig; + level?: LogLevel; +} + +export interface NormalizedLoggingConsoleConfig { + level?: LogLevel; +} + +export interface NormalizedLoggingFileConfig { + level?: LogLevel; + path?: string; + rotation?: NormalizedLoggingFileRotationConfig; +} + +export interface NormalizedLoggingFileRotationConfig { + frequency?: RotationFrequency; + maxFiles?: number; + size?: string; +} + export interface NormalizedServer { listen?: NormalizedServerListen; + logging?: NormalizedLoggingConfig; + storage?: NormalizedServerStorage; } export interface NormalizedServerListen { @@ -27,11 +95,30 @@ export interface NormalizedServerListen { port?: number; } +export interface NormalizedServerStorage { + dataDir?: string; +} + export interface ResolvedConfig { + configDir: string; + dataDir: string; host: string; + logging: ResolvedLoggingConfig; port: number; } +export interface ResolvedLoggingConfig { + consoleLevel: LogLevel; + fileLevel: LogLevel; + filePath: string; + rotationFrequency: RotationFrequency; + rotationMaxFiles: number; + rotationSizeBytes: number; + rotationSizeRaw: string; +} + +export type RotationFrequency = "daily" | "hourly" | "weekly"; + export interface ValidatedConfig { server?: NormalizedServer; } diff --git a/src/server/dev.ts b/src/server/dev.ts index 690e126..a319ac1 100644 --- a/src/server/dev.ts +++ b/src/server/dev.ts @@ -1,5 +1,6 @@ import { bootstrap } from "./bootstrap"; import { parseRuntimeArgs } from "./config"; +import { createConsoleFallback } from "./logger"; async function main() { const { configPath } = parseRuntimeArgs(); @@ -7,6 +8,6 @@ async function main() { } void main().catch((error) => { - console.error("启动失败:", error instanceof Error ? error.message : error); + createConsoleFallback().fatal(`启动失败: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); }); diff --git a/src/server/logger.ts b/src/server/logger.ts new file mode 100644 index 0000000..3d7fbe7 --- /dev/null +++ b/src/server/logger.ts @@ -0,0 +1,279 @@ +import type pino from "pino"; + +import { mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +import type { LogLevel, ResolvedLoggingConfig } from "./config/types"; + +import { APP } from "../shared/app"; + +export interface Logger { + child(bindings: Record): Logger; + debug(obj: Record, msg?: string): void; + debug(msg: string): void; + error(obj: Record, msg?: string): void; + error(msg: string): void; + fatal(obj: Record, msg?: string): void; + fatal(msg: string): void; + flush(): void; + info(obj: Record, msg?: string): void; + info(msg: string): void; + trace(obj: Record, msg?: string): void; + trace(msg: string): void; + warn(obj: Record, msg?: string): void; + warn(msg: string): void; +} + +export const REDACT_PATHS = [ + "authorization", + "cookie", + "set-cookie", + "*.set-cookie", + "authToken", + "key", + "password", + "token", + "apiKey", + "*.authorization", + "*.cookie", + "*.authToken", + "*.key", + "*.password", + "*.token", + "*.apiKey", +]; + +const LOG_LEVEL_MAP: Record = { + debug: "debug", + error: "error", + fatal: "fatal", + info: "info", + trace: "trace", + warn: "warn", +}; + +type LogFn = (objOrMsg: Record | string, msg?: string) => void; + +const voidLog: LogFn = () => undefined; + +class ConsoleFallbackLogger implements Logger { + child(_bindings: Record): Logger { + return this; + } + + debug(objOrMsg: Record | string, msg?: string): void { + console.log(formatMsg(objOrMsg, msg)); + } + + error(objOrMsg: Record | string, msg?: string): void { + console.error(formatMsg(objOrMsg, msg)); + } + + fatal(objOrMsg: Record | string, msg?: string): void { + console.error(formatMsg(objOrMsg, msg)); + } + + flush: () => void = () => undefined; + + info(objOrMsg: Record | string, msg?: string): void { + console.log(formatMsg(objOrMsg, msg)); + } + + trace(objOrMsg: Record | string, msg?: string): void { + console.log(formatMsg(objOrMsg, msg)); + } + + warn(objOrMsg: Record | string, msg?: string): void { + console.warn(formatMsg(objOrMsg, msg)); + } +} + +class NoopLogger implements Logger { + debug: LogFn = voidLog; + error: LogFn = voidLog; + fatal: LogFn = voidLog; + info: LogFn = voidLog; + trace: LogFn = voidLog; + warn: LogFn = voidLog; + child(_bindings: Record): Logger { + return this; + } + flush: () => void = () => undefined; +} + +class PinoLoggerWrapper implements Logger { + private pino: pino.Logger; + + constructor(pinoLogger: pino.Logger) { + this.pino = pinoLogger; + } + + child(bindings: Record): Logger { + return new PinoLoggerWrapper(this.pino.child(bindings)); + } + + debug(objOrMsg: Record | string, msg?: string): void { + if (typeof objOrMsg === "string") this.pino.debug(objOrMsg); + else this.pino.debug(objOrMsg, msg); + } + + error(objOrMsg: Record | string, msg?: string): void { + if (typeof objOrMsg === "string") this.pino.error(objOrMsg); + else this.pino.error(objOrMsg, msg); + } + + fatal(objOrMsg: Record | string, msg?: string): void { + if (typeof objOrMsg === "string") this.pino.fatal(objOrMsg); + else this.pino.fatal(objOrMsg, msg); + } + + flush(): void { + this.pino.flush(); + } + + info(objOrMsg: Record | string, msg?: string): void { + if (typeof objOrMsg === "string") this.pino.info(objOrMsg); + else this.pino.info(objOrMsg, msg); + } + + trace(objOrMsg: Record | string, msg?: string): void { + if (typeof objOrMsg === "string") this.pino.trace(objOrMsg); + else this.pino.trace(objOrMsg, msg); + } + + warn(objOrMsg: Record | string, msg?: string): void { + if (typeof objOrMsg === "string") this.pino.warn(objOrMsg); + else this.pino.warn(objOrMsg, msg); + } +} + +export class MemoryLogger implements Logger { + entries: Array<{ level: string; msg: string; obj?: Record }> = []; + + child(_bindings: Record): Logger { + return this; + } + + debug(objOrMsg: Record | string, msg?: string): void { + this.capture("debug", objOrMsg, msg); + } + + error(objOrMsg: Record | string, msg?: string): void { + this.capture("error", objOrMsg, msg); + } + + fatal(objOrMsg: Record | string, msg?: string): void { + this.capture("fatal", objOrMsg, msg); + } + + flush: () => void = () => undefined; + + info(objOrMsg: Record | string, msg?: string): void { + this.capture("info", objOrMsg, msg); + } + + trace(objOrMsg: Record | string, msg?: string): void { + this.capture("trace", objOrMsg, msg); + } + + warn(objOrMsg: Record | string, msg?: string): void { + this.capture("warn", objOrMsg, msg); + } + + private capture(level: string, objOrMsg: Record | string, msg?: string): void { + if (typeof objOrMsg === "string") { + this.entries.push({ level, msg: objOrMsg }); + } else { + this.entries.push({ level, msg: msg ?? "", obj: objOrMsg }); + } + } +} + +export function createConsoleFallback(): Logger { + return new ConsoleFallbackLogger(); +} + +export function createMemoryLogger(): MemoryLogger { + return new MemoryLogger(); +} + +export function createNoopLogger(): Logger { + return new NoopLogger(); +} + +export async function createRuntimeLogger( + config: ResolvedLoggingConfig, + mode: string, + version?: string, +): Promise { + const pinoLib = await import("pino"); + const pinoPretty = await import("pino-pretty"); + + mkdirSync(dirname(config.filePath), { recursive: true }); + + const rootLevel = resolveRootLevel(config.consoleLevel, config.fileLevel); + + const prettyStream = pinoPretty.default({ + colorize: true, + ignore: "pid,hostname", + singleLine: true, + translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l", + }); + + const fileStream = await createRollingFileStream(config); + + const streams: pino.StreamEntry[] = [ + { level: toPinoLevel(config.consoleLevel) as pino.Level, stream: prettyStream }, + { level: toPinoLevel(config.fileLevel) as pino.Level, stream: fileStream }, + ]; + + const base: Record = { mode, service: APP.name }; + if (version) base["version"] = version; + + const logger = pinoLib.default( + { + base, + level: rootLevel, + redact: { censor: "[Redacted]", paths: REDACT_PATHS }, + timestamp: pinoLib.stdTimeFunctions.isoTime, + }, + pinoLib.multistream(streams), + ); + + return new PinoLoggerWrapper(logger); +} + +async function createRollingFileStream(config: ResolvedLoggingConfig): Promise { + const dir = dirname(config.filePath); + const base = resolve(dir, config.filePath.replace(/^.*[\\/]/, "").replace(/\.log$/, "")); + + try { + const buildPinoRoll = (await import("pino-roll")).default; + return await buildPinoRoll({ + file: base, + frequency: config.rotationFrequency, + limit: { count: config.rotationMaxFiles }, + mkdir: true, + size: config.rotationSizeRaw, + }); + } catch { + const fs = await import("node:fs"); + return fs.createWriteStream(config.filePath, { flags: "a" }); + } +} + +function formatMsg(objOrMsg: Record | string, msg?: string): string { + if (typeof objOrMsg === "string") return objOrMsg; + return msg ? `${msg} ${JSON.stringify(objOrMsg)}` : JSON.stringify(objOrMsg); +} + +function resolveRootLevel(consoleLevel: LogLevel, fileLevel: LogLevel): string { + const order: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"]; + const ci = order.indexOf(consoleLevel); + const fi = order.indexOf(fileLevel); + return LOG_LEVEL_MAP[order[Math.min(ci, fi)]!] ?? "info"; +} + +function toPinoLevel(level: LogLevel): string { + return LOG_LEVEL_MAP[level]; +} diff --git a/src/server/main.ts b/src/server/main.ts index d17164c..24a7daa 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -1,5 +1,6 @@ import { bootstrap } from "./bootstrap"; import { parseRuntimeArgs } from "./config"; +import { createConsoleFallback } from "./logger"; async function main() { const { configPath } = parseRuntimeArgs(); @@ -7,6 +8,6 @@ async function main() { } void main().catch((error) => { - console.error("启动失败:", error instanceof Error ? error.message : error); + createConsoleFallback().fatal(`启动失败: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); }); diff --git a/src/server/server.ts b/src/server/server.ts index 706f07c..1907e37 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1,22 +1,22 @@ import type { RuntimeMode } from "../shared/api"; -import type { ServerConfig } from "./config"; +import type { Logger } from "./logger"; import type { StaticAssets } from "./static"; -import { APP } from "../shared/app"; import { createApiError, jsonResponse } from "./helpers"; import { handleMeta } from "./routes/meta"; import { serveStaticAsset } from "./static"; import { readAppVersion } from "./version"; export interface StartServerOptions { - config: ServerConfig; + config: { host: string; port: number }; + logger: Logger; mode: RuntimeMode; staticAssets?: StaticAssets; version?: string; } export function startServer(options: StartServerOptions) { - const { config, mode, staticAssets, version } = options; + const { config, logger, mode, staticAssets, version } = options; const resolveVersion = (): Promise => { if (version) return Promise.resolve(version); @@ -43,7 +43,7 @@ export function startServer(options: StartServerOptions) { }, }); - console.log(`${APP.name} listening on ${server.url}`); + logger.info({ host: config.host, port: config.port, url: server.url.toString() }, "服务启动"); return server; } diff --git a/tests/server/bootstrap.test.ts b/tests/server/bootstrap.test.ts index 2533c4d..3dac9e8 100644 --- a/tests/server/bootstrap.test.ts +++ b/tests/server/bootstrap.test.ts @@ -1,50 +1,80 @@ -/* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars, @typescript-eslint/require-await, @typescript-eslint/unbound-method */ +/* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/require-await */ import { describe, expect, test } from "bun:test"; +import { mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ResolvedConfig } from "../../src/server/config/types"; +import type { Logger } from "../../src/server/logger"; import type { StartServerOptions } from "../../src/server/server"; import { bootstrap, type BootstrapDependencies } from "../../src/server/bootstrap"; +import { createMemoryLogger } from "../../src/server/logger"; -const origExit = process.exit; +function makeTempConfig(overrides: Partial = {}): ResolvedConfig { + const base = join(tmpdir(), `bootstrap-test-${Date.now()}`); + mkdirSync(base, { recursive: true }); + return { + configDir: base, + dataDir: join(base, "data"), + host: "127.0.0.1", + logging: { + consoleLevel: "info", + fileLevel: "info", + filePath: join(base, "data", "logs", "test.log"), + rotationFrequency: "daily", + rotationMaxFiles: 14, + rotationSizeBytes: 52428800, + rotationSizeRaw: "50MB", + }, + port: 0, + ...overrides, + }; +} describe("bootstrap", () => { test("使用默认依赖启动", async () => { let started = false; let signalRegistered = false; + let loggerPassedToServer: Logger | undefined; - const mockLoadConfig = (async () => ({ - host: "127.0.0.1", - port: 0, - })) as unknown as BootstrapDependencies["loadConfig"]; - const mockLogError = () => {}; + const cfg = makeTempConfig(); + const mockLoadConfig = (async () => cfg) as unknown as BootstrapDependencies["loadConfig"]; const mockOnSignal = (_signal: string, _handler: () => void) => { signalRegistered = true; }; - const mockStartServer = (_options: StartServerOptions) => { - expect(_options.version).toBeUndefined(); + const mockStartServer = (options: StartServerOptions) => { + loggerPassedToServer = options.logger; started = true; return {}; }; const deps: BootstrapDependencies = { + createLogger: async () => createMemoryLogger(), loadConfig: mockLoadConfig, - logError: mockLogError, onSignal: mockOnSignal, startServer: mockStartServer, }; - await bootstrap({ mode: "production" }, deps); + await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps); expect(started).toBe(true); expect(signalRegistered).toBe(true); + expect(loggerPassedToServer).toBeDefined(); }); test("传递 version 给 startServer", async () => { let receivedVersion: string | undefined; + let loggerCreated = false; + const cfg = makeTempConfig(); const deps: BootstrapDependencies = { - loadConfig: async () => ({ host: "127.0.0.1", port: 0 }), - logError: () => {}, + createLogger: async (_logConfig, _mode, version) => { + loggerCreated = true; + expect(version).toBe("1.2.3"); + return createMemoryLogger(); + }, + loadConfig: async () => cfg, onSignal: () => {}, startServer: (options: StartServerOptions) => { receivedVersion = options.version; @@ -52,39 +82,127 @@ describe("bootstrap", () => { }, }; - await bootstrap({ mode: "production", version: "1.2.3" }, deps); + await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production", version: "1.2.3" }, deps); expect(receivedVersion).toBe("1.2.3"); + expect(loggerCreated).toBe(true); }); - test("启动失败时调用 logError", async () => { - let errorLogged = false; - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - process.exit = ((code?: number) => { - throw new Error("process.exit called"); - }) as unknown as typeof process.exit; + test("logger 初始化失败时使用 fallback 并退出", async () => { + let exitCode: number | undefined; + const cfg = makeTempConfig(); const deps: BootstrapDependencies = { - loadConfig: async () => { - throw new Error("test config error"); + createLogger: async () => { + throw new Error("pino import failed"); }, - logError: () => { - errorLogged = true; + exit: (code: number) => { + exitCode = code; + throw new Error("exit called"); }, + loadConfig: async () => cfg, startServer: () => { throw new Error("should not reach"); }, }; try { - await bootstrap({ mode: "production" }, deps); + await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps); } catch { - // process.exit throws to interrupt flow + // expected - exit threw } - process.exit = origExit; + expect(exitCode).toBe(1); + }); - expect(errorLogged).toBe(true); + test("启动失败时调用 logger.fatal 并 flush", async () => { + let fatalCalled = false; + let flushCalled = false; + let exitCode: number | undefined; + + const mockLogger = createMemoryLogger(); + const origFatal = mockLogger.fatal.bind(mockLogger); + const origFlush = mockLogger.flush.bind(mockLogger); + mockLogger.fatal = (objOrMsg, msg?) => { + fatalCalled = true; + origFatal(objOrMsg, msg); + }; + mockLogger.flush = () => { + flushCalled = true; + origFlush(); + }; + + const cfg = makeTempConfig(); + const deps: BootstrapDependencies = { + createLogger: async () => mockLogger, + exit: (code: number) => { + exitCode = code; + throw new Error("exit called"); + }, + loadConfig: async () => cfg, + startServer: () => { + throw new Error("server start failed"); + }, + }; + + try { + await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps); + } catch { + // expected + } + + expect(fatalCalled).toBe(true); + expect(flushCalled).toBe(true); + expect(exitCode).toBe(1); + }); + + test("数据目录创建后记录日志", async () => { + const cfg = makeTempConfig(); + let infoDataDir: string | undefined; + + const mockLogger = createMemoryLogger(); + const origInfo = mockLogger.info.bind(mockLogger); + mockLogger.info = (objOrMsg, msg?) => { + if (typeof objOrMsg === "object" && "dataDir" in objOrMsg) { + infoDataDir = objOrMsg["dataDir"] as string; + } + origInfo(objOrMsg, msg); + }; + + const deps: BootstrapDependencies = { + createLogger: async () => mockLogger, + loadConfig: async () => cfg, + startServer: () => ({}), + }; + + await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "development" }, deps); + + expect(infoDataDir).toBe(cfg.dataDir); + }); + + test("shutdown 时 flush logger", async () => { + let flushed = false; + let shutdownHandler: (() => void) | undefined; + + const mockLogger = createMemoryLogger(); + mockLogger.flush = () => { + flushed = true; + }; + + const cfg = makeTempConfig(); + const deps: BootstrapDependencies = { + createLogger: async () => mockLogger, + loadConfig: async () => cfg, + onSignal: (_signal, handler) => { + shutdownHandler = handler; + }, + startServer: () => ({}), + }; + + await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps); + + expect(shutdownHandler).toBeDefined(); + shutdownHandler!(); + expect(flushed).toBe(true); }); }); diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts index 512b924..45c00ad 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -1,14 +1,19 @@ import { describe, expect, test } from "bun:test"; -import { rm, writeFile } from "node:fs/promises"; +import { mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { loadServerConfig, parseRuntimeArgs } from "../../src/server/config"; +import { loadServerConfig, parseRuntimeArgs, parseSize } from "../../src/server/config"; +import { APP } from "../../src/shared/app"; describe("parseRuntimeArgs", () => { - test("无参数返回空对象", () => { - const result = parseRuntimeArgs([]); - expect(result).toEqual({}); + test("无参数抛出需要配置文件路径错误", () => { + try { + parseRuntimeArgs([]); + expect.unreachable(); + } catch (error) { + expect((error as Error).message).toContain("需要指定 YAML 配置文件路径"); + } }); test("有参数返回 configPath", () => { @@ -16,96 +21,48 @@ describe("parseRuntimeArgs", () => { expect(result).toEqual({ configPath: "config.yaml" }); }); - test("--help 输出用法并退出", () => { - const logs: string[] = []; - let exitCode: number | undefined; - const originalLog = console.log; - console.log = (...args: unknown[]) => logs.push(args.join(" ")); - Object.defineProperty(process, "exit", { - configurable: true, - value: (code: number) => { - exitCode = code; - }, - writable: true, - }); + test("--help 抛出错误", () => { try { parseRuntimeArgs(["--help"]); - } finally { - Object.defineProperty(process, "exit", { - configurable: true, - value: process.exit.bind(process), - writable: true, - }); - console.log = originalLog; + expect.unreachable(); + } catch (error) { + expect((error as Error).message).toContain("用法"); } - expect(exitCode).toBe(0); - expect(logs.some((l) => l.includes("用法"))).toBe(true); }); - test("-h 输出用法并退出", () => { - const logs: string[] = []; - let exitCode: number | undefined; - const originalLog = console.log; - console.log = (...args: unknown[]) => logs.push(args.join(" ")); - Object.defineProperty(process, "exit", { - configurable: true, - value: (code: number) => { - exitCode = code; - }, - writable: true, - }); + test("-h 抛出错误", () => { try { parseRuntimeArgs(["-h"]); - } finally { - Object.defineProperty(process, "exit", { - configurable: true, - value: process.exit.bind(process), - writable: true, - }); - console.log = originalLog; + expect.unreachable(); + } catch (error) { + expect((error as Error).message).toContain("用法"); + } + }); +}); + +describe("parseSize", () => { + test("解析数字字节值", () => { + expect(parseSize(1024)).toBe(1024); + }); + + test("解析字符串大小", () => { + expect(parseSize("1KB")).toBe(1024); + expect(parseSize("50MB")).toBe(52428800); + expect(parseSize("1GB")).toBe(1073741824); + expect(parseSize("1024B")).toBe(1024); + }); + + test("非法格式抛出错误", () => { + try { + parseSize("invalid"); + expect.unreachable(); + } catch (error) { + expect((error as Error).message).toContain("无效的 size 格式"); } - expect(exitCode).toBe(0); - expect(logs.some((l) => l.includes("用法"))).toBe(true); }); }); describe("loadServerConfig", () => { - test("无 configPath 使用默认值", async () => { - const config = await loadServerConfig(); - expect(config.host).toBe("127.0.0.1"); - expect(config.port).toBe(3000); - }); - - test("环境变量 HOST 不影响默认值(无隐式覆盖)", async () => { - const prev = process.env["HOST"]; - process.env["HOST"] = "0.0.0.0"; - try { - const config = await loadServerConfig(); - expect(config.host).toBe("127.0.0.1"); - } finally { - if (prev === undefined) { - delete process.env["HOST"]; - } else { - process.env["HOST"] = prev; - } - } - }); - - test("环境变量 PORT 不影响默认值(无隐式覆盖)", async () => { - const prev = process.env["PORT"]; - process.env["PORT"] = "8080"; - try { - const config = await loadServerConfig(); - expect(config.port).toBe(3000); - } finally { - if (prev === undefined) { - delete process.env["PORT"]; - } else { - process.env["PORT"] = prev; - } - } - }); - test("YAML 配置文件不存在时报错", async () => { try { await loadServerConfig("/nonexistent/path/config.yaml"); @@ -115,16 +72,20 @@ describe("loadServerConfig", () => { } }); - test("新布局 server.listen 加载成功", async () => { + test("最简配置解析成功", async () => { const temp = tmpdir(); - const yamlPath = join(temp, "test-listen.yaml"); - const yamlContent = 'server:\n listen:\n host: "0.0.0.0"\n port: 9999\n'; - await writeFile(yamlPath, yamlContent); + const yamlPath = join(temp, "minimal.yaml"); + await writeFile(yamlPath, 'server:\n listen:\n host: "0.0.0.0"\n port: 9999\n'); try { - const config = await loadServerConfig(yamlPath); - expect(config.host).toBe("0.0.0.0"); - expect(config.port).toBe(9999); + const result = await loadServerConfig(yamlPath); + expect(result.host).toBe("0.0.0.0"); + expect(result.port).toBe(9999); + expect(result.configDir).toBe(temp); + expect(result.dataDir).toBe(join(temp, "data")); + expect(result.logging.filePath).toBe(join(temp, "data", "logs", `${APP.name}.log`)); + expect(result.logging.consoleLevel).toBe("info"); + expect(result.logging.fileLevel).toBe("info"); } finally { await rm(yamlPath, { force: true }); } @@ -149,8 +110,7 @@ describe("loadServerConfig", () => { test("非法端口被拒绝", async () => { const temp = tmpdir(); const yamlPath = join(temp, "test-bad-port.yaml"); - const yamlContent = "server:\n listen:\n port: 99999\n"; - await writeFile(yamlPath, yamlContent); + await writeFile(yamlPath, "server:\n listen:\n port: 99999\n"); try { await loadServerConfig(yamlPath); @@ -170,13 +130,12 @@ describe("loadServerConfig", () => { const temp = tmpdir(); const yamlPath = join(temp, "test-env-var.yaml"); - const yamlContent = 'server:\n listen:\n host: "${HOST}"\n port: ${PORT}\n'; - await writeFile(yamlPath, yamlContent); + await writeFile(yamlPath, 'server:\n listen:\n host: "${HOST}"\n port: ${PORT}\n'); try { - const config = await loadServerConfig(yamlPath); - expect(config.host).toBe("10.0.0.1"); - expect(config.port).toBe(4000); + const result = await loadServerConfig(yamlPath); + expect(result.host).toBe("10.0.0.1"); + expect(result.port).toBe(4000); } finally { await rm(yamlPath, { force: true }); if (prevHost === undefined) delete process.env["HOST"]; @@ -190,13 +149,142 @@ describe("loadServerConfig", () => { delete process.env["MY_HOST"]; const temp = tmpdir(); const yamlPath = join(temp, "test-default.yaml"); - const yamlContent = 'server:\n listen:\n host: "${MY_HOST|0.0.0.0}"\n port: ${MY_PORT|5000}\n'; - await writeFile(yamlPath, yamlContent); + await writeFile(yamlPath, 'server:\n listen:\n host: "${MY_HOST|0.0.0.0}"\n port: ${MY_PORT|5000}\n'); try { - const config = await loadServerConfig(yamlPath); - expect(config.host).toBe("0.0.0.0"); - expect(config.port).toBe(5000); + const result = await loadServerConfig(yamlPath); + expect(result.host).toBe("0.0.0.0"); + expect(result.port).toBe(5000); + } finally { + await rm(yamlPath, { force: true }); + } + }); + + test("绝对 dataDir 保持不变", async () => { + const temp = tmpdir(); + const dataDir = join(temp, "absolute-data"); + await mkdir(dataDir, { recursive: true }); + const yamlPath = join(temp, "absolute-dir.yaml"); + await writeFile(yamlPath, `server:\n storage:\n dataDir: ${JSON.stringify(dataDir)}\n`); + + try { + const result = await loadServerConfig(yamlPath); + expect(result.dataDir).toBe(dataDir); + } finally { + await rm(yamlPath, { force: true }); + } + }); + + test("相对 dataDir 基于 configDir", async () => { + const temp = tmpdir(); + const yamlPath = join(temp, "rel-dir.yaml"); + await writeFile(yamlPath, 'server:\n storage:\n dataDir: "./my-data"\n'); + + try { + const result = await loadServerConfig(yamlPath); + expect(result.dataDir).toBe(join(temp, "my-data")); + } finally { + await rm(yamlPath, { force: true }); + } + }); + + test("显式相对日志路径基于 configDir", async () => { + const temp = tmpdir(); + const yamlPath = join(temp, "log-path.yaml"); + await writeFile(yamlPath, 'server:\n logging:\n file:\n path: "./logs/app.log"\n'); + + try { + const result = await loadServerConfig(yamlPath); + expect(result.logging.filePath).toBe(join(temp, "logs", "app.log")); + } finally { + await rm(yamlPath, { force: true }); + } + }); + + test("绝对日志路径保持不变", async () => { + const temp = tmpdir(); + const logPath = join(temp, "my-app.log"); + const yamlPath = join(temp, "abs-log.yaml"); + await writeFile(yamlPath, `server:\n logging:\n file:\n path: ${JSON.stringify(logPath)}\n`); + + try { + const result = await loadServerConfig(yamlPath); + expect(result.logging.filePath).toBe(logPath); + } finally { + await rm(yamlPath, { force: true }); + } + }); + + test("非法 logging.level 抛出错误", async () => { + const temp = tmpdir(); + const yamlPath = join(temp, "bad-level.yaml"); + await writeFile(yamlPath, 'server:\n logging:\n level: "invalid"\n'); + + try { + await loadServerConfig(yamlPath); + expect.unreachable(); + } catch (error) { + expect((error as Error).message).toContain("日志等级"); + } finally { + await rm(yamlPath, { force: true }); + } + }); + + test("空白 logging.file.path 抛出错误", async () => { + const temp = tmpdir(); + const yamlPath = join(temp, "blank-path.yaml"); + await writeFile(yamlPath, 'server:\n logging:\n file:\n path: " "\n'); + + try { + await loadServerConfig(yamlPath); + expect.unreachable(); + } catch (error) { + expect((error as Error).message).toContain("日志路径不能为空字符串或空白字符串"); + } finally { + await rm(yamlPath, { force: true }); + } + }); + + test("非法 rotation.size 抛出错误", async () => { + const temp = tmpdir(); + const yamlPath = join(temp, "bad-size.yaml"); + await writeFile(yamlPath, 'server:\n logging:\n file:\n rotation:\n size: "99XX"\n'); + + try { + await loadServerConfig(yamlPath); + expect.unreachable(); + } catch (error) { + expect((error as Error).message).toContain("无效的 size 格式"); + } finally { + await rm(yamlPath, { force: true }); + } + }); + + test("非法 rotation.frequency 抛出错误", async () => { + const temp = tmpdir(); + const yamlPath = join(temp, "bad-freq.yaml"); + await writeFile(yamlPath, 'server:\n logging:\n file:\n rotation:\n frequency: "yearly"\n'); + + try { + await loadServerConfig(yamlPath); + expect.unreachable(); + } catch (error) { + expect((error as Error).message).toContain("rotation.frequency"); + } finally { + await rm(yamlPath, { force: true }); + } + }); + + test("非法 rotation.maxFiles 抛出错误", async () => { + const temp = tmpdir(); + const yamlPath = join(temp, "bad-max.yaml"); + await writeFile(yamlPath, "server:\n logging:\n file:\n rotation:\n maxFiles: 0\n"); + + try { + await loadServerConfig(yamlPath); + expect.unreachable(); + } catch (error) { + expect((error as Error).message).toContain("maxFiles"); } finally { await rm(yamlPath, { force: true }); } diff --git a/tests/server/config/schema.test.ts b/tests/server/config/schema.test.ts index 2452273..aff3602 100644 --- a/tests/server/config/schema.test.ts +++ b/tests/server/config/schema.test.ts @@ -37,7 +37,41 @@ describe("Authoring schema 校验", () => { expect(validate({ variables: { HOST: "127.0.0.1" } })).toBe(true); }); - test("拒绝未知字段 server.host", () => { + test("接受 server.storage.dataDir", () => { + expect(validate({ server: { storage: { dataDir: "./data" } } })).toBe(true); + }); + + test("接受 server.logging 合法配置", () => { + expect( + validate({ + server: { + logging: { + console: { level: "debug" }, + file: { + level: "warn", + path: "/var/log/app.log", + rotation: { frequency: "daily", maxFiles: 14, size: "50MB" }, + }, + level: "info", + }, + }, + }), + ).toBe(true); + }); + + test("接受 server.logging.level 变量引用", () => { + expect(validate({ server: { logging: { level: "${LOG_LEVEL|info}" } } })).toBe(true); + }); + + test("拒绝 server.logging 中未知字段", () => { + expect(validate({ server: { logging: { unknownField: true } } })).toBe(false); + }); + + test("拒绝 server.logging.level 非法枚举值", () => { + expect(validate({ server: { logging: { level: "verbose" } } })).toBe(false); + }); + + test("拒绝 unknown 字段 server.host", () => { expect(validate({ server: { host: "127.0.0.1" } })).toBe(false); const issues = issuesFromAjvErrors(validate.errors ?? [], {}); expect(issues.some((i) => i.code === "unknown-field")).toBe(true); @@ -82,6 +116,28 @@ describe("Normalized schema 校验", () => { expect(validate({ server: { listen: { port: "${PORT|3000}" } } })).toBe(false); }); + test("接受 server.storage.dataDir", () => { + expect(validate({ server: { storage: { dataDir: "./data" } } })).toBe(true); + }); + + test("接受 server.logging 合法配置", () => { + expect( + validate({ + server: { + logging: { + console: { level: "debug" }, + file: { + level: "warn", + path: "/var/log/app.log", + rotation: { frequency: "daily", maxFiles: 14, size: "50MB" }, + }, + level: "info", + }, + }, + }), + ).toBe(true); + }); + test("接受空对象", () => { expect(validate({})).toBe(true); }); diff --git a/tests/server/logger.test.ts b/tests/server/logger.test.ts new file mode 100644 index 0000000..6820303 --- /dev/null +++ b/tests/server/logger.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test"; + +import type { Logger } from "../../src/server/logger"; + +import { createConsoleFallback, createMemoryLogger, createNoopLogger, REDACT_PATHS } from "../../src/server/logger"; + +describe("NoopLogger", () => { + test("所有方法不抛异常", () => { + const logger = createNoopLogger(); + logger.trace("trace"); + logger.debug("debug"); + logger.info("info"); + logger.warn("warn"); + logger.error("error"); + logger.fatal("fatal"); + logger.flush(); + const child = logger.child({ component: "test" }); + expect(child).toBeDefined(); + }); +}); + +describe("MemoryLogger", () => { + test("记录所有等级日志", () => { + const logger = createMemoryLogger(); + logger.trace("trace-msg"); + logger.debug("debug-msg"); + logger.info("info-msg"); + logger.warn("warn-msg"); + logger.error("error-msg"); + logger.fatal("fatal-msg"); + + expect(logger.entries).toHaveLength(6); + expect(logger.entries[0]).toEqual({ level: "trace", msg: "trace-msg" }); + expect(logger.entries[5]).toEqual({ level: "fatal", msg: "fatal-msg" }); + }); + + test("记录结构化日志", () => { + const logger = createMemoryLogger(); + logger.info({ matched: true, targetId: "abc" }, "check complete"); + + expect(logger.entries).toHaveLength(1); + expect(logger.entries[0]!.level).toBe("info"); + expect(logger.entries[0]!.msg).toBe("check complete"); + expect(logger.entries[0]!.obj).toEqual({ matched: true, targetId: "abc" }); + }); + + test("child 返回自身", () => { + const logger = createMemoryLogger(); + const child = logger.child({ component: "test" }); + child.info("child-msg"); + expect(logger.entries).toHaveLength(1); + }); + + test("flush 不抛异常", () => { + const logger = createMemoryLogger(); + logger.flush(); + }); +}); + +describe("ConsoleFallbackLogger", () => { + test("不抛异常", () => { + const logger = createConsoleFallback(); + logger.trace("trace"); + logger.debug("debug"); + logger.info("info"); + logger.warn("warn"); + logger.error("error"); + logger.fatal("fatal"); + logger.flush(); + const child = logger.child({ component: "test" }); + expect(child).toBeDefined(); + }); +}); + +describe("Logger 接口契约", () => { + function assertLogger(logger: Logger): void { + logger.trace("trace"); + logger.debug("debug"); + logger.info("info"); + logger.warn("warn"); + logger.error("error"); + logger.fatal("fatal"); + logger.info({ key: "value" }, "structured"); + logger.child({ component: "test" }).info("child"); + logger.flush(); + } + + test("NoopLogger 满足 Logger 接口", () => { + expect(() => assertLogger(createNoopLogger())).not.toThrow(); + }); + + test("MemoryLogger 满足 Logger 接口", () => { + expect(() => assertLogger(createMemoryLogger())).not.toThrow(); + }); + + test("ConsoleFallbackLogger 满足 Logger 接口", () => { + expect(() => assertLogger(createConsoleFallback())).not.toThrow(); + }); +}); + +describe("redaction 敏感信息保护", () => { + test("MemoryLogger 不做 redaction(测试用途,仅 Pino 运行时 redact)", () => { + const logger = createMemoryLogger(); + logger.info({ authorization: "Bearer secret", password: "hunter2" }, "test"); + const entry = logger.entries[0]!; + expect(entry.obj!["authorization"]).toBe("Bearer secret"); + expect(entry.obj!["password"]).toBe("hunter2"); + }); + + test("REDACT_PATHS 覆盖所有敏感字段键名", () => { + const sensitiveKeys = ["authorization", "cookie", "set-cookie", "authToken", "key", "password", "token", "apiKey"]; + for (const key of sensitiveKeys) { + expect(REDACT_PATHS).toContain(key); + expect(REDACT_PATHS).toContain(`*.${key}`); + } + }); +});