feat: 引入运行时日志体系和存储配置,配置文件改为必填

- 新增 pino/pino-pretty/pino-roll 依赖,实现结构化日志(console pretty + file JSONL rolling)
- 新增 Logger 接口及 PinoLoggerWrapper/ConsoleFallbackLogger/NoopLogger/MemoryLogger 实现
- 新增 src/pino-roll.d.ts 类型声明
- 新增 server.storage.dataDir 配置(默认 ./data,相对路径基于配置文件目录)
- 新增 server.logging 配置(level/console/file/rotation,支持变量引用)
- 配置文件从可选改为必填,parseRuntimeArgs 无参数时抛错
- bootstrap 创建 logger、确保 dataDir、shutdown flush、失败路径 fallback
- startServer 接收 logger 并输出结构化监听日志
- ESLint 新增 no-restricted-syntax 禁止 src/server 直接 console.*(排除 logger.ts)
- 更新 config.example.yaml、README.md、DEVELOPMENT.md 同步配置和日志文档
- 完善测试覆盖:logger、config、schema、bootstrap 共 150 个测试通过
This commit is contained in:
2026-05-25 14:44:37 +08:00
parent c592f2b97c
commit 60d50afad1
22 changed files with 1658 additions and 219 deletions

View File

@@ -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 字段用于 createRuntimeLoggerdataDir 字段用于 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` | `<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: <code> }`,状态码 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` 命令独立运行。

127
README.md
View File

@@ -9,8 +9,9 @@ Bun 全栈应用模板,基于 Bun + React + TDesign 的前后端一体化开
```bash
git clone <your-repo-url> 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 <config>` | 启动开发模式(并行启动后端 + 前端 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 | 日志文件路径,默认 `<dataDir>/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) | 日志文件轮转 |
### 前端

View File

@@ -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=="],

View File

@@ -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

View File

@@ -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"
}
}
}
}
},

View File

@@ -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: {

View File

@@ -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",

View File

@@ -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);`,
`});`,
"",

11
src/pino-roll.d.ts vendored Normal file
View File

@@ -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<NodeJS.WritableStream>;
}

View File

@@ -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<ServerConfig>;
logError?: (...data: unknown[]) => void;
createLogger?: (config: ResolvedLoggingConfig, mode: string, version?: string) => Promise<Logger>;
exit?: (code: number) => never;
loadConfig?: (configPath: string) => Promise<ResolvedConfig>;
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<void> {
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);
}
}

View File

@@ -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<ServerConfig> {
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<ResolvedConfig> {
const file = Bun.file(configPath);
if (!(await file.exists())) {
throw new Error(`配置文件不存在: ${configPath}`);
@@ -43,33 +46,178 @@ export async function loadServerConfig(configPath?: string): Promise<ServerConfi
const runtimeIssues = validateRuntimeConfig(contractResult.config);
allIssues.push(...runtimeIssues);
const configDir = dirname(resolve(configPath));
const configRecord = contractResult.config as Record<string, unknown>;
const server = configRecord["server"] as Record<string, unknown> | undefined;
const listen = server?.["listen"] as Record<string, unknown> | undefined;
const storage = server?.["storage"] as Record<string, unknown> | 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} <config.yaml>`);
}
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} <config.yaml>`);
}
return { configPath: firstArg };
return { configPath: firstArg! };
}
function resolveServerConfig(config: object): ServerConfig {
const configRecord = config as Record<string, unknown>;
const server = configRecord["server"] as Record<string, unknown> | undefined;
const listen = server?.["listen"] as Record<string, unknown> | 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<string, unknown> | 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[] {

View File

@@ -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";

View File

@@ -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 },
);

View File

@@ -3,8 +3,32 @@ export interface AuthoringConfig {
variables?: Record<string, ConfigVariableValue>;
}
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;
}

View File

@@ -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);
});

279
src/server/logger.ts Normal file
View File

