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:
@@ -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` | `<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
127
README.md
@@ -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) | 日志文件轮转 |
|
||||
|
||||
### 前端
|
||||
|
||||
|
||||
61
bun.lock
61
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=="],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
11
src/pino-roll.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
279
src/server/logger.ts
Normal 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];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
117
tests/server/logger.test.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user