@@ -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<string, unknown>): Logger;
debug(obj: Record<string, unknown>, msg?: string): void;
debug(msg: string): void;
error(obj: Record<string, unknown>, msg?: string): void;
error(msg: string): void;
fatal(obj: Record<string, unknown>, msg?: string): void;
fatal(msg: string): void;
flush(): void;
info(obj: Record<string, unknown>, msg?: string): void;
info(msg: string): void;
trace(obj: Record<string, unknown>, msg?: string): void;
trace(msg: string): void;
warn(obj: Record<string, unknown>, 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<LogLevel, string> = {
debug: "debug",
error: "error",
fatal: "fatal",
info: "info",
trace: "trace",
warn: "warn",
};
type LogFn = (objOrMsg: Record<string, unknown> | string, msg?: string) => void;
const voidLog: LogFn = () => undefined;
class ConsoleFallbackLogger implements Logger {
child(_bindings: Record<string, unknown>): Logger {
return this;
}
debug(objOrMsg: Record<string, unknown> | string, msg?: string): void {
console.log(formatMsg(objOrMsg, msg));
}
error(objOrMsg: Record<string, unknown> | string, msg?: string): void {
console.error(formatMsg(objOrMsg, msg));
}
fatal(objOrMsg: Record<string, unknown> | string, msg?: string): void {
console.error(formatMsg(objOrMsg, msg));
}
flush: () => void = () => undefined;
info(objOrMsg: Record<string, unknown> | string, msg?: string): void {
console.log(formatMsg(objOrMsg, msg));
}
trace(objOrMsg: Record<string, unknown> | string, msg?: string): void {
console.log(formatMsg(objOrMsg, msg));
}
warn(objOrMsg: Record<string, unknown> | 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<string, unknown>): Logger {
return this;
}
flush: () => void = () => undefined;
}
class PinoLoggerWrapper implements Logger {
private pino: pino.Logger;
constructor(pinoLogger: pino.Logger) {
this.pino = pinoLogger;
}
child(bindings: Record<string, unknown>): Logger {
return new PinoLoggerWrapper(this.pino.child(bindings));
}
debug(objOrMsg: Record<string, unknown> | string, msg?: string): void {
if (typeof objOrMsg === "string") this.pino.debug(objOrMsg);
else this.pino.debug(objOrMsg, msg);
}
error(objOrMsg: Record<string, unknown> | string, msg?: string): void {
if (typeof objOrMsg === "string") this.pino.error(objOrMsg);
else this.pino.error(objOrMsg, msg);
}
fatal(objOrMsg: Record<string, unknown> | 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, unknown> | string, msg?: string): void {
if (typeof objOrMsg === "string") this.pino.info(objOrMsg);
else this.pino.info(objOrMsg, msg);
}
trace(objOrMsg: Record<string, unknown> | string, msg?: string): void {
if (typeof objOrMsg === "string") this.pino.trace(objOrMsg);
else this.pino.trace(objOrMsg, msg);
}
warn(objOrMsg: Record<string, unknown> | 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<string, unknown> }> = [];
child(_bindings: Record<string, unknown>): Logger {
return this;
}
debug(objOrMsg: Record<string, unknown> | string, msg?: string): void {
this.capture("debug", objOrMsg, msg);
}
error(objOrMsg: Record<string, unknown> | string, msg?: string): void {
this.capture("error", objOrMsg, msg);
}
fatal(objOrMsg: Record<string, unknown> | string, msg?: string): void {
this.capture("fatal", objOrMsg, msg);
}
flush: () => void = () => undefined;
info(objOrMsg: Record<string, unknown> | string, msg?: string): void {
this.capture("info", objOrMsg, msg);
}
trace(objOrMsg: Record<string, unknown> | string, msg?: string): void {
this.capture("trace", objOrMsg, msg);
}
warn(objOrMsg: Record<string, unknown> | string, msg?: string): void {
this.capture("warn", objOrMsg, msg);
}
private capture(level: string, objOrMsg: Record<string, unknown> | 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<Logger> {
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<string, unknown> = { 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<NodeJS.WritableStream> {
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, unknown> | 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];
}

View File

@@ -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);
});

View File

@@ -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<string> => {
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;
}

View File

@@ -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> = {}): 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);
});
});

View File

@@ -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 });
}

View File

@@ -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);
});

117
tests/server/logger.test.ts Normal file
View File

@@ -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}`);
}
});
